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