3139 lines
92 KiB
Markdown
3139 lines
92 KiB
Markdown
# 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
|
|
<!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**
|
|
|
|
```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(
|
|
<React.StrictMode>
|
|
<ThemeProvider theme={theme}>
|
|
<CssBaseline />
|
|
<App />
|
|
</ThemeProvider>
|
|
</React.StrictMode>
|
|
)
|
|
```
|
|
|
|
**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 (
|
|
<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**
|
|
|
|
```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<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**
|
|
|
|
```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 <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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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=<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**
|
|
|
|
```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 <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:**
|
|
```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 `<config-name>-shorewall.zip` containing all five files ready to copy to `/etc/shorewall/`.
|
|
|
|
---
|
|
|
|
## Kubernetes Deployment (Helm)
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```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.
|