Compare commits

...

37 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
02c8f71957 feat: complete snat with all shorewall columns (proto, port, ipsec, mark, user, switch, origdest, probability)
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 1m14s
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 2m2s
2026-03-01 11:28:25 +01:00
36224cebcd feat: complete rules with all shorewall columns (origdest, rate, user, mark, connlimit, time, headers, switch, helper) 2026-03-01 11:25:09 +01:00
3c259a1862 feat: add placeholder support to EntityForm FieldDef 2026-03-01 11:18:53 +01:00
e05e9d5975 feat: add limit:burst and connlimit:mask fields to policies 2026-03-01 11:18:26 +01:00
3dc97df6cd feat: allow 'all' for policy source and destination zones 2026-03-01 11:14:42 +01:00
8b787a99c2 feat: add broadcast field to interfaces 2026-03-01 11:13:13 +01:00
58ef0dec63 feat: allow interfaces to have no zone (shorewall '-' zone) 2026-03-01 11:11:52 +01:00
21d404229a feat: add hosts and params files, fix rules SECTION NEW header
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 1m32s
2026-03-01 01:43:15 +01:00
15f28cb070 chore: bump container version to 0.007
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 1m3s
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 1m29s
2026-03-01 01:31:17 +01:00
686ce911bb feat: rename masq to snat throughout, update generator to Shorewall 5 snat format 2026-03-01 01:30:19 +01:00
1b543ed44a chore: remove OIDC debug logging 2026-03-01 01:24:45 +01:00
59d9b438a1 debug: decode and log raw ID token payload in OIDC callback 2026-03-01 01:24:06 +01:00
388e945343 chore: remove temporary OIDC debug logging 2026-03-01 01:21:22 +01:00
38 changed files with 6374 additions and 170 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 - **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
``` ```

View File

@@ -0,0 +1,20 @@
"""rename masq table to snat
Revision ID: 0003
Revises: 0002
Create Date: 2026-03-01
"""
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.rename_table("masq", "snat")
def downgrade() -> None:
op.rename_table("snat", "masq")

View File

@@ -0,0 +1,37 @@
"""add hosts and params tables
Revision ID: 0004
Revises: 0003
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"hosts",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False),
sa.Column("interface", sa.String(32), nullable=False),
sa.Column("subnet", sa.String(64), nullable=False),
sa.Column("options", sa.Text, server_default="''"),
)
op.create_table(
"params",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("value", sa.String(255), nullable=False),
)
def downgrade() -> None:
op.drop_table("params")
op.drop_table("hosts")

View File

@@ -0,0 +1,21 @@
"""make interface zone_id nullable
Revision ID: 0005
Revises: 0004
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("interfaces", "zone_id", existing_type=sa.Integer(), nullable=True)
def downgrade() -> None:
op.alter_column("interfaces", "zone_id", existing_type=sa.Integer(), nullable=False)

View File

@@ -0,0 +1,21 @@
"""add broadcast column to interfaces
Revision ID: 0006
Revises: 0005
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("interfaces", sa.Column("broadcast", sa.String(64), server_default="''", nullable=False))
def downgrade() -> None:
op.drop_column("interfaces", "broadcast")

View File

@@ -0,0 +1,23 @@
"""make policy zone ids nullable (support 'all')
Revision ID: 0007
Revises: 0006
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("policies", "src_zone_id", existing_type=sa.Integer(), nullable=True)
op.alter_column("policies", "dst_zone_id", existing_type=sa.Integer(), nullable=True)
def downgrade() -> None:
op.alter_column("policies", "src_zone_id", existing_type=sa.Integer(), nullable=False)
op.alter_column("policies", "dst_zone_id", existing_type=sa.Integer(), nullable=False)

View File

@@ -0,0 +1,23 @@
"""add limit_burst and connlimit_mask to policies
Revision ID: 0008
Revises: 0007
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("policies", sa.Column("limit_burst", sa.String(64), server_default="''", nullable=False))
op.add_column("policies", sa.Column("connlimit_mask", sa.String(32), server_default="''", nullable=False))
def downgrade() -> None:
op.drop_column("policies", "connlimit_mask")
op.drop_column("policies", "limit_burst")

View File

@@ -0,0 +1,35 @@
"""add missing shorewall rule columns
Revision ID: 0009
Revises: 0008
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
_NEW_COLS = [
("origdest", sa.String(128)),
("rate_limit", sa.String(64)),
("user_group", sa.String(64)),
("mark", sa.String(32)),
("connlimit", sa.String(32)),
("time", sa.String(128)),
("headers", sa.String(128)),
("switch_name", sa.String(32)),
("helper", sa.String(32)),
]
def upgrade() -> None:
for col_name, col_type in _NEW_COLS:
op.add_column("rules", sa.Column(col_name, col_type, server_default="''", nullable=False))
def downgrade() -> None:
for col_name, _ in reversed(_NEW_COLS):
op.drop_column("rules", col_name)

View File

@@ -0,0 +1,34 @@
"""add missing shorewall snat columns
Revision ID: 0010
Revises: 0009
Create Date: 2026-03-01
"""
from alembic import op
import sqlalchemy as sa
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
_NEW_COLS = [
("proto", sa.String(16)),
("port", sa.String(64)),
("ipsec", sa.String(128)),
("mark", sa.String(32)),
("user_group", sa.String(64)),
("switch_name", sa.String(32)),
("origdest", sa.String(128)),
("probability", sa.String(16)),
]
def upgrade() -> None:
for col_name, col_type in _NEW_COLS:
op.add_column("snat", sa.Column(col_name, col_type, server_default="''", nullable=False))
def downgrade() -> None:
for col_name, _ in reversed(_NEW_COLS):
op.drop_column("snat", col_name)

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

@@ -25,12 +25,6 @@ async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Redi
userinfo = token.get("userinfo") or {} userinfo = token.get("userinfo") or {}
groups = userinfo.get("groups", []) groups = userinfo.get("groups", [])
import logging as _logging
_logging.getLogger("shorefront.auth").warning(
"OIDC callback — userinfo keys: %s | groups claim: %r",
list(userinfo.keys()),
groups,
)
if FIREWALL_ADMINS_GROUP not in groups: if FIREWALL_ADMINS_GROUP not in groups:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not in firewall admins group") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not in firewall admins group")

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 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(
@@ -92,13 +120,13 @@ def generate_config(
selectinload(models.Config.policies).selectinload(models.Policy.dst_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.src_zone),
selectinload(models.Config.rules).selectinload(models.Rule.dst_zone), selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
selectinload(models.Config.masq_entries), selectinload(models.Config.snat_entries),
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() .first()
) )
if not config:
raise HTTPException(status_code=404, detail="Config not found")
generator = ShorewallGenerator(config) generator = ShorewallGenerator(config)
@@ -111,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}

64
backend/app/api/hosts.py Normal file
View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/hosts", response_model=list[schemas.HostOut])
def list_hosts(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Host).filter(models.Host.config_id == config_id).all()
@router.post("/{config_id}/hosts", response_model=schemas.HostOut, status_code=201)
def create_host(config_id: int, body: schemas.HostCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = models.Host(**body.model_dump(), config_id=config_id)
db.add(host)
db.commit()
db.refresh(host)
return host
@router.get("/{config_id}/hosts/{host_id}", response_model=schemas.HostOut)
def get_host(config_id: int, host_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = db.query(models.Host).filter(models.Host.id == host_id, models.Host.config_id == config_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host entry not found")
return host
@router.put("/{config_id}/hosts/{host_id}", response_model=schemas.HostOut)
def update_host(config_id: int, host_id: int, body: schemas.HostUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = db.query(models.Host).filter(models.Host.id == host_id, models.Host.config_id == config_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host entry not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(host, field, value)
db.commit()
db.refresh(host)
return host
@router.delete("/{config_id}/hosts/{host_id}", status_code=204)
def delete_host(config_id: int, host_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
host = db.query(models.Host).filter(models.Host.id == host_id, models.Host.config_id == config_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host entry not found")
db.delete(host)
db.commit()

View File

@@ -1,64 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/masq", response_model=list[schemas.MasqOut])
def list_masq(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Masq).filter(models.Masq.config_id == config_id).all()
@router.post("/{config_id}/masq", response_model=schemas.MasqOut, status_code=201)
def create_masq(config_id: int, body: schemas.MasqCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = models.Masq(**body.model_dump(), config_id=config_id)
db.add(masq)
db.commit()
db.refresh(masq)
return masq
@router.get("/{config_id}/masq/{masq_id}", response_model=schemas.MasqOut)
def get_masq(config_id: int, masq_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = db.query(models.Masq).filter(models.Masq.id == masq_id, models.Masq.config_id == config_id).first()
if not masq:
raise HTTPException(status_code=404, detail="Masq entry not found")
return masq
@router.put("/{config_id}/masq/{masq_id}", response_model=schemas.MasqOut)
def update_masq(config_id: int, masq_id: int, body: schemas.MasqUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = db.query(models.Masq).filter(models.Masq.id == masq_id, models.Masq.config_id == config_id).first()
if not masq:
raise HTTPException(status_code=404, detail="Masq entry not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(masq, field, value)
db.commit()
db.refresh(masq)
return masq
@router.delete("/{config_id}/masq/{masq_id}", status_code=204)
def delete_masq(config_id: int, masq_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = db.query(models.Masq).filter(models.Masq.id == masq_id, models.Masq.config_id == config_id).first()
if not masq:
raise HTTPException(status_code=404, detail="Masq entry not found")
db.delete(masq)
db.commit()

64
backend/app/api/params.py Normal file
View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/params", response_model=list[schemas.ParamOut])
def list_params(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Param).filter(models.Param.config_id == config_id).all()
@router.post("/{config_id}/params", response_model=schemas.ParamOut, status_code=201)
def create_param(config_id: int, body: schemas.ParamCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = models.Param(**body.model_dump(), config_id=config_id)
db.add(param)
db.commit()
db.refresh(param)
return param
@router.get("/{config_id}/params/{param_id}", response_model=schemas.ParamOut)
def get_param(config_id: int, param_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = db.query(models.Param).filter(models.Param.id == param_id, models.Param.config_id == config_id).first()
if not param:
raise HTTPException(status_code=404, detail="Param not found")
return param
@router.put("/{config_id}/params/{param_id}", response_model=schemas.ParamOut)
def update_param(config_id: int, param_id: int, body: schemas.ParamUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = db.query(models.Param).filter(models.Param.id == param_id, models.Param.config_id == config_id).first()
if not param:
raise HTTPException(status_code=404, detail="Param not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(param, field, value)
db.commit()
db.refresh(param)
return param
@router.delete("/{config_id}/params/{param_id}", status_code=204)
def delete_param(config_id: int, param_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
param = db.query(models.Param).filter(models.Param.id == param_id, models.Param.config_id == config_id).first()
if not param:
raise HTTPException(status_code=404, detail="Param not found")
db.delete(param)
db.commit()

64
backend/app/api/snat.py Normal file
View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/snat", response_model=list[schemas.SnatOut])
def list_snat(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Snat).filter(models.Snat.config_id == config_id).all()
@router.post("/{config_id}/snat", response_model=schemas.SnatOut, status_code=201)
def create_snat(config_id: int, body: schemas.SnatCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = models.Snat(**body.model_dump(), config_id=config_id)
db.add(snat)
db.commit()
db.refresh(snat)
return snat
@router.get("/{config_id}/snat/{snat_id}", response_model=schemas.SnatOut)
def get_snat(config_id: int, snat_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = db.query(models.Snat).filter(models.Snat.id == snat_id, models.Snat.config_id == config_id).first()
if not snat:
raise HTTPException(status_code=404, detail="SNAT entry not found")
return snat
@router.put("/{config_id}/snat/{snat_id}", response_model=schemas.SnatOut)
def update_snat(config_id: int, snat_id: int, body: schemas.SnatUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = db.query(models.Snat).filter(models.Snat.id == snat_id, models.Snat.config_id == config_id).first()
if not snat:
raise HTTPException(status_code=404, detail="SNAT entry not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(snat, field, value)
db.commit()
db.refresh(snat)
return snat
@router.delete("/{config_id}/snat/{snat_id}", status_code=204)
def delete_snat(config_id: int, snat_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = db.query(models.Snat).filter(models.Snat.id == snat_id, models.Snat.config_id == config_id).first()
if not snat:
raise HTTPException(status_code=404, detail="SNAT entry not found")
db.delete(snat)
db.commit()

View File

@@ -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

View File

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

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, masq from app.api import auth, configs, zones, interfaces, policies, rules, snat, hosts, params
from app.database import settings from app.database import settings
app = FastAPI(title="Shorefront", version="0.1.0") app = FastAPI(title="Shorefront", version="0.1.0")
@@ -21,9 +21,11 @@ app.include_router(zones.router, prefix="/configs", tags=["zones"])
app.include_router(interfaces.router, prefix="/configs", tags=["interfaces"]) app.include_router(interfaces.router, prefix="/configs", tags=["interfaces"])
app.include_router(policies.router, prefix="/configs", tags=["policies"]) app.include_router(policies.router, prefix="/configs", tags=["policies"])
app.include_router(rules.router, prefix="/configs", tags=["rules"]) app.include_router(rules.router, prefix="/configs", tags=["rules"])
app.include_router(masq.router, prefix="/configs", tags=["masq"]) app.include_router(snat.router, prefix="/configs", tags=["snat"])
app.include_router(hosts.router, prefix="/configs", tags=["hosts"])
app.include_router(params.router, prefix="/configs", tags=["params"])
@app.get("/health") @app.get("/health")
def health() -> dict: 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 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")
@@ -34,7 +41,9 @@ class Config(Base):
interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="config", cascade="all, delete-orphan") interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="config", cascade="all, delete-orphan")
policies: Mapped[list["Policy"]] = relationship("Policy", back_populates="config", cascade="all, delete-orphan", order_by="Policy.position") policies: Mapped[list["Policy"]] = relationship("Policy", back_populates="config", cascade="all, delete-orphan", order_by="Policy.position")
rules: Mapped[list["Rule"]] = relationship("Rule", back_populates="config", cascade="all, delete-orphan", order_by="Rule.position") rules: Mapped[list["Rule"]] = relationship("Rule", back_populates="config", cascade="all, delete-orphan", order_by="Rule.position")
masq_entries: Mapped[list["Masq"]] = relationship("Masq", back_populates="config", cascade="all, delete-orphan") snat_entries: Mapped[list["Snat"]] = relationship("Snat", back_populates="config", cascade="all, delete-orphan")
host_entries: Mapped[list["Host"]] = relationship("Host", back_populates="config", cascade="all, delete-orphan")
params: Mapped[list["Param"]] = relationship("Param", back_populates="config", cascade="all, delete-orphan")
class Zone(Base): class Zone(Base):
@@ -57,11 +66,12 @@ class Interface(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False) config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
name: Mapped[str] = mapped_column(String(32), nullable=False) name: Mapped[str] = mapped_column(String(32), nullable=False)
zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False) zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
broadcast: Mapped[str] = mapped_column(String(64), default="detect")
options: Mapped[str] = mapped_column(Text, default="") options: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="interfaces") config: Mapped["Config"] = relationship("Config", back_populates="interfaces")
zone: Mapped["Zone"] = relationship("Zone", back_populates="interfaces") zone: Mapped["Zone | None"] = relationship("Zone", back_populates="interfaces")
class Policy(Base): class Policy(Base):
@@ -69,16 +79,18 @@ class Policy(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False) config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
src_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False) src_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
dst_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False) dst_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
policy: Mapped[str] = mapped_column(String(16), nullable=False) policy: Mapped[str] = mapped_column(String(16), nullable=False)
log_level: Mapped[str] = mapped_column(String(16), default="") log_level: Mapped[str] = mapped_column(String(16), default="")
limit_burst: Mapped[str] = mapped_column(String(64), default="")
connlimit_mask: Mapped[str] = mapped_column(String(32), default="")
comment: Mapped[str] = mapped_column(Text, default="") comment: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0) position: Mapped[int] = mapped_column(Integer, default=0)
config: Mapped["Config"] = relationship("Config", back_populates="policies") config: Mapped["Config"] = relationship("Config", back_populates="policies")
src_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[src_zone_id]) src_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[src_zone_id])
dst_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[dst_zone_id]) dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Rule(Base): class Rule(Base):
@@ -94,6 +106,15 @@ class Rule(Base):
proto: Mapped[str] = mapped_column(String(16), default="") proto: Mapped[str] = mapped_column(String(16), default="")
dport: Mapped[str] = mapped_column(String(64), default="") dport: Mapped[str] = mapped_column(String(64), default="")
sport: Mapped[str] = mapped_column(String(64), default="") sport: Mapped[str] = mapped_column(String(64), default="")
origdest: Mapped[str] = mapped_column(String(128), default="")
rate_limit: Mapped[str] = mapped_column(String(64), default="")
user_group: Mapped[str] = mapped_column(String(64), default="")
mark: Mapped[str] = mapped_column(String(32), default="")
connlimit: Mapped[str] = mapped_column(String(32), default="")
time: Mapped[str] = mapped_column(String(128), default="")
headers: Mapped[str] = mapped_column(String(128), default="")
switch_name: Mapped[str] = mapped_column(String(32), default="")
helper: Mapped[str] = mapped_column(String(32), default="")
comment: Mapped[str] = mapped_column(Text, default="") comment: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0) position: Mapped[int] = mapped_column(Integer, default=0)
@@ -102,14 +123,47 @@ class Rule(Base):
dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id]) dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Masq(Base): class Snat(Base):
__tablename__ = "masq" __tablename__ = "snat"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False) config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
source_network: Mapped[str] = mapped_column(String(64), nullable=False) source_network: Mapped[str] = mapped_column(String(64), nullable=False)
out_interface: Mapped[str] = mapped_column(String(32), nullable=False) out_interface: Mapped[str] = mapped_column(String(32), nullable=False)
to_address: Mapped[str] = mapped_column(String(64), default="") to_address: Mapped[str] = mapped_column(String(64), default="")
proto: Mapped[str] = mapped_column(String(16), default="")
port: Mapped[str] = mapped_column(String(64), default="")
ipsec: Mapped[str] = mapped_column(String(128), default="")
mark: Mapped[str] = mapped_column(String(32), default="")
user_group: Mapped[str] = mapped_column(String(64), default="")
switch_name: Mapped[str] = mapped_column(String(32), default="")
origdest: Mapped[str] = mapped_column(String(128), default="")
probability: Mapped[str] = mapped_column(String(16), default="")
comment: Mapped[str] = mapped_column(Text, default="") comment: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="masq_entries") config: Mapped["Config"] = relationship("Config", back_populates="snat_entries")
class Host(Base):
__tablename__ = "hosts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
interface: Mapped[str] = mapped_column(String(32), nullable=False)
subnet: Mapped[str] = mapped_column(String(64), nullable=False)
options: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="host_entries")
zone: Mapped["Zone"] = relationship("Zone")
class Param(Base):
__tablename__ = "params"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
value: Mapped[str] = mapped_column(String(255), nullable=False)
config: Mapped["Config"] = relationship("Config", back_populates="params")

View File

@@ -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}
@@ -64,13 +65,15 @@ class ZoneOut(BaseModel):
# --- Interface --- # --- Interface ---
class InterfaceCreate(BaseModel): class InterfaceCreate(BaseModel):
name: str name: str
zone_id: int zone_id: Optional[int] = None
broadcast: str = "detect"
options: str = "" options: str = ""
class InterfaceUpdate(BaseModel): class InterfaceUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
zone_id: Optional[int] = None zone_id: Optional[int] = None
broadcast: Optional[str] = None
options: Optional[str] = None options: Optional[str] = None
@@ -78,7 +81,8 @@ class InterfaceOut(BaseModel):
id: int id: int
config_id: int config_id: int
name: str name: str
zone_id: int zone_id: Optional[int]
broadcast: str
options: str options: str
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -86,10 +90,12 @@ class InterfaceOut(BaseModel):
# --- Policy --- # --- Policy ---
class PolicyCreate(BaseModel): class PolicyCreate(BaseModel):
src_zone_id: int src_zone_id: Optional[int] = None
dst_zone_id: int dst_zone_id: Optional[int] = None
policy: str policy: str
log_level: str = "" log_level: str = ""
limit_burst: str = ""
connlimit_mask: str = ""
comment: str = "" comment: str = ""
position: int = 0 position: int = 0
@@ -99,6 +105,8 @@ class PolicyUpdate(BaseModel):
dst_zone_id: Optional[int] = None dst_zone_id: Optional[int] = None
policy: Optional[str] = None policy: Optional[str] = None
log_level: Optional[str] = None log_level: Optional[str] = None
limit_burst: Optional[str] = None
connlimit_mask: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None
position: Optional[int] = None position: Optional[int] = None
@@ -106,10 +114,12 @@ class PolicyUpdate(BaseModel):
class PolicyOut(BaseModel): class PolicyOut(BaseModel):
id: int id: int
config_id: int config_id: int
src_zone_id: int src_zone_id: Optional[int]
dst_zone_id: int dst_zone_id: Optional[int]
policy: str policy: str
log_level: str log_level: str
limit_burst: str
connlimit_mask: str
comment: str comment: str
position: int position: int
@@ -126,6 +136,15 @@ class RuleCreate(BaseModel):
proto: str = "" proto: str = ""
dport: str = "" dport: str = ""
sport: str = "" sport: str = ""
origdest: str = ""
rate_limit: str = ""
user_group: str = ""
mark: str = ""
connlimit: str = ""
time: str = ""
headers: str = ""
switch_name: str = ""
helper: str = ""
comment: str = "" comment: str = ""
position: int = 0 position: int = 0
@@ -139,6 +158,15 @@ class RuleUpdate(BaseModel):
proto: Optional[str] = None proto: Optional[str] = None
dport: Optional[str] = None dport: Optional[str] = None
sport: Optional[str] = None sport: Optional[str] = None
origdest: Optional[str] = None
rate_limit: Optional[str] = None
user_group: Optional[str] = None
mark: Optional[str] = None
connlimit: Optional[str] = None
time: Optional[str] = None
headers: Optional[str] = None
switch_name: Optional[str] = None
helper: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None
position: Optional[int] = None position: Optional[int] = None
@@ -154,42 +182,131 @@ class RuleOut(BaseModel):
proto: str proto: str
dport: str dport: str
sport: str sport: str
origdest: str
rate_limit: str
user_group: str
mark: str
connlimit: str
time: str
headers: str
switch_name: str
helper: str
comment: str comment: str
position: int position: int
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# --- Masq --- # --- Snat ---
class MasqCreate(BaseModel): class SnatCreate(BaseModel):
source_network: str source_network: str
out_interface: str out_interface: str
to_address: str = "" to_address: str = ""
proto: str = ""
port: str = ""
ipsec: str = ""
mark: str = ""
user_group: str = ""
switch_name: str = ""
origdest: str = ""
probability: str = ""
comment: str = "" comment: str = ""
class MasqUpdate(BaseModel): class SnatUpdate(BaseModel):
source_network: Optional[str] = None source_network: Optional[str] = None
out_interface: Optional[str] = None out_interface: Optional[str] = None
to_address: Optional[str] = None to_address: Optional[str] = None
proto: Optional[str] = None
port: Optional[str] = None
ipsec: Optional[str] = None
mark: Optional[str] = None
user_group: Optional[str] = None
switch_name: Optional[str] = None
origdest: Optional[str] = None
probability: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None
class MasqOut(BaseModel): class SnatOut(BaseModel):
id: int id: int
config_id: int config_id: int
source_network: str source_network: str
out_interface: str out_interface: str
to_address: str to_address: str
proto: str
port: str
ipsec: str
mark: str
user_group: str
switch_name: str
origdest: str
probability: str
comment: str comment: str
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# --- Host ---
class HostCreate(BaseModel):
zone_id: int
interface: str
subnet: str
options: str = ""
class HostUpdate(BaseModel):
zone_id: Optional[int] = None
interface: Optional[str] = None
subnet: Optional[str] = None
options: Optional[str] = None
class HostOut(BaseModel):
id: int
config_id: int
zone_id: int
interface: str
subnet: str
options: str
model_config = {"from_attributes": True}
# --- Param ---
class ParamCreate(BaseModel):
name: str
value: str
class ParamUpdate(BaseModel):
name: Optional[str] = None
value: Optional[str] = None
class ParamOut(BaseModel):
id: int
config_id: int
name: str
value: str
model_config = {"from_attributes": True}
# --- 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
policy: str policy: str
rules: str rules: str
masq: str snat: str
hosts: str
params: str

View File

@@ -26,32 +26,83 @@ class ShorewallGenerator:
return "".join(lines) return "".join(lines)
def interfaces(self) -> str: def interfaces(self) -> str:
lines = [self._header("interfaces"), "#ZONE".ljust(16) + "INTERFACE".ljust(16) + "OPTIONS\n"] lines = [self._header("interfaces"), "#ZONE".ljust(16) + "INTERFACE".ljust(16) + "BROADCAST".ljust(16) + "OPTIONS\n"]
for iface in self._config.interfaces: for iface in self._config.interfaces:
lines.append(self._col(iface.zone.name, iface.name, iface.options or "-")) zone = iface.zone.name if iface.zone else "-"
lines.append(self._col(zone, iface.name, iface.broadcast or "-", iface.options or "-"))
return "".join(lines) return "".join(lines)
def policy(self) -> str: def policy(self) -> str:
lines = [self._header("policy"), "#SOURCE".ljust(16) + "DEST".ljust(16) + "POLICY".ljust(16) + "LOG LEVEL\n"] lines = [
self._header("policy"),
"#SOURCE".ljust(16) + "DEST".ljust(16) + "POLICY".ljust(16)
+ "LOG LEVEL".ljust(16) + "LIMIT:BURST".ljust(20) + "CONNLIMIT:MASK\n",
]
for p in sorted(self._config.policies, key=lambda x: x.position): for p in sorted(self._config.policies, key=lambda x: x.position):
lines.append(self._col(p.src_zone.name, p.dst_zone.name, p.policy, p.log_level or "-")) src = p.src_zone.name if p.src_zone else "all"
dst = p.dst_zone.name if p.dst_zone else "all"
lines.append(self._col(
src, dst, p.policy,
p.log_level or "-",
p.limit_burst or "-",
p.connlimit_mask or "-",
width=16,
))
return "".join(lines) return "".join(lines)
def rules(self) -> str: def rules(self) -> str:
lines = [ lines = [
self._header("rules"), self._header("rules"),
"#ACTION".ljust(16) + "SOURCE".ljust(24) + "DEST".ljust(24) + "PROTO".ljust(10) + "DPORT".ljust(10) + "SPORT\n", "#ACTION".ljust(16) + "SOURCE".ljust(24) + "DEST".ljust(24)
+ "PROTO".ljust(10) + "DPORT".ljust(16) + "SPORT".ljust(16)
+ "ORIGDEST".ljust(20) + "RATE".ljust(16) + "USER".ljust(16)
+ "MARK".ljust(12) + "CONNLIMIT".ljust(14) + "TIME".ljust(20)
+ "HEADERS".ljust(16) + "SWITCH".ljust(16) + "HELPER\n",
"SECTION NEW\n",
] ]
for r in sorted(self._config.rules, key=lambda x: x.position): for r in sorted(self._config.rules, key=lambda x: x.position):
src = (r.src_zone.name if r.src_zone else "all") + (f":{r.src_ip}" if r.src_ip else "") src = (r.src_zone.name if r.src_zone else "all") + (f":{r.src_ip}" if r.src_ip else "")
dst = (r.dst_zone.name if r.dst_zone else "all") + (f":{r.dst_ip}" if r.dst_ip else "") dst = (r.dst_zone.name if r.dst_zone else "all") + (f":{r.dst_ip}" if r.dst_ip else "")
lines.append(self._col(r.action, src, dst, r.proto or "-", r.dport or "-", r.sport or "-", width=16)) lines.append(self._col(
r.action, src, dst,
r.proto or "-", r.dport or "-", r.sport or "-",
r.origdest or "-", r.rate_limit or "-", r.user_group or "-",
r.mark or "-", r.connlimit or "-", r.time or "-",
r.headers or "-", r.switch_name or "-", r.helper or "-",
width=16,
))
return "".join(lines) return "".join(lines)
def masq(self) -> str: def hosts(self) -> str:
lines = [self._header("masq"), "#INTERFACE".ljust(24) + "SOURCE".ljust(24) + "ADDRESS\n"] lines = [self._header("hosts"), "#ZONE".ljust(16) + "HOSTS\n"]
for m in self._config.masq_entries: for h in self._config.host_entries:
lines.append(self._col(m.out_interface, m.source_network, m.to_address or "-", width=24)) hosts_val = f"{h.interface}:{h.subnet}"
lines.append(self._col(h.zone.name, hosts_val, h.options or "-", width=16))
return "".join(lines)
def params(self) -> str:
lines = [self._header("params")]
for p in self._config.params:
lines.append(f"{p.name}={p.value}\n")
return "".join(lines)
def snat(self) -> str:
lines = [
self._header("snat"),
"#ACTION".ljust(24) + "SOURCE".ljust(24) + "DEST".ljust(20)
+ "PROTO".ljust(10) + "PORT".ljust(16) + "IPSEC".ljust(16)
+ "MARK".ljust(12) + "USER/GROUP".ljust(16) + "SWITCH".ljust(16)
+ "ORIGDEST".ljust(20) + "PROBABILITY\n",
]
for m in self._config.snat_entries:
action = f"SNAT:{m.to_address}" if m.to_address else "MASQUERADE"
lines.append(self._col(
action, m.source_network, m.out_interface,
m.proto or "-", m.port or "-", m.ipsec or "-",
m.mark or "-", m.user_group or "-", m.switch_name or "-",
m.origdest or "-", m.probability or "-",
width=16,
))
return "".join(lines) return "".join(lines)
def as_json(self) -> dict: def as_json(self) -> dict:
@@ -60,7 +111,9 @@ class ShorewallGenerator:
"interfaces": self.interfaces(), "interfaces": self.interfaces(),
"policy": self.policy(), "policy": self.policy(),
"rules": self.rules(), "rules": self.rules(),
"masq": self.masq(), "snat": self.snat(),
"hosts": self.hosts(),
"params": self.params(),
} }
def as_zip(self) -> bytes: def as_zip(self) -> bytes:
@@ -70,5 +123,7 @@ class ShorewallGenerator:
zf.writestr("interfaces", self.interfaces()) zf.writestr("interfaces", self.interfaces())
zf.writestr("policy", self.policy()) zf.writestr("policy", self.policy())
zf.writestr("rules", self.rules()) zf.writestr("rules", self.rules())
zf.writestr("masq", self.masq()) zf.writestr("snat", self.snat())
zf.writestr("hosts", self.hosts())
zf.writestr("params", self.params())
return buf.getvalue() return buf.getvalue()

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,9 +38,11 @@ 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, masq) --- // --- Nested resources (zones, interfaces, policies, rules, snat) ---
const nestedApi = (resource: string) => ({ const nestedApi = (resource: string) => ({
list: (configId: number) => api.get(`/configs/${configId}/${resource}`), list: (configId: number) => api.get(`/configs/${configId}/${resource}`),
create: (configId: number, data: object) => api.post(`/configs/${configId}/${resource}`, data), create: (configId: number, data: object) => api.post(`/configs/${configId}/${resource}`, data),
@@ -54,4 +56,6 @@ export const zonesApi = nestedApi('zones')
export const interfacesApi = nestedApi('interfaces') export const interfacesApi = nestedApi('interfaces')
export const policiesApi = nestedApi('policies') export const policiesApi = nestedApi('policies')
export const rulesApi = nestedApi('rules') export const rulesApi = nestedApi('rules')
export const masqApi = nestedApi('masq') export const snatApi = nestedApi('snat')
export const hostsApi = nestedApi('hosts')
export const paramsApi = nestedApi('params')

View File

@@ -14,6 +14,7 @@ export interface FieldDef {
required?: boolean required?: boolean
type?: 'text' | 'select' | 'number' type?: 'text' | 'select' | 'number'
options?: { value: string | number; label: string }[] options?: { value: string | number; label: string }[]
placeholder?: string
} }
interface Props { interface Props {
@@ -37,7 +38,13 @@ export default function EntityForm({ open, title, fields, initialValues, onClose
const handleSubmit = async () => { const handleSubmit = async () => {
setSubmitting(true) 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 ( return (
@@ -67,6 +74,7 @@ export default function EntityForm({ open, title, fields, initialValues, onClose
value={values[f.name] ?? ''} value={values[f.name] ?? ''}
onChange={(e) => handleChange(f.name, e.target.value)} onChange={(e) => handleChange(f.name, e.target.value)}
size="small" size="small"
placeholder={f.placeholder}
/> />
) )
)} )}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent' import DialogContent from '@mui/material/DialogContent'
@@ -18,7 +18,9 @@ interface GeneratedFiles {
interfaces: string interfaces: string
policy: string policy: string
rules: string rules: string
masq: string snat: string
hosts: string
params: string
} }
interface Props { interface Props {
@@ -28,13 +30,17 @@ interface Props {
onClose: () => void onClose: () => void
} }
const TABS = ['zones', 'interfaces', 'policy', 'rules', 'masq'] as const const TABS = ['zones', 'interfaces', 'policy', 'rules', 'snat', 'hosts', 'params'] as const
export default function GenerateModal({ open, configId, configName, onClose }: Props) { export default function GenerateModal({ open, configId, configName, onClose }: Props) {
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
const [files, setFiles] = useState<GeneratedFiles | null>(null) const [files, setFiles] = useState<GeneratedFiles | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) setFiles(null)
}, [open])
const handleOpen = async () => { const handleOpen = async () => {
if (files) return if (files) return
setLoading(true) 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 { useNavigate, useLocation } from 'react-router-dom'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Drawer from '@mui/material/Drawer' import Drawer from '@mui/material/Drawer'
@@ -13,6 +13,7 @@ import Tooltip from '@mui/material/Tooltip'
import DnsIcon from '@mui/icons-material/Dns' import DnsIcon from '@mui/icons-material/Dns'
import LogoutIcon from '@mui/icons-material/Logout' import LogoutIcon from '@mui/icons-material/Logout'
import { useAuth } from '../store/auth' import { useAuth } from '../store/auth'
import api from '../api'
const DRAWER_WIDTH = 240 const DRAWER_WIDTH = 240
@@ -22,6 +23,11 @@ export default function Layout({ children, title }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
api.get('/health').then((r) => setVersion(r.data.version)).catch(() => {})
}, [])
return ( return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}> <Box sx={{ display: 'flex', minHeight: '100vh' }}>
@@ -47,7 +53,10 @@ export default function Layout({ children, title }: Props) {
</List> </List>
<Divider sx={{ borderColor: '#2d3748' }} /> <Divider sx={{ borderColor: '#2d3748' }} />
<Box sx={{ px: 2, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <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"> <Tooltip title="Logout">
<IconButton onClick={logout} size="small" sx={{ color: '#94a3b8' }}><LogoutIcon fontSize="small" /></IconButton> <IconButton onClick={logout} size="small" sx={{ color: '#94a3b8' }}><LogoutIcon fontSize="small" /></IconButton>
</Tooltip> </Tooltip>

View File

@@ -10,16 +10,26 @@ 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 { zonesApi, interfacesApi, policiesApi, rulesApi, masqApi, configsApi } from '../api' 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 ---- // ---- Types ----
interface Zone { id: number; name: string; type: string; options: string } interface Zone { id: number; name: string; type: string; options: string }
interface Iface { id: number; name: string; zone_id: number; options: string } interface Iface { id: number; name: string; zone_id: number; options: string }
interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number } interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number }
interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; comment: string; position: number } interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; origdest: string; rate_limit: string; user_group: string; mark: string; connlimit: string; time: string; headers: string; switch_name: string; helper: string; comment: string; position: number }
interface Masq { id: number; source_network: string; out_interface: string; to_address: string; comment: string } interface Snat { id: number; source_network: string; out_interface: string; to_address: string; proto: string; port: string; ipsec: string; mark: string; user_group: string; switch_name: string; origdest: string; probability: string; comment: string }
interface Host { id: number; zone_id: number; interface: string; subnet: string; options: string }
interface Param { id: number; name: string; value: string }
type AnyEntity = { id: number } & Record<string, unknown> type AnyEntity = { id: number } & Record<string, unknown>
@@ -33,18 +43,26 @@ export default function ConfigDetail() {
const [interfaces, setInterfaces] = useState<Iface[]>([]) const [interfaces, setInterfaces] = useState<Iface[]>([])
const [policies, setPolicies] = useState<Policy[]>([]) const [policies, setPolicies] = useState<Policy[]>([])
const [rules, setRules] = useState<Rule[]>([]) const [rules, setRules] = useState<Rule[]>([])
const [masq, setMasq] = useState<Masq[]>([]) const [snat, setSnat] = useState<Snat[]>([])
const [hosts, setHosts] = useState<Host[]>([])
const [paramsList, setParamsList] = useState<Param[]>([])
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))
rulesApi.list(configId).then((r) => setRules(r.data)) rulesApi.list(configId).then((r) => setRules(r.data))
masqApi.list(configId).then((r) => setMasq(r.data)) snatApi.list(configId).then((r) => setSnat(r.data))
hostsApi.list(configId).then((r) => setHosts(r.data))
paramsApi.list(configId).then((r) => setParamsList(r.data))
}, [configId]) }, [configId])
const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name })) const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name }))
@@ -77,13 +95,15 @@ export default function ConfigDetail() {
{ {
key: 'zone_id' as const, key: 'zone_id' as const,
label: 'Zone', label: 'Zone',
render: (r: AnyEntity) => zones.find((z) => z.id === r['zone_id'])?.name ?? String(r['zone_id']), render: (r: AnyEntity) => r['zone_id'] == null ? '-' : (zones.find((z) => z.id === r['zone_id'])?.name ?? String(r['zone_id'])),
}, },
{ key: 'broadcast' as const, label: 'Broadcast' },
{ key: 'options' as const, label: 'Options' }, { key: 'options' as const, label: 'Options' },
] as Column<AnyEntity>[], ] as Column<AnyEntity>[],
fields: [ fields: [
{ name: 'name', label: 'Interface Name', required: true }, { name: 'name', label: 'Interface Name', required: true },
{ name: 'zone_id', label: 'Zone', required: true, type: 'select' as const, options: zoneOptions }, { name: 'zone_id', label: 'Zone', type: 'select' as const, options: [{ value: '', label: '- (no zone)' }, ...zoneOptions] },
{ name: 'broadcast', label: 'Broadcast' },
{ name: 'options', label: 'Options' }, { name: 'options', label: 'Options' },
] as FieldDef[], ] as FieldDef[],
}, },
@@ -96,22 +116,26 @@ export default function ConfigDetail() {
{ {
key: 'src_zone_id' as const, key: 'src_zone_id' as const,
label: 'Source', label: 'Source',
render: (r: AnyEntity) => zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id']), render: (r: AnyEntity) => r['src_zone_id'] == null ? 'all' : (zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id'])),
}, },
{ {
key: 'dst_zone_id' as const, key: 'dst_zone_id' as const,
label: 'Destination', label: 'Destination',
render: (r: AnyEntity) => zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id']), render: (r: AnyEntity) => r['dst_zone_id'] == null ? 'all' : (zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id'])),
}, },
{ key: 'policy' as const, label: 'Policy' }, { key: 'policy' as const, label: 'Policy' },
{ key: 'log_level' as const, label: 'Log Level' }, { key: 'log_level' as const, label: 'Log Level' },
{ key: 'limit_burst' as const, label: 'Limit:Burst' },
{ key: 'connlimit_mask' as const, label: 'ConnLimit:Mask' },
{ key: 'position' as const, label: 'Position' }, { key: 'position' as const, label: 'Position' },
] as Column<AnyEntity>[], ] as Column<AnyEntity>[],
fields: [ fields: [
{ name: 'src_zone_id', label: 'Source Zone', required: true, type: 'select' as const, options: zoneOptions }, { name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
{ name: 'dst_zone_id', label: 'Destination Zone', required: true, type: 'select' as const, options: zoneOptions }, { name: 'dst_zone_id', label: 'Destination Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
{ name: 'policy', label: 'Policy', required: true, type: 'select' as const, options: [{ value: 'ACCEPT', label: 'ACCEPT' }, { value: 'DROP', label: 'DROP' }, { value: 'REJECT', label: 'REJECT' }, { value: 'CONTINUE', label: 'CONTINUE' }] }, { name: 'policy', label: 'Policy', required: true, type: 'select' as const, options: [{ value: 'ACCEPT', label: 'ACCEPT' }, { value: 'DROP', label: 'DROP' }, { value: 'REJECT', label: 'REJECT' }, { value: 'CONTINUE', label: 'CONTINUE' }] },
{ name: 'log_level', label: 'Log Level' }, { name: 'log_level', label: 'Log Level' },
{ name: 'limit_burst', label: 'Limit:Burst', placeholder: 'e.g. 10/sec:20' },
{ name: 'connlimit_mask', label: 'ConnLimit:Mask', placeholder: 'e.g. 10:24' },
{ name: 'comment', label: 'Comment' }, { name: 'comment', label: 'Comment' },
{ name: 'position', label: 'Position', type: 'number' as const }, { name: 'position', label: 'Position', type: 'number' as const },
] as FieldDef[], ] as FieldDef[],
@@ -135,39 +159,103 @@ export default function ConfigDetail() {
}, },
{ key: 'proto' as const, label: 'Proto' }, { key: 'proto' as const, label: 'Proto' },
{ key: 'dport' as const, label: 'DPort' }, { key: 'dport' as const, label: 'DPort' },
{ key: 'origdest' as const, label: 'OrigDest' },
{ key: 'position' as const, label: 'Position' }, { key: 'position' as const, label: 'Position' },
] as Column<AnyEntity>[], ] as Column<AnyEntity>[],
fields: [ fields: [
{ name: 'action', label: 'Action', required: true }, { name: 'action', label: 'Action', required: true },
{ name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: zoneOptions }, { name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
{ name: 'dst_zone_id', label: 'Dest Zone', type: 'select' as const, options: zoneOptions }, { name: 'dst_zone_id', label: 'Dest Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
{ name: 'src_ip', label: 'Source IP/CIDR' }, { name: 'src_ip', label: 'Source IP/CIDR' },
{ name: 'dst_ip', label: 'Dest IP/CIDR' }, { name: 'dst_ip', label: 'Dest IP/CIDR' },
{ name: 'proto', label: 'Protocol' }, { name: 'proto', label: 'Protocol', placeholder: 'e.g. tcp, udp, icmp' },
{ name: 'dport', label: 'Dest Port' }, { name: 'dport', label: 'Dest Port(s)' },
{ name: 'sport', label: 'Source Port' }, { name: 'sport', label: 'Source Port(s)' },
{ name: 'origdest', label: 'Original Dest', placeholder: 'e.g. 192.168.1.1' },
{ name: 'rate_limit', label: 'Rate Limit', placeholder: 'e.g. 10/sec:20' },
{ name: 'user_group', label: 'User/Group', placeholder: 'e.g. joe:wheel' },
{ name: 'mark', label: 'Mark', placeholder: 'e.g. 0x100/0xff0' },
{ name: 'connlimit', label: 'ConnLimit', placeholder: 'e.g. 10:24' },
{ name: 'time', label: 'Time', placeholder: 'e.g. timestart=09:00&timestop=17:00' },
{ name: 'headers', label: 'Headers (IPv6)', placeholder: 'e.g. auth,esp' },
{ name: 'switch_name', label: 'Switch', placeholder: 'e.g. vpn_enabled' },
{ name: 'helper', label: 'Helper', type: 'select' as const, options: [
{ value: '', label: '(none)' },
{ value: 'amanda', label: 'amanda' },
{ value: 'ftp', label: 'ftp' },
{ value: 'irc', label: 'irc' },
{ value: 'netbios-ns', label: 'netbios-ns' },
{ value: 'pptp', label: 'pptp' },
{ value: 'Q.931', label: 'Q.931' },
{ value: 'RAS', label: 'RAS' },
{ value: 'sane', label: 'sane' },
{ value: 'sip', label: 'sip' },
{ value: 'snmp', label: 'snmp' },
{ value: 'tftp', label: 'tftp' },
]},
{ name: 'comment', label: 'Comment' }, { name: 'comment', label: 'Comment' },
{ name: 'position', label: 'Position', type: 'number' as const }, { name: 'position', label: 'Position', type: 'number' as const },
] as FieldDef[], ] as FieldDef[],
}, },
{ {
label: 'Masq/NAT', label: 'SNAT',
rows: masq as unknown as AnyEntity[], rows: snat as unknown as AnyEntity[],
setRows: setMasq as unknown as Dispatch<SetStateAction<AnyEntity[]>>, setRows: setSnat as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: masqApi, api: snatApi,
columns: [ columns: [
{ key: 'out_interface' as const, label: 'Out Interface' }, { key: 'out_interface' as const, label: 'Out Interface' },
{ key: 'source_network' as const, label: 'Source Network' }, { key: 'source_network' as const, label: 'Source Network' },
{ key: 'to_address' as const, label: 'To Address' }, { key: 'to_address' as const, label: 'To Address' },
{ key: 'comment' as const, label: 'Comment' }, { key: 'proto' as const, label: 'Proto' },
{ key: 'probability' as const, label: 'Probability' },
] as Column<AnyEntity>[], ] as Column<AnyEntity>[],
fields: [ fields: [
{ name: 'out_interface', label: 'Out Interface', required: true }, { name: 'out_interface', label: 'Out Interface', required: true },
{ name: 'source_network', label: 'Source Network', required: true }, { name: 'source_network', label: 'Source Network', required: true },
{ name: 'to_address', label: 'To Address' }, { name: 'to_address', label: 'To Address (blank = MASQUERADE)', placeholder: 'e.g. 1.2.3.4' },
{ name: 'proto', label: 'Protocol', placeholder: 'e.g. tcp, udp' },
{ name: 'port', label: 'Port', placeholder: 'e.g. 80, 1024:65535' },
{ name: 'ipsec', label: 'IPsec', placeholder: 'e.g. mode=tunnel' },
{ name: 'mark', label: 'Mark', placeholder: 'e.g. 0x100/0xff0' },
{ name: 'user_group', label: 'User/Group', placeholder: 'e.g. joe:wheel' },
{ name: 'switch_name', label: 'Switch', placeholder: 'e.g. vpn_enabled' },
{ name: 'origdest', label: 'Orig Dest', placeholder: 'e.g. 1.2.3.4' },
{ name: 'probability', label: 'Probability', placeholder: 'e.g. 0.25' },
{ name: 'comment', label: 'Comment' }, { name: 'comment', label: 'Comment' },
] as FieldDef[], ] as FieldDef[],
}, },
{
label: 'Hosts',
rows: hosts as unknown as AnyEntity[],
setRows: setHosts as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: hostsApi,
columns: [
{ key: 'zone_id' as const, label: 'Zone' },
{ key: 'interface' as const, label: 'Interface' },
{ key: 'subnet' as const, label: 'Subnet' },
{ key: 'options' as const, label: 'Options' },
] as Column<AnyEntity>[],
fields: [
{ name: 'zone_id', label: 'Zone', type: 'select' as const, options: zoneOptions, required: true },
{ name: 'interface', label: 'Interface', required: true },
{ name: 'subnet', label: 'Subnet', required: true },
{ name: 'options', label: 'Options' },
] as FieldDef[],
},
{
label: 'Params',
rows: paramsList as unknown as AnyEntity[],
setRows: setParamsList as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: paramsApi,
columns: [
{ key: 'name' as const, label: 'Name' },
{ key: 'value' as const, label: 'Value' },
] as Column<AnyEntity>[],
fields: [
{ name: 'name', label: 'Name', required: true },
{ name: 'value', label: 'Value', required: true },
] as FieldDef[],
},
] ]
const current = tabConfig[tab] const current = tabConfig[tab]
@@ -193,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 }}>
@@ -207,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} />)}

View File

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

View File

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

View File

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

View File

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

View File

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