feat: add hosts and params files, fix rules SECTION NEW header
All checks were successful
Build containers when image tags change / build-if-image-changed (backend, shorefront-backend, shorefront backend, backend/Dockerfile, git.baumann.gr/adebaumann/shorefront-backend, .backend.image) (push) Successful in 44s
Build containers when image tags change / build-if-image-changed (frontend, shorefront-frontend, shorefront frontend, frontend/Dockerfile, git.baumann.gr/adebaumann/shorefront-frontend, .frontend.image) (push) Successful in 1m32s

This commit is contained in:
2026-03-01 01:42:28 +01:00
parent 15f28cb070
commit 21d404229a
12 changed files with 308 additions and 4 deletions

View File

@@ -0,0 +1,37 @@
"""add hosts and params tables
Revision ID: 0004
Revises: 0003
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"hosts",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
sa.Column("interface", sa.String(32), nullable=False),
sa.Column("subnet", sa.String(64), nullable=False),
sa.Column("options", sa.Text, server_default="''"),
)
op.create_table(
"params",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("value", sa.String(255), nullable=False),
)
def downgrade() -> None:
op.drop_table("params")
op.drop_table("hosts")

View File

@@ -93,6 +93,8 @@ def generate_config(
selectinload(models.Config.rules).selectinload(models.Rule.src_zone),
selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
selectinload(models.Config.snat_entries),
selectinload(models.Config.host_entries).selectinload(models.Host.zone),
selectinload(models.Config.params),
)
.filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
.first()

64
backend/app/api/hosts.py Normal file
View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/hosts", response_model=list[schemas.HostOut])
def list_hosts(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Host).filter(models.Host.config_id == config_id).all()
@router.post("/{config_id}/hosts", response_model=schemas.HostOut, status_code=201)
def create_host(config_id: int, body: schemas.HostCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = models.Host(**body.model_dump(), config_id=config_id)
db.add(host)
db.commit()
db.refresh(host)
return host
@router.get("/{config_id}/hosts/{host_id}", response_model=schemas.HostOut)
def get_host(config_id: int, host_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = db.query(models.Host).filter(models.Host.id == host_id, models.Host.config_id == config_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host entry not found")
return host
@router.put("/{config_id}/hosts/{host_id}", response_model=schemas.HostOut)
def update_host(config_id: int, host_id: int, body: schemas.HostUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = db.query(models.Host).filter(models.Host.id == host_id, models.Host.config_id == config_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host entry not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(host, field, value)
db.commit()
db.refresh(host)
return host
@router.delete("/{config_id}/hosts/{host_id}", status_code=204)
def delete_host(config_id: int, host_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = db.query(models.Host).filter(models.Host.id == host_id, models.Host.config_id == config_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host entry not found")
db.delete(host)
db.commit()

64
backend/app/api/params.py Normal file
View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/params", response_model=list[schemas.ParamOut])
def list_params(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Param).filter(models.Param.config_id == config_id).all()
@router.post("/{config_id}/params", response_model=schemas.ParamOut, status_code=201)
def create_param(config_id: int, body: schemas.ParamCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = models.Param(**body.model_dump(), config_id=config_id)
db.add(param)
db.commit()
db.refresh(param)
return param
@router.get("/{config_id}/params/{param_id}", response_model=schemas.ParamOut)
def get_param(config_id: int, param_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = db.query(models.Param).filter(models.Param.id == param_id, models.Param.config_id == config_id).first()
if not param:
raise HTTPException(status_code=404, detail="Param not found")
return param
@router.put("/{config_id}/params/{param_id}", response_model=schemas.ParamOut)
def update_param(config_id: int, param_id: int, body: schemas.ParamUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = db.query(models.Param).filter(models.Param.id == param_id, models.Param.config_id == config_id).first()
if not param:
raise HTTPException(status_code=404, detail="Param not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(param, field, value)
db.commit()
db.refresh(param)
return param
@router.delete("/{config_id}/params/{param_id}", status_code=204)
def delete_param(config_id: int, param_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = db.query(models.Param).filter(models.Param.id == param_id, models.Param.config_id == config_id).first()
if not param:
raise HTTPException(status_code=404, detail="Param not found")
db.delete(param)
db.commit()

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, snat
from app.api import auth, configs, zones, interfaces, policies, rules, snat, hosts, params
from app.database import settings
app = FastAPI(title="Shorefront", version="0.1.0")
@@ -22,6 +22,8 @@ app.include_router(interfaces.router, prefix="/configs", tags=["interfaces"])
app.include_router(policies.router, prefix="/configs", tags=["policies"])
app.include_router(rules.router, prefix="/configs", tags=["rules"])
app.include_router(snat.router, prefix="/configs", tags=["snat"])
app.include_router(hosts.router, prefix="/configs", tags=["hosts"])
app.include_router(params.router, prefix="/configs", tags=["params"])
@app.get("/health")

View File

@@ -35,6 +35,8 @@ class Config(Base):
policies: Mapped[list["Policy"]] = relationship("Policy", back_populates="config", cascade="all, delete-orphan", order_by="Policy.position")
rules: Mapped[list["Rule"]] = relationship("Rule", back_populates="config", cascade="all, delete-orphan", order_by="Rule.position")
snat_entries: Mapped[list["Snat"]] = relationship("Snat", back_populates="config", cascade="all, delete-orphan")
host_entries: Mapped[list["Host"]] = relationship("Host", back_populates="config", cascade="all, delete-orphan")
params: Mapped[list["Param"]] = relationship("Param", back_populates="config", cascade="all, delete-orphan")
class Zone(Base):
@@ -113,3 +115,28 @@ class Snat(Base):
comment: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="snat_entries")
class Host(Base):
__tablename__ = "hosts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
interface: Mapped[str] = mapped_column(String(32), nullable=False)
subnet: Mapped[str] = mapped_column(String(64), nullable=False)
options: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="host_entries")
zone: Mapped["Zone"] = relationship("Zone")
class Param(Base):
__tablename__ = "params"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
value: Mapped[str] = mapped_column(String(255), nullable=False)
config: Mapped["Config"] = relationship("Config", back_populates="params")

View File

@@ -186,6 +186,52 @@ class SnatOut(BaseModel):
model_config = {"from_attributes": True}
# --- Host ---
class HostCreate(BaseModel):
zone_id: int
interface: str
subnet: str
options: str = ""
class HostUpdate(BaseModel):
zone_id: Optional[int] = None
interface: Optional[str] = None
subnet: Optional[str] = None
options: Optional[str] = None
class HostOut(BaseModel):
id: int
config_id: int
zone_id: int
interface: str
subnet: str
options: str
model_config = {"from_attributes": True}
# --- Param ---
class ParamCreate(BaseModel):
name: str
value: str
class ParamUpdate(BaseModel):
name: Optional[str] = None
value: Optional[str] = None
class ParamOut(BaseModel):
id: int
config_id: int
name: str
value: str
model_config = {"from_attributes": True}
# --- Generate ---
class GenerateOut(BaseModel):
zones: str
@@ -193,3 +239,5 @@ class GenerateOut(BaseModel):
policy: str
rules: str
snat: str
hosts: str
params: str

View File

@@ -41,6 +41,7 @@ class ShorewallGenerator:
lines = [
self._header("rules"),
"#ACTION".ljust(16) + "SOURCE".ljust(24) + "DEST".ljust(24) + "PROTO".ljust(10) + "DPORT".ljust(10) + "SPORT\n",
"SECTION NEW\n",
]
for r in sorted(self._config.rules, key=lambda x: x.position):
src = (r.src_zone.name if r.src_zone else "all") + (f":{r.src_ip}" if r.src_ip else "")
@@ -48,6 +49,19 @@ class ShorewallGenerator:
lines.append(self._col(r.action, src, dst, r.proto or "-", r.dport or "-", r.sport or "-", width=16))
return "".join(lines)
def hosts(self) -> str:
lines = [self._header("hosts"), "#ZONE".ljust(16) + "HOSTS\n"]
for h in self._config.host_entries:
hosts_val = f"{h.interface}:{h.subnet}"
lines.append(self._col(h.zone.name, hosts_val, h.options or "-", width=16))
return "".join(lines)
def params(self) -> str:
lines = [self._header("params")]
for p in self._config.params:
lines.append(f"{p.name}={p.value}\n")
return "".join(lines)
def snat(self) -> str:
lines = [self._header("snat"), "#ACTION".ljust(24) + "SOURCE".ljust(24) + "DEST\n"]
for m in self._config.snat_entries:
@@ -62,6 +76,8 @@ class ShorewallGenerator:
"policy": self.policy(),
"rules": self.rules(),
"snat": self.snat(),
"hosts": self.hosts(),
"params": self.params(),
}
def as_zip(self) -> bytes:
@@ -72,4 +88,6 @@ class ShorewallGenerator:
zf.writestr("policy", self.policy())
zf.writestr("rules", self.rules())
zf.writestr("snat", self.snat())
zf.writestr("hosts", self.hosts())
zf.writestr("params", self.params())
return buf.getvalue()

View File

@@ -55,3 +55,5 @@ export const interfacesApi = nestedApi('interfaces')
export const policiesApi = nestedApi('policies')
export const rulesApi = nestedApi('rules')
export const snatApi = nestedApi('snat')
export const hostsApi = nestedApi('hosts')
export const paramsApi = nestedApi('params')

View File

@@ -19,6 +19,8 @@ interface GeneratedFiles {
policy: string
rules: string
snat: string
hosts: string
params: string
}
interface Props {
@@ -28,7 +30,7 @@ interface Props {
onClose: () => void
}
const TABS = ['zones', 'interfaces', 'policy', 'rules', 'snat'] as const
const TABS = ['zones', 'interfaces', 'policy', 'rules', 'snat', 'hosts', 'params'] as const
export default function GenerateModal({ open, configId, configName, onClose }: Props) {
const [tab, setTab] = useState(0)

View File

@@ -12,7 +12,7 @@ import Typography from '@mui/material/Typography'
import Breadcrumbs from '@mui/material/Breadcrumbs'
import AddIcon from '@mui/icons-material/Add'
import BuildIcon from '@mui/icons-material/Build'
import { zonesApi, interfacesApi, policiesApi, rulesApi, snatApi, configsApi } from '../api'
import { zonesApi, interfacesApi, policiesApi, rulesApi, snatApi, hostsApi, paramsApi, configsApi } from '../api'
// ---- Types ----
interface Zone { id: number; name: string; type: string; options: string }
@@ -20,6 +20,8 @@ interface Iface { id: number; name: string; zone_id: number; options: string }
interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number }
interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; comment: string; position: number }
interface Snat { id: number; source_network: string; out_interface: string; to_address: string; comment: string }
interface Host { id: number; zone_id: number; interface: string; subnet: string; options: string }
interface Param { id: number; name: string; value: string }
type AnyEntity = { id: number } & Record<string, unknown>
@@ -34,6 +36,8 @@ export default function ConfigDetail() {
const [policies, setPolicies] = useState<Policy[]>([])
const [rules, setRules] = useState<Rule[]>([])
const [snat, setSnat] = useState<Snat[]>([])
const [hosts, setHosts] = useState<Host[]>([])
const [paramsList, setParamsList] = useState<Param[]>([])
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AnyEntity | null>(null)
const [generateOpen, setGenerateOpen] = useState(false)
@@ -45,6 +49,8 @@ export default function ConfigDetail() {
policiesApi.list(configId).then((r) => setPolicies(r.data))
rulesApi.list(configId).then((r) => setRules(r.data))
snatApi.list(configId).then((r) => setSnat(r.data))
hostsApi.list(configId).then((r) => setHosts(r.data))
paramsApi.list(configId).then((r) => setParamsList(r.data))
}, [configId])
const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name }))
@@ -168,6 +174,38 @@ export default function ConfigDetail() {
{ name: 'comment', label: 'Comment' },
] as FieldDef[],
},
{
label: 'Hosts',
rows: hosts as unknown as AnyEntity[],
setRows: setHosts as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: hostsApi,
columns: [
{ key: 'zone_id' as const, label: 'Zone' },
{ key: 'interface' as const, label: 'Interface' },
{ key: 'subnet' as const, label: 'Subnet' },
{ key: 'options' as const, label: 'Options' },
] as Column<AnyEntity>[],
fields: [
{ name: 'zone_id', label: 'Zone', type: 'select' as const, options: zoneOptions, required: true },
{ name: 'interface', label: 'Interface', required: true },
{ name: 'subnet', label: 'Subnet', required: true },
{ name: 'options', label: 'Options' },
] as FieldDef[],
},
{
label: 'Params',
rows: paramsList as unknown as AnyEntity[],
setRows: setParamsList as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: paramsApi,
columns: [
{ key: 'name' as const, label: 'Name' },
{ key: 'value' as const, label: 'Value' },
] as Column<AnyEntity>[],
fields: [
{ name: 'name', label: 'Name', required: true },
{ name: 'value', label: 'Value', required: true },
] as FieldDef[],
},
]
const current = tabConfig[tab]

View File

@@ -42,4 +42,4 @@ keycloak:
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
containers:
version: "0.007"
version: "0.008"