Compare commits
18 Commits
4d0164ed02
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 956f0c2cf5 | |||
| 2b9cbfd884 | |||
| 6699bf7421 | |||
| 881f812fa8 | |||
| 593daa17bf | |||
| 0943798399 | |||
| c24c00b307 | |||
| ab181e802f | |||
| b71e1e5989 | |||
| 2e0cda834b | |||
| 29b71b267e | |||
| 3b90373b78 | |||
| 9b15c081b0 | |||
| e9a91a7794 | |||
| d6e3904f0a | |||
| c55d73fd58 | |||
| ad35b00023 | |||
| d28d034a17 |
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
|
||||||
98
README.md
98
README.md
@@ -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
|
- **Backend:** Python 3.12, FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 15
|
||||||
- **Frontend:** React 18, TypeScript, Vite, MUI v5, React Router v6, Axios
|
- **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)
|
- **Infra:** Docker Compose (local dev), Helm + Kubernetes + Traefik (production)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -22,7 +23,7 @@ docker compose up --build
|
|||||||
# 3. Open http://localhost
|
# 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
|
# Set environment variables
|
||||||
export DATABASE_URL=postgresql://shorefront:changeme@localhost:5432/shorefront
|
export DATABASE_URL=postgresql://shorefront:changeme@localhost:5432/shorefront
|
||||||
export JWT_SECRET_KEY=dev-secret
|
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)
|
# Run migrations (creates schema + seed data)
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
@@ -60,12 +66,12 @@ npm run dev
|
|||||||
|
|
||||||
## First Steps After Login
|
## 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:
|
2. A sample **homelab** config is pre-loaded with:
|
||||||
- Zones: `fw` (firewall), `net` (ipv4), `loc` (ipv4)
|
- Zones: `fw` (firewall), `net` (ipv4), `loc` (ipv4)
|
||||||
- Interface: `eth0` → zone `net`
|
- Interface: `eth0` → zone `net`
|
||||||
- Policies: loc→net ACCEPT, net→fw DROP, etc.
|
- 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.
|
3. Click **homelab** to open the Config Detail page.
|
||||||
4. Click **Generate Config** to preview or download the Shorewall files.
|
4. Click **Generate Config** to preview or download the Shorewall files.
|
||||||
5. Create your own configs from the **Configurations** page.
|
5. Create your own configs from the **Configurations** page.
|
||||||
@@ -76,8 +82,33 @@ npm run dev
|
|||||||
|
|
||||||
On the Config Detail page, click **Generate Config**:
|
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.
|
- **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 five files ready to copy to `/etc/shorewall/`.
|
- **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
|
- Kubernetes cluster with Traefik as the ingress controller
|
||||||
- NFS share accessible at `192.168.17.199:/mnt/user/kubernetesdata/shorefront`
|
- 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
|
```bash
|
||||||
docker build -t <registry>/shorefront-backend:latest ./backend
|
export POSTGRES_PASSWORD=<strong-password>
|
||||||
docker build -t <registry>/shorefront-frontend:latest ./frontend
|
export JWT_SECRET_KEY=<strong-jwt-secret>
|
||||||
docker push <registry>/shorefront-backend:latest
|
export KEYCLOAK_CLIENT_SECRET=<keycloak-client-secret>
|
||||||
docker push <registry>/shorefront-frontend:latest
|
|
||||||
|
bash scripts/create-secrets.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy
|
This creates/updates the `shorefront-secret` Secret in the `shorefront` namespace.
|
||||||
|
|
||||||
|
### 2. Deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
helm upgrade --install shorefront ./helm/shorefront \
|
helm upgrade --install shorefront ./helm/shorefront \
|
||||||
--values ./helm/shorefront/values-prod.yaml \
|
--namespace shorefront \
|
||||||
--set backend.image=<registry>/shorefront-backend \
|
--create-namespace
|
||||||
--set frontend.image=<registry>/shorefront-frontend \
|
|
||||||
--set ingress.host=shorefront.yourdomain.com \
|
|
||||||
--set secrets.postgresPassword=<strong-password> \
|
|
||||||
--set secrets.jwtSecretKey=<strong-jwt-secret>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify Rollout
|
Override values as needed (e.g. ingress host, Keycloak URL) via `--set` or a custom values file.
|
||||||
|
|
||||||
|
### 3. Verify Rollout
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl rollout status deployment/backend -n shorefront
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -154,9 +201,18 @@ shorefront/
|
|||||||
│ ├── main.py # FastAPI app
|
│ ├── main.py # FastAPI app
|
||||||
│ ├── models.py # SQLAlchemy ORM models
|
│ ├── models.py # SQLAlchemy ORM models
|
||||||
│ ├── schemas.py # Pydantic schemas
|
│ ├── schemas.py # Pydantic schemas
|
||||||
│ ├── auth.py # JWT auth
|
│ ├── auth.py # JWT + OIDC auth
|
||||||
│ ├── shorewall_generator.py
|
│ ├── shorewall_generator.py
|
||||||
│ └── api/ # Route handlers
|
│ └── 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/
|
├── frontend/
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── nginx.conf
|
│ ├── nginx.conf
|
||||||
@@ -166,6 +222,8 @@ shorefront/
|
|||||||
│ ├── routes/ # Page components
|
│ ├── routes/ # Page components
|
||||||
│ └── components/ # Shared UI components
|
│ └── components/ # Shared UI components
|
||||||
├── helm/shorefront/ # Kubernetes Helm chart
|
├── helm/shorefront/ # Kubernetes Helm chart
|
||||||
|
├── scripts/
|
||||||
|
│ └── create-secrets.sh # Creates k8s Secret before deploy
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|||||||
39
backend/alembic/versions/0012_config_add_download_token.py
Normal file
39
backend/alembic/versions/0012_config_add_download_token.py
Normal 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")
|
||||||
@@ -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 fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
from app import models, schemas
|
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.database import get_db
|
||||||
from app.shorewall_generator import ShorewallGenerator
|
from app.shorewall_generator import ShorewallGenerator
|
||||||
import io
|
import io
|
||||||
|
import secrets
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -79,10 +81,36 @@ def delete_config(
|
|||||||
@router.post("/{config_id}/generate")
|
@router.post("/{config_id}/generate")
|
||||||
def generate_config(
|
def generate_config(
|
||||||
config_id: int,
|
config_id: int,
|
||||||
format: str = "json",
|
format: Literal["json", "zip"] = "json",
|
||||||
|
body: Optional[schemas.GenerateRequest] = None,
|
||||||
db: Session = Depends(get_db),
|
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 = (
|
config = (
|
||||||
db.query(models.Config)
|
db.query(models.Config)
|
||||||
.options(
|
.options(
|
||||||
@@ -96,11 +124,9 @@ def generate_config(
|
|||||||
selectinload(models.Config.host_entries).selectinload(models.Host.zone),
|
selectinload(models.Config.host_entries).selectinload(models.Host.zone),
|
||||||
selectinload(models.Config.params),
|
selectinload(models.Config.params),
|
||||||
)
|
)
|
||||||
.filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
|
.filter(models.Config.id == config_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not config:
|
|
||||||
raise HTTPException(status_code=404, detail="Config not found")
|
|
||||||
|
|
||||||
generator = ShorewallGenerator(config)
|
generator = ShorewallGenerator(config)
|
||||||
|
|
||||||
@@ -113,3 +139,16 @@ def generate_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return generator.as_json()
|
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}
|
||||||
|
|||||||
@@ -51,3 +51,15 @@ def get_current_user(
|
|||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
return user
|
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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
@@ -27,6 +28,12 @@ class Config(Base):
|
|||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
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())
|
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_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
owner: Mapped["User"] = relationship("User", back_populates="configs")
|
owner: Mapped["User"] = relationship("User", back_populates="configs")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# --- Auth ---
|
# --- Auth ---
|
||||||
@@ -34,6 +34,7 @@ class ConfigOut(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
owner_id: int
|
owner_id: int
|
||||||
|
download_token: str
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -293,6 +294,14 @@ class ParamOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# --- Generate ---
|
# --- Generate ---
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
token: Optional[str] = Field(default=None, min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class RegenerateTokenOut(BaseModel):
|
||||||
|
download_token: str
|
||||||
|
|
||||||
|
|
||||||
class GenerateOut(BaseModel):
|
class GenerateOut(BaseModel):
|
||||||
zones: str
|
zones: str
|
||||||
interfaces: str
|
interfaces: str
|
||||||
|
|||||||
77
docs/plans/2026-03-01-config-download-token-design.md
Normal file
77
docs/plans/2026-03-01-config-download-token-design.md
Normal 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.
|
||||||
506
docs/plans/2026-03-01-config-download-token.md
Normal file
506
docs/plans/2026-03-01-config-download-token.md
Normal 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
4112
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,8 @@ export const configsApi = {
|
|||||||
api.post(`/configs/${id}/generate?format=${format}`, null, {
|
api.post(`/configs/${id}/generate?format=${format}`, null, {
|
||||||
responseType: format === 'zip' ? 'blob' : 'json',
|
responseType: format === 'zip' ? 'blob' : 'json',
|
||||||
}),
|
}),
|
||||||
|
regenerateToken: (id: number) =>
|
||||||
|
api.post<{ download_token: string }>(`/configs/${id}/regenerate-token`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Nested resources (zones, interfaces, policies, rules, snat) ---
|
// --- Nested resources (zones, interfaces, policies, rules, snat) ---
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ import Tabs from '@mui/material/Tabs'
|
|||||||
import Tab from '@mui/material/Tab'
|
import Tab from '@mui/material/Tab'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import Breadcrumbs from '@mui/material/Breadcrumbs'
|
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 AddIcon from '@mui/icons-material/Add'
|
||||||
import BuildIcon from '@mui/icons-material/Build'
|
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'
|
import { zonesApi, interfacesApi, policiesApi, rulesApi, snatApi, hostsApi, paramsApi, configsApi } from '../api'
|
||||||
|
|
||||||
// ---- Types ----
|
// ---- Types ----
|
||||||
@@ -41,9 +49,13 @@ export default function ConfigDetail() {
|
|||||||
const [formOpen, setFormOpen] = useState(false)
|
const [formOpen, setFormOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<AnyEntity | null>(null)
|
const [editing, setEditing] = useState<AnyEntity | null>(null)
|
||||||
const [generateOpen, setGenerateOpen] = useState(false)
|
const [generateOpen, setGenerateOpen] = useState(false)
|
||||||
|
const [downloadToken, setDownloadToken] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
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))
|
zonesApi.list(configId).then((r) => setZones(r.data))
|
||||||
interfacesApi.list(configId).then((r) => setInterfaces(r.data))
|
interfacesApi.list(configId).then((r) => setInterfaces(r.data))
|
||||||
policiesApi.list(configId).then((r) => setPolicies(r.data))
|
policiesApi.list(configId).then((r) => setPolicies(r.data))
|
||||||
@@ -269,6 +281,12 @@ export default function ConfigDetail() {
|
|||||||
current.setRows(res.data as any)
|
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 (
|
return (
|
||||||
<Layout title={configName || 'Config Detail'}>
|
<Layout title={configName || 'Config Detail'}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
@@ -283,6 +301,31 @@ export default function ConfigDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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' }}>
|
<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 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
|
||||||
{tabConfig.map((tc) => <Tab key={tc.label} label={tc.label} />)}
|
{tabConfig.map((tc) => <Tab key={tc.label} label={tc.label} />)}
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ keycloak:
|
|||||||
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
|
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
|
||||||
|
|
||||||
containers:
|
containers:
|
||||||
version: "0.013"
|
version: "0.014"
|
||||||
|
|||||||
Reference in New Issue
Block a user