Compare commits

..

24 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
d56075a74e feat: expose app version from ConfigMap in sidebar footer
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 59s
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 1m27s
2026-03-01 11:51:30 +01:00
9382106e8d fix: reset generated files cache when modal closes so reopening fetches fresh data 2026-03-01 11:45:47 +01:00
390774c79a feat: default interface broadcast to 'detect' 2026-03-01 11:43:58 +01:00
08dddb7297 fix: convert empty select values to null before submitting
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 32s
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 1m27s
2026-03-01 11:37:25 +01:00
24 changed files with 5612 additions and 40 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,33 @@
"""change interface broadcast default to detect
Revision ID: 0011
Revises: 0010
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"interfaces", "broadcast",
existing_type=sa.String(64),
server_default="detect",
nullable=False,
)
op.execute("UPDATE interfaces SET broadcast = 'detect' WHERE broadcast = ''")
def downgrade() -> None:
op.execute("UPDATE interfaces SET broadcast = '' WHERE broadcast = 'detect'")
op.alter_column(
"interfaces", "broadcast",
existing_type=sa.String(64),
server_default="''",
nullable=False,
)

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

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
keycloak_client_id: str
keycloak_client_secret: str
keycloak_redirect_uri: str
app_version: str = "dev"
class Config:
env_file = ".env"

View File

@@ -28,4 +28,4 @@ app.include_router(params.router, prefix="/configs", tags=["params"])
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
return {"status": "ok", "version": settings.app_version}

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")
@@ -60,7 +67,7 @@ class Interface(Base):
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 | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
broadcast: Mapped[str] = mapped_column(String(64), default="")
broadcast: Mapped[str] = mapped_column(String(64), default="detect")
options: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="interfaces")

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}
@@ -65,7 +66,7 @@ class ZoneOut(BaseModel):
class InterfaceCreate(BaseModel):
name: str
zone_id: Optional[int] = None
broadcast: str = ""
broadcast: str = "detect"
options: str = ""
@@ -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**

View File

@@ -0,0 +1,584 @@
# Keycloak SSO Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace local username/password auth with Keycloak OIDC using a backend callback flow.
**Architecture:** Backend acts as confidential OIDC client using `authlib`. On login, backend redirects browser to Keycloak; Keycloak redirects back to `/auth/oidc/callback`; backend validates the token, checks the `firewall admins` group claim, auto-provisions the user in the `users` table on first login, then issues its own httpOnly JWT cookie. The frontend only changes the login page.
**Tech Stack:** authlib, httpx, FastAPI, Alembic, React/MUI
---
### Before starting: Keycloak manual setup
These steps must be done in Keycloak at `https://sso.baumann.gr` before the code is deployed:
1. Clients → Create client
- Client ID: `shorefront`
- Client authentication: ON (confidential)
- Valid redirect URIs: `https://shorefront.baumann.gr/api/auth/oidc/callback`
- Web Origins: `https://shorefront.baumann.gr`
2. On the client's **Credentials** tab: copy the client secret (needed for `KEYCLOAK_CLIENT_SECRET`)
3. On the client's **Client scopes** tab → add mapper:
- Type: Group Membership
- Token Claim Name: `groups`
- Add to ID token: ON
- Full group path: OFF
4. Create group `firewall admins` and add your user to it
---
### Task 1: Update dependencies
**Files:**
- Modify: `backend/requirements.txt`
**Step 1: Replace passlib/bcrypt with authlib + httpx**
Replace the content of `backend/requirements.txt` with:
```
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
python-multipart==0.0.9
pydantic[email]==2.7.1
pydantic-settings==2.2.1
authlib==1.3.1
httpx==0.27.0
itsdangerous==2.2.0
```
Removed: `passlib[bcrypt]`, `bcrypt<4.0.0`
Added: `authlib`, `httpx`, `itsdangerous` (required by Starlette SessionMiddleware)
**Step 2: Verify the file looks correct**
```bash
cat backend/requirements.txt
```
**Step 3: Commit**
```bash
git add backend/requirements.txt
git commit -m "feat(sso): replace passlib/bcrypt with authlib + httpx"
```
---
### Task 2: Add Keycloak settings and ConfigMap
**Files:**
- Modify: `backend/app/database.py`
- Modify: `helm/shorefront/templates/configmap.yaml`
- Modify: `helm/shorefront/values.yaml`
**Step 1: Update `backend/app/database.py` — add Keycloak fields to Settings**
Replace the `Settings` class:
```python
class Settings(BaseSettings):
database_url: str
jwt_secret_key: str
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60
keycloak_url: str
keycloak_realm: str
keycloak_client_id: str
keycloak_client_secret: str
keycloak_redirect_uri: str
class Config:
env_file = ".env"
```
**Step 2: Update `helm/shorefront/templates/configmap.yaml` — add Keycloak non-secret config**
Replace the file content with:
```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 }}
JWT_ALGORITHM: "HS256"
JWT_EXPIRE_MINUTES: "60"
KEYCLOAK_URL: {{ .Values.keycloak.url | quote }}
KEYCLOAK_REALM: {{ .Values.keycloak.realm | quote }}
KEYCLOAK_CLIENT_ID: {{ .Values.keycloak.clientId | quote }}
KEYCLOAK_REDIRECT_URI: {{ .Values.keycloak.redirectUri | quote }}
```
**Step 3: Update `helm/shorefront/values.yaml` — add keycloak block**
Add after the `ingress:` block (before `containers:`):
```yaml
keycloak:
url: https://sso.baumann.gr
realm: homelab
clientId: shorefront
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
```
**Step 4: Commit**
```bash
git add backend/app/database.py helm/shorefront/templates/configmap.yaml helm/shorefront/values.yaml
git commit -m "feat(sso): add Keycloak settings to database.py and Helm ConfigMap"
```
---
### Task 3: Add KEYCLOAK_CLIENT_SECRET to secrets script and deployments
**Files:**
- Modify: `scripts/create-secrets.sh`
- Modify: `helm/shorefront/templates/backend-deployment.yaml`
**Step 1: Update `scripts/create-secrets.sh` — add KEYCLOAK_CLIENT_SECRET**
Replace the existing script content with:
```bash
#!/usr/bin/env bash
set -euo pipefail
NAMESPACE="shorefront"
# --- Preflight checks ---
if ! command -v kubectl &>/dev/null; then
echo "Error: kubectl is not installed or not in PATH" >&2
exit 1
fi
# --- Validate required env vars ---
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
: "${JWT_SECRET_KEY:?JWT_SECRET_KEY is required}"
: "${KEYCLOAK_CLIENT_SECRET:?KEYCLOAK_CLIENT_SECRET is required}"
echo "Creating namespace '${NAMESPACE}' if it does not exist..."
kubectl create namespace "${NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f -
echo "Creating/updating secret 'shorefront-secret' in namespace '${NAMESPACE}'..."
kubectl create secret generic shorefront-secret \
--namespace "${NAMESPACE}" \
--from-literal="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \
--from-literal="JWT_SECRET_KEY=${JWT_SECRET_KEY}" \
--from-literal="KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
echo "Done. Secret 'shorefront-secret' is ready in namespace '${NAMESPACE}'."
```
**Step 2: Update `helm/shorefront/templates/backend-deployment.yaml` — add Keycloak env vars to both init container and main container**
In the `migrate` init container, after the `DATABASE_URL` env var, add:
```yaml
- name: KEYCLOAK_URL
valueFrom:
configMapKeyRef:
name: shorefront-config
key: KEYCLOAK_URL
- name: KEYCLOAK_REALM
valueFrom:
configMapKeyRef:
name: shorefront-config
key: KEYCLOAK_REALM
- name: KEYCLOAK_CLIENT_ID
valueFrom:
configMapKeyRef:
name: shorefront-config
key: KEYCLOAK_CLIENT_ID
- name: KEYCLOAK_REDIRECT_URI
valueFrom:
configMapKeyRef:
name: shorefront-config
key: KEYCLOAK_REDIRECT_URI
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: shorefront-secret
key: KEYCLOAK_CLIENT_SECRET
```
Add the same 5 env vars to the main `backend` container (after `JWT_EXPIRE_MINUTES`).
**Step 3: Commit**
```bash
git add scripts/create-secrets.sh helm/shorefront/templates/backend-deployment.yaml
git commit -m "feat(sso): add KEYCLOAK_CLIENT_SECRET to secrets script and backend deployment"
```
---
### Task 4: Alembic migration — add keycloak_sub, make hashed_password nullable
**Files:**
- Create: `backend/alembic/versions/0002_keycloak_sso.py`
**Step 1: Create the migration file**
Create `backend/alembic/versions/0002_keycloak_sso.py`:
```python
"""add keycloak_sub, make hashed_password nullable
Revision ID: 0002
Revises: 0001
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("keycloak_sub", sa.String(255), nullable=True))
op.create_unique_constraint("uq_users_keycloak_sub", "users", ["keycloak_sub"])
op.alter_column("users", "hashed_password", nullable=True)
def downgrade() -> None:
op.alter_column("users", "hashed_password", nullable=False)
op.drop_constraint("uq_users_keycloak_sub", "users", type_="unique")
op.drop_column("users", "keycloak_sub")
```
**Step 2: Commit**
```bash
git add backend/alembic/versions/0002_keycloak_sso.py
git commit -m "feat(sso): migration — add keycloak_sub, make hashed_password nullable"
```
---
### Task 5: Update User model and schemas
**Files:**
- Modify: `backend/app/models.py`
- Modify: `backend/app/schemas.py`
**Step 1: Update `backend/app/models.py` — add keycloak_sub, make hashed_password optional**
Replace the `User` class:
```python
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 | None] = mapped_column(String(255), nullable=True)
keycloak_sub: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
configs: Mapped[list["Config"]] = relationship("Config", back_populates="owner")
```
**Step 2: Update `backend/app/schemas.py` — remove UserCreate and LoginRequest**
Remove the `UserCreate` and `LoginRequest` classes entirely (lines 7-24). Keep `UserOut` unchanged.
**Step 3: Commit**
```bash
git add backend/app/models.py backend/app/schemas.py
git commit -m "feat(sso): update User model and schemas for Keycloak"
```
---
### Task 6: Replace auth.py and api/auth.py with OIDC implementation
**Files:**
- Modify: `backend/app/auth.py`
- Modify: `backend/app/api/auth.py`
- Modify: `backend/app/main.py`
**Step 1: Replace `backend/app/auth.py`**
Replace the entire file:
```python
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from authlib.integrations.starlette_client import OAuth
from fastapi import Cookie, HTTPException, status, Depends
from sqlalchemy.orm import Session
from app.database import get_db, settings
from app import models
oauth = OAuth()
oauth.register(
name="keycloak",
client_id=settings.keycloak_client_id,
client_secret=settings.keycloak_client_secret,
server_metadata_url=(
f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
"/.well-known/openid-configuration"
),
client_kwargs={"scope": "openid email profile"},
)
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: Replace `backend/app/api/auth.py`**
Replace the entire file:
```python
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from authlib.integrations.starlette_client import OAuthError
from app import models, schemas
from app.auth import create_access_token, get_current_user, oauth
from app.database import get_db, settings
router = APIRouter()
FIREWALL_ADMINS_GROUP = "firewall admins"
@router.get("/oidc/login")
async def oidc_login(request: Request) -> RedirectResponse:
return await oauth.keycloak.authorize_redirect(request, settings.keycloak_redirect_uri)
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
try:
token = await oauth.keycloak.authorize_access_token(request)
except OAuthError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
userinfo = token.get("userinfo") or {}
groups = userinfo.get("groups", [])
if FIREWALL_ADMINS_GROUP not in groups:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not in firewall admins group")
sub = userinfo["sub"]
email = userinfo.get("email", "")
username = userinfo.get("preferred_username", sub)
user = db.query(models.User).filter(models.User.keycloak_sub == sub).first()
if not user:
user = models.User(
keycloak_sub=sub,
email=email,
username=username,
hashed_password=None,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
access_token = create_access_token(user.id)
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
samesite="lax",
max_age=3600,
)
return response
@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 3: Update `backend/app/main.py` — add SessionMiddleware**
Add the SessionMiddleware import and registration. The final file:
```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, masq
from app.database import settings
app = FastAPI(title="Shorefront", version="0.1.0")
app.add_middleware(SessionMiddleware, secret_key=settings.jwt_secret_key)
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 4: Commit**
```bash
git add backend/app/auth.py backend/app/api/auth.py backend/app/main.py
git commit -m "feat(sso): replace local auth with Keycloak OIDC callback flow"
```
---
### Task 7: Update frontend Login page
**Files:**
- Modify: `frontend/src/routes/Login.tsx`
**Step 1: Replace `frontend/src/routes/Login.tsx`**
Replace the entire file:
```tsx
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
export default function Login() {
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>
<Button
variant="contained"
fullWidth
onClick={() => { window.location.href = '/api/auth/oidc/login' }}
>
Sign in with SSO
</Button>
</CardContent>
</Card>
</Box>
)
}
```
**Step 2: Commit**
```bash
git add frontend/src/routes/Login.tsx
git commit -m "feat(sso): replace login form with SSO redirect button"
```
---
### Task 8: Bump container version and update secrets
**Files:**
- Modify: `helm/shorefront/values.yaml`
**Step 1: Bump `containers.version`**
In `helm/shorefront/values.yaml`, update:
```yaml
containers:
version: "0.003"
```
(or whatever the next version number is — check current value first with `grep version helm/shorefront/values.yaml`)
**Step 2: Re-run create-secrets.sh with the new variable**
```bash
export POSTGRES_PASSWORD=<existing-password>
export JWT_SECRET_KEY=<existing-key>
export KEYCLOAK_CLIENT_SECRET=<from-keycloak-client-credentials-tab>
bash scripts/create-secrets.sh
```
**Step 3: Commit**
```bash
git add helm/shorefront/values.yaml
git commit -m "feat(sso): bump container version for SSO release"
```
---
### Verification (after deployment)
1. Visit `https://shorefront.baumann.gr` — should redirect to login page
2. Click "Sign in with SSO" — should redirect to `https://sso.baumann.gr/realms/homelab/...`
3. Log in with a user in the `firewall admins` group — should redirect back and land on `/configs`
4. Log in with a user NOT in `firewall admins` — should get a 403 error
5. Check database: `SELECT id, username, email, keycloak_sub, hashed_password FROM users;` — should show new row with `keycloak_sub` set and `hashed_password` NULL

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

@@ -38,7 +38,13 @@ export default function EntityForm({ open, title, fields, initialValues, onClose
const handleSubmit = async () => {
setSubmitting(true)
try { await onSubmit(values) } finally { setSubmitting(false) }
const submitted = Object.fromEntries(
Object.entries(values).map(([k, v]) => {
const field = fields.find((f) => f.name === k)
return [k, field?.type === 'select' && v === '' ? null : v]
})
)
try { await onSubmit(submitted) } finally { setSubmitting(false) }
}
return (

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
@@ -37,6 +37,10 @@ export default function GenerateModal({ open, configId, configName, onClose }: P
const [files, setFiles] = useState<GeneratedFiles | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) setFiles(null)
}, [open])
const handleOpen = async () => {
if (files) return
setLoading(true)

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import Box from '@mui/material/Box'
import Drawer from '@mui/material/Drawer'
@@ -13,6 +13,7 @@ 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'
import api from '../api'
const DRAWER_WIDTH = 240
@@ -22,6 +23,11 @@ export default function Layout({ children, title }: Props) {
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuth()
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
api.get('/health').then((r) => setVersion(r.data.version)).catch(() => {})
}, [])
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
@@ -47,7 +53,10 @@ export default function Layout({ children, title }: Props) {
</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>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>{user?.username}</Typography>
{version && <Typography variant="caption" sx={{ color: '#4a5568', fontSize: 10 }}>v{version}</Typography>}
</Box>
<Tooltip title="Logout">
<IconButton onClick={logout} size="small" sx={{ color: '#94a3b8' }}><LogoutIcon fontSize="small" /></IconButton>
</Tooltip>

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

@@ -110,6 +110,11 @@ spec:
secretKeyRef:
name: shorefront-secret
key: KEYCLOAK_CLIENT_SECRET
- name: APP_VERSION
valueFrom:
configMapKeyRef:
name: shorefront-config
key: APP_VERSION
ports:
- containerPort: 8000
resources:

View File

@@ -14,3 +14,4 @@ data:
KEYCLOAK_REALM: {{ .Values.keycloak.realm | quote }}
KEYCLOAK_CLIENT_ID: {{ .Values.keycloak.clientId | quote }}
KEYCLOAK_REDIRECT_URI: {{ .Values.keycloak.redirectUri | quote }}
APP_VERSION: {{ .Values.containers.version | quote }}

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.009"
version: "0.014"