Compare commits

..

20 Commits

Author SHA1 Message Date
956f0c2cf5 docs: update README to reflect OIDC auth, secrets management, and CI/CD
Replace admin/admin credentials with Keycloak OIDC flow, fix Kubernetes
deploy section to use create-secrets.sh instead of --set secrets.*, add
CI/CD section, and update domain model references (masq→snat, new hosts/params).
2026-03-07 12:36:11 +01:00
2b9cbfd884 chore: add .gitignore and track frontend/package-lock.json
Some checks failed
Build containers when image tags change / build-if-image-changed (backend, shorefront-backend, shorefront backend, backend/Dockerfile, git.baumann.gr/adebaumann/shorefront-backend, .backend.image) (push) Has been cancelled
Build containers when image tags change / build-if-image-changed (frontend, shorefront-frontend, shorefront frontend, frontend/Dockerfile, git.baumann.gr/adebaumann/shorefront-frontend, .frontend.image) (push) Has been cancelled
2026-03-01 22:07:45 +01:00
6699bf7421 docs: document command-line download via config token in README 2026-03-01 22:03:30 +01:00
881f812fa8 Bump
All checks were successful
Build containers when image tags change / build-if-image-changed (frontend, shorefront-frontend, shorefront frontend, frontend/Dockerfile, git.baumann.gr/adebaumann/shorefront-frontend, .frontend.image) (push) Successful in 1m53s
Build containers when image tags change / build-if-image-changed (backend, shorefront-backend, shorefront backend, backend/Dockerfile, git.baumann.gr/adebaumann/shorefront-backend, .backend.image) (push) Successful in 1m58s
2026-03-01 17:50:13 +01:00
593daa17bf fix: move readOnly into inputProps on download token OutlinedInput 2026-03-01 16:39:42 +01:00
0943798399 feat: show download token with copy and regenerate on Config Detail 2026-03-01 16:33:38 +01:00
c24c00b307 feat: add regenerateToken to configsApi 2026-03-01 16:27:11 +01:00
ab181e802f fix: remove unused Response import, constrain format param to Literal[json,zip] 2026-03-01 16:26:43 +01:00
b71e1e5989 feat: token auth on generate endpoint and regenerate-token endpoint 2026-03-01 16:24:28 +01:00
2e0cda834b feat: add get_optional_user for unauthenticated generate access 2026-03-01 16:05:15 +01:00
29b71b267e feat: reject empty-string tokens in GenerateRequest (min_length=1) 2026-03-01 16:04:43 +01:00
3b90373b78 feat: add download_token to ConfigOut, add GenerateRequest and RegenerateTokenOut schemas 2026-03-01 16:02:38 +01:00
9b15c081b0 feat: add index on configs.download_token for token-auth lookups 2026-03-01 16:01:57 +01:00
e9a91a7794 feat: add download_token field to Config model 2026-03-01 15:59:59 +01:00
d6e3904f0a fix: remove permanent server_default from download_token migration 2026-03-01 15:59:20 +01:00
c55d73fd58 feat: migration 0012 — add download_token to configs 2026-03-01 15:35:21 +01:00
ad35b00023 docs: add implementation plan for config download token 2026-03-01 15:33:19 +01:00
d28d034a17 docs: add design doc for config download token 2026-03-01 15:31:42 +01:00
4d0164ed02 chore: use storageClassName nfs for postgres PV/PVC
All checks were successful
Build containers when image tags change / build-if-image-changed (backend, shorefront-backend, shorefront backend, backend/Dockerfile, git.baumann.gr/adebaumann/shorefront-backend, .backend.image) (push) Successful in 11s
Build containers when image tags change / build-if-image-changed (frontend, shorefront-frontend, shorefront frontend, frontend/Dockerfile, git.baumann.gr/adebaumann/shorefront-frontend, .frontend.image) (push) Successful in 11s
2026-03-01 14:54:45 +01:00
426fb8fbfd chore: use existing 'nfs' PV instead of creating one
All checks were successful
Build containers when image tags change / build-if-image-changed (backend, shorefront-backend, shorefront backend, backend/Dockerfile, git.baumann.gr/adebaumann/shorefront-backend, .backend.image) (push) Successful in 44s
Build containers when image tags change / build-if-image-changed (frontend, shorefront-frontend, shorefront frontend, frontend/Dockerfile, git.baumann.gr/adebaumann/shorefront-frontend, .frontend.image) (push) Successful in 1m34s
2026-03-01 14:49:56 +01:00
15 changed files with 4962 additions and 33 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.env
.venv/
venv/
# Frontend
frontend/node_modules/
frontend/dist/
frontend/.env.local
# Generated shorewall output
shorewall/
shorewall.tar.gz
# Secrets
secrets
secrets/
# Editor / OS
.DS_Store
.idea/
.vscode/
*.swp

View File

@@ -6,6 +6,7 @@ A production-ready web application for managing [Shorewall](http://shorewall.net
- **Backend:** Python 3.12, FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 15
- **Frontend:** React 18, TypeScript, Vite, MUI v5, React Router v6, Axios
- **Auth:** Keycloak (OIDC), JWT session cookie
- **Infra:** Docker Compose (local dev), Helm + Kubernetes + Traefik (production)
---
@@ -22,7 +23,7 @@ docker compose up --build
# 3. Open http://localhost
```
Default credentials: **admin** / **admin** — change on first login.
Authentication is handled via Keycloak OIDC. You will be redirected to your Keycloak instance to log in. Your Keycloak user must be a member of the **firewall admins** group.
---
@@ -37,6 +38,11 @@ pip install -r requirements.txt
# Set environment variables
export DATABASE_URL=postgresql://shorefront:changeme@localhost:5432/shorefront
export JWT_SECRET_KEY=dev-secret
export KEYCLOAK_URL=https://sso.example.com
export KEYCLOAK_REALM=myrealm
export KEYCLOAK_CLIENT_ID=shorefront
export KEYCLOAK_CLIENT_SECRET=<client-secret>
export KEYCLOAK_REDIRECT_URI=http://localhost:8000/auth/oidc/callback
# Run migrations (creates schema + seed data)
alembic upgrade head
@@ -60,12 +66,12 @@ npm run dev
## First Steps After Login
1. Log in at `/login` with **admin** / **admin**.
1. Log in — you will be redirected to Keycloak. Your account must be in the **firewall admins** group.
2. A sample **homelab** config is pre-loaded with:
- Zones: `fw` (firewall), `net` (ipv4), `loc` (ipv4)
- Interface: `eth0` → zone `net`
- Policies: loc→net ACCEPT, net→fw DROP, etc.
- Masq: `192.168.1.0/24` via `eth0`
- SNAT: `192.168.1.0/24` via `eth0`
3. Click **homelab** to open the Config Detail page.
4. Click **Generate Config** to preview or download the Shorewall files.
5. Create your own configs from the **Configurations** page.
@@ -76,8 +82,33 @@ npm run dev
On the Config Detail page, click **Generate Config**:
- **Preview:** File contents appear in a tabbed modal (zones / interfaces / policy / rules / masq) with copy-to-clipboard buttons.
- **Download ZIP:** Downloads `<config-name>-shorewall.zip` with all five files ready to copy to `/etc/shorewall/`.
- **Preview:** File contents appear in a tabbed modal (zones / interfaces / policy / rules / snat) with copy-to-clipboard buttons.
- **Download ZIP:** Downloads `<config-name>-shorewall.zip` with all files ready to copy to `/etc/shorewall/`.
---
## Command-Line Download
Each config has a **Download Token** — a secret string that allows downloading the generated ZIP without an active session. Useful for automation scripts and CI pipelines.
### Finding your token
Open a config in the UI. The **Download Token** field is shown above the tabs. Click the copy icon to copy it.
### Downloading via curl
```bash
curl -X POST "https://<host>/api/configs/<config-id>/generate?format=zip" \
-H 'Content-Type: application/json' \
-d '{"token": "<your-download-token>"}' \
-o shorewall.zip
```
Replace `<config-id>` with the numeric ID visible in the URL when you open a config (e.g. `/configs/1`).
### Rotating the token
Click the **Regenerate** button (⟳) next to the token field. The old token is immediately invalidated. Update any scripts that use it.
---
@@ -96,30 +127,34 @@ FastAPI generates interactive docs automatically:
- Kubernetes cluster with Traefik as the ingress controller
- NFS share accessible at `192.168.17.199:/mnt/user/kubernetesdata/shorefront`
- Images pushed to a container registry
- Keycloak instance with a `shorefront` client configured
- Images available in the container registry (see CI/CD below)
### Build and Push Images
### 1. Create Secrets
Helm does **not** manage the Kubernetes secret. Run this once before the first deploy (and whenever credentials change):
```bash
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
export POSTGRES_PASSWORD=<strong-password>
export JWT_SECRET_KEY=<strong-jwt-secret>
export KEYCLOAK_CLIENT_SECRET=<keycloak-client-secret>
bash scripts/create-secrets.sh
```
### Deploy
This creates/updates the `shorefront-secret` Secret in the `shorefront` namespace.
### 2. Deploy
```bash
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 ingress.host=shorefront.yourdomain.com \
--set secrets.postgresPassword=<strong-password> \
--set secrets.jwtSecretKey=<strong-jwt-secret>
--namespace shorefront \
--create-namespace
```
### Verify Rollout
Override values as needed (e.g. ingress host, Keycloak URL) via `--set` or a custom values file.
### 3. Verify Rollout
```bash
kubectl rollout status deployment/backend -n shorefront
@@ -142,6 +177,18 @@ kubectl delete pv shorefront-postgres-pv
---
## CI/CD
A Gitea Actions workflow (`.gitea/workflows/build-containers-on-demand.yml`) automatically builds and pushes images when `helm/shorefront/values.yaml`, `frontend/Dockerfile`, or `backend/Dockerfile` change.
- Image tag is read from `containers.version` in `values.yaml`
- Images are pushed to `git.baumann.gr/adebaumann/shorefront-{frontend,backend}:<version>`
- Requires a `REGISTRY_TOKEN` secret configured in Gitea
**To release a new version:** bump `containers.version` in `values.yaml` and push. The workflow builds and pushes both images. Then run `helm upgrade` to deploy.
---
## Project Structure
```
@@ -154,9 +201,18 @@ shorefront/
│ ├── main.py # FastAPI app
│ ├── models.py # SQLAlchemy ORM models
│ ├── schemas.py # Pydantic schemas
│ ├── auth.py # JWT auth
│ ├── auth.py # JWT + OIDC auth
│ ├── shorewall_generator.py
│ └── api/ # Route handlers
│ ├── auth.py # OIDC login/callback/logout
│ ├── configs.py # Config CRUD + generate
│ ├── zones.py
│ ├── interfaces.py
│ ├── policies.py
│ ├── rules.py
│ ├── snat.py
│ ├── hosts.py
│ └── params.py
├── frontend/
│ ├── Dockerfile
│ ├── nginx.conf
@@ -166,6 +222,8 @@ shorefront/
│ ├── routes/ # Page components
│ └── components/ # Shared UI components
├── helm/shorefront/ # Kubernetes Helm chart
├── scripts/
│ └── create-secrets.sh # Creates k8s Secret before deploy
├── docker-compose.yml
└── README.md
```

View File

@@ -0,0 +1,39 @@
"""add download_token to configs
Revision ID: 0012
Revises: 0011
Create Date: 2026-03-01
"""
import secrets
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"configs",
sa.Column("download_token", sa.String(64), nullable=False, server_default="''"),
)
# Backfill existing rows with unique tokens
configs = table("configs", column("id", sa.Integer), column("download_token", sa.String(64)))
conn = op.get_bind()
for row in conn.execute(sa.select(configs.c.id)):
conn.execute(
configs.update()
.where(configs.c.id == row.id)
.values(download_token=secrets.token_urlsafe(32))
)
# Remove the DB-level default — ORM model provides Python-level default
op.alter_column("configs", "download_token", server_default=None)
op.create_index("ix_configs_download_token", "configs", ["download_token"])
def downgrade() -> None:
op.drop_index("ix_configs_download_token", table_name="configs")
op.drop_column("configs", "download_token")

View File

@@ -1,11 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
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.auth import get_current_user, get_optional_user
from app.database import get_db
from app.shorewall_generator import ShorewallGenerator
import io
import secrets
router = APIRouter()
@@ -79,10 +81,36 @@ def delete_config(
@router.post("/{config_id}/generate")
def generate_config(
config_id: int,
format: str = "json",
format: Literal["json", "zip"] = "json",
body: Optional[schemas.GenerateRequest] = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
current_user: Optional[models.User] = Depends(get_optional_user),
):
token = body.token if body else None
# Determine access: OIDC session or matching download token
if current_user is not None:
# Authenticated via OIDC — enforce owner check
config = (
db.query(models.Config)
.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")
elif token:
# Token auth — no owner filter, just match token
config = (
db.query(models.Config)
.filter(models.Config.id == config_id, models.Config.download_token == token)
.first()
)
if not config:
raise HTTPException(status_code=401, detail="Invalid token")
else:
raise HTTPException(status_code=401, detail="Authentication required")
# Eagerly load relationships
config = (
db.query(models.Config)
.options(
@@ -96,11 +124,9 @@ def generate_config(
selectinload(models.Config.host_entries).selectinload(models.Host.zone),
selectinload(models.Config.params),
)
.filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
.filter(models.Config.id == config_id)
.first()
)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
generator = ShorewallGenerator(config)
@@ -113,3 +139,16 @@ def generate_config(
)
return generator.as_json()
@router.post("/{config_id}/regenerate-token", response_model=schemas.RegenerateTokenOut)
def regenerate_token(
config_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> dict:
config = _get_config_or_404(config_id, db, current_user)
config.download_token = secrets.token_urlsafe(32)
db.commit()
db.refresh(config)
return {"download_token": config.download_token}

View File

@@ -51,3 +51,15 @@ def get_current_user(
if user is None or not user.is_active:
raise credentials_exception
return user
def get_optional_user(
access_token: Optional[str] = Cookie(default=None),
db: Session = Depends(get_db),
) -> Optional[models.User]:
if not access_token:
return None
try:
return get_current_user(access_token=access_token, db=db)
except HTTPException:
return None

View File

@@ -1,3 +1,4 @@
import secrets
from datetime import datetime
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
@@ -27,6 +28,12 @@ class Config(Base):
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())
download_token: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
default=lambda: secrets.token_urlsafe(32),
)
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
owner: Mapped["User"] = relationship("User", back_populates="configs")

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
# --- Auth ---
@@ -34,6 +34,7 @@ class ConfigOut(BaseModel):
created_at: datetime
updated_at: datetime
owner_id: int
download_token: str
model_config = {"from_attributes": True}
@@ -293,6 +294,14 @@ class ParamOut(BaseModel):
# --- Generate ---
class GenerateRequest(BaseModel):
token: Optional[str] = Field(default=None, min_length=1)
class RegenerateTokenOut(BaseModel):
download_token: str
class GenerateOut(BaseModel):
zones: str
interfaces: str

View File

@@ -0,0 +1,77 @@
# Config Download Token Design
**Date:** 2026-03-01
**Status:** Approved
## Problem
Downloading a generated config ZIP from the command line requires extracting an httpOnly OIDC
session cookie from the browser, which is fragile and not scriptable. Users need a stable,
per-config token they can embed in automation scripts.
## Solution
Add a `download_token` field to each `Config`. The existing generate endpoint accepts this token
in the POST body as an alternative to OIDC cookie auth, allowing unauthenticated-but-authorized
downloads.
## Data Model
- Add `download_token: str` column to `configs` table.
- Value: `secrets.token_urlsafe(32)` — 32 bytes of URL-safe random data (43 characters).
- Generated automatically on config creation.
- Stored as plaintext (the token is low-value; it only grants read access to a single config's
generated output).
- Alembic migration backfills existing configs with auto-generated tokens.
## API Changes
### Modified: `POST /api/configs/{id}/generate`
Accepts an optional JSON body:
```json
{ "token": "..." }
```
Auth logic (either is sufficient):
1. Valid OIDC `access_token` cookie + `owner_id` match
2. `token` in body matches `config.download_token` (no owner filter needed)
Error responses:
- No cookie and no/wrong token → 401
- Valid token but wrong config ID → 404
Example curl usage:
```bash
curl -X POST "https://host/api/configs/1/generate?format=zip" \
-H 'Content-Type: application/json' \
-d '{"token": "abc..."}' -o shorewall.zip
```
### New: `POST /api/configs/{id}/regenerate-token`
- OIDC-protected, owner-only.
- Generates a new `secrets.token_urlsafe(32)`, saves it to the config, returns it.
- Response: `{ "download_token": "..." }`
- Non-owner → 403.
## Schema Changes
- `ConfigOut` gains `download_token: str`.
- New `GenerateRequest` Pydantic model: `token: Optional[str] = None`.
## Frontend Changes
On the Config Detail page header area (above the tabs):
- Read-only text field showing `download_token`.
- Copy-to-clipboard icon button.
- "Regenerate" button that calls the new endpoint and updates the displayed value.
## Migration
New Alembic migration `0012_config_add_download_token.py`:
- Add `download_token` column with `server_default=''`.
- Backfill with `secrets.token_urlsafe(32)` for all existing rows via a data migration step.

View File

@@ -0,0 +1,506 @@
# Config Download Token Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a per-config `download_token` field that allows downloading the generated ZIP without OIDC auth by POSTing the token in the request body.
**Architecture:** A `download_token` (random 43-char URL-safe string) is added to the `Config` model and auto-generated on creation. The existing `POST /configs/{id}/generate` endpoint accepts an optional JSON body; if the token matches, access is granted regardless of OIDC session. A new `POST /configs/{id}/regenerate-token` endpoint (OIDC-only) replaces the token. The frontend shows the token on the Config Detail page with copy and regenerate buttons.
**Tech Stack:** Python `secrets.token_urlsafe`, FastAPI optional body with `Optional` dependency, SQLAlchemy, Alembic, React + MUI.
---
### Task 1: Alembic migration — add `download_token` to configs
**Files:**
- Create: `backend/alembic/versions/0012_config_add_download_token.py`
**Step 1: Create the migration file**
```python
"""add download_token to configs
Revision ID: 0012
Revises: 0011
Create Date: 2026-03-01
"""
import secrets
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"configs",
sa.Column("download_token", sa.String(64), nullable=False, server_default=""),
)
# Backfill existing rows with unique tokens
configs = table("configs", column("id", sa.Integer), column("download_token", sa.String(64)))
conn = op.get_bind()
for row in conn.execute(sa.select(configs.c.id)):
conn.execute(
configs.update()
.where(configs.c.id == row.id)
.values(download_token=secrets.token_urlsafe(32))
)
def downgrade() -> None:
op.drop_column("configs", "download_token")
```
**Step 2: Verify migration runs (if you have a local DB)**
```bash
cd backend && alembic upgrade head
```
Expected: no errors, `configs` table gains a `download_token` column with non-empty values.
**Step 3: Commit**
```bash
git add backend/alembic/versions/0012_config_add_download_token.py
git commit -m "feat: migration 0012 — add download_token to configs"
```
---
### Task 2: SQLAlchemy model — add `download_token` field
**Files:**
- Modify: `backend/app/models.py:21-39`
**Step 1: Add the field to the `Config` model**
In `backend/app/models.py`, add this import at the top alongside existing imports:
```python
import secrets
```
Then add `download_token` to the `Config` class after `updated_at`:
```python
download_token: Mapped[str] = mapped_column(
String(64),
nullable=False,
default=lambda: secrets.token_urlsafe(32),
)
```
The `default=lambda: secrets.token_urlsafe(32)` ensures new configs created via SQLAlchemy get a token automatically.
**Step 2: Commit**
```bash
git add backend/app/models.py
git commit -m "feat: add download_token field to Config model"
```
---
### Task 3: Pydantic schemas — expose token and add request body
**Files:**
- Modify: `backend/app/schemas.py`
**Step 1: Add `download_token` to `ConfigOut`**
In `backend/app/schemas.py`, update `ConfigOut` (currently lines 29-38) to add:
```python
class ConfigOut(BaseModel):
id: int
name: str
description: str
is_active: bool
created_at: datetime
updated_at: datetime
owner_id: int
download_token: str # ← add this line
model_config = {"from_attributes": True}
```
**Step 2: Add `GenerateRequest` body schema**
Add this new class near the bottom of `backend/app/schemas.py`, before `GenerateOut`:
```python
# --- Generate request ---
class GenerateRequest(BaseModel):
token: Optional[str] = None
```
`Optional` is already imported at line 3.
**Step 3: Add `RegenerateTokenOut` response schema**
```python
class RegenerateTokenOut(BaseModel):
download_token: str
```
**Step 4: Commit**
```bash
git add backend/app/schemas.py
git commit -m "feat: add download_token to ConfigOut and GenerateRequest schema"
```
---
### Task 4: API — modify generate endpoint and add regenerate-token endpoint
**Files:**
- Modify: `backend/app/api/configs.py`
**Step 1: Update imports**
At the top of `backend/app/api/configs.py`, add `Optional` to the imports:
```python
from typing import Optional
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
import secrets
```
**Step 2: Replace the `generate_config` endpoint**
Replace the entire `generate_config` function (lines 79-115) with:
```python
@router.post("/{config_id}/generate")
def generate_config(
config_id: int,
format: str = "json",
body: Optional[schemas.GenerateRequest] = None,
db: Session = Depends(get_db),
current_user: Optional[models.User] = Depends(get_current_user),
):
token = body.token if body else None
# Determine access: OIDC session or matching download token
if current_user is not None:
# Authenticated via OIDC — enforce owner check
config = (
db.query(models.Config)
.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")
elif token:
# Token auth — no owner filter, just match token
config = (
db.query(models.Config)
.filter(models.Config.id == config_id, models.Config.download_token == token)
.first()
)
if not config:
raise HTTPException(status_code=401, detail="Invalid token")
else:
raise HTTPException(status_code=401, detail="Authentication required")
# Eagerly load relationships
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.snat_entries),
selectinload(models.Config.host_entries).selectinload(models.Host.zone),
selectinload(models.Config.params),
)
.filter(models.Config.id == config_id)
.first()
)
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()
```
**Important:** `get_current_user` currently raises a 401 if no cookie is present. You need to make it return `None` instead of raising when there is no cookie, so the generate endpoint can fall back to token auth. See Task 5 for that change — do Task 5 before testing this.
**Step 3: Add the `regenerate_token` endpoint**
Add this after `generate_config`:
```python
@router.post("/{config_id}/regenerate-token", response_model=schemas.RegenerateTokenOut)
def regenerate_token(
config_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
) -> dict:
config = _get_config_or_404(config_id, db, current_user)
config.download_token = secrets.token_urlsafe(32)
db.commit()
db.refresh(config)
return {"download_token": config.download_token}
```
**Step 4: Commit**
```bash
git add backend/app/api/configs.py
git commit -m "feat: token auth on generate endpoint and regenerate-token endpoint"
```
---
### Task 5: Auth — make `get_current_user` optional for the generate endpoint
**Files:**
- Modify: `backend/app/auth.py`
The `generate_config` endpoint needs to accept requests with no OIDC cookie (token-only auth). Currently `get_current_user` raises 401 when the cookie is missing. Add an `optional` variant that returns `None` instead.
**Step 1: Read current `get_current_user`**
Open `backend/app/auth.py` and find the `get_current_user` function. It currently looks roughly like:
```python
async def get_current_user(
access_token: Optional[str] = Cookie(default=None),
db: Session = Depends(get_db),
) -> models.User:
if not access_token:
raise HTTPException(status_code=401, ...)
...
return user
```
**Step 2: Add `get_optional_user` that returns `None` when unauthenticated**
Add this function after `get_current_user`:
```python
async def get_optional_user(
access_token: Optional[str] = Cookie(default=None),
db: Session = Depends(get_db),
) -> Optional[models.User]:
if not access_token:
return None
try:
return await get_current_user(access_token=access_token, db=db)
except HTTPException:
return None
```
**Step 3: Update `generate_config` to use `get_optional_user`**
In `backend/app/api/configs.py`, update the import and the dependency in `generate_config`:
```python
from app.auth import get_current_user, get_optional_user
```
And change the dependency in `generate_config`:
```python
current_user: Optional[models.User] = Depends(get_optional_user),
```
**Step 4: Commit**
```bash
git add backend/app/auth.py backend/app/api/configs.py
git commit -m "feat: add get_optional_user for unauthenticated generate access"
```
---
### Task 6: Frontend API client — add `regenerateToken`
**Files:**
- Modify: `frontend/src/api.ts:31-41`
**Step 1: Add `regenerateToken` to `configsApi`**
In `frontend/src/api.ts`, update the `configsApi` object:
```typescript
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',
}),
regenerateToken: (id: number) =>
api.post<{ download_token: string }>(`/configs/${id}/regenerate-token`),
}
```
**Step 2: Commit**
```bash
git add frontend/src/api.ts
git commit -m "feat: add regenerateToken to configsApi"
```
---
### Task 7: Frontend — display token with copy and regenerate on Config Detail
**Files:**
- Modify: `frontend/src/routes/ConfigDetail.tsx`
**Step 1: Add state for `downloadToken`**
In `ConfigDetail.tsx`, add a state variable near the other `useState` calls (around line 32):
```typescript
const [downloadToken, setDownloadToken] = useState('')
```
**Step 2: Load token in `useEffect`**
The existing `useEffect` already fetches the config name. Update it to also set the token:
```typescript
useEffect(() => {
configsApi.get(configId).then((r) => {
setConfigName(r.data.name)
setDownloadToken(r.data.download_token)
})
// ... rest unchanged
}, [configId])
```
**Step 3: Add the token UI block**
Add these MUI imports at the top of the file (alongside existing imports):
```typescript
import IconButton from '@mui/material/IconButton'
import InputAdornment from '@mui/material/InputAdornment'
import OutlinedInput from '@mui/material/OutlinedInput'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import Tooltip from '@mui/material/Tooltip'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import RefreshIcon from '@mui/icons-material/Refresh'
```
Add a `handleRegenerate` function after `handleDelete`:
```typescript
const handleRegenerate = async () => {
if (!confirm('Regenerate the download token? The old token will stop working.')) return
const res = await configsApi.regenerateToken(configId)
setDownloadToken(res.data.download_token)
}
```
In the JSX, insert this block between the breadcrumbs/generate-button row and the tabs card (i.e., after the closing `</Box>` of the top row, before the `<Box sx={{ bgcolor: 'white' ...`):
```tsx
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel>Download Token</InputLabel>
<OutlinedInput
label="Download Token"
value={downloadToken}
readOnly
endAdornment={
<InputAdornment position="end">
<Tooltip title="Copy token">
<IconButton onClick={() => navigator.clipboard.writeText(downloadToken)} edge="end">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
}
inputProps={{ style: { fontFamily: 'monospace', fontSize: 12 } }}
/>
</FormControl>
<Tooltip title="Regenerate token">
<IconButton onClick={handleRegenerate} color="warning">
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
```
**Step 4: Commit**
```bash
git add frontend/src/routes/ConfigDetail.tsx
git commit -m "feat: show download token with copy and regenerate on Config Detail"
```
---
### Task 8: Manual end-to-end verification
**Step 1: Rebuild and restart**
```bash
docker compose up --build -d
```
**Step 2: Verify token appears in UI**
Log in, open a config. You should see a "Download Token" field with a 43-character token.
**Step 3: Test copy button**
Click the copy icon — paste somewhere to confirm the token was copied.
**Step 4: Test ZIP download with token**
```bash
curl -X POST "http://localhost/api/configs/1/generate?format=zip" \
-H 'Content-Type: application/json' \
-d '{"token": "<paste-token>"}' \
-o /tmp/shorewall.zip
file /tmp/shorewall.zip # should say: Zip archive data
```
**Step 5: Test invalid token returns 401**
```bash
curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://localhost/api/configs/1/generate?format=zip" \
-H 'Content-Type: application/json' \
-d '{"token": "wrong"}'
# Expected: 401
```
**Step 6: Test regenerate**
Click "Regenerate" in the UI, confirm the old token stops working and the new one works.
**Step 7: Commit any fixups found during testing**

4112
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,8 @@ export const configsApi = {
api.post(`/configs/${id}/generate?format=${format}`, null, {
responseType: format === 'zip' ? 'blob' : 'json',
}),
regenerateToken: (id: number) =>
api.post<{ download_token: string }>(`/configs/${id}/regenerate-token`),
}
// --- Nested resources (zones, interfaces, policies, rules, snat) ---

View File

@@ -10,8 +10,16 @@ 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 IconButton from '@mui/material/IconButton'
import InputAdornment from '@mui/material/InputAdornment'
import OutlinedInput from '@mui/material/OutlinedInput'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import Tooltip from '@mui/material/Tooltip'
import AddIcon from '@mui/icons-material/Add'
import BuildIcon from '@mui/icons-material/Build'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import RefreshIcon from '@mui/icons-material/Refresh'
import { zonesApi, interfacesApi, policiesApi, rulesApi, snatApi, hostsApi, paramsApi, configsApi } from '../api'
// ---- Types ----
@@ -41,9 +49,13 @@ export default function ConfigDetail() {
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AnyEntity | null>(null)
const [generateOpen, setGenerateOpen] = useState(false)
const [downloadToken, setDownloadToken] = useState('')
useEffect(() => {
configsApi.get(configId).then((r) => setConfigName(r.data.name))
configsApi.get(configId).then((r) => {
setConfigName(r.data.name)
setDownloadToken(r.data.download_token)
})
zonesApi.list(configId).then((r) => setZones(r.data))
interfacesApi.list(configId).then((r) => setInterfaces(r.data))
policiesApi.list(configId).then((r) => setPolicies(r.data))
@@ -269,6 +281,12 @@ export default function ConfigDetail() {
current.setRows(res.data as any)
}
const handleRegenerate = async () => {
if (!confirm('Regenerate the download token? The old token will stop working.')) return
const res = await configsApi.regenerateToken(configId)
setDownloadToken(res.data.download_token)
}
return (
<Layout title={configName || 'Config Detail'}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
@@ -283,6 +301,31 @@ export default function ConfigDetail() {
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel>Download Token</InputLabel>
<OutlinedInput
label="Download Token"
value={downloadToken}
endAdornment={
<InputAdornment position="end">
<Tooltip title="Copy token">
<IconButton onClick={() => navigator.clipboard.writeText(downloadToken)} edge="end">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
}
inputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: 12 } }}
/>
</FormControl>
<Tooltip title="Regenerate token">
<IconButton onClick={handleRegenerate} color="warning">
<RefreshIcon />
</IconButton>
</Tooltip>
</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} />)}

View File

@@ -10,7 +10,7 @@ spec:
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
storageClassName: nfs
nfs:
server: {{ .Values.nfs.server }}
path: {{ .Values.nfs.path }}

View File

@@ -8,8 +8,7 @@ metadata:
spec:
accessModes:
- ReadWriteOnce
storageClassName: ""
volumeName: shorefront-postgres-pv
storageClassName: nfs
resources:
requests:
storage: {{ .Values.nfs.storage }}

View File

@@ -42,4 +42,4 @@ keycloak:
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
containers:
version: "0.012"
version: "0.014"