From cb9b802d43428f2e6655e6c668cfce0466f61f87 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sat, 28 Feb 2026 19:53:19 +0100 Subject: [PATCH] feat: add project skeleton and docker-compose --- backend/.env.example | 4 + docker-compose.yml | 49 + docs/plans/2026-02-28-shorewall-manager.md | 3138 ++++++++++++++++++++ frontend/.env.example | 1 + 4 files changed, 3192 insertions(+) create mode 100644 backend/.env.example create mode 100644 docker-compose.yml create mode 100644 docs/plans/2026-02-28-shorewall-manager.md create mode 100644 frontend/.env.example 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 ( + + {title} + + + {fields.map((f) => + f.type === 'select' ? ( + handleChange(f.name, e.target.value)} + size="small" + > + {f.options?.map((o) => {o.label})} + + ) : ( + handleChange(f.name, e.target.value)} + size="small" + /> + ) + )} + + + + + + + + ) +} +``` + +**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 ( + + + + + { 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 ( + + Generated Shorewall Config — {configName} + + setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}> + {TABS.map((t) => )} + + + + handleCopy(currentFile)}> + + + + + {loading ? 'Generating…' : currentFile} + + + + + + + + + ) +} +``` + +**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} + + + + + + setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}> + {tabConfig.map((tc) => )} + + + + + + []} + 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