92 KiB
Shorefront — Shorewall Configuration Manager Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a production-ready web app to manage Shorewall firewall configurations with a FastAPI backend, React/TypeScript frontend, PostgreSQL database, Docker Compose for local dev, and Helm charts for Kubernetes deployment.
Architecture: Monorepo with backend/ and frontend/ as separate services. Nginx in the frontend container reverse-proxies /api to the backend. JWT stored in httpOnly cookies protects all config endpoints. A ShorewallGenerator class converts DB records to Shorewall text files and bundles them as JSON or ZIP.
Tech Stack: Python 3.12, FastAPI, SQLAlchemy 2.x, Alembic, passlib/bcrypt, python-jose; React 18, TypeScript, Vite, MUI v5, React Router v6, Axios; PostgreSQL 15; Docker Compose; Helm 3 / Kubernetes / Traefik.
Phase 1: Infrastructure & Scaffolding
Task 1: Create project directory structure and Docker Compose
Files:
- Create:
docker-compose.yml - Create:
backend/.env.example - Create:
frontend/.env.example
Step 1: Create directory skeleton
mkdir -p backend/app/api backend/alembic/versions frontend/src/{routes,components} helm/shorefront/templates docs/plans
Step 2: Write docker-compose.yml
# docker-compose.yml
version: "3.9"
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: shorefront
POSTGRES_USER: shorefront
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shorefront"]
interval: 5s
timeout: 5s
retries: 5
networks:
- shorefront-net
backend:
build: ./backend
environment:
DATABASE_URL: postgresql://shorefront:${POSTGRES_PASSWORD:-changeme}@postgres:5432/shorefront
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret-change-me}
JWT_ALGORITHM: HS256
JWT_EXPIRE_MINUTES: 60
depends_on:
postgres:
condition: service_healthy
ports:
- "8000:8000"
networks:
- shorefront-net
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- shorefront-net
volumes:
postgres_data:
networks:
shorefront-net:
driver: bridge
Step 3: Write backend/.env.example
POSTGRES_PASSWORD=changeme
JWT_SECRET_KEY=change-this-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=60
Step 4: Write frontend/.env.example
VITE_API_BASE_URL=/api
Step 5: Commit
git init
git add docker-compose.yml backend/.env.example frontend/.env.example
git commit -m "feat: add project skeleton and docker-compose"
Task 2: Backend Dockerfile and requirements.txt
Files:
- Create:
backend/Dockerfile - Create:
backend/requirements.txt
Step 1: Write requirements.txt
fastapi==0.111.0
uvicorn[standard]==0.30.1
sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
pydantic[email]==2.7.1
pydantic-settings==2.2.1
Step 2: Write backend/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
Step 3: Commit
git add backend/Dockerfile backend/requirements.txt
git commit -m "feat: add backend Dockerfile and requirements"
Task 3: Frontend Dockerfile, package.json, Vite config, and Nginx config
Files:
- Create:
frontend/Dockerfile - Create:
frontend/package.json - Create:
frontend/tsconfig.json - Create:
frontend/vite.config.ts - Create:
frontend/nginx.conf - Create:
frontend/index.html
Step 1: Write package.json
{
"name": "shorefront",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.18",
"@mui/material": "^5.15.18",
"axios": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.4.5",
"vite": "^5.2.11"
}
}
Step 2: Write tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
Step 3: Write vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
Step 4: Write nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}
Step 5: Write frontend/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Step 6: Write index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shorefront</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Step 7: Commit
git add frontend/
git commit -m "feat: add frontend Dockerfile, Vite config, and Nginx config"
Phase 2: Backend Core
Task 4: Database setup and SQLAlchemy models
Files:
- Create:
backend/app/database.py - Create:
backend/app/models.py
Step 1: Write backend/app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
jwt_secret_key: str
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60
class Config:
env_file = ".env"
settings = Settings()
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Step 2: Write backend/app/models.py
from datetime import datetime
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
configs: Mapped[list["Config"]] = relationship("Config", back_populates="owner")
class Config(Base):
__tablename__ = "configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
owner: Mapped["User"] = relationship("User", back_populates="configs")
zones: Mapped[list["Zone"]] = relationship("Zone", back_populates="config", cascade="all, delete-orphan")
interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="config", cascade="all, delete-orphan")
policies: Mapped[list["Policy"]] = relationship("Policy", back_populates="config", cascade="all, delete-orphan", order_by="Policy.position")
rules: Mapped[list["Rule"]] = relationship("Rule", back_populates="config", cascade="all, delete-orphan", order_by="Rule.position")
masq_entries: Mapped[list["Masq"]] = relationship("Masq", back_populates="config", cascade="all, delete-orphan")
class Zone(Base):
__tablename__ = "zones"
__table_args__ = (UniqueConstraint("config_id", "name", name="uq_zone_name_per_config"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
name: Mapped[str] = mapped_column(String(32), nullable=False)
type: Mapped[str] = mapped_column(String(16), nullable=False) # ipv4, ipv6, firewall
options: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="zones")
interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="zone")
class Interface(Base):
__tablename__ = "interfaces"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
name: Mapped[str] = mapped_column(String(32), nullable=False)
zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
options: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="interfaces")
zone: Mapped["Zone"] = relationship("Zone", back_populates="interfaces")
class Policy(Base):
__tablename__ = "policies"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
src_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
dst_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
policy: Mapped[str] = mapped_column(String(16), nullable=False) # ACCEPT, DROP, REJECT, CONTINUE
log_level: Mapped[str] = mapped_column(String(16), default="")
comment: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0)
config: Mapped["Config"] = relationship("Config", back_populates="policies")
src_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[src_zone_id])
dst_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Rule(Base):
__tablename__ = "rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
src_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
dst_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
src_ip: Mapped[str] = mapped_column(String(64), default="")
dst_ip: Mapped[str] = mapped_column(String(64), default="")
proto: Mapped[str] = mapped_column(String(16), default="")
dport: Mapped[str] = mapped_column(String(64), default="")
sport: Mapped[str] = mapped_column(String(64), default="")
comment: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0)
config: Mapped["Config"] = relationship("Config", back_populates="rules")
src_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[src_zone_id])
dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Masq(Base):
__tablename__ = "masq"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
source_network: Mapped[str] = mapped_column(String(64), nullable=False)
out_interface: Mapped[str] = mapped_column(String(32), nullable=False)
to_address: Mapped[str] = mapped_column(String(64), default="")
comment: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="masq_entries")
Step 3: Commit
git add backend/app/database.py backend/app/models.py
git commit -m "feat: add SQLAlchemy models and database setup"
Task 5: Alembic setup and initial migration with seed data
Files:
- Create:
backend/alembic.ini - Create:
backend/alembic/env.py - Create:
backend/alembic/versions/0001_initial.py
Step 1: Write alembic.ini
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://shorefront:changeme@localhost:5432/shorefront
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
Step 2: Write backend/alembic/env.py
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Override sqlalchemy.url from environment
database_url = os.environ.get("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
from app.models import Base # noqa: E402
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Step 3: Write backend/alembic/versions/0001_initial.py
"""initial schema and seed data
Revision ID: 0001
Revises:
Create Date: 2026-02-28
"""
from alembic import op
import sqlalchemy as sa
from passlib.context import CryptContext
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def upgrade() -> None:
# --- Schema ---
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("username", sa.String(64), nullable=False, unique=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean, nullable=False, default=True),
)
op.create_table(
"configs",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("description", sa.Text, default=""),
sa.Column("is_active", sa.Boolean, default=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("owner_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
)
op.create_table(
"zones",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("name", sa.String(32), nullable=False),
sa.Column("type", sa.String(16), nullable=False),
sa.Column("options", sa.Text, default=""),
sa.UniqueConstraint("config_id", "name", name="uq_zone_name_per_config"),
)
op.create_table(
"interfaces",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("name", sa.String(32), nullable=False),
sa.Column("zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
sa.Column("options", sa.Text, default=""),
)
op.create_table(
"policies",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("src_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
sa.Column("dst_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
sa.Column("policy", sa.String(16), nullable=False),
sa.Column("log_level", sa.String(16), default=""),
sa.Column("comment", sa.Text, default=""),
sa.Column("position", sa.Integer, default=0),
)
op.create_table(
"rules",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("action", sa.String(32), nullable=False),
sa.Column("src_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=True),
sa.Column("dst_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=True),
sa.Column("src_ip", sa.String(64), default=""),
sa.Column("dst_ip", sa.String(64), default=""),
sa.Column("proto", sa.String(16), default=""),
sa.Column("dport", sa.String(64), default=""),
sa.Column("sport", sa.String(64), default=""),
sa.Column("comment", sa.Text, default=""),
sa.Column("position", sa.Integer, default=0),
)
op.create_table(
"masq",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("source_network", sa.String(64), nullable=False),
sa.Column("out_interface", sa.String(32), nullable=False),
sa.Column("to_address", sa.String(64), default=""),
sa.Column("comment", sa.Text, default=""),
)
# --- Seed data ---
conn = op.get_bind()
# Admin user
conn.execute(
sa.text(
"INSERT INTO users (username, email, hashed_password, is_active) "
"VALUES (:u, :e, :p, true)"
),
{"u": "admin", "e": "admin@localhost", "p": pwd_context.hash("admin")},
)
user_id = conn.execute(sa.text("SELECT id FROM users WHERE username='admin'")).scalar()
# Sample config
conn.execute(
sa.text(
"INSERT INTO configs (name, description, is_active, owner_id) "
"VALUES (:n, :d, true, :o)"
),
{"n": "homelab", "d": "Sample homelab Shorewall config", "o": user_id},
)
config_id = conn.execute(sa.text("SELECT id FROM configs WHERE name='homelab'")).scalar()
# Zones
for z_name, z_type in [("fw", "firewall"), ("net", "ipv4"), ("loc", "ipv4")]:
conn.execute(
sa.text("INSERT INTO zones (config_id, name, type, options) VALUES (:c, :n, :t, '')"),
{"c": config_id, "n": z_name, "t": z_type},
)
fw_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='fw'"), {"c": config_id}).scalar()
net_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='net'"), {"c": config_id}).scalar()
loc_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='loc'"), {"c": config_id}).scalar()
# Interface
conn.execute(
sa.text("INSERT INTO interfaces (config_id, name, zone_id, options) VALUES (:c, :n, :z, '')"),
{"c": config_id, "n": "eth0", "z": net_id},
)
# Policies
policies = [
(loc_id, net_id, "ACCEPT", "", "loc to net", 1),
(net_id, fw_id, "DROP", "info", "net to fw", 2),
(net_id, loc_id, "DROP", "info", "net to loc", 3),
(fw_id, net_id, "ACCEPT", "", "fw to net", 4),
(fw_id, loc_id, "ACCEPT", "", "fw to loc", 5),
]
for src, dst, pol, log, comment, pos in policies:
conn.execute(
sa.text(
"INSERT INTO policies (config_id, src_zone_id, dst_zone_id, policy, log_level, comment, position) "
"VALUES (:c, :s, :d, :p, :l, :cm, :pos)"
),
{"c": config_id, "s": src, "d": dst, "p": pol, "l": log, "cm": comment, "pos": pos},
)
# Masq
conn.execute(
sa.text("INSERT INTO masq (config_id, source_network, out_interface, to_address, comment) VALUES (:c, :s, :o, '', :cm)"),
{"c": config_id, "s": "192.168.1.0/24", "o": "eth0", "cm": "LAN masquerade"},
)
def downgrade() -> None:
op.drop_table("masq")
op.drop_table("rules")
op.drop_table("policies")
op.drop_table("interfaces")
op.drop_table("zones")
op.drop_table("configs")
op.drop_table("users")
Step 4: Commit
git add backend/alembic/ backend/alembic.ini
git commit -m "feat: add Alembic migration with schema and seed data"
Task 6: Auth module (JWT + password hashing)
Files:
- Create:
backend/app/auth.py
Step 1: Write backend/app/auth.py
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Cookie, HTTPException, status, Depends
from sqlalchemy.orm import Session
from app.database import get_db, settings
from app import models
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
return jwt.encode(
{"sub": str(user_id), "exp": expire},
settings.jwt_secret_key,
algorithm=settings.jwt_algorithm,
)
def get_current_user(
access_token: Optional[str] = Cookie(default=None),
db: Session = Depends(get_db),
) -> models.User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
if not access_token:
raise credentials_exception
try:
payload = jwt.decode(access_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.get(models.User, int(user_id))
if user is None or not user.is_active:
raise credentials_exception
return user
Step 2: Commit
git add backend/app/auth.py
git commit -m "feat: add JWT auth module"
Task 7: Pydantic schemas
Files:
- Create:
backend/app/schemas.py
Step 1: Write backend/app/schemas.py
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# --- Auth ---
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
username: str
email: str
is_active: bool
model_config = {"from_attributes": True}
class LoginRequest(BaseModel):
username: str
password: str
# --- Config ---
class ConfigCreate(BaseModel):
name: str
description: str = ""
is_active: bool = True
class ConfigUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class ConfigOut(BaseModel):
id: int
name: str
description: str
is_active: bool
created_at: datetime
updated_at: datetime
owner_id: int
model_config = {"from_attributes": True}
# --- Zone ---
class ZoneCreate(BaseModel):
name: str
type: str
options: str = ""
class ZoneUpdate(BaseModel):
name: Optional[str] = None
type: Optional[str] = None
options: Optional[str] = None
class ZoneOut(BaseModel):
id: int
config_id: int
name: str
type: str
options: str
model_config = {"from_attributes": True}
# --- Interface ---
class InterfaceCreate(BaseModel):
name: str
zone_id: int
options: str = ""
class InterfaceUpdate(BaseModel):
name: Optional[str] = None
zone_id: Optional[int] = None
options: Optional[str] = None
class InterfaceOut(BaseModel):
id: int
config_id: int
name: str
zone_id: int
options: str
model_config = {"from_attributes": True}
# --- Policy ---
class PolicyCreate(BaseModel):
src_zone_id: int
dst_zone_id: int
policy: str
log_level: str = ""
comment: str = ""
position: int = 0
class PolicyUpdate(BaseModel):
src_zone_id: Optional[int] = None
dst_zone_id: Optional[int] = None
policy: Optional[str] = None
log_level: Optional[str] = None
comment: Optional[str] = None
position: Optional[int] = None
class PolicyOut(BaseModel):
id: int
config_id: int
src_zone_id: int
dst_zone_id: int
policy: str
log_level: str
comment: str
position: int
model_config = {"from_attributes": True}
# --- Rule ---
class RuleCreate(BaseModel):
action: str
src_zone_id: Optional[int] = None
dst_zone_id: Optional[int] = None
src_ip: str = ""
dst_ip: str = ""
proto: str = ""
dport: str = ""
sport: str = ""
comment: str = ""
position: int = 0
class RuleUpdate(BaseModel):
action: Optional[str] = None
src_zone_id: Optional[int] = None
dst_zone_id: Optional[int] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
proto: Optional[str] = None
dport: Optional[str] = None
sport: Optional[str] = None
comment: Optional[str] = None
position: Optional[int] = None
class RuleOut(BaseModel):
id: int
config_id: int
action: str
src_zone_id: Optional[int]
dst_zone_id: Optional[int]
src_ip: str
dst_ip: str
proto: str
dport: str
sport: str
comment: str
position: int
model_config = {"from_attributes": True}
# --- Masq ---
class MasqCreate(BaseModel):
source_network: str
out_interface: str
to_address: str = ""
comment: str = ""
class MasqUpdate(BaseModel):
source_network: Optional[str] = None
out_interface: Optional[str] = None
to_address: Optional[str] = None
comment: Optional[str] = None
class MasqOut(BaseModel):
id: int
config_id: int
source_network: str
out_interface: str
to_address: str
comment: str
model_config = {"from_attributes": True}
# --- Generate ---
class GenerateOut(BaseModel):
zones: str
interfaces: str
policy: str
rules: str
masq: str
Step 2: Commit
git add backend/app/schemas.py
git commit -m "feat: add Pydantic schemas"
Task 8: FastAPI main app
Files:
- Create:
backend/app/main.py - Create:
backend/app/__init__.py - Create:
backend/app/api/__init__.py
Step 1: Write backend/app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, masq
app = FastAPI(title="Shorefront", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:80"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(configs.router, prefix="/configs", tags=["configs"])
app.include_router(zones.router, prefix="/configs", tags=["zones"])
app.include_router(interfaces.router, prefix="/configs", tags=["interfaces"])
app.include_router(policies.router, prefix="/configs", tags=["policies"])
app.include_router(rules.router, prefix="/configs", tags=["rules"])
app.include_router(masq.router, prefix="/configs", tags=["masq"])
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
Step 2: Create __init__.py files
Create empty files at:
backend/app/__init__.pybackend/app/api/__init__.py
Step 3: Commit
git add backend/app/main.py backend/app/__init__.py backend/app/api/__init__.py
git commit -m "feat: add FastAPI app entrypoint"
Phase 3: Backend API Routers
Task 9: Auth router
Files:
- Create:
backend/app/api/auth.py
Step 1: Write backend/app/api/auth.py
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import create_access_token, get_current_user, hash_password, verify_password
from app.database import get_db
router = APIRouter()
@router.post("/register", response_model=schemas.UserOut, status_code=201)
def register(body: schemas.UserCreate, db: Session = Depends(get_db)) -> models.User:
if db.query(models.User).filter(models.User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already registered")
if db.query(models.User).filter(models.User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = models.User(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login")
def login(body: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)) -> dict:
user = db.query(models.User).filter(models.User.username == body.username).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(user.id)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
samesite="lax",
max_age=3600,
)
return {"message": "Logged in"}
@router.post("/logout")
def logout(response: Response) -> dict:
response.delete_cookie("access_token")
return {"message": "Logged out"}
@router.get("/me", response_model=schemas.UserOut)
def me(current_user: models.User = Depends(get_current_user)) -> models.User:
return current_user
Step 2: Commit
git add backend/app/api/auth.py
git commit -m "feat: add auth router (register/login/logout/me)"
Task 10: Configs router (CRUD + generate)
Files:
- Create:
backend/app/api/configs.py
Step 1: Write backend/app/api/configs.py
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, selectinload
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
from app.shorewall_generator import ShorewallGenerator
import io
router = APIRouter()
def _get_config_or_404(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id,
models.Config.owner_id == user.id,
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("", response_model=list[schemas.ConfigOut])
def list_configs(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> list[models.Config]:
return db.query(models.Config).filter(models.Config.owner_id == current_user.id).all()
@router.post("", response_model=schemas.ConfigOut, status_code=201)
def create_config(
body: schemas.ConfigCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> models.Config:
config = models.Config(**body.model_dump(), owner_id=current_user.id)
db.add(config)
db.commit()
db.refresh(config)
return config
@router.get("/{config_id}", response_model=schemas.ConfigOut)
def get_config(
config_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> models.Config:
return _get_config_or_404(config_id, db, current_user)
@router.put("/{config_id}", response_model=schemas.ConfigOut)
def update_config(
config_id: int,
body: schemas.ConfigUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> models.Config:
config = _get_config_or_404(config_id, db, current_user)
for field, value in body.model_dump(exclude_none=True).items():
setattr(config, field, value)
db.commit()
db.refresh(config)
return config
@router.delete("/{config_id}", status_code=204)
def delete_config(
config_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> None:
config = _get_config_or_404(config_id, db, current_user)
db.delete(config)
db.commit()
@router.post("/{config_id}/generate")
def generate_config(
config_id: int,
format: str = "json",
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> Response:
config = (
db.query(models.Config)
.options(
selectinload(models.Config.zones),
selectinload(models.Config.interfaces).selectinload(models.Interface.zone),
selectinload(models.Config.policies).selectinload(models.Policy.src_zone),
selectinload(models.Config.policies).selectinload(models.Policy.dst_zone),
selectinload(models.Config.rules).selectinload(models.Rule.src_zone),
selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
selectinload(models.Config.masq_entries),
)
.filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
.first()
)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
generator = ShorewallGenerator(config)
if format == "zip":
zip_bytes = generator.as_zip()
return StreamingResponse(
io.BytesIO(zip_bytes),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={config.name}-shorewall.zip"},
)
return generator.as_json()
Step 2: Commit
git add backend/app/api/configs.py
git commit -m "feat: add configs CRUD router with generate endpoint"
Task 11: Nested resource routers (zones, interfaces, policies, rules, masq)
Files:
- Create:
backend/app/api/zones.py - Create:
backend/app/api/interfaces.py - Create:
backend/app/api/policies.py - Create:
backend/app/api/rules.py - Create:
backend/app/api/masq.py
Step 1: Write backend/app/api/zones.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/zones", response_model=list[schemas.ZoneOut])
def list_zones(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Zone).filter(models.Zone.config_id == config_id).all()
@router.post("/{config_id}/zones", response_model=schemas.ZoneOut, status_code=201)
def create_zone(config_id: int, body: schemas.ZoneCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
zone = models.Zone(**body.model_dump(), config_id=config_id)
db.add(zone)
db.commit()
db.refresh(zone)
return zone
@router.get("/{config_id}/zones/{zone_id}", response_model=schemas.ZoneOut)
def get_zone(config_id: int, zone_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
zone = db.query(models.Zone).filter(models.Zone.id == zone_id, models.Zone.config_id == config_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Zone not found")
return zone
@router.put("/{config_id}/zones/{zone_id}", response_model=schemas.ZoneOut)
def update_zone(config_id: int, zone_id: int, body: schemas.ZoneUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
zone = db.query(models.Zone).filter(models.Zone.id == zone_id, models.Zone.config_id == config_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Zone not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(zone, field, value)
db.commit()
db.refresh(zone)
return zone
@router.delete("/{config_id}/zones/{zone_id}", status_code=204)
def delete_zone(config_id: int, zone_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
zone = db.query(models.Zone).filter(models.Zone.id == zone_id, models.Zone.config_id == config_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Zone not found")
db.delete(zone)
db.commit()
Step 2: Write backend/app/api/interfaces.py — same pattern as zones.py but for Interface model using schemas.InterfaceCreate/Update/Out. Fields: name, zone_id, options.
Step 3: Write backend/app/api/policies.py — same pattern for Policy model using schemas.PolicyCreate/Update/Out. Fields: src_zone_id, dst_zone_id, policy, log_level, comment, position.
Step 4: Write backend/app/api/rules.py — same pattern for Rule model using schemas.RuleCreate/Update/Out.
Step 5: Write backend/app/api/masq.py — same pattern for Masq model using schemas.MasqCreate/Update/Out. Fields: source_network, out_interface, to_address, comment.
Note: Each router follows the exact same CRUD pattern as zones.py. The implementing agent should follow that pattern precisely, substituting the correct model class and schema types.
Step 6: Commit
git add backend/app/api/
git commit -m "feat: add nested resource routers for zones, interfaces, policies, rules, masq"
Phase 4: Shorewall Generator
Task 12: ShorewallGenerator class
Files:
- Create:
backend/app/shorewall_generator.py
Step 1: Write backend/app/shorewall_generator.py
import io
import zipfile
from datetime import datetime, timezone
from app import models
class ShorewallGenerator:
def __init__(self, config: models.Config) -> None:
self._config = config
self._ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _header(self, filename: str) -> str:
return (
f"# {filename} — generated by shorefront "
f"| config: {self._config.name} "
f"| {self._ts}\n"
)
def _col(self, *values: str, width: int = 16) -> str:
return "".join(v.ljust(width) for v in values).rstrip() + "\n"
def zones(self) -> str:
lines = [self._header("zones"), "#ZONE".ljust(16) + "TYPE".ljust(16) + "OPTIONS\n"]
for z in self._config.zones:
lines.append(self._col(z.name, z.type, z.options or "-"))
return "".join(lines)
def interfaces(self) -> str:
lines = [self._header("interfaces"), "#ZONE".ljust(16) + "INTERFACE".ljust(16) + "OPTIONS\n"]
for iface in self._config.interfaces:
lines.append(self._col(iface.zone.name, iface.name, iface.options or "-"))
return "".join(lines)
def policy(self) -> str:
lines = [self._header("policy"), "#SOURCE".ljust(16) + "DEST".ljust(16) + "POLICY".ljust(16) + "LOG LEVEL\n"]
for p in sorted(self._config.policies, key=lambda x: x.position):
lines.append(self._col(p.src_zone.name, p.dst_zone.name, p.policy, p.log_level or "-"))
return "".join(lines)
def rules(self) -> str:
lines = [
self._header("rules"),
"#ACTION".ljust(16) + "SOURCE".ljust(24) + "DEST".ljust(24) + "PROTO".ljust(10) + "DPORT".ljust(10) + "SPORT\n",
]
for r in sorted(self._config.rules, key=lambda x: x.position):
src = (r.src_zone.name if r.src_zone else "all") + (f":{r.src_ip}" if r.src_ip else "")
dst = (r.dst_zone.name if r.dst_zone else "all") + (f":{r.dst_ip}" if r.dst_ip else "")
lines.append(self._col(r.action, src, dst, r.proto or "-", r.dport or "-", r.sport or "-", width=16))
return "".join(lines)
def masq(self) -> str:
lines = [self._header("masq"), "#INTERFACE".ljust(24) + "SOURCE".ljust(24) + "ADDRESS\n"]
for m in self._config.masq_entries:
lines.append(self._col(m.out_interface, m.source_network, m.to_address or "-", width=24))
return "".join(lines)
def as_json(self) -> dict:
return {
"zones": self.zones(),
"interfaces": self.interfaces(),
"policy": self.policy(),
"rules": self.rules(),
"masq": self.masq(),
}
def as_zip(self) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("zones", self.zones())
zf.writestr("interfaces", self.interfaces())
zf.writestr("policy", self.policy())
zf.writestr("rules", self.rules())
zf.writestr("masq", self.masq())
return buf.getvalue()
Step 2: Commit
git add backend/app/shorewall_generator.py
git commit -m "feat: add ShorewallGenerator (zones, interfaces, policy, rules, masq, json, zip)"
Phase 5: Frontend
Task 13: Frontend entry point, App, theme, and router
Files:
- Create:
frontend/src/main.tsx - Create:
frontend/src/App.tsx - Create:
frontend/src/theme.ts
Step 1: Write frontend/src/theme.ts
import { createTheme } from '@mui/material/styles'
export const theme = createTheme({
palette: {
mode: 'light',
primary: { main: '#3b82f6' },
background: { default: '#f5f7fa', paper: '#ffffff' },
},
components: {
MuiAppBar: { styleOverrides: { root: { backgroundColor: '#1a1f2e' } } },
MuiDrawer: {
styleOverrides: {
paper: { backgroundColor: '#1a1f2e', color: '#e2e8f0', borderRight: 'none' },
},
},
},
})
Step 2: Write frontend/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ThemeProvider } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
import { theme } from './theme'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>
)
Step 3: Write frontend/src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Login from './routes/Login'
import ConfigList from './routes/ConfigList'
import ConfigDetail from './routes/ConfigDetail'
import ProtectedRoute from './components/ProtectedRoute'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute />}>
<Route path="/configs" element={<ConfigList />} />
<Route path="/configs/:id" element={<ConfigDetail />} />
</Route>
<Route path="*" element={<Navigate to="/configs" replace />} />
</Routes>
</BrowserRouter>
)
}
Step 4: Commit
git add frontend/src/main.tsx frontend/src/App.tsx frontend/src/theme.ts
git commit -m "feat: add frontend entry point, theme, and router"
Task 14: API client and auth store
Files:
- Create:
frontend/src/api.ts - Create:
frontend/src/store/auth.ts
Step 1: Write frontend/src/api.ts
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
withCredentials: true,
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401 && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default api
// --- Auth ---
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
logout: () => api.post('/auth/logout'),
me: () => api.get('/auth/me'),
register: (username: string, email: string, password: string) =>
api.post('/auth/register', { username, email, password }),
}
// --- Configs ---
export const configsApi = {
list: () => api.get('/configs'),
create: (data: object) => api.post('/configs', data),
get: (id: number) => api.get(`/configs/${id}`),
update: (id: number, data: object) => api.put(`/configs/${id}`, data),
delete: (id: number) => api.delete(`/configs/${id}`),
generate: (id: number, format: 'json' | 'zip' = 'json') =>
api.post(`/configs/${id}/generate?format=${format}`, null, {
responseType: format === 'zip' ? 'blob' : 'json',
}),
}
// --- Nested resources (zones, interfaces, policies, rules, masq) ---
const nestedApi = (resource: string) => ({
list: (configId: number) => api.get(`/configs/${configId}/${resource}`),
create: (configId: number, data: object) => api.post(`/configs/${configId}/${resource}`, data),
update: (configId: number, id: number, data: object) =>
api.put(`/configs/${configId}/${resource}/${id}`, data),
delete: (configId: number, id: number) =>
api.delete(`/configs/${configId}/${resource}/${id}`),
})
export const zonesApi = nestedApi('zones')
export const interfacesApi = nestedApi('interfaces')
export const policiesApi = nestedApi('policies')
export const rulesApi = nestedApi('rules')
export const masqApi = nestedApi('masq')
Step 2: Write frontend/src/store/auth.ts
import { useState, useEffect } from 'react'
import { authApi } from '../api'
export interface User {
id: number
username: string
email: string
is_active: boolean
}
// Simple module-level state (no external lib needed)
let currentUser: User | null = null
const listeners = new Set<() => void>()
export function useAuth() {
const [user, setUser] = useState<User | null>(currentUser)
const [loading, setLoading] = useState(currentUser === null)
useEffect(() => {
const update = () => setUser(currentUser)
listeners.add(update)
if (currentUser === null) {
authApi.me()
.then((res) => { currentUser = res.data; listeners.forEach((l) => l()) })
.catch(() => { currentUser = null; listeners.forEach((l) => l()) })
.finally(() => setLoading(false))
}
return () => { listeners.delete(update) }
}, [])
const logout = async () => {
await authApi.logout()
currentUser = null
listeners.forEach((l) => l())
window.location.href = '/login'
}
return { user, loading, logout }
}
Step 3: Commit
git add frontend/src/api.ts frontend/src/store/
git commit -m "feat: add API client and auth store"
Task 15: Layout and ProtectedRoute components
Files:
- Create:
frontend/src/components/Layout.tsx - Create:
frontend/src/components/ProtectedRoute.tsx
Step 1: Write frontend/src/components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../store/auth'
import CircularProgress from '@mui/material/CircularProgress'
import Box from '@mui/material/Box'
export default function ProtectedRoute() {
const { user, loading } = useAuth()
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>
if (!user) return <Navigate to="/login" replace />
return <Outlet />
}
Step 2: Write frontend/src/components/Layout.tsx
import { ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import Box from '@mui/material/Box'
import Drawer from '@mui/material/Drawer'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Typography from '@mui/material/Typography'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import DnsIcon from '@mui/icons-material/Dns'
import LogoutIcon from '@mui/icons-material/Logout'
import { useAuth } from '../store/auth'
const DRAWER_WIDTH = 240
interface Props { children: ReactNode; title: string }
export default function Layout({ children, title }: Props) {
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuth()
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Drawer variant="permanent" sx={{ width: DRAWER_WIDTH, '& .MuiDrawer-paper': { width: DRAWER_WIDTH } }}>
<Box sx={{ px: 2, py: 3 }}>
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 700, letterSpacing: 1 }}>
Shorefront
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Shorewall Manager
</Typography>
</Box>
<Divider sx={{ borderColor: '#2d3748' }} />
<List sx={{ flex: 1 }}>
<ListItemButton
selected={location.pathname.startsWith('/configs')}
onClick={() => navigate('/configs')}
sx={{ '&.Mui-selected': { backgroundColor: '#2d3748' }, '&:hover': { backgroundColor: '#2d3748' } }}
>
<ListItemIcon sx={{ color: '#94a3b8', minWidth: 36 }}><DnsIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="Configurations" primaryTypographyProps={{ sx: { color: '#e2e8f0', fontSize: 14 } }} />
</ListItemButton>
</List>
<Divider sx={{ borderColor: '#2d3748' }} />
<Box sx={{ px: 2, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>{user?.username}</Typography>
<Tooltip title="Logout">
<IconButton onClick={logout} size="small" sx={{ color: '#94a3b8' }}><LogoutIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
</Drawer>
<Box component="main" sx={{ flex: 1, bgcolor: 'background.default' }}>
<Box sx={{ px: 4, py: 3, bgcolor: 'white', borderBottom: '1px solid #e2e8f0' }}>
<Typography variant="h5" fontWeight={600}>{title}</Typography>
</Box>
<Box sx={{ p: 4 }}>{children}</Box>
</Box>
</Box>
)
}
Step 3: Commit
git add frontend/src/components/Layout.tsx frontend/src/components/ProtectedRoute.tsx
git commit -m "feat: add Layout and ProtectedRoute components"
Task 16: DataTable and EntityForm reusable components
Files:
- Create:
frontend/src/components/DataTable.tsx - Create:
frontend/src/components/EntityForm.tsx
Step 1: Write frontend/src/components/DataTable.tsx
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Paper from '@mui/material/Paper'
import IconButton from '@mui/material/IconButton'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import Typography from '@mui/material/Typography'
export interface Column<T> {
key: keyof T
label: string
render?: (row: T) => React.ReactNode
}
interface Props<T extends { id: number }> {
columns: Column<T>[]
rows: T[]
onEdit: (row: T) => void
onDelete: (row: T) => void
}
export default function DataTable<T extends { id: number }>({ columns, rows, onEdit, onDelete }: Props<T>) {
if (rows.length === 0) {
return <Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No entries yet.</Typography>
}
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
{columns.map((col) => (
<TableCell key={String(col.key)} sx={{ fontWeight: 600, fontSize: 12, color: '#64748b' }}>
{col.label}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 600, fontSize: 12, color: '#64748b' }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} hover>
{columns.map((col) => (
<TableCell key={String(col.key)}>
{col.render ? col.render(row) : String(row[col.key] ?? '')}
</TableCell>
))}
<TableCell align="right">
<IconButton size="small" onClick={() => onEdit(row)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => onDelete(row)} color="error"><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
Step 2: Write frontend/src/components/EntityForm.tsx
import { useState, useEffect } from 'react'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
import MenuItem from '@mui/material/MenuItem'
import Stack from '@mui/material/Stack'
export interface FieldDef {
name: string
label: string
required?: boolean
type?: 'text' | 'select' | 'number'
options?: { value: string | number; label: string }[]
}
interface Props {
open: boolean
title: string
fields: FieldDef[]
initialValues?: Record<string, unknown>
onClose: () => void
onSubmit: (values: Record<string, unknown>) => Promise<void>
}
export default function EntityForm({ open, title, fields, initialValues, onClose, onSubmit }: Props) {
const [values, setValues] = useState<Record<string, unknown>>({})
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (open) setValues(initialValues ?? {})
}, [open, initialValues])
const handleChange = (name: string, value: unknown) => setValues((v) => ({ ...v, [name]: value }))
const handleSubmit = async () => {
setSubmitting(true)
try { await onSubmit(values) } finally { setSubmitting(false) }
}
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{fields.map((f) =>
f.type === 'select' ? (
<TextField
key={f.name}
select
label={f.label}
required={f.required}
value={values[f.name] ?? ''}
onChange={(e) => handleChange(f.name, e.target.value)}
size="small"
>
{f.options?.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
) : (
<TextField
key={f.name}
label={f.label}
required={f.required}
type={f.type ?? 'text'}
value={values[f.name] ?? ''}
onChange={(e) => handleChange(f.name, e.target.value)}
size="small"
/>
)
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Saving…' : 'Save'}
</Button>
</DialogActions>
</Dialog>
)
}
Step 3: Commit
git add frontend/src/components/DataTable.tsx frontend/src/components/EntityForm.tsx
git commit -m "feat: add reusable DataTable and EntityForm components"
Task 17: Login page
Files:
- Create:
frontend/src/routes/Login.tsx
Step 1: Write frontend/src/routes/Login.tsx
import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import TextField from '@mui/material/TextField'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import Alert from '@mui/material/Alert'
import { authApi } from '../api'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const navigate = useNavigate()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
try {
await authApi.login(username, password)
window.location.href = '/configs'
} catch {
setError('Invalid username or password')
}
}
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#f5f7fa' }}>
<Card sx={{ width: 380, boxShadow: 3 }}>
<CardContent sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} gutterBottom>Shorefront</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>Sign in to manage your Shorewall configs</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box component="form" onSubmit={handleSubmit}>
<TextField fullWidth label="Username" value={username} onChange={(e) => setUsername(e.target.value)} sx={{ mb: 2 }} size="small" required />
<TextField fullWidth label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} sx={{ mb: 3 }} size="small" required />
<Button type="submit" variant="contained" fullWidth>Sign In</Button>
</Box>
</CardContent>
</Card>
</Box>
)
}
Step 2: Commit
git add frontend/src/routes/Login.tsx
git commit -m "feat: add Login page"
Task 18: Config List page
Files:
- Create:
frontend/src/routes/ConfigList.tsx
Step 1: Write frontend/src/routes/ConfigList.tsx
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Layout from '../components/Layout'
import DataTable, { Column } from '../components/DataTable'
import EntityForm from '../components/EntityForm'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import AddIcon from '@mui/icons-material/Add'
import { configsApi } from '../api'
interface Config {
id: number
name: string
description: string
is_active: boolean
created_at: string
}
const COLUMNS: Column<Config>[] = [
{ key: 'name', label: 'Name' },
{ key: 'description', label: 'Description' },
{ key: 'is_active', label: 'Status', render: (r) => <Chip label={r.is_active ? 'Active' : 'Inactive'} color={r.is_active ? 'success' : 'default'} size="small" /> },
{ key: 'created_at', label: 'Created', render: (r) => new Date(r.created_at).toLocaleDateString() },
]
const FIELDS = [
{ name: 'name', label: 'Name', required: true },
{ name: 'description', label: 'Description' },
]
export default function ConfigList() {
const [configs, setConfigs] = useState<Config[]>([])
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<Config | null>(null)
const navigate = useNavigate()
const load = () => configsApi.list().then((r) => setConfigs(r.data))
useEffect(() => { load() }, [])
const handleSubmit = async (values: Record<string, unknown>) => {
if (editing) {
await configsApi.update(editing.id, values)
} else {
await configsApi.create(values)
}
setFormOpen(false)
setEditing(null)
load()
}
const handleDelete = async (row: Config) => {
if (!confirm(`Delete config "${row.name}"?`)) return
await configsApi.delete(row.id)
load()
}
return (
<Layout title="Configurations">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditing(null); setFormOpen(true) }}>
New Config
</Button>
</Box>
<DataTable
columns={COLUMNS}
rows={configs}
onEdit={(row) => { navigate(`/configs/${row.id}`) }}
onDelete={handleDelete}
/>
<EntityForm
open={formOpen}
title={editing ? 'Edit Config' : 'New Config'}
fields={FIELDS}
initialValues={editing ?? undefined}
onClose={() => { setFormOpen(false); setEditing(null) }}
onSubmit={handleSubmit}
/>
</Layout>
)
}
Step 2: Commit
git add frontend/src/routes/ConfigList.tsx
git commit -m "feat: add Config List page"
Task 19: GenerateModal component
Files:
- Create:
frontend/src/components/GenerateModal.tsx
Step 1: Write frontend/src/components/GenerateModal.tsx
import { useState } from 'react'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Button from '@mui/material/Button'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DownloadIcon from '@mui/icons-material/Download'
import { configsApi } from '../api'
interface GeneratedFiles {
zones: string
interfaces: string
policy: string
rules: string
masq: string
}
interface Props {
open: boolean
configId: number
configName: string
onClose: () => void
}
const TABS = ['zones', 'interfaces', 'policy', 'rules', 'masq'] as const
export default function GenerateModal({ open, configId, configName, onClose }: Props) {
const [tab, setTab] = useState(0)
const [files, setFiles] = useState<GeneratedFiles | null>(null)
const [loading, setLoading] = useState(false)
const handleOpen = async () => {
if (files) return
setLoading(true)
try {
const res = await configsApi.generate(configId, 'json')
setFiles(res.data)
} finally {
setLoading(false)
}
}
const handleDownloadZip = async () => {
const res = await configsApi.generate(configId, 'zip')
const url = URL.createObjectURL(new Blob([res.data]))
const a = document.createElement('a')
a.href = url
a.download = `${configName}-shorewall.zip`
a.click()
URL.revokeObjectURL(url)
}
const handleCopy = (text: string) => navigator.clipboard.writeText(text)
if (open && !files && !loading) handleOpen()
const currentFile = files ? files[TABS[tab]] : ''
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Generated Shorewall Config — {configName}</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
{TABS.map((t) => <Tab key={t} label={t} />)}
</Tabs>
<Box sx={{ position: 'relative', p: 2 }}>
<Tooltip title="Copy">
<IconButton size="small" sx={{ position: 'absolute', top: 16, right: 16 }} onClick={() => handleCopy(currentFile)}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Box
component="pre"
sx={{ fontFamily: 'monospace', fontSize: 13, bgcolor: '#1e293b', color: '#e2e8f0', p: 2, borderRadius: 1, overflowX: 'auto', minHeight: 300, whiteSpace: 'pre' }}
>
{loading ? 'Generating…' : currentFile}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button startIcon={<DownloadIcon />} onClick={handleDownloadZip} variant="outlined">
Download ZIP
</Button>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
)
}
Step 2: Commit
git add frontend/src/components/GenerateModal.tsx
git commit -m "feat: add GenerateModal component"
Task 20: Config Detail page (tabbed)
Files:
- Create:
frontend/src/routes/ConfigDetail.tsx
Step 1: Write frontend/src/routes/ConfigDetail.tsx
This is the largest frontend file. It contains:
- Breadcrumb + config name header
- "Generate Config" button (opens
GenerateModal) - MUI Tabs: Zones / Interfaces / Policies / Rules / Masq/NAT
- Each tab:
DataTable+ "Add" button →EntityForm
import { useState, useEffect, useCallback } from 'react'
import { useParams, Link } from 'react-router-dom'
import Layout from '../components/Layout'
import DataTable, { Column } from '../components/DataTable'
import EntityForm, { FieldDef } from '../components/EntityForm'
import GenerateModal from '../components/GenerateModal'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Typography from '@mui/material/Typography'
import Breadcrumbs from '@mui/material/Breadcrumbs'
import AddIcon from '@mui/icons-material/Add'
import BuildIcon from '@mui/icons-material/Build'
import { zonesApi, interfacesApi, policiesApi, rulesApi, masqApi, configsApi } from '../api'
// ---- Types ----
interface Zone { id: number; name: string; type: string; options: string }
interface Interface { id: number; name: string; zone_id: number; options: string }
interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number }
interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; comment: string; position: number }
interface Masq { id: number; source_network: string; out_interface: string; to_address: string; comment: string }
export default function ConfigDetail() {
const { id } = useParams<{ id: string }>()
const configId = Number(id)
const [configName, setConfigName] = useState('')
const [tab, setTab] = useState(0)
const [zones, setZones] = useState<Zone[]>([])
const [interfaces, setInterfaces] = useState<Interface[]>([])
const [policies, setPolicies] = useState<Policy[]>([])
const [rules, setRules] = useState<Rule[]>([])
const [masq, setMasq] = useState<Masq[]>([])
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<Record<string, unknown> | null>(null)
const [generateOpen, setGenerateOpen] = useState(false)
useEffect(() => {
configsApi.get(configId).then((r) => setConfigName(r.data.name))
zonesApi.list(configId).then((r) => setZones(r.data))
interfacesApi.list(configId).then((r) => setInterfaces(r.data))
policiesApi.list(configId).then((r) => setPolicies(r.data))
rulesApi.list(configId).then((r) => setRules(r.data))
masqApi.list(configId).then((r) => setMasq(r.data))
}, [configId])
const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name }))
// ---- Tab configs ----
const tabConfig = [
{
label: 'Zones',
rows: zones,
setRows: setZones,
api: zonesApi,
columns: [
{ key: 'name' as const, label: 'Name' },
{ key: 'type' as const, label: 'Type' },
{ key: 'options' as const, label: 'Options' },
] as Column<Zone>[],
fields: [
{ name: 'name', label: 'Name', required: true },
{ name: 'type', label: 'Type', required: true, type: 'select' as const, options: [{ value: 'ipv4', label: 'ipv4' }, { value: 'ipv6', label: 'ipv6' }, { value: 'firewall', label: 'firewall' }] },
{ name: 'options', label: 'Options' },
] as FieldDef[],
},
{
label: 'Interfaces',
rows: interfaces,
setRows: setInterfaces,
api: interfacesApi,
columns: [
{ key: 'name' as const, label: 'Interface' },
{ key: 'zone_id' as const, label: 'Zone', render: (r: Interface) => zones.find((z) => z.id === r.zone_id)?.name ?? r.zone_id },
{ key: 'options' as const, label: 'Options' },
] as Column<Interface>[],
fields: [
{ name: 'name', label: 'Interface Name', required: true },
{ name: 'zone_id', label: 'Zone', required: true, type: 'select' as const, options: zoneOptions },
{ name: 'options', label: 'Options' },
] as FieldDef[],
},
{
label: 'Policies',
rows: policies,
setRows: setPolicies,
api: policiesApi,
columns: [
{ key: 'src_zone_id' as const, label: 'Source', render: (r: Policy) => zones.find((z) => z.id === r.src_zone_id)?.name ?? r.src_zone_id },
{ key: 'dst_zone_id' as const, label: 'Destination', render: (r: Policy) => zones.find((z) => z.id === r.dst_zone_id)?.name ?? r.dst_zone_id },
{ key: 'policy' as const, label: 'Policy' },
{ key: 'log_level' as const, label: 'Log Level' },
{ key: 'position' as const, label: 'Position' },
] as Column<Policy>[],
fields: [
{ name: 'src_zone_id', label: 'Source Zone', required: true, type: 'select' as const, options: zoneOptions },
{ name: 'dst_zone_id', label: 'Destination Zone', required: true, type: 'select' as const, options: zoneOptions },
{ name: 'policy', label: 'Policy', required: true, type: 'select' as const, options: [{ value: 'ACCEPT', label: 'ACCEPT' }, { value: 'DROP', label: 'DROP' }, { value: 'REJECT', label: 'REJECT' }, { value: 'CONTINUE', label: 'CONTINUE' }] },
{ name: 'log_level', label: 'Log Level' },
{ name: 'comment', label: 'Comment' },
{ name: 'position', label: 'Position', type: 'number' as const },
] as FieldDef[],
},
{
label: 'Rules',
rows: rules,
setRows: setRules,
api: rulesApi,
columns: [
{ key: 'action' as const, label: 'Action' },
{ key: 'src_zone_id' as const, label: 'Source', render: (r: Rule) => r.src_zone_id ? (zones.find((z) => z.id === r.src_zone_id)?.name ?? r.src_zone_id) : 'all' },
{ key: 'dst_zone_id' as const, label: 'Destination', render: (r: Rule) => r.dst_zone_id ? (zones.find((z) => z.id === r.dst_zone_id)?.name ?? r.dst_zone_id) : 'all' },
{ key: 'proto' as const, label: 'Proto' },
{ key: 'dport' as const, label: 'DPort' },
{ key: 'position' as const, label: 'Position' },
] as Column<Rule>[],
fields: [
{ name: 'action', label: 'Action', required: true },
{ name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: zoneOptions },
{ name: 'dst_zone_id', label: 'Dest Zone', type: 'select' as const, options: zoneOptions },
{ name: 'src_ip', label: 'Source IP/CIDR' },
{ name: 'dst_ip', label: 'Dest IP/CIDR' },
{ name: 'proto', label: 'Protocol' },
{ name: 'dport', label: 'Dest Port' },
{ name: 'sport', label: 'Source Port' },
{ name: 'comment', label: 'Comment' },
{ name: 'position', label: 'Position', type: 'number' as const },
] as FieldDef[],
},
{
label: 'Masq/NAT',
rows: masq,
setRows: setMasq,
api: masqApi,
columns: [
{ key: 'out_interface' as const, label: 'Out Interface' },
{ key: 'source_network' as const, label: 'Source Network' },
{ key: 'to_address' as const, label: 'To Address' },
{ key: 'comment' as const, label: 'Comment' },
] as Column<Masq>[],
fields: [
{ name: 'out_interface', label: 'Out Interface', required: true },
{ name: 'source_network', label: 'Source Network', required: true },
{ name: 'to_address', label: 'To Address' },
{ name: 'comment', label: 'Comment' },
] as FieldDef[],
},
]
const current = tabConfig[tab]
const handleSubmit = async (values: Record<string, unknown>) => {
if (editing && editing.id) {
await current.api.update(configId, editing.id as number, values)
} else {
await current.api.create(configId, values)
}
const res = await current.api.list(configId)
current.setRows(res.data)
setFormOpen(false)
setEditing(null)
}
const handleDelete = async (row: { id: number }) => {
if (!confirm('Delete this entry?')) return
await current.api.delete(configId, row.id)
const res = await current.api.list(configId)
current.setRows(res.data)
}
return (
<Layout title={configName || 'Config Detail'}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Breadcrumbs>
<Link to="/configs" style={{ color: 'inherit', textDecoration: 'none' }}>
<Typography variant="body2" color="text.secondary">Configurations</Typography>
</Link>
<Typography variant="body2">{configName}</Typography>
</Breadcrumbs>
<Button variant="contained" startIcon={<BuildIcon />} onClick={() => setGenerateOpen(true)}>
Generate Config
</Button>
</Box>
<Box sx={{ bgcolor: 'white', borderRadius: 2, border: '1px solid #e2e8f0', overflow: 'hidden' }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
{tabConfig.map((tc) => <Tab key={tc.label} label={tc.label} />)}
</Tabs>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => { setEditing(null); setFormOpen(true) }}>
Add {current.label.replace('/NAT', '')}
</Button>
</Box>
<DataTable
columns={current.columns as Column<{ id: number }>[]}
rows={current.rows as { id: number }[]}
onEdit={(row) => { setEditing(row as Record<string, unknown>); setFormOpen(true) }}
onDelete={handleDelete}
/>
</Box>
</Box>
<EntityForm
open={formOpen}
title={`${editing ? 'Edit' : 'Add'} ${current.label}`}
fields={current.fields}
initialValues={editing ?? undefined}
onClose={() => { setFormOpen(false); setEditing(null) }}
onSubmit={handleSubmit}
/>
<GenerateModal
open={generateOpen}
configId={configId}
configName={configName}
onClose={() => setGenerateOpen(false)}
/>
</Layout>
)
}
Step 2: Commit
git add frontend/src/routes/ConfigDetail.tsx
git commit -m "feat: add Config Detail page with tabbed entity management"
Phase 6: Helm Charts
Task 21: Helm chart scaffolding and values
Files:
- Create:
helm/shorefront/Chart.yaml - Create:
helm/shorefront/values.yaml - Create:
helm/shorefront/values-prod.yaml - Create:
helm/shorefront/templates/_helpers.tpl
Step 1: Write helm/shorefront/Chart.yaml
apiVersion: v2
name: shorefront
description: Shorewall configuration manager
type: application
version: 0.1.0
appVersion: "0.1.0"
Step 2: Write helm/shorefront/values.yaml
namespace: shorefront
backend:
image: shorefront-backend
tag: latest
replicas: 1
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
frontend:
image: shorefront-frontend
tag: latest
replicas: 1
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 200m, memory: 128Mi }
postgres:
image: postgres
tag: "15-alpine"
database: shorefront
user: shorefront
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
nfs:
server: 192.168.17.199
path: /mnt/user/kubernetesdata/shorefront
storage: 5Gi
ingress:
host: shorefront.example.com
ingressClassName: traefik
secrets:
postgresPassword: changeme-in-prod
jwtSecretKey: changeme-in-prod
Step 3: Write helm/shorefront/values-prod.yaml
ingress:
host: shorefront.yourdomain.com
# Override secrets at deploy time:
# helm upgrade --install shorefront ./helm/shorefront \
# --values helm/shorefront/values-prod.yaml \
# --set secrets.postgresPassword=<real-password> \
# --set secrets.jwtSecretKey=<real-jwt-secret>
Step 4: Write helm/shorefront/templates/_helpers.tpl
{{- define "shorefront.name" -}}
{{- .Release.Name }}
{{- end }}
{{- define "shorefront.labels" -}}
app.kubernetes.io/name: {{ include "shorefront.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
Step 5: Commit
git add helm/shorefront/Chart.yaml helm/shorefront/values.yaml helm/shorefront/values-prod.yaml helm/shorefront/templates/_helpers.tpl
git commit -m "feat: add Helm chart scaffolding and values"
Task 22: Helm templates — namespace, secret, configmap
Files:
- Create:
helm/shorefront/templates/namespace.yaml - Create:
helm/shorefront/templates/secret.yaml - Create:
helm/shorefront/templates/configmap.yaml
Step 1: Write namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
Step 2: Write secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: shorefront-secret
namespace: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
type: Opaque
stringData:
POSTGRES_PASSWORD: {{ .Values.secrets.postgresPassword | quote }}
JWT_SECRET_KEY: {{ .Values.secrets.jwtSecretKey | quote }}
Step 3: Write configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: shorefront-config
namespace: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
data:
POSTGRES_DB: {{ .Values.postgres.database | quote }}
POSTGRES_USER: {{ .Values.postgres.user | quote }}
DATABASE_URL: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
JWT_ALGORITHM: "HS256"
JWT_EXPIRE_MINUTES: "60"
Step 4: Commit
git add helm/shorefront/templates/namespace.yaml helm/shorefront/templates/secret.yaml helm/shorefront/templates/configmap.yaml
git commit -m "feat: add Helm namespace, secret, and configmap templates"
Task 23: Helm templates — NFS PV/PVC and Postgres
Files:
- Create:
helm/shorefront/templates/pv.yaml - Create:
helm/shorefront/templates/pvc.yaml - Create:
helm/shorefront/templates/postgres-deployment.yaml - Create:
helm/shorefront/templates/postgres-service.yaml
Step 1: Write pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: shorefront-postgres-pv
labels:
{{- include "shorefront.labels" . | nindent 4 }}
spec:
capacity:
storage: {{ .Values.nfs.storage }}
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
nfs:
server: {{ .Values.nfs.server }}
path: {{ .Values.nfs.path }}
Step 2: Write pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shorefront-postgres-pvc
namespace: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
storageClassName: ""
volumeName: shorefront-postgres-pv
resources:
requests:
storage: {{ .Values.nfs.storage }}
Step 3: Write postgres-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: "{{ .Values.postgres.image }}:{{ .Values.postgres.tag }}"
env:
- name: POSTGRES_DB
valueFrom:
configMapKeyRef:
name: shorefront-config
key: POSTGRES_DB
- name: POSTGRES_USER
valueFrom:
configMapKeyRef:
name: shorefront-config
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: shorefront-secret
key: POSTGRES_PASSWORD
ports:
- containerPort: 5432
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
resources:
{{- toYaml .Values.postgres.resources | nindent 12 }}
readinessProbe:
exec:
command: ["pg_isready", "-U", "{{ .Values.postgres.user }}"]
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: pgdata
persistentVolumeClaim:
claimName: shorefront-postgres-pvc
Step 4: Write postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: {{ .Values.namespace }}
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
type: ClusterIP
Step 5: Commit
git add helm/shorefront/templates/pv.yaml helm/shorefront/templates/pvc.yaml helm/shorefront/templates/postgres-deployment.yaml helm/shorefront/templates/postgres-service.yaml
git commit -m "feat: add Helm PV/PVC and Postgres deployment templates"
Task 24: Helm templates — backend and frontend deployments + ingress
Files:
- Create:
helm/shorefront/templates/backend-deployment.yaml - Create:
helm/shorefront/templates/backend-service.yaml - Create:
helm/shorefront/templates/frontend-deployment.yaml - Create:
helm/shorefront/templates/frontend-service.yaml - Create:
helm/shorefront/templates/ingress.yaml
Step 1: Write backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
app: backend
spec:
replicas: {{ .Values.backend.replicas }}
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
initContainers:
- name: migrate
image: "{{ .Values.backend.image }}:{{ .Values.backend.tag }}"
command: ["alembic", "upgrade", "head"]
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: shorefront-secret
key: POSTGRES_PASSWORD
- name: DATABASE_URL
value: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
containers:
- name: backend
image: "{{ .Values.backend.image }}:{{ .Values.backend.tag }}"
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: shorefront-secret
key: POSTGRES_PASSWORD
- name: JWT_SECRET_KEY
valueFrom:
secretKeyRef:
name: shorefront-secret
key: JWT_SECRET_KEY
- name: DATABASE_URL
value: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
- name: JWT_ALGORITHM
valueFrom:
configMapKeyRef:
name: shorefront-config
key: JWT_ALGORITHM
- name: JWT_EXPIRE_MINUTES
valueFrom:
configMapKeyRef:
name: shorefront-config
key: JWT_EXPIRE_MINUTES
ports:
- containerPort: 8000
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
Step 2: Write backend-service.yaml
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: {{ .Values.namespace }}
spec:
selector:
app: backend
ports:
- port: 8000
targetPort: 8000
type: ClusterIP
Step 3: Write frontend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
app: frontend
spec:
replicas: {{ .Values.frontend.replicas }}
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: "{{ .Values.frontend.image }}:{{ .Values.frontend.tag }}"
ports:
- containerPort: 80
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
Step 4: Write frontend-service.yaml
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: {{ .Values.namespace }}
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80
type: ClusterIP
Step 5: Write ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shorefront
namespace: {{ .Values.namespace }}
labels:
{{- include "shorefront.labels" . | nindent 4 }}
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
spec:
ingressClassName: {{ .Values.ingress.ingressClassName }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 80
Step 6: Commit
git add helm/shorefront/templates/backend-deployment.yaml helm/shorefront/templates/backend-service.yaml helm/shorefront/templates/frontend-deployment.yaml helm/shorefront/templates/frontend-service.yaml helm/shorefront/templates/ingress.yaml
git commit -m "feat: add Helm backend, frontend deployment and ingress templates"
Phase 7: Documentation
Task 25: README
Files:
- Create:
README.md
Step 1: Write README.md
# Shorefront
A production-ready web application for managing [Shorewall](http://shorewall.net/) firewall configurations.
## Stack
- **Backend:** Python 3.12, FastAPI, SQLAlchemy 2, Alembic, PostgreSQL
- **Frontend:** React 18, TypeScript, Vite, MUI v5, React Router v6
- **Infra:** Docker Compose (local dev), Helm + Kubernetes + Traefik (production)
---
## Quick Start (Docker Compose)
```bash
# 1. Clone and enter the repo
git clone <repo-url> shorefront && cd shorefront
# 2. Copy env files
cp backend/.env.example .env
# 3. Start everything (postgres + backend + frontend)
docker compose up --build
# 4. Open http://localhost
Default credentials: admin / admin (change immediately in production).
Development (without Docker)
Backend:
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Set env vars (or create .env):
export DATABASE_URL=postgresql://shorefront:changeme@localhost:5432/shorefront
export JWT_SECRET_KEY=dev-secret
alembic upgrade head
uvicorn app.main:app --reload
Frontend:
cd frontend
npm install
npm run dev # Vite dev server on http://localhost:5173, proxies /api to localhost:8000
First Steps After Login
- Log in at
/loginwith admin / admin. - A sample homelab config is pre-loaded with zones, interfaces, policies, and a masquerade entry.
- Navigate to the config → click Generate Config to preview or download the Shorewall files.
- Create your own configs from the Configurations page.
Generating Shorewall Files
On the Config Detail page, click Generate Config:
- Preview: File contents appear in a tabbed modal (zones / interfaces / policy / rules / masq).
- Download ZIP: Downloads
<config-name>-shorewall.zipcontaining all five files ready to copy to/etc/shorewall/.
Kubernetes Deployment (Helm)
# Build and push images first
docker build -t <registry>/shorefront-backend:latest ./backend
docker build -t <registry>/shorefront-frontend:latest ./frontend
docker push <registry>/shorefront-backend:latest
docker push <registry>/shorefront-frontend:latest
# Deploy
helm upgrade --install shorefront ./helm/shorefront \
--values ./helm/shorefront/values-prod.yaml \
--set backend.image=<registry>/shorefront-backend \
--set frontend.image=<registry>/shorefront-frontend \
--set secrets.postgresPassword=<strong-password> \
--set secrets.jwtSecretKey=<strong-jwt-secret> \
--set ingress.host=shorefront.yourdomain.com
# Check rollout
kubectl rollout status deployment/backend -n shorefront
kubectl rollout status deployment/frontend -n shorefront
NFS Storage: Postgres data is persisted to 192.168.17.199:/mnt/user/kubernetesdata/shorefront. Ensure the NFS export is accessible from your cluster nodes before deploying.
API Docs
FastAPI auto-generates interactive docs at:
- Swagger UI:
http://localhost:8000/docs - ReDoc:
http://localhost:8000/redoc
**Step 2: Commit**
```bash
git add README.md
git commit -m "docs: add README with quickstart and deployment instructions"
Final Verification
After all tasks are complete, verify the full stack runs:
# Start Docker Compose
docker compose up --build
# Verify backend health
curl http://localhost:8000/health
# Expected: {"status":"ok"}
# Verify frontend loads
curl -s http://localhost | grep "Shorefront"
# Expected: HTML with Shorefront in title
# Test login
curl -c cookies.txt -X POST http://localhost/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}'
# Expected: {"message":"Logged in"}
# List configs
curl -b cookies.txt http://localhost/api/configs
# Expected: JSON array with "homelab" config
# Generate files
curl -b cookies.txt -X POST "http://localhost/api/configs/1/generate"
# Expected: JSON with zones, interfaces, policy, rules, masq keys
Then open http://localhost in a browser, log in, and confirm the UI works end-to-end.