# 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.