Files
shorefront/docs/plans/2026-02-28-shorewall-manager.md

92 KiB

Shorefront — Shorewall Configuration Manager Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a production-ready web app to manage Shorewall firewall configurations with a FastAPI backend, React/TypeScript frontend, PostgreSQL database, Docker Compose for local dev, and Helm charts for Kubernetes deployment.

Architecture: Monorepo with backend/ and frontend/ as separate services. Nginx in the frontend container reverse-proxies /api to the backend. JWT stored in httpOnly cookies protects all config endpoints. A ShorewallGenerator class converts DB records to Shorewall text files and bundles them as JSON or ZIP.

Tech Stack: Python 3.12, FastAPI, SQLAlchemy 2.x, Alembic, passlib/bcrypt, python-jose; React 18, TypeScript, Vite, MUI v5, React Router v6, Axios; PostgreSQL 15; Docker Compose; Helm 3 / Kubernetes / Traefik.


Phase 1: Infrastructure & Scaffolding

Task 1: Create project directory structure and Docker Compose

Files:

  • Create: docker-compose.yml
  • Create: backend/.env.example
  • Create: frontend/.env.example

Step 1: Create directory skeleton

mkdir -p backend/app/api backend/alembic/versions frontend/src/{routes,components} helm/shorefront/templates docs/plans

Step 2: Write docker-compose.yml

# docker-compose.yml
version: "3.9"

services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: shorefront
      POSTGRES_USER: shorefront
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shorefront"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - shorefront-net

  backend:
    build: ./backend
    environment:
      DATABASE_URL: postgresql://shorefront:${POSTGRES_PASSWORD:-changeme}@postgres:5432/shorefront
      JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret-change-me}
      JWT_ALGORITHM: HS256
      JWT_EXPIRE_MINUTES: 60
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "8000:8000"
    networks:
      - shorefront-net

  frontend:
    build: ./frontend
    ports:
      - "80:80"
    depends_on:
      - backend
    networks:
      - shorefront-net

volumes:
  postgres_data:

networks:
  shorefront-net:
    driver: bridge

Step 3: Write backend/.env.example

POSTGRES_PASSWORD=changeme
JWT_SECRET_KEY=change-this-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=60

Step 4: Write frontend/.env.example

VITE_API_BASE_URL=/api

Step 5: Commit

git init
git add docker-compose.yml backend/.env.example frontend/.env.example
git commit -m "feat: add project skeleton and docker-compose"

Task 2: Backend Dockerfile and requirements.txt

Files:

  • Create: backend/Dockerfile
  • Create: backend/requirements.txt

Step 1: Write requirements.txt

fastapi==0.111.0
uvicorn[standard]==0.30.1
sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
pydantic[email]==2.7.1
pydantic-settings==2.2.1

Step 2: Write backend/Dockerfile

FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

Step 3: Commit

git add backend/Dockerfile backend/requirements.txt
git commit -m "feat: add backend Dockerfile and requirements"

Task 3: Frontend Dockerfile, package.json, Vite config, and Nginx config

Files:

  • Create: frontend/Dockerfile
  • Create: frontend/package.json
  • Create: frontend/tsconfig.json
  • Create: frontend/vite.config.ts
  • Create: frontend/nginx.conf
  • Create: frontend/index.html

Step 1: Write package.json

{
  "name": "shorefront",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@emotion/react": "^11.11.4",
    "@emotion/styled": "^11.11.5",
    "@mui/icons-material": "^5.15.18",
    "@mui/material": "^5.15.18",
    "axios": "^1.7.2",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^6.23.1"
  },
  "devDependencies": {
    "@types/react": "^18.3.2",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.2.1",
    "typescript": "^5.4.5",
    "vite": "^5.2.11"
  }
}

Step 2: Write tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

Step 3: Write vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
})

Step 4: Write nginx.conf

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://backend:8000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Step 5: Write frontend/Dockerfile

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

Step 6: Write index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Shorefront</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Step 7: Commit

git add frontend/
git commit -m "feat: add frontend Dockerfile, Vite config, and Nginx config"

Phase 2: Backend Core

Task 4: Database setup and SQLAlchemy models

Files:

  • Create: backend/app/database.py
  • Create: backend/app/models.py

Step 1: Write backend/app/database.py

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str
    jwt_secret_key: str
    jwt_algorithm: str = "HS256"
    jwt_expire_minutes: int = 60

    class Config:
        env_file = ".env"


settings = Settings()

engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Step 2: Write backend/app/models.py

from datetime import datetime
from sqlalchemy import (
    Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
    hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
    configs: Mapped[list["Config"]] = relationship("Config", back_populates="owner")


class Config(Base):
    __tablename__ = "configs"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(128), nullable=False)
    description: Mapped[str] = mapped_column(Text, default="")
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
    owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)

    owner: Mapped["User"] = relationship("User", back_populates="configs")
    zones: Mapped[list["Zone"]] = relationship("Zone", back_populates="config", cascade="all, delete-orphan")
    interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="config", cascade="all, delete-orphan")
    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")
    masq_entries: Mapped[list["Masq"]] = relationship("Masq", back_populates="config", cascade="all, delete-orphan")


class Zone(Base):
    __tablename__ = "zones"
    __table_args__ = (UniqueConstraint("config_id", "name", name="uq_zone_name_per_config"),)

    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(32), nullable=False)
    type: Mapped[str] = mapped_column(String(16), nullable=False)  # ipv4, ipv6, firewall
    options: Mapped[str] = mapped_column(Text, default="")

    config: Mapped["Config"] = relationship("Config", back_populates="zones")
    interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="zone")


class Interface(Base):
    __tablename__ = "interfaces"

    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(32), nullable=False)
    zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
    options: Mapped[str] = mapped_column(Text, default="")

    config: Mapped["Config"] = relationship("Config", back_populates="interfaces")
    zone: Mapped["Zone"] = relationship("Zone", back_populates="interfaces")


class Policy(Base):
    __tablename__ = "policies"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
    src_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
    dst_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
    policy: Mapped[str] = mapped_column(String(16), nullable=False)  # ACCEPT, DROP, REJECT, CONTINUE
    log_level: Mapped[str] = mapped_column(String(16), default="")
    comment: Mapped[str] = mapped_column(Text, default="")
    position: Mapped[int] = mapped_column(Integer, default=0)

    config: Mapped["Config"] = relationship("Config", back_populates="policies")
    src_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[src_zone_id])
    dst_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[dst_zone_id])


class Rule(Base):
    __tablename__ = "rules"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
    action: Mapped[str] = mapped_column(String(32), nullable=False)
    src_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
    dst_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
    src_ip: Mapped[str] = mapped_column(String(64), default="")
    dst_ip: Mapped[str] = mapped_column(String(64), default="")
    proto: Mapped[str] = mapped_column(String(16), default="")
    dport: Mapped[str] = mapped_column(String(64), default="")
    sport: Mapped[str] = mapped_column(String(64), default="")
    comment: Mapped[str] = mapped_column(Text, default="")
    position: Mapped[int] = mapped_column(Integer, default=0)

    config: Mapped["Config"] = relationship("Config", back_populates="rules")
    src_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[src_zone_id])
    dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])


class Masq(Base):
    __tablename__ = "masq"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
    source_network: Mapped[str] = mapped_column(String(64), nullable=False)
    out_interface: Mapped[str] = mapped_column(String(32), nullable=False)
    to_address: Mapped[str] = mapped_column(String(64), default="")
    comment: Mapped[str] = mapped_column(Text, default="")

    config: Mapped["Config"] = relationship("Config", back_populates="masq_entries")

Step 3: Commit

git add backend/app/database.py backend/app/models.py
git commit -m "feat: add SQLAlchemy models and database setup"

Task 5: Alembic setup and initial migration with seed data

Files:

  • Create: backend/alembic.ini
  • Create: backend/alembic/env.py
  • Create: backend/alembic/versions/0001_initial.py

Step 1: Write alembic.ini

[alembic]
script_location = alembic
sqlalchemy.url = postgresql://shorefront:changeme@localhost:5432/shorefront

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

Step 2: Write backend/alembic/env.py

import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context

config = context.config

if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# Override sqlalchemy.url from environment
database_url = os.environ.get("DATABASE_URL")
if database_url:
    config.set_main_option("sqlalchemy.url", database_url)

from app.models import Base  # noqa: E402
target_metadata = Base.metadata


def run_migrations_offline() -> None:
    url = config.get_main_option("sqlalchemy.url")
    context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online() -> None:
    connectable = engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    with connectable.connect() as connection:
        context.configure(connection=connection, target_metadata=target_metadata)
        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

Step 3: Write backend/alembic/versions/0001_initial.py

"""initial schema and seed data

Revision ID: 0001
Revises:
Create Date: 2026-02-28
"""
from alembic import op
import sqlalchemy as sa
from passlib.context import CryptContext

revision = "0001"
down_revision = None
branch_labels = None
depends_on = None

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def upgrade() -> None:
    # --- Schema ---
    op.create_table(
        "users",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("username", sa.String(64), nullable=False, unique=True),
        sa.Column("email", sa.String(255), nullable=False, unique=True),
        sa.Column("hashed_password", sa.String(255), nullable=False),
        sa.Column("is_active", sa.Boolean, nullable=False, default=True),
    )
    op.create_table(
        "configs",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("name", sa.String(128), nullable=False),
        sa.Column("description", sa.Text, default=""),
        sa.Column("is_active", sa.Boolean, default=True),
        sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
        sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
        sa.Column("owner_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
    )
    op.create_table(
        "zones",
        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(32), nullable=False),
        sa.Column("type", sa.String(16), nullable=False),
        sa.Column("options", sa.Text, default=""),
        sa.UniqueConstraint("config_id", "name", name="uq_zone_name_per_config"),
    )
    op.create_table(
        "interfaces",
        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(32), nullable=False),
        sa.Column("zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
        sa.Column("options", sa.Text, default=""),
    )
    op.create_table(
        "policies",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
        sa.Column("src_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
        sa.Column("dst_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
        sa.Column("policy", sa.String(16), nullable=False),
        sa.Column("log_level", sa.String(16), default=""),
        sa.Column("comment", sa.Text, default=""),
        sa.Column("position", sa.Integer, default=0),
    )
    op.create_table(
        "rules",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
        sa.Column("action", sa.String(32), nullable=False),
        sa.Column("src_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=True),
        sa.Column("dst_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=True),
        sa.Column("src_ip", sa.String(64), default=""),
        sa.Column("dst_ip", sa.String(64), default=""),
        sa.Column("proto", sa.String(16), default=""),
        sa.Column("dport", sa.String(64), default=""),
        sa.Column("sport", sa.String(64), default=""),
        sa.Column("comment", sa.Text, default=""),
        sa.Column("position", sa.Integer, default=0),
    )
    op.create_table(
        "masq",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
        sa.Column("source_network", sa.String(64), nullable=False),
        sa.Column("out_interface", sa.String(32), nullable=False),
        sa.Column("to_address", sa.String(64), default=""),
        sa.Column("comment", sa.Text, default=""),
    )

    # --- Seed data ---
    conn = op.get_bind()

    # Admin user
    conn.execute(
        sa.text(
            "INSERT INTO users (username, email, hashed_password, is_active) "
            "VALUES (:u, :e, :p, true)"
        ),
        {"u": "admin", "e": "admin@localhost", "p": pwd_context.hash("admin")},
    )
    user_id = conn.execute(sa.text("SELECT id FROM users WHERE username='admin'")).scalar()

    # Sample config
    conn.execute(
        sa.text(
            "INSERT INTO configs (name, description, is_active, owner_id) "
            "VALUES (:n, :d, true, :o)"
        ),
        {"n": "homelab", "d": "Sample homelab Shorewall config", "o": user_id},
    )
    config_id = conn.execute(sa.text("SELECT id FROM configs WHERE name='homelab'")).scalar()

    # Zones
    for z_name, z_type in [("fw", "firewall"), ("net", "ipv4"), ("loc", "ipv4")]:
        conn.execute(
            sa.text("INSERT INTO zones (config_id, name, type, options) VALUES (:c, :n, :t, '')"),
            {"c": config_id, "n": z_name, "t": z_type},
        )
    fw_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='fw'"), {"c": config_id}).scalar()
    net_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='net'"), {"c": config_id}).scalar()
    loc_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='loc'"), {"c": config_id}).scalar()

    # Interface
    conn.execute(
        sa.text("INSERT INTO interfaces (config_id, name, zone_id, options) VALUES (:c, :n, :z, '')"),
        {"c": config_id, "n": "eth0", "z": net_id},
    )

    # Policies
    policies = [
        (loc_id, net_id, "ACCEPT", "", "loc to net", 1),
        (net_id, fw_id, "DROP", "info", "net to fw", 2),
        (net_id, loc_id, "DROP", "info", "net to loc", 3),
        (fw_id, net_id, "ACCEPT", "", "fw to net", 4),
        (fw_id, loc_id, "ACCEPT", "", "fw to loc", 5),
    ]
    for src, dst, pol, log, comment, pos in policies:
        conn.execute(
            sa.text(
                "INSERT INTO policies (config_id, src_zone_id, dst_zone_id, policy, log_level, comment, position) "
                "VALUES (:c, :s, :d, :p, :l, :cm, :pos)"
            ),
            {"c": config_id, "s": src, "d": dst, "p": pol, "l": log, "cm": comment, "pos": pos},
        )

    # Masq
    conn.execute(
        sa.text("INSERT INTO masq (config_id, source_network, out_interface, to_address, comment) VALUES (:c, :s, :o, '', :cm)"),
        {"c": config_id, "s": "192.168.1.0/24", "o": "eth0", "cm": "LAN masquerade"},
    )


def downgrade() -> None:
    op.drop_table("masq")
    op.drop_table("rules")
    op.drop_table("policies")
    op.drop_table("interfaces")
    op.drop_table("zones")
    op.drop_table("configs")
    op.drop_table("users")

Step 4: Commit

git add backend/alembic/ backend/alembic.ini
git commit -m "feat: add Alembic migration with schema and seed data"

Task 6: Auth module (JWT + password hashing)

Files:

  • Create: backend/app/auth.py

Step 1: Write backend/app/auth.py

from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Cookie, HTTPException, status, Depends
from sqlalchemy.orm import Session
from app.database import get_db, settings
from app import models

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(password: str) -> str:
    return pwd_context.hash(password)


def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)


def create_access_token(user_id: int) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
    return jwt.encode(
        {"sub": str(user_id), "exp": expire},
        settings.jwt_secret_key,
        algorithm=settings.jwt_algorithm,
    )


def get_current_user(
    access_token: Optional[str] = Cookie(default=None),
    db: Session = Depends(get_db),
) -> models.User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Not authenticated",
    )
    if not access_token:
        raise credentials_exception
    try:
        payload = jwt.decode(access_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
        user_id: str = payload.get("sub")
        if user_id is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = db.get(models.User, int(user_id))
    if user is None or not user.is_active:
        raise credentials_exception
    return user

Step 2: Commit

git add backend/app/auth.py
git commit -m "feat: add JWT auth module"

Task 7: Pydantic schemas

Files:

  • Create: backend/app/schemas.py

Step 1: Write backend/app/schemas.py

from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr


# --- Auth ---
class UserCreate(BaseModel):
    username: str
    email: EmailStr
    password: str


class UserOut(BaseModel):
    id: int
    username: str
    email: str
    is_active: bool

    model_config = {"from_attributes": True}


class LoginRequest(BaseModel):
    username: str
    password: str


# --- Config ---
class ConfigCreate(BaseModel):
    name: str
    description: str = ""
    is_active: bool = True


class ConfigUpdate(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    is_active: Optional[bool] = None


class ConfigOut(BaseModel):
    id: int
    name: str
    description: str
    is_active: bool
    created_at: datetime
    updated_at: datetime
    owner_id: int

    model_config = {"from_attributes": True}


# --- Zone ---
class ZoneCreate(BaseModel):
    name: str
    type: str
    options: str = ""


class ZoneUpdate(BaseModel):
    name: Optional[str] = None
    type: Optional[str] = None
    options: Optional[str] = None


class ZoneOut(BaseModel):
    id: int
    config_id: int
    name: str
    type: str
    options: str

    model_config = {"from_attributes": True}


# --- Interface ---
class InterfaceCreate(BaseModel):
    name: str
    zone_id: int
    options: str = ""


class InterfaceUpdate(BaseModel):
    name: Optional[str] = None
    zone_id: Optional[int] = None
    options: Optional[str] = None


class InterfaceOut(BaseModel):
    id: int
    config_id: int
    name: str
    zone_id: int
    options: str

    model_config = {"from_attributes": True}


# --- Policy ---
class PolicyCreate(BaseModel):
    src_zone_id: int
    dst_zone_id: int
    policy: str
    log_level: str = ""
    comment: str = ""
    position: int = 0


class PolicyUpdate(BaseModel):
    src_zone_id: Optional[int] = None
    dst_zone_id: Optional[int] = None
    policy: Optional[str] = None
    log_level: Optional[str] = None
    comment: Optional[str] = None
    position: Optional[int] = None


class PolicyOut(BaseModel):
    id: int
    config_id: int
    src_zone_id: int
    dst_zone_id: int
    policy: str
    log_level: str
    comment: str
    position: int

    model_config = {"from_attributes": True}


# --- Rule ---
class RuleCreate(BaseModel):
    action: str
    src_zone_id: Optional[int] = None
    dst_zone_id: Optional[int] = None
    src_ip: str = ""
    dst_ip: str = ""
    proto: str = ""
    dport: str = ""
    sport: str = ""
    comment: str = ""
    position: int = 0


class RuleUpdate(BaseModel):
    action: Optional[str] = None
    src_zone_id: Optional[int] = None
    dst_zone_id: Optional[int] = None
    src_ip: Optional[str] = None
    dst_ip: Optional[str] = None
    proto: Optional[str] = None
    dport: Optional[str] = None
    sport: Optional[str] = None
    comment: Optional[str] = None
    position: Optional[int] = None


class RuleOut(BaseModel):
    id: int
    config_id: int
    action: str
    src_zone_id: Optional[int]
    dst_zone_id: Optional[int]
    src_ip: str
    dst_ip: str
    proto: str
    dport: str
    sport: str
    comment: str
    position: int

    model_config = {"from_attributes": True}


# --- Masq ---
class MasqCreate(BaseModel):
    source_network: str
    out_interface: str
    to_address: str = ""
    comment: str = ""


class MasqUpdate(BaseModel):
    source_network: Optional[str] = None
    out_interface: Optional[str] = None
    to_address: Optional[str] = None
    comment: Optional[str] = None


class MasqOut(BaseModel):
    id: int
    config_id: int
    source_network: str
    out_interface: str
    to_address: str
    comment: str

    model_config = {"from_attributes": True}


# --- Generate ---
class GenerateOut(BaseModel):
    zones: str
    interfaces: str
    policy: str
    rules: str
    masq: str

Step 2: Commit

git add backend/app/schemas.py
git commit -m "feat: add Pydantic schemas"

Task 8: FastAPI main app

Files:

  • Create: backend/app/main.py
  • Create: backend/app/__init__.py
  • Create: backend/app/api/__init__.py

Step 1: Write backend/app/main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, masq

app = FastAPI(title="Shorefront", version="0.1.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://localhost:80"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(configs.router, prefix="/configs", tags=["configs"])
app.include_router(zones.router, prefix="/configs", tags=["zones"])
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(masq.router, prefix="/configs", tags=["masq"])


@app.get("/health")
def health() -> dict:
    return {"status": "ok"}

Step 2: Create __init__.py files

Create empty files at:

  • backend/app/__init__.py
  • backend/app/api/__init__.py

Step 3: Commit

git add backend/app/main.py backend/app/__init__.py backend/app/api/__init__.py
git commit -m "feat: add FastAPI app entrypoint"

Phase 3: Backend API Routers

Task 9: Auth router

Files:

  • Create: backend/app/api/auth.py

Step 1: Write backend/app/api/auth.py

from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import create_access_token, get_current_user, hash_password, verify_password
from app.database import get_db

router = APIRouter()


@router.post("/register", response_model=schemas.UserOut, status_code=201)
def register(body: schemas.UserCreate, db: Session = Depends(get_db)) -> models.User:
    if db.query(models.User).filter(models.User.username == body.username).first():
        raise HTTPException(status_code=400, detail="Username already registered")
    if db.query(models.User).filter(models.User.email == body.email).first():
        raise HTTPException(status_code=400, detail="Email already registered")
    user = models.User(
        username=body.username,
        email=body.email,
        hashed_password=hash_password(body.password),
    )
    db.add(user)
    db.commit()
    db.refresh(user)
    return user


@router.post("/login")
def login(body: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)) -> dict:
    user = db.query(models.User).filter(models.User.username == body.username).first()
    if not user or not verify_password(body.password, user.hashed_password):
        raise HTTPException(status_code=401, detail="Invalid credentials")
    token = create_access_token(user.id)
    response.set_cookie(
        key="access_token",
        value=token,
        httponly=True,
        samesite="lax",
        max_age=3600,
    )
    return {"message": "Logged in"}


@router.post("/logout")
def logout(response: Response) -> dict:
    response.delete_cookie("access_token")
    return {"message": "Logged out"}


@router.get("/me", response_model=schemas.UserOut)
def me(current_user: models.User = Depends(get_current_user)) -> models.User:
    return current_user

Step 2: Commit

git add backend/app/api/auth.py
git commit -m "feat: add auth router (register/login/logout/me)"

Task 10: Configs router (CRUD + generate)

Files:

  • Create: backend/app/api/configs.py

Step 1: Write backend/app/api/configs.py

from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, selectinload
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
from app.shorewall_generator import ShorewallGenerator
import io

router = APIRouter()


def _get_config_or_404(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("", response_model=list[schemas.ConfigOut])
def list_configs(
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user),
) -> list[models.Config]:
    return db.query(models.Config).filter(models.Config.owner_id == current_user.id).all()


@router.post("", response_model=schemas.ConfigOut, status_code=201)
def create_config(
    body: schemas.ConfigCreate,
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user),
) -> models.Config:
    config = models.Config(**body.model_dump(), owner_id=current_user.id)
    db.add(config)
    db.commit()
    db.refresh(config)
    return config


@router.get("/{config_id}", response_model=schemas.ConfigOut)
def get_config(
    config_id: int,
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user),
) -> models.Config:
    return _get_config_or_404(config_id, db, current_user)


@router.put("/{config_id}", response_model=schemas.ConfigOut)
def update_config(
    config_id: int,
    body: schemas.ConfigUpdate,
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user),
) -> models.Config:
    config = _get_config_or_404(config_id, db, current_user)
    for field, value in body.model_dump(exclude_none=True).items():
        setattr(config, field, value)
    db.commit()
    db.refresh(config)
    return config


@router.delete("/{config_id}", status_code=204)
def delete_config(
    config_id: int,
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user),
) -> None:
    config = _get_config_or_404(config_id, db, current_user)
    db.delete(config)
    db.commit()


@router.post("/{config_id}/generate")
def generate_config(
    config_id: int,
    format: str = "json",
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user),
) -> Response:
    config = (
        db.query(models.Config)
        .options(
            selectinload(models.Config.zones),
            selectinload(models.Config.interfaces).selectinload(models.Interface.zone),
            selectinload(models.Config.policies).selectinload(models.Policy.src_zone),
            selectinload(models.Config.policies).selectinload(models.Policy.dst_zone),
            selectinload(models.Config.rules).selectinload(models.Rule.src_zone),
            selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
            selectinload(models.Config.masq_entries),
        )
        .filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
        .first()
    )
    if not config:
        raise HTTPException(status_code=404, detail="Config not found")

    generator = ShorewallGenerator(config)

    if format == "zip":
        zip_bytes = generator.as_zip()
        return StreamingResponse(
            io.BytesIO(zip_bytes),
            media_type="application/zip",
            headers={"Content-Disposition": f"attachment; filename={config.name}-shorewall.zip"},
        )

    return generator.as_json()

Step 2: Commit

git add backend/app/api/configs.py
git commit -m "feat: add configs CRUD router with generate endpoint"

Task 11: Nested resource routers (zones, interfaces, policies, rules, masq)

Files:

  • Create: backend/app/api/zones.py
  • Create: backend/app/api/interfaces.py
  • Create: backend/app/api/policies.py
  • Create: backend/app/api/rules.py
  • Create: backend/app/api/masq.py

Step 1: Write backend/app/api/zones.py

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}/zones", response_model=list[schemas.ZoneOut])
def list_zones(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.Zone).filter(models.Zone.config_id == config_id).all()


@router.post("/{config_id}/zones", response_model=schemas.ZoneOut, status_code=201)
def create_zone(config_id: int, body: schemas.ZoneCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
    _owner_config(config_id, db, user)
    zone = models.Zone(**body.model_dump(), config_id=config_id)
    db.add(zone)
    db.commit()
    db.refresh(zone)
    return zone


@router.get("/{config_id}/zones/{zone_id}", response_model=schemas.ZoneOut)
def get_zone(config_id: int, zone_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
    _owner_config(config_id, db, user)
    zone = db.query(models.Zone).filter(models.Zone.id == zone_id, models.Zone.config_id == config_id).first()
    if not zone:
        raise HTTPException(status_code=404, detail="Zone not found")
    return zone


@router.put("/{config_id}/zones/{zone_id}", response_model=schemas.ZoneOut)
def update_zone(config_id: int, zone_id: int, body: schemas.ZoneUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
    _owner_config(config_id, db, user)
    zone = db.query(models.Zone).filter(models.Zone.id == zone_id, models.Zone.config_id == config_id).first()
    if not zone:
        raise HTTPException(status_code=404, detail="Zone not found")
    for field, value in body.model_dump(exclude_none=True).items():
        setattr(zone, field, value)
    db.commit()
    db.refresh(zone)
    return zone


@router.delete("/{config_id}/zones/{zone_id}", status_code=204)
def delete_zone(config_id: int, zone_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
    _owner_config(config_id, db, user)
    zone = db.query(models.Zone).filter(models.Zone.id == zone_id, models.Zone.config_id == config_id).first()
    if not zone:
        raise HTTPException(status_code=404, detail="Zone not found")
    db.delete(zone)
    db.commit()

Step 2: Write backend/app/api/interfaces.py — same pattern as zones.py but for Interface model using schemas.InterfaceCreate/Update/Out. Fields: name, zone_id, options.

Step 3: Write backend/app/api/policies.py — same pattern for Policy model using schemas.PolicyCreate/Update/Out. Fields: src_zone_id, dst_zone_id, policy, log_level, comment, position.

Step 4: Write backend/app/api/rules.py — same pattern for Rule model using schemas.RuleCreate/Update/Out.

Step 5: Write backend/app/api/masq.py — same pattern for Masq model using schemas.MasqCreate/Update/Out. Fields: source_network, out_interface, to_address, comment.

Note: Each router follows the exact same CRUD pattern as zones.py. The implementing agent should follow that pattern precisely, substituting the correct model class and schema types.

Step 6: Commit

git add backend/app/api/
git commit -m "feat: add nested resource routers for zones, interfaces, policies, rules, masq"

Phase 4: Shorewall Generator

Task 12: ShorewallGenerator class

Files:

  • Create: backend/app/shorewall_generator.py

Step 1: Write backend/app/shorewall_generator.py

import io
import zipfile
from datetime import datetime, timezone
from app import models


class ShorewallGenerator:
    def __init__(self, config: models.Config) -> None:
        self._config = config
        self._ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

    def _header(self, filename: str) -> str:
        return (
            f"# {filename} — generated by shorefront "
            f"| config: {self._config.name} "
            f"| {self._ts}\n"
        )

    def _col(self, *values: str, width: int = 16) -> str:
        return "".join(v.ljust(width) for v in values).rstrip() + "\n"

    def zones(self) -> str:
        lines = [self._header("zones"), "#ZONE".ljust(16) + "TYPE".ljust(16) + "OPTIONS\n"]
        for z in self._config.zones:
            lines.append(self._col(z.name, z.type, z.options or "-"))
        return "".join(lines)

    def interfaces(self) -> str:
        lines = [self._header("interfaces"), "#ZONE".ljust(16) + "INTERFACE".ljust(16) + "OPTIONS\n"]
        for iface in self._config.interfaces:
            lines.append(self._col(iface.zone.name, iface.name, iface.options or "-"))
        return "".join(lines)

    def policy(self) -> str:
        lines = [self._header("policy"), "#SOURCE".ljust(16) + "DEST".ljust(16) + "POLICY".ljust(16) + "LOG LEVEL\n"]
        for p in sorted(self._config.policies, key=lambda x: x.position):
            lines.append(self._col(p.src_zone.name, p.dst_zone.name, p.policy, p.log_level or "-"))
        return "".join(lines)

    def rules(self) -> str:
        lines = [
            self._header("rules"),
            "#ACTION".ljust(16) + "SOURCE".ljust(24) + "DEST".ljust(24) + "PROTO".ljust(10) + "DPORT".ljust(10) + "SPORT\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 "")
            dst = (r.dst_zone.name if r.dst_zone else "all") + (f":{r.dst_ip}" if r.dst_ip else "")
            lines.append(self._col(r.action, src, dst, r.proto or "-", r.dport or "-", r.sport or "-", width=16))
        return "".join(lines)

    def masq(self) -> str:
        lines = [self._header("masq"), "#INTERFACE".ljust(24) + "SOURCE".ljust(24) + "ADDRESS\n"]
        for m in self._config.masq_entries:
            lines.append(self._col(m.out_interface, m.source_network, m.to_address or "-", width=24))
        return "".join(lines)

    def as_json(self) -> dict:
        return {
            "zones": self.zones(),
            "interfaces": self.interfaces(),
            "policy": self.policy(),
            "rules": self.rules(),
            "masq": self.masq(),
        }

    def as_zip(self) -> bytes:
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("zones", self.zones())
            zf.writestr("interfaces", self.interfaces())
            zf.writestr("policy", self.policy())
            zf.writestr("rules", self.rules())
            zf.writestr("masq", self.masq())
        return buf.getvalue()

Step 2: Commit

git add backend/app/shorewall_generator.py
git commit -m "feat: add ShorewallGenerator (zones, interfaces, policy, rules, masq, json, zip)"

Phase 5: Frontend

Task 13: Frontend entry point, App, theme, and router

Files:

  • Create: frontend/src/main.tsx
  • Create: frontend/src/App.tsx
  • Create: frontend/src/theme.ts

Step 1: Write frontend/src/theme.ts

import { createTheme } from '@mui/material/styles'

export const theme = createTheme({
  palette: {
    mode: 'light',
    primary: { main: '#3b82f6' },
    background: { default: '#f5f7fa', paper: '#ffffff' },
  },
  components: {
    MuiAppBar: { styleOverrides: { root: { backgroundColor: '#1a1f2e' } } },
    MuiDrawer: {
      styleOverrides: {
        paper: { backgroundColor: '#1a1f2e', color: '#e2e8f0', borderRight: 'none' },
      },
    },
  },
})

Step 2: Write frontend/src/main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { ThemeProvider } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
import { theme } from './theme'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <App />
    </ThemeProvider>
  </React.StrictMode>
)

Step 3: Write frontend/src/App.tsx

import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Login from './routes/Login'
import ConfigList from './routes/ConfigList'
import ConfigDetail from './routes/ConfigDetail'
import ProtectedRoute from './components/ProtectedRoute'

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<Login />} />
        <Route element={<ProtectedRoute />}>
          <Route path="/configs" element={<ConfigList />} />
          <Route path="/configs/:id" element={<ConfigDetail />} />
        </Route>
        <Route path="*" element={<Navigate to="/configs" replace />} />
      </Routes>
    </BrowserRouter>
  )
}

Step 4: Commit

git add frontend/src/main.tsx frontend/src/App.tsx frontend/src/theme.ts
git commit -m "feat: add frontend entry point, theme, and router"

Task 14: API client and auth store

Files:

  • Create: frontend/src/api.ts
  • Create: frontend/src/store/auth.ts

Step 1: Write frontend/src/api.ts

import axios from 'axios'

const api = axios.create({
  baseURL: '/api',
  withCredentials: true,
})

api.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401 && window.location.pathname !== '/login') {
      window.location.href = '/login'
    }
    return Promise.reject(err)
  }
)

export default api

// --- Auth ---
export const authApi = {
  login: (username: string, password: string) =>
    api.post('/auth/login', { username, password }),
  logout: () => api.post('/auth/logout'),
  me: () => api.get('/auth/me'),
  register: (username: string, email: string, password: string) =>
    api.post('/auth/register', { username, email, password }),
}

// --- Configs ---
export const configsApi = {
  list: () => api.get('/configs'),
  create: (data: object) => api.post('/configs', data),
  get: (id: number) => api.get(`/configs/${id}`),
  update: (id: number, data: object) => api.put(`/configs/${id}`, data),
  delete: (id: number) => api.delete(`/configs/${id}`),
  generate: (id: number, format: 'json' | 'zip' = 'json') =>
    api.post(`/configs/${id}/generate?format=${format}`, null, {
      responseType: format === 'zip' ? 'blob' : 'json',
    }),
}

// --- Nested resources (zones, interfaces, policies, rules, masq) ---
const nestedApi = (resource: string) => ({
  list: (configId: number) => api.get(`/configs/${configId}/${resource}`),
  create: (configId: number, data: object) => api.post(`/configs/${configId}/${resource}`, data),
  update: (configId: number, id: number, data: object) =>
    api.put(`/configs/${configId}/${resource}/${id}`, data),
  delete: (configId: number, id: number) =>
    api.delete(`/configs/${configId}/${resource}/${id}`),
})

export const zonesApi = nestedApi('zones')
export const interfacesApi = nestedApi('interfaces')
export const policiesApi = nestedApi('policies')
export const rulesApi = nestedApi('rules')
export const masqApi = nestedApi('masq')

Step 2: Write frontend/src/store/auth.ts

import { useState, useEffect } from 'react'
import { authApi } from '../api'

export interface User {
  id: number
  username: string
  email: string
  is_active: boolean
}

// Simple module-level state (no external lib needed)
let currentUser: User | null = null
const listeners = new Set<() => void>()

export function useAuth() {
  const [user, setUser] = useState<User | null>(currentUser)
  const [loading, setLoading] = useState(currentUser === null)

  useEffect(() => {
    const update = () => setUser(currentUser)
    listeners.add(update)
    if (currentUser === null) {
      authApi.me()
        .then((res) => { currentUser = res.data; listeners.forEach((l) => l()) })
        .catch(() => { currentUser = null; listeners.forEach((l) => l()) })
        .finally(() => setLoading(false))
    }
    return () => { listeners.delete(update) }
  }, [])

  const logout = async () => {
    await authApi.logout()
    currentUser = null
    listeners.forEach((l) => l())
    window.location.href = '/login'
  }

  return { user, loading, logout }
}

Step 3: Commit

git add frontend/src/api.ts frontend/src/store/
git commit -m "feat: add API client and auth store"

Task 15: Layout and ProtectedRoute components

Files:

  • Create: frontend/src/components/Layout.tsx
  • Create: frontend/src/components/ProtectedRoute.tsx

Step 1: Write frontend/src/components/ProtectedRoute.tsx

import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../store/auth'
import CircularProgress from '@mui/material/CircularProgress'
import Box from '@mui/material/Box'

export default function ProtectedRoute() {
  const { user, loading } = useAuth()
  if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>
  if (!user) return <Navigate to="/login" replace />
  return <Outlet />
}

Step 2: Write frontend/src/components/Layout.tsx

import { ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import Box from '@mui/material/Box'
import Drawer from '@mui/material/Drawer'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Typography from '@mui/material/Typography'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import DnsIcon from '@mui/icons-material/Dns'
import LogoutIcon from '@mui/icons-material/Logout'
import { useAuth } from '../store/auth'

const DRAWER_WIDTH = 240

interface Props { children: ReactNode; title: string }

export default function Layout({ children, title }: Props) {
  const navigate = useNavigate()
  const location = useLocation()
  const { user, logout } = useAuth()

  return (
    <Box sx={{ display: 'flex', minHeight: '100vh' }}>
      <Drawer variant="permanent" sx={{ width: DRAWER_WIDTH, '& .MuiDrawer-paper': { width: DRAWER_WIDTH } }}>
        <Box sx={{ px: 2, py: 3 }}>
          <Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 700, letterSpacing: 1 }}>
            Shorefront
          </Typography>
          <Typography variant="caption" sx={{ color: '#94a3b8' }}>
            Shorewall Manager
          </Typography>
        </Box>
        <Divider sx={{ borderColor: '#2d3748' }} />
        <List sx={{ flex: 1 }}>
          <ListItemButton
            selected={location.pathname.startsWith('/configs')}
            onClick={() => navigate('/configs')}
            sx={{ '&.Mui-selected': { backgroundColor: '#2d3748' }, '&:hover': { backgroundColor: '#2d3748' } }}
          >
            <ListItemIcon sx={{ color: '#94a3b8', minWidth: 36 }}><DnsIcon fontSize="small" /></ListItemIcon>
            <ListItemText primary="Configurations" primaryTypographyProps={{ sx: { color: '#e2e8f0', fontSize: 14 } }} />
          </ListItemButton>
        </List>
        <Divider sx={{ borderColor: '#2d3748' }} />
        <Box sx={{ px: 2, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <Typography variant="caption" sx={{ color: '#94a3b8' }}>{user?.username}</Typography>
          <Tooltip title="Logout">
            <IconButton onClick={logout} size="small" sx={{ color: '#94a3b8' }}><LogoutIcon fontSize="small" /></IconButton>
          </Tooltip>
        </Box>
      </Drawer>

      <Box component="main" sx={{ flex: 1, bgcolor: 'background.default' }}>
        <Box sx={{ px: 4, py: 3, bgcolor: 'white', borderBottom: '1px solid #e2e8f0' }}>
          <Typography variant="h5" fontWeight={600}>{title}</Typography>
        </Box>
        <Box sx={{ p: 4 }}>{children}</Box>
      </Box>
    </Box>
  )
}

Step 3: Commit

git add frontend/src/components/Layout.tsx frontend/src/components/ProtectedRoute.tsx
git commit -m "feat: add Layout and ProtectedRoute components"

Task 16: DataTable and EntityForm reusable components

Files:

  • Create: frontend/src/components/DataTable.tsx
  • Create: frontend/src/components/EntityForm.tsx

Step 1: Write frontend/src/components/DataTable.tsx

import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Paper from '@mui/material/Paper'
import IconButton from '@mui/material/IconButton'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import Typography from '@mui/material/Typography'

export interface Column<T> {
  key: keyof T
  label: string
  render?: (row: T) => React.ReactNode
}

interface Props<T extends { id: number }> {
  columns: Column<T>[]
  rows: T[]
  onEdit: (row: T) => void
  onDelete: (row: T) => void
}

export default function DataTable<T extends { id: number }>({ columns, rows, onEdit, onDelete }: Props<T>) {
  if (rows.length === 0) {
    return <Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No entries yet.</Typography>
  }
  return (
    <TableContainer component={Paper} variant="outlined">
      <Table size="small">
        <TableHead>
          <TableRow sx={{ bgcolor: '#f8fafc' }}>
            {columns.map((col) => (
              <TableCell key={String(col.key)} sx={{ fontWeight: 600, fontSize: 12, color: '#64748b' }}>
                {col.label}
              </TableCell>
            ))}
            <TableCell align="right" sx={{ fontWeight: 600, fontSize: 12, color: '#64748b' }}>Actions</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) => (
            <TableRow key={row.id} hover>
              {columns.map((col) => (
                <TableCell key={String(col.key)}>
                  {col.render ? col.render(row) : String(row[col.key] ?? '')}
                </TableCell>
              ))}
              <TableCell align="right">
                <IconButton size="small" onClick={() => onEdit(row)}><EditIcon fontSize="small" /></IconButton>
                <IconButton size="small" onClick={() => onDelete(row)} color="error"><DeleteIcon fontSize="small" /></IconButton>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  )
}

Step 2: Write frontend/src/components/EntityForm.tsx

import { useState, useEffect } from 'react'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
import MenuItem from '@mui/material/MenuItem'
import Stack from '@mui/material/Stack'

export interface FieldDef {
  name: string
  label: string
  required?: boolean
  type?: 'text' | 'select' | 'number'
  options?: { value: string | number; label: string }[]
}

interface Props {
  open: boolean
  title: string
  fields: FieldDef[]
  initialValues?: Record<string, unknown>
  onClose: () => void
  onSubmit: (values: Record<string, unknown>) => Promise<void>
}

export default function EntityForm({ open, title, fields, initialValues, onClose, onSubmit }: Props) {
  const [values, setValues] = useState<Record<string, unknown>>({})
  const [submitting, setSubmitting] = useState(false)

  useEffect(() => {
    if (open) setValues(initialValues ?? {})
  }, [open, initialValues])

  const handleChange = (name: string, value: unknown) => setValues((v) => ({ ...v, [name]: value }))

  const handleSubmit = async () => {
    setSubmitting(true)
    try { await onSubmit(values) } finally { setSubmitting(false) }
  }

  return (
    <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
      <DialogTitle>{title}</DialogTitle>
      <DialogContent>
        <Stack spacing={2} sx={{ mt: 1 }}>
          {fields.map((f) =>
            f.type === 'select' ? (
              <TextField
                key={f.name}
                select
                label={f.label}
                required={f.required}
                value={values[f.name] ?? ''}
                onChange={(e) => handleChange(f.name, e.target.value)}
                size="small"
              >
                {f.options?.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
              </TextField>
            ) : (
              <TextField
                key={f.name}
                label={f.label}
                required={f.required}
                type={f.type ?? 'text'}
                value={values[f.name] ?? ''}
                onChange={(e) => handleChange(f.name, e.target.value)}
                size="small"
              />
            )
          )}
        </Stack>
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose}>Cancel</Button>
        <Button variant="contained" onClick={handleSubmit} disabled={submitting}>
          {submitting ? 'Saving…' : 'Save'}
        </Button>
      </DialogActions>
    </Dialog>
  )
}

Step 3: Commit

git add frontend/src/components/DataTable.tsx frontend/src/components/EntityForm.tsx
git commit -m "feat: add reusable DataTable and EntityForm components"

Task 17: Login page

Files:

  • Create: frontend/src/routes/Login.tsx

Step 1: Write frontend/src/routes/Login.tsx

import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import TextField from '@mui/material/TextField'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import Alert from '@mui/material/Alert'
import { authApi } from '../api'

export default function Login() {
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const navigate = useNavigate()

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    setError('')
    try {
      await authApi.login(username, password)
      window.location.href = '/configs'
    } catch {
      setError('Invalid username or password')
    }
  }

  return (
    <Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#f5f7fa' }}>
      <Card sx={{ width: 380, boxShadow: 3 }}>
        <CardContent sx={{ p: 4 }}>
          <Typography variant="h5" fontWeight={700} gutterBottom>Shorefront</Typography>
          <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>Sign in to manage your Shorewall configs</Typography>
          {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
          <Box component="form" onSubmit={handleSubmit}>
            <TextField fullWidth label="Username" value={username} onChange={(e) => setUsername(e.target.value)} sx={{ mb: 2 }} size="small" required />
            <TextField fullWidth label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} sx={{ mb: 3 }} size="small" required />
            <Button type="submit" variant="contained" fullWidth>Sign In</Button>
          </Box>
        </CardContent>
      </Card>
    </Box>
  )
}

Step 2: Commit

git add frontend/src/routes/Login.tsx
git commit -m "feat: add Login page"

Task 18: Config List page

Files:

  • Create: frontend/src/routes/ConfigList.tsx

Step 1: Write frontend/src/routes/ConfigList.tsx

import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Layout from '../components/Layout'
import DataTable, { Column } from '../components/DataTable'
import EntityForm from '../components/EntityForm'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import AddIcon from '@mui/icons-material/Add'
import { configsApi } from '../api'

interface Config {
  id: number
  name: string
  description: string
  is_active: boolean
  created_at: string
}

const COLUMNS: Column<Config>[] = [
  { key: 'name', label: 'Name' },
  { key: 'description', label: 'Description' },
  { key: 'is_active', label: 'Status', render: (r) => <Chip label={r.is_active ? 'Active' : 'Inactive'} color={r.is_active ? 'success' : 'default'} size="small" /> },
  { key: 'created_at', label: 'Created', render: (r) => new Date(r.created_at).toLocaleDateString() },
]

const FIELDS = [
  { name: 'name', label: 'Name', required: true },
  { name: 'description', label: 'Description' },
]

export default function ConfigList() {
  const [configs, setConfigs] = useState<Config[]>([])
  const [formOpen, setFormOpen] = useState(false)
  const [editing, setEditing] = useState<Config | null>(null)
  const navigate = useNavigate()

  const load = () => configsApi.list().then((r) => setConfigs(r.data))
  useEffect(() => { load() }, [])

  const handleSubmit = async (values: Record<string, unknown>) => {
    if (editing) {
      await configsApi.update(editing.id, values)
    } else {
      await configsApi.create(values)
    }
    setFormOpen(false)
    setEditing(null)
    load()
  }

  const handleDelete = async (row: Config) => {
    if (!confirm(`Delete config "${row.name}"?`)) return
    await configsApi.delete(row.id)
    load()
  }

  return (
    <Layout title="Configurations">
      <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
        <Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditing(null); setFormOpen(true) }}>
          New Config
        </Button>
      </Box>
      <DataTable
        columns={COLUMNS}
        rows={configs}
        onEdit={(row) => { navigate(`/configs/${row.id}`) }}
        onDelete={handleDelete}
      />
      <EntityForm
        open={formOpen}
        title={editing ? 'Edit Config' : 'New Config'}
        fields={FIELDS}
        initialValues={editing ?? undefined}
        onClose={() => { setFormOpen(false); setEditing(null) }}
        onSubmit={handleSubmit}
      />
    </Layout>
  )
}

Step 2: Commit

git add frontend/src/routes/ConfigList.tsx
git commit -m "feat: add Config List page"

Task 19: GenerateModal component

Files:

  • Create: frontend/src/components/GenerateModal.tsx

Step 1: Write frontend/src/components/GenerateModal.tsx

import { useState } from 'react'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Button from '@mui/material/Button'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DownloadIcon from '@mui/icons-material/Download'
import { configsApi } from '../api'

interface GeneratedFiles {
  zones: string
  interfaces: string
  policy: string
  rules: string
  masq: string
}

interface Props {
  open: boolean
  configId: number
  configName: string
  onClose: () => void
}

const TABS = ['zones', 'interfaces', 'policy', 'rules', 'masq'] as const

export default function GenerateModal({ open, configId, configName, onClose }: Props) {
  const [tab, setTab] = useState(0)
  const [files, setFiles] = useState<GeneratedFiles | null>(null)
  const [loading, setLoading] = useState(false)

  const handleOpen = async () => {
    if (files) return
    setLoading(true)
    try {
      const res = await configsApi.generate(configId, 'json')
      setFiles(res.data)
    } finally {
      setLoading(false)
    }
  }

  const handleDownloadZip = async () => {
    const res = await configsApi.generate(configId, 'zip')
    const url = URL.createObjectURL(new Blob([res.data]))
    const a = document.createElement('a')
    a.href = url
    a.download = `${configName}-shorewall.zip`
    a.click()
    URL.revokeObjectURL(url)
  }

  const handleCopy = (text: string) => navigator.clipboard.writeText(text)

  if (open && !files && !loading) handleOpen()

  const currentFile = files ? files[TABS[tab]] : ''

  return (
    <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
      <DialogTitle>Generated Shorewall Config  {configName}</DialogTitle>
      <DialogContent sx={{ p: 0 }}>
        <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
          {TABS.map((t) => <Tab key={t} label={t} />)}
        </Tabs>
        <Box sx={{ position: 'relative', p: 2 }}>
          <Tooltip title="Copy">
            <IconButton size="small" sx={{ position: 'absolute', top: 16, right: 16 }} onClick={() => handleCopy(currentFile)}>
              <ContentCopyIcon fontSize="small" />
            </IconButton>
          </Tooltip>
          <Box
            component="pre"
            sx={{ fontFamily: 'monospace', fontSize: 13, bgcolor: '#1e293b', color: '#e2e8f0', p: 2, borderRadius: 1, overflowX: 'auto', minHeight: 300, whiteSpace: 'pre' }}
          >
            {loading ? 'Generating…' : currentFile}
          </Box>
        </Box>
      </DialogContent>
      <DialogActions>
        <Button startIcon={<DownloadIcon />} onClick={handleDownloadZip} variant="outlined">
          Download ZIP
        </Button>
        <Button onClick={onClose}>Close</Button>
      </DialogActions>
    </Dialog>
  )
}

Step 2: Commit

git add frontend/src/components/GenerateModal.tsx
git commit -m "feat: add GenerateModal component"

Task 20: Config Detail page (tabbed)

Files:

  • Create: frontend/src/routes/ConfigDetail.tsx

Step 1: Write frontend/src/routes/ConfigDetail.tsx

This is the largest frontend file. It contains:

  • Breadcrumb + config name header
  • "Generate Config" button (opens GenerateModal)
  • MUI Tabs: Zones / Interfaces / Policies / Rules / Masq/NAT
  • Each tab: DataTable + "Add" button → EntityForm
import { useState, useEffect, useCallback } from 'react'
import { useParams, Link } from 'react-router-dom'
import Layout from '../components/Layout'
import DataTable, { Column } from '../components/DataTable'
import EntityForm, { FieldDef } from '../components/EntityForm'
import GenerateModal from '../components/GenerateModal'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
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, masqApi, configsApi } from '../api'

// ---- Types ----
interface Zone { id: number; name: string; type: string; options: string }
interface Interface { 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 Masq { id: number; source_network: string; out_interface: string; to_address: string; comment: string }

export default function ConfigDetail() {
  const { id } = useParams<{ id: string }>()
  const configId = Number(id)

  const [configName, setConfigName] = useState('')
  const [tab, setTab] = useState(0)
  const [zones, setZones] = useState<Zone[]>([])
  const [interfaces, setInterfaces] = useState<Interface[]>([])
  const [policies, setPolicies] = useState<Policy[]>([])
  const [rules, setRules] = useState<Rule[]>([])
  const [masq, setMasq] = useState<Masq[]>([])
  const [formOpen, setFormOpen] = useState(false)
  const [editing, setEditing] = useState<Record<string, unknown> | null>(null)
  const [generateOpen, setGenerateOpen] = useState(false)

  useEffect(() => {
    configsApi.get(configId).then((r) => setConfigName(r.data.name))
    zonesApi.list(configId).then((r) => setZones(r.data))
    interfacesApi.list(configId).then((r) => setInterfaces(r.data))
    policiesApi.list(configId).then((r) => setPolicies(r.data))
    rulesApi.list(configId).then((r) => setRules(r.data))
    masqApi.list(configId).then((r) => setMasq(r.data))
  }, [configId])

  const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name }))

  // ---- Tab configs ----
  const tabConfig = [
    {
      label: 'Zones',
      rows: zones,
      setRows: setZones,
      api: zonesApi,
      columns: [
        { key: 'name' as const, label: 'Name' },
        { key: 'type' as const, label: 'Type' },
        { key: 'options' as const, label: 'Options' },
      ] as Column<Zone>[],
      fields: [
        { name: 'name', label: 'Name', required: true },
        { name: 'type', label: 'Type', required: true, type: 'select' as const, options: [{ value: 'ipv4', label: 'ipv4' }, { value: 'ipv6', label: 'ipv6' }, { value: 'firewall', label: 'firewall' }] },
        { name: 'options', label: 'Options' },
      ] as FieldDef[],
    },
    {
      label: 'Interfaces',
      rows: interfaces,
      setRows: setInterfaces,
      api: interfacesApi,
      columns: [
        { key: 'name' as const, label: 'Interface' },
        { key: 'zone_id' as const, label: 'Zone', render: (r: Interface) => zones.find((z) => z.id === r.zone_id)?.name ?? r.zone_id },
        { key: 'options' as const, label: 'Options' },
      ] as Column<Interface>[],
      fields: [
        { name: 'name', label: 'Interface Name', required: true },
        { name: 'zone_id', label: 'Zone', required: true, type: 'select' as const, options: zoneOptions },
        { name: 'options', label: 'Options' },
      ] as FieldDef[],
    },
    {
      label: 'Policies',
      rows: policies,
      setRows: setPolicies,
      api: policiesApi,
      columns: [
        { key: 'src_zone_id' as const, label: 'Source', render: (r: Policy) => zones.find((z) => z.id === r.src_zone_id)?.name ?? r.src_zone_id },
        { key: 'dst_zone_id' as const, label: 'Destination', render: (r: Policy) => zones.find((z) => z.id === r.dst_zone_id)?.name ?? r.dst_zone_id },
        { key: 'policy' as const, label: 'Policy' },
        { key: 'log_level' as const, label: 'Log Level' },
        { key: 'position' as const, label: 'Position' },
      ] as Column<Policy>[],
      fields: [
        { name: 'src_zone_id', label: 'Source Zone', required: true, type: 'select' as const, options: zoneOptions },
        { name: 'dst_zone_id', label: 'Destination Zone', required: true, type: 'select' as const, options: zoneOptions },
        { name: 'policy', label: 'Policy', required: true, type: 'select' as const, options: [{ value: 'ACCEPT', label: 'ACCEPT' }, { value: 'DROP', label: 'DROP' }, { value: 'REJECT', label: 'REJECT' }, { value: 'CONTINUE', label: 'CONTINUE' }] },
        { name: 'log_level', label: 'Log Level' },
        { name: 'comment', label: 'Comment' },
        { name: 'position', label: 'Position', type: 'number' as const },
      ] as FieldDef[],
    },
    {
      label: 'Rules',
      rows: rules,
      setRows: setRules,
      api: rulesApi,
      columns: [
        { key: 'action' as const, label: 'Action' },
        { key: 'src_zone_id' as const, label: 'Source', render: (r: Rule) => r.src_zone_id ? (zones.find((z) => z.id === r.src_zone_id)?.name ?? r.src_zone_id) : 'all' },
        { key: 'dst_zone_id' as const, label: 'Destination', render: (r: Rule) => r.dst_zone_id ? (zones.find((z) => z.id === r.dst_zone_id)?.name ?? r.dst_zone_id) : 'all' },
        { key: 'proto' as const, label: 'Proto' },
        { key: 'dport' as const, label: 'DPort' },
        { key: 'position' as const, label: 'Position' },
      ] as Column<Rule>[],
      fields: [
        { name: 'action', label: 'Action', required: true },
        { name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: zoneOptions },
        { name: 'dst_zone_id', label: 'Dest Zone', type: 'select' as const, options: zoneOptions },
        { name: 'src_ip', label: 'Source IP/CIDR' },
        { name: 'dst_ip', label: 'Dest IP/CIDR' },
        { name: 'proto', label: 'Protocol' },
        { name: 'dport', label: 'Dest Port' },
        { name: 'sport', label: 'Source Port' },
        { name: 'comment', label: 'Comment' },
        { name: 'position', label: 'Position', type: 'number' as const },
      ] as FieldDef[],
    },
    {
      label: 'Masq/NAT',
      rows: masq,
      setRows: setMasq,
      api: masqApi,
      columns: [
        { key: 'out_interface' as const, label: 'Out Interface' },
        { key: 'source_network' as const, label: 'Source Network' },
        { key: 'to_address' as const, label: 'To Address' },
        { key: 'comment' as const, label: 'Comment' },
      ] as Column<Masq>[],
      fields: [
        { name: 'out_interface', label: 'Out Interface', required: true },
        { name: 'source_network', label: 'Source Network', required: true },
        { name: 'to_address', label: 'To Address' },
        { name: 'comment', label: 'Comment' },
      ] as FieldDef[],
    },
  ]

  const current = tabConfig[tab]

  const handleSubmit = async (values: Record<string, unknown>) => {
    if (editing && editing.id) {
      await current.api.update(configId, editing.id as number, values)
    } else {
      await current.api.create(configId, values)
    }
    const res = await current.api.list(configId)
    current.setRows(res.data)
    setFormOpen(false)
    setEditing(null)
  }

  const handleDelete = async (row: { id: number }) => {
    if (!confirm('Delete this entry?')) return
    await current.api.delete(configId, row.id)
    const res = await current.api.list(configId)
    current.setRows(res.data)
  }

  return (
    <Layout title={configName || 'Config Detail'}>
      <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
        <Breadcrumbs>
          <Link to="/configs" style={{ color: 'inherit', textDecoration: 'none' }}>
            <Typography variant="body2" color="text.secondary">Configurations</Typography>
          </Link>
          <Typography variant="body2">{configName}</Typography>
        </Breadcrumbs>
        <Button variant="contained" startIcon={<BuildIcon />} onClick={() => setGenerateOpen(true)}>
          Generate Config
        </Button>
      </Box>

      <Box sx={{ bgcolor: 'white', borderRadius: 2, border: '1px solid #e2e8f0', overflow: 'hidden' }}>
        <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
          {tabConfig.map((tc) => <Tab key={tc.label} label={tc.label} />)}
        </Tabs>
        <Box sx={{ p: 3 }}>
          <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
            <Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => { setEditing(null); setFormOpen(true) }}>
              Add {current.label.replace('/NAT', '')}
            </Button>
          </Box>
          <DataTable
            columns={current.columns as Column<{ id: number }>[]}
            rows={current.rows as { id: number }[]}
            onEdit={(row) => { setEditing(row as Record<string, unknown>); setFormOpen(true) }}
            onDelete={handleDelete}
          />
        </Box>
      </Box>

      <EntityForm
        open={formOpen}
        title={`${editing ? 'Edit' : 'Add'} ${current.label}`}
        fields={current.fields}
        initialValues={editing ?? undefined}
        onClose={() => { setFormOpen(false); setEditing(null) }}
        onSubmit={handleSubmit}
      />

      <GenerateModal
        open={generateOpen}
        configId={configId}
        configName={configName}
        onClose={() => setGenerateOpen(false)}
      />
    </Layout>
  )
}

Step 2: Commit

git add frontend/src/routes/ConfigDetail.tsx
git commit -m "feat: add Config Detail page with tabbed entity management"

Phase 6: Helm Charts

Task 21: Helm chart scaffolding and values

Files:

  • Create: helm/shorefront/Chart.yaml
  • Create: helm/shorefront/values.yaml
  • Create: helm/shorefront/values-prod.yaml
  • Create: helm/shorefront/templates/_helpers.tpl

Step 1: Write helm/shorefront/Chart.yaml

apiVersion: v2
name: shorefront
description: Shorewall configuration manager
type: application
version: 0.1.0
appVersion: "0.1.0"

Step 2: Write helm/shorefront/values.yaml

namespace: shorefront

backend:
  image: shorefront-backend
  tag: latest
  replicas: 1
  resources:
    requests: { cpu: 100m, memory: 128Mi }
    limits: { cpu: 500m, memory: 512Mi }

frontend:
  image: shorefront-frontend
  tag: latest
  replicas: 1
  resources:
    requests: { cpu: 50m, memory: 64Mi }
    limits: { cpu: 200m, memory: 128Mi }

postgres:
  image: postgres
  tag: "15-alpine"
  database: shorefront
  user: shorefront
  resources:
    requests: { cpu: 100m, memory: 128Mi }
    limits: { cpu: 500m, memory: 512Mi }

nfs:
  server: 192.168.17.199
  path: /mnt/user/kubernetesdata/shorefront
  storage: 5Gi

ingress:
  host: shorefront.example.com
  ingressClassName: traefik

secrets:
  postgresPassword: changeme-in-prod
  jwtSecretKey: changeme-in-prod

Step 3: Write helm/shorefront/values-prod.yaml

ingress:
  host: shorefront.yourdomain.com

# Override secrets at deploy time:
# helm upgrade --install shorefront ./helm/shorefront \
#   --values helm/shorefront/values-prod.yaml \
#   --set secrets.postgresPassword=<real-password> \
#   --set secrets.jwtSecretKey=<real-jwt-secret>

Step 4: Write helm/shorefront/templates/_helpers.tpl

{{- define "shorefront.name" -}}
{{- .Release.Name }}
{{- end }}

{{- define "shorefront.labels" -}}
app.kubernetes.io/name: {{ include "shorefront.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

Step 5: Commit

git add helm/shorefront/Chart.yaml helm/shorefront/values.yaml helm/shorefront/values-prod.yaml helm/shorefront/templates/_helpers.tpl
git commit -m "feat: add Helm chart scaffolding and values"

Task 22: Helm templates — namespace, secret, configmap

Files:

  • Create: helm/shorefront/templates/namespace.yaml
  • Create: helm/shorefront/templates/secret.yaml
  • Create: helm/shorefront/templates/configmap.yaml

Step 1: Write namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  name: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}

Step 2: Write secret.yaml

apiVersion: v1
kind: Secret
metadata:
  name: shorefront-secret
  namespace: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
type: Opaque
stringData:
  POSTGRES_PASSWORD: {{ .Values.secrets.postgresPassword | quote }}
  JWT_SECRET_KEY: {{ .Values.secrets.jwtSecretKey | quote }}

Step 3: Write configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: shorefront-config
  namespace: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
data:
  POSTGRES_DB: {{ .Values.postgres.database | quote }}
  POSTGRES_USER: {{ .Values.postgres.user | quote }}
  DATABASE_URL: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
  JWT_ALGORITHM: "HS256"
  JWT_EXPIRE_MINUTES: "60"

Step 4: Commit

git add helm/shorefront/templates/namespace.yaml helm/shorefront/templates/secret.yaml helm/shorefront/templates/configmap.yaml
git commit -m "feat: add Helm namespace, secret, and configmap templates"

Task 23: Helm templates — NFS PV/PVC and Postgres

Files:

  • Create: helm/shorefront/templates/pv.yaml
  • Create: helm/shorefront/templates/pvc.yaml
  • Create: helm/shorefront/templates/postgres-deployment.yaml
  • Create: helm/shorefront/templates/postgres-service.yaml

Step 1: Write pv.yaml

apiVersion: v1
kind: PersistentVolume
metadata:
  name: shorefront-postgres-pv
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
spec:
  capacity:
    storage: {{ .Values.nfs.storage }}
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  nfs:
    server: {{ .Values.nfs.server }}
    path: {{ .Values.nfs.path }}

Step 2: Write pvc.yaml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: shorefront-postgres-pvc
  namespace: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: ""
  volumeName: shorefront-postgres-pv
  resources:
    requests:
      storage: {{ .Values.nfs.storage }}

Step 3: Write postgres-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
    app: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: "{{ .Values.postgres.image }}:{{ .Values.postgres.tag }}"
          env:
            - name: POSTGRES_DB
              valueFrom:
                configMapKeyRef:
                  name: shorefront-config
                  key: POSTGRES_DB
            - name: POSTGRES_USER
              valueFrom:
                configMapKeyRef:
                  name: shorefront-config
                  key: POSTGRES_USER
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: shorefront-secret
                  key: POSTGRES_PASSWORD
          ports:
            - containerPort: 5432
          volumeMounts:
            - name: pgdata
              mountPath: /var/lib/postgresql/data
          resources:
            {{- toYaml .Values.postgres.resources | nindent 12 }}
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "{{ .Values.postgres.user }}"]
            initialDelaySeconds: 5
            periodSeconds: 5
      volumes:
        - name: pgdata
          persistentVolumeClaim:
            claimName: shorefront-postgres-pvc

Step 4: Write postgres-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: {{ .Values.namespace }}
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432
  type: ClusterIP

Step 5: Commit

git add helm/shorefront/templates/pv.yaml helm/shorefront/templates/pvc.yaml helm/shorefront/templates/postgres-deployment.yaml helm/shorefront/templates/postgres-service.yaml
git commit -m "feat: add Helm PV/PVC and Postgres deployment templates"

Task 24: Helm templates — backend and frontend deployments + ingress

Files:

  • Create: helm/shorefront/templates/backend-deployment.yaml
  • Create: helm/shorefront/templates/backend-service.yaml
  • Create: helm/shorefront/templates/frontend-deployment.yaml
  • Create: helm/shorefront/templates/frontend-service.yaml
  • Create: helm/shorefront/templates/ingress.yaml

Step 1: Write backend-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
  namespace: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
    app: backend
spec:
  replicas: {{ .Values.backend.replicas }}
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      initContainers:
        - name: migrate
          image: "{{ .Values.backend.image }}:{{ .Values.backend.tag }}"
          command: ["alembic", "upgrade", "head"]
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: shorefront-secret
                  key: POSTGRES_PASSWORD
            - name: DATABASE_URL
              value: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
      containers:
        - name: backend
          image: "{{ .Values.backend.image }}:{{ .Values.backend.tag }}"
          command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: shorefront-secret
                  key: POSTGRES_PASSWORD
            - name: JWT_SECRET_KEY
              valueFrom:
                secretKeyRef:
                  name: shorefront-secret
                  key: JWT_SECRET_KEY
            - name: DATABASE_URL
              value: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
            - name: JWT_ALGORITHM
              valueFrom:
                configMapKeyRef:
                  name: shorefront-config
                  key: JWT_ALGORITHM
            - name: JWT_EXPIRE_MINUTES
              valueFrom:
                configMapKeyRef:
                  name: shorefront-config
                  key: JWT_EXPIRE_MINUTES
          ports:
            - containerPort: 8000
          resources:
            {{- toYaml .Values.backend.resources | nindent 12 }}
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 5
            periodSeconds: 10

Step 2: Write backend-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: backend
  namespace: {{ .Values.namespace }}
spec:
  selector:
    app: backend
  ports:
    - port: 8000
      targetPort: 8000
  type: ClusterIP

Step 3: Write frontend-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
    app: frontend
spec:
  replicas: {{ .Values.frontend.replicas }}
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
        - name: frontend
          image: "{{ .Values.frontend.image }}:{{ .Values.frontend.tag }}"
          ports:
            - containerPort: 80
          resources:
            {{- toYaml .Values.frontend.resources | nindent 12 }}
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 10

Step 4: Write frontend-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: frontend
  namespace: {{ .Values.namespace }}
spec:
  selector:
    app: frontend
  ports:
    - port: 80
      targetPort: 80
  type: ClusterIP

Step 5: Write ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shorefront
  namespace: {{ .Values.namespace }}
  labels:
    {{- include "shorefront.labels" . | nindent 4 }}
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
spec:
  ingressClassName: {{ .Values.ingress.ingressClassName }}
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: backend
                port:
                  number: 8000
          - path: /
            pathType: Prefix
            backend:
              service:
                name: frontend
                port:
                  number: 80

Step 6: Commit

git add helm/shorefront/templates/backend-deployment.yaml helm/shorefront/templates/backend-service.yaml helm/shorefront/templates/frontend-deployment.yaml helm/shorefront/templates/frontend-service.yaml helm/shorefront/templates/ingress.yaml
git commit -m "feat: add Helm backend, frontend deployment and ingress templates"

Phase 7: Documentation

Task 25: README

Files:

  • Create: README.md

Step 1: Write README.md

# Shorefront

A production-ready web application for managing [Shorewall](http://shorewall.net/) firewall configurations.

## Stack

- **Backend:** Python 3.12, FastAPI, SQLAlchemy 2, Alembic, PostgreSQL
- **Frontend:** React 18, TypeScript, Vite, MUI v5, React Router v6
- **Infra:** Docker Compose (local dev), Helm + Kubernetes + Traefik (production)

---

## Quick Start (Docker Compose)

```bash
# 1. Clone and enter the repo
git clone <repo-url> shorefront && cd shorefront

# 2. Copy env files
cp backend/.env.example .env

# 3. Start everything (postgres + backend + frontend)
docker compose up --build

# 4. Open http://localhost

Default credentials: admin / admin (change immediately in production).


Development (without Docker)

Backend:

cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Set env vars (or create .env):
export DATABASE_URL=postgresql://shorefront:changeme@localhost:5432/shorefront
export JWT_SECRET_KEY=dev-secret
alembic upgrade head
uvicorn app.main:app --reload

Frontend:

cd frontend
npm install
npm run dev   # Vite dev server on http://localhost:5173, proxies /api to localhost:8000

First Steps After Login

  1. Log in at /login with admin / admin.
  2. A sample homelab config is pre-loaded with zones, interfaces, policies, and a masquerade entry.
  3. Navigate to the config → click Generate Config to preview or download the Shorewall files.
  4. Create your own configs from the Configurations page.

Generating Shorewall Files

On the Config Detail page, click Generate Config:

  • Preview: File contents appear in a tabbed modal (zones / interfaces / policy / rules / masq).
  • Download ZIP: Downloads <config-name>-shorewall.zip containing all five files ready to copy to /etc/shorewall/.

Kubernetes Deployment (Helm)

# Build and push images first
docker build -t <registry>/shorefront-backend:latest ./backend
docker build -t <registry>/shorefront-frontend:latest ./frontend
docker push <registry>/shorefront-backend:latest
docker push <registry>/shorefront-frontend:latest

# Deploy
helm upgrade --install shorefront ./helm/shorefront \
  --values ./helm/shorefront/values-prod.yaml \
  --set backend.image=<registry>/shorefront-backend \
  --set frontend.image=<registry>/shorefront-frontend \
  --set secrets.postgresPassword=<strong-password> \
  --set secrets.jwtSecretKey=<strong-jwt-secret> \
  --set ingress.host=shorefront.yourdomain.com

# Check rollout
kubectl rollout status deployment/backend -n shorefront
kubectl rollout status deployment/frontend -n shorefront

NFS Storage: Postgres data is persisted to 192.168.17.199:/mnt/user/kubernetesdata/shorefront. Ensure the NFS export is accessible from your cluster nodes before deploying.


API Docs

FastAPI auto-generates interactive docs at:

  • Swagger UI: http://localhost:8000/docs
  • ReDoc: http://localhost:8000/redoc

**Step 2: Commit**

```bash
git add README.md
git commit -m "docs: add README with quickstart and deployment instructions"

Final Verification

After all tasks are complete, verify the full stack runs:

# Start Docker Compose
docker compose up --build

# Verify backend health
curl http://localhost:8000/health
# Expected: {"status":"ok"}

# Verify frontend loads
curl -s http://localhost | grep "Shorefront"
# Expected: HTML with Shorefront in title

# Test login
curl -c cookies.txt -X POST http://localhost/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin"}'
# Expected: {"message":"Logged in"}

# List configs
curl -b cookies.txt http://localhost/api/configs
# Expected: JSON array with "homelab" config

# Generate files
curl -b cookies.txt -X POST "http://localhost/api/configs/1/generate"
# Expected: JSON with zones, interfaces, policy, rules, masq keys

Then open http://localhost in a browser, log in, and confirm the UI works end-to-end.