From 21d404229af8fd51fd295b2f0f47076a15759c35 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sun, 1 Mar 2026 01:42:28 +0100 Subject: [PATCH] feat: add hosts and params files, fix rules SECTION NEW header --- .../versions/0004_add_hosts_and_params.py | 37 +++++++++++ backend/app/api/configs.py | 2 + backend/app/api/hosts.py | 64 +++++++++++++++++++ backend/app/api/params.py | 64 +++++++++++++++++++ backend/app/main.py | 4 +- backend/app/models.py | 27 ++++++++ backend/app/schemas.py | 48 ++++++++++++++ backend/app/shorewall_generator.py | 18 ++++++ frontend/src/api.ts | 2 + frontend/src/components/GenerateModal.tsx | 4 +- frontend/src/routes/ConfigDetail.tsx | 40 +++++++++++- helm/shorefront/values.yaml | 2 +- 12 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0004_add_hosts_and_params.py create mode 100644 backend/app/api/hosts.py create mode 100644 backend/app/api/params.py diff --git a/backend/alembic/versions/0004_add_hosts_and_params.py b/backend/alembic/versions/0004_add_hosts_and_params.py new file mode 100644 index 0000000..835a8d0 --- /dev/null +++ b/backend/alembic/versions/0004_add_hosts_and_params.py @@ -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") diff --git a/backend/app/api/configs.py b/backend/app/api/configs.py index 8e8acf7..2f163de 100644 --- a/backend/app/api/configs.py +++ b/backend/app/api/configs.py @@ -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() diff --git a/backend/app/api/hosts.py b/backend/app/api/hosts.py new file mode 100644 index 0000000..b0f566a --- /dev/null +++ b/backend/app/api/hosts.py @@ -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() diff --git a/backend/app/api/params.py b/backend/app/api/params.py new file mode 100644 index 0000000..d08195f --- /dev/null +++ b/backend/app/api/params.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index baab9dd..e307272 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 9500635..ff3a3b9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0298f4f..2de38b6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/shorewall_generator.py b/backend/app/shorewall_generator.py index 49bc626..b51545f 100644 --- a/backend/app/shorewall_generator.py +++ b/backend/app/shorewall_generator.py @@ -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() diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 83e92e4..c187c08 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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') diff --git a/frontend/src/components/GenerateModal.tsx b/frontend/src/components/GenerateModal.tsx index 9565bfc..b37327e 100644 --- a/frontend/src/components/GenerateModal.tsx +++ b/frontend/src/components/GenerateModal.tsx @@ -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) diff --git a/frontend/src/routes/ConfigDetail.tsx b/frontend/src/routes/ConfigDetail.tsx index c656925..8337dc1 100644 --- a/frontend/src/routes/ConfigDetail.tsx +++ b/frontend/src/routes/ConfigDetail.tsx @@ -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 @@ -34,6 +36,8 @@ export default function ConfigDetail() { const [policies, setPolicies] = useState([]) const [rules, setRules] = useState([]) const [snat, setSnat] = useState([]) + const [hosts, setHosts] = useState([]) + const [paramsList, setParamsList] = useState([]) const [formOpen, setFormOpen] = useState(false) const [editing, setEditing] = useState(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>, + 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[], + 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>, + api: paramsApi, + columns: [ + { key: 'name' as const, label: 'Name' }, + { key: 'value' as const, label: 'Value' }, + ] as Column[], + fields: [ + { name: 'name', label: 'Name', required: true }, + { name: 'value', label: 'Value', required: true }, + ] as FieldDef[], + }, ] const current = tabConfig[tab] diff --git a/helm/shorefront/values.yaml b/helm/shorefront/values.yaml index bc77d6c..f35e3ec 100644 --- a/helm/shorefront/values.yaml +++ b/helm/shorefront/values.yaml @@ -42,4 +42,4 @@ keycloak: redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback containers: - version: "0.007" + version: "0.008"