diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..1ad82eb
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,4 @@
+POSTGRES_PASSWORD=changeme
+JWT_SECRET_KEY=change-this-in-production
+JWT_ALGORITHM=HS256
+JWT_EXPIRE_MINUTES=60
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..da5ea61
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,49 @@
+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
diff --git a/docs/plans/2026-02-28-shorewall-manager.md b/docs/plans/2026-02-28-shorewall-manager.md
new file mode 100644
index 0000000..f3c216b
--- /dev/null
+++ b/docs/plans/2026-02-28-shorewall-manager.md
@@ -0,0 +1,3138 @@
+# 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**
+
+```bash
+mkdir -p backend/app/api backend/alembic/versions frontend/src/{routes,components} helm/shorefront/templates docs/plans
+```
+
+**Step 2: Write docker-compose.yml**
+
+```yaml
+# 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**
+
+```bash
+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**
+
+```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**
+
+```bash
+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**
+
+```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**
+
+```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**
+
+```typescript
+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**
+
+```nginx
+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**
+
+```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**
+
+```html
+
+
+
+
+
+ Shorefront
+
+
+
+
+
+
+```
+
+**Step 7: Commit**
+
+```bash
+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**
+
+```python
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```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**
+
+```python
+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**
+
+```python
+"""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**
+
+```bash
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```python
+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**
+
+```bash
+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**
+
+```typescript
+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**
+
+```typescript
+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(
+
+
+
+
+
+
+)
+```
+
+**Step 3: Write frontend/src/App.tsx**
+
+```typescript
+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 (
+
+
+ } />
+ }>
+ } />
+ } />
+
+ } />
+
+
+ )
+}
+```
+
+**Step 4: Commit**
+
+```bash
+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**
+
+```typescript
+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**
+
+```typescript
+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(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**
+
+```bash
+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**
+
+```typescript
+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
+ if (!user) return
+ return
+}
+```
+
+**Step 2: Write frontend/src/components/Layout.tsx**
+
+```typescript
+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 (
+
+
+
+
+ Shorefront
+
+
+ Shorewall Manager
+
+
+
+
+ navigate('/configs')}
+ sx={{ '&.Mui-selected': { backgroundColor: '#2d3748' }, '&:hover': { backgroundColor: '#2d3748' } }}
+ >
+
+
+
+
+
+
+ {user?.username}
+
+
+
+
+
+
+
+
+ {title}
+
+ {children}
+
+
+ )
+}
+```
+
+**Step 3: Commit**
+
+```bash
+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**
+
+```typescript
+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 {
+ key: keyof T
+ label: string
+ render?: (row: T) => React.ReactNode
+}
+
+interface Props {
+ columns: Column[]
+ rows: T[]
+ onEdit: (row: T) => void
+ onDelete: (row: T) => void
+}
+
+export default function DataTable({ columns, rows, onEdit, onDelete }: Props) {
+ if (rows.length === 0) {
+ return No entries yet.
+ }
+ return (
+
+
+
+
+ {columns.map((col) => (
+
+ {col.label}
+
+ ))}
+ Actions
+
+
+
+ {rows.map((row) => (
+
+ {columns.map((col) => (
+
+ {col.render ? col.render(row) : String(row[col.key] ?? '')}
+
+ ))}
+
+ onEdit(row)}>
+ onDelete(row)} color="error">
+
+
+ ))}
+
+
+
+ )
+}
+```
+
+**Step 2: Write frontend/src/components/EntityForm.tsx**
+
+```typescript
+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
+ onClose: () => void
+ onSubmit: (values: Record) => Promise
+}
+
+export default function EntityForm({ open, title, fields, initialValues, onClose, onSubmit }: Props) {
+ const [values, setValues] = useState>({})
+ 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 (
+
+ )
+}
+```
+
+**Step 3: Commit**
+
+```bash
+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**
+
+```typescript
+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 (
+
+
+
+ Shorefront
+ Sign in to manage your Shorewall configs
+ {error && {error}}
+
+ setUsername(e.target.value)} sx={{ mb: 2 }} size="small" required />
+ setPassword(e.target.value)} sx={{ mb: 3 }} size="small" required />
+
+
+
+
+
+ )
+}
+```
+
+**Step 2: Commit**
+
+```bash
+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**
+
+```typescript
+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[] = [
+ { key: 'name', label: 'Name' },
+ { key: 'description', label: 'Description' },
+ { key: 'is_active', label: 'Status', render: (r) => },
+ { 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([])
+ const [formOpen, setFormOpen] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const navigate = useNavigate()
+
+ const load = () => configsApi.list().then((r) => setConfigs(r.data))
+ useEffect(() => { load() }, [])
+
+ const handleSubmit = async (values: Record) => {
+ 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 (
+
+
+ } onClick={() => { setEditing(null); setFormOpen(true) }}>
+ New Config
+
+
+ { navigate(`/configs/${row.id}`) }}
+ onDelete={handleDelete}
+ />
+ { setFormOpen(false); setEditing(null) }}
+ onSubmit={handleSubmit}
+ />
+
+ )
+}
+```
+
+**Step 2: Commit**
+
+```bash
+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**
+
+```typescript
+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(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 (
+
+ )
+}
+```
+
+**Step 2: Commit**
+
+```bash
+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`
+
+```typescript
+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([])
+ const [interfaces, setInterfaces] = useState([])
+ const [policies, setPolicies] = useState([])
+ const [rules, setRules] = useState([])
+ const [masq, setMasq] = useState([])
+ const [formOpen, setFormOpen] = useState(false)
+ const [editing, setEditing] = useState | 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[],
+ 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[],
+ 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[],
+ 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[],
+ 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[],
+ 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) => {
+ 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 (
+
+
+
+
+ Configurations
+
+ {configName}
+
+ } onClick={() => setGenerateOpen(true)}>
+ Generate Config
+
+
+
+
+ setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
+ {tabConfig.map((tc) => )}
+
+
+
+ } onClick={() => { setEditing(null); setFormOpen(true) }}>
+ Add {current.label.replace('/NAT', '')}
+
+
+ []}
+ rows={current.rows as { id: number }[]}
+ onEdit={(row) => { setEditing(row as Record); setFormOpen(true) }}
+ onDelete={handleDelete}
+ />
+
+
+
+ { setFormOpen(false); setEditing(null) }}
+ onSubmit={handleSubmit}
+ />
+
+ setGenerateOpen(false)}
+ />
+
+ )
+}
+```
+
+**Step 2: Commit**
+
+```bash
+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**
+
+```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**
+
+```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**
+
+```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= \
+# --set secrets.jwtSecretKey=
+```
+
+**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**
+
+```bash
+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**
+
+```yaml
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: {{ .Values.namespace }}
+ labels:
+ {{- include "shorefront.labels" . | nindent 4 }}
+```
+
+**Step 2: Write secret.yaml**
+
+```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**
+
+```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**
+
+```bash
+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**
+
+```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**
+
+```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**
+
+```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**
+
+```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**
+
+```bash
+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**
+
+```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**
+
+```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**
+
+```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**
+
+```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**
+
+```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**
+
+```bash
+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**
+
+```markdown
+# 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 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:**
+```bash
+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:**
+```bash
+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 `-shorewall.zip` containing all five files ready to copy to `/etc/shorewall/`.
+
+---
+
+## Kubernetes Deployment (Helm)
+
+```bash
+# Build and push images first
+docker build -t /shorefront-backend:latest ./backend
+docker build -t /shorefront-frontend:latest ./frontend
+docker push /shorefront-backend:latest
+docker push /shorefront-frontend:latest
+
+# Deploy
+helm upgrade --install shorefront ./helm/shorefront \
+ --values ./helm/shorefront/values-prod.yaml \
+ --set backend.image=/shorefront-backend \
+ --set frontend.image=/shorefront-frontend \
+ --set secrets.postgresPassword= \
+ --set secrets.jwtSecretKey= \
+ --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:
+
+```bash
+# 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.
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..14ea4ad
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1 @@
+VITE_API_BASE_URL=/api