Compare commits

..

49 Commits

Author SHA1 Message Date
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
740983277f debug: log userinfo keys and groups claim in OIDC callback
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 45s
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 1m31s
2026-03-01 01:17:12 +01:00
6b340f50cb fix: remove passlib import from migration 0001, embed static hash
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 30s
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:10:17 +01:00
aaa6e7def4 Version bump to test sso
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 1m18s
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 1m36s
2026-03-01 01:02:44 +01:00
2b6cd29413 feat(sso): bump container version for SSO release 2026-03-01 00:55:19 +01:00
daabafc595 feat(sso): replace login form with SSO redirect button 2026-03-01 00:55:07 +01:00
1daa6f6e90 feat(sso): replace local auth with Keycloak OIDC callback flow 2026-03-01 00:51:14 +01:00
f28240c37f feat(sso): update User model and schemas for Keycloak 2026-03-01 00:50:44 +01:00
95fbe99b61 feat(sso): migration — add keycloak_sub, make hashed_password nullable 2026-03-01 00:50:20 +01:00
ff4aa155d1 feat(sso): add KEYCLOAK_CLIENT_SECRET to secrets script and backend deployment 2026-03-01 00:45:37 +01:00
924e51ffaa feat(sso): add Keycloak settings to database.py and Helm ConfigMap 2026-03-01 00:45:07 +01:00
58f0fd50d8 feat(sso): replace passlib/bcrypt with authlib + httpx 2026-03-01 00:44:18 +01:00
40113bc634 docs: add Keycloak SSO integration design 2026-03-01 00:37:49 +01:00
4c4cdf0a52 fix: route all traffic through nginx; remove direct /api->backend ingress rule
Traefik forwards /api/auth/login to the backend verbatim, causing 404.
Nginx already strips the /api prefix correctly via proxy_pass with trailing
slash. Routing everything through frontend/nginx avoids the double-routing
and the need for a StripPrefix middleware.
2026-03-01 00:12:33 +01:00
45 changed files with 6564 additions and 230 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

@@ -81,6 +81,31 @@ On the Config Detail page, click **Generate Config**:
---
## Command-Line Download
Each config has a **Download Token** — a secret string that allows downloading the generated ZIP without an OIDC session. This is 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. You will need to update any scripts that use it.
---
## API Documentation
FastAPI generates interactive docs automatically:

View File

@@ -6,14 +6,16 @@ Create Date: 2026-02-28
"""
from alembic import op
import sqlalchemy as sa
from passlib.context import CryptContext
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Pre-computed bcrypt hash of "admin". Used only for seeding the initial admin
# row; hashed_password becomes nullable in migration 0002 (SSO transition) and
# is never validated at login.
_ADMIN_HASH = "$2b$12$E8Td3Igd66V.udnDuflWFOhhhZDYFRHwLpgIsw.cRQtMf4h102sj."
def upgrade() -> None:
@@ -98,7 +100,7 @@ def upgrade() -> None:
"INSERT INTO users (username, email, hashed_password, is_active) "
"VALUES (:u, :e, :p, true)"
),
{"u": "admin", "e": "admin@localhost", "p": pwd_context.hash("admin")},
{"u": "admin", "e": "admin@localhost", "p": _ADMIN_HASH},
)
user_id = conn.execute(sa.text("SELECT id FROM users WHERE username='admin'")).scalar()

View File

@@ -0,0 +1,25 @@
"""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")

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

@@ -1,43 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
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, hash_password, verify_password
from app.database import get_db
from app.auth import create_access_token, get_current_user, oauth
from app.database import get_db, settings
router = APIRouter()
@router.post("/register", response_model=schemas.UserOut, status_code=201)
def register(body: schemas.UserCreate, db: Session = Depends(get_db)) -> models.User:
if db.query(models.User).filter(models.User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already registered")
if db.query(models.User).filter(models.User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = models.User(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
FIREWALL_ADMINS_GROUP = "firewall admins"
@router.post("/login")
def login(body: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)) -> dict:
user = db.query(models.User).filter(models.User.username == body.username).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(user.id)
@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=token,
value=access_token,
httponly=True,
samesite="lax",
max_age=3600,
)
return {"message": "Logged in"}
return response
@router.post("/logout")

View File

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

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

@@ -1,21 +1,23 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
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
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
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:
@@ -49,3 +51,15 @@ def get_current_user(
if user is None or not user.is_active:
raise credentials_exception
return user
def get_optional_user(
access_token: Optional[str] = Cookie(default=None),
db: Session = Depends(get_db),
) -> Optional[models.User]:
if not access_token:
return None
try:
return get_current_user(access_token=access_token, db=db)
except HTTPException:
return None

View File

@@ -8,6 +8,12 @@ class Settings(BaseSettings):
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
app_version: str = "dev"
class Config:
env_file = ".env"

View File

@@ -1,9 +1,12 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, masq
from starlette.middleware.sessions import SessionMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, snat, hosts, params
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"],
@@ -18,9 +21,11 @@ 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.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")
def health() -> dict:
return {"status": "ok"}
return {"status": "ok", "version": settings.app_version}

View File

@@ -1,3 +1,4 @@
import secrets
from datetime import datetime
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
@@ -12,7 +13,8 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
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")
@@ -26,6 +28,12 @@ class Config(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
download_token: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
default=lambda: secrets.token_urlsafe(32),
)
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
owner: Mapped["User"] = relationship("User", back_populates="configs")
@@ -33,7 +41,9 @@ class Config(Base):
interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="config", cascade="all, delete-orphan")
policies: Mapped[list["Policy"]] = relationship("Policy", back_populates="config", cascade="all, delete-orphan", order_by="Policy.position")
rules: Mapped[list["Rule"]] = relationship("Rule", back_populates="config", cascade="all, delete-orphan", order_by="Rule.position")
masq_entries: Mapped[list["Masq"]] = relationship("Masq", back_populates="config", cascade="all, delete-orphan")
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):
@@ -56,11 +66,12 @@ class Interface(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
name: Mapped[str] = mapped_column(String(32), nullable=False)
zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
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="")
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):
@@ -68,16 +79,18 @@ class Policy(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
src_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
dst_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False)
src_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
dst_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True)
policy: Mapped[str] = mapped_column(String(16), nullable=False)
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="")
position: Mapped[int] = mapped_column(Integer, default=0)
config: Mapped["Config"] = relationship("Config", back_populates="policies")
src_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[src_zone_id])
dst_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[dst_zone_id])
src_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[src_zone_id])
dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Rule(Base):
@@ -93,6 +106,15 @@ class Rule(Base):
proto: Mapped[str] = mapped_column(String(16), default="")
dport: 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="")
position: Mapped[int] = mapped_column(Integer, default=0)
@@ -101,14 +123,47 @@ class Rule(Base):
dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Masq(Base):
__tablename__ = "masq"
class Snat(Base):
__tablename__ = "snat"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
source_network: Mapped[str] = mapped_column(String(64), nullable=False)
out_interface: Mapped[str] = mapped_column(String(32), nullable=False)
to_address: Mapped[str] = mapped_column(String(64), default="")
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="")
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,15 +1,9 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, Field
# --- Auth ---
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
username: str
@@ -19,11 +13,6 @@ class UserOut(BaseModel):
model_config = {"from_attributes": True}
class LoginRequest(BaseModel):
username: str
password: str
# --- Config ---
class ConfigCreate(BaseModel):
name: str
@@ -45,6 +34,7 @@ class ConfigOut(BaseModel):
created_at: datetime
updated_at: datetime
owner_id: int
download_token: str
model_config = {"from_attributes": True}
@@ -75,13 +65,15 @@ class ZoneOut(BaseModel):
# --- Interface ---
class InterfaceCreate(BaseModel):
name: str
zone_id: int
zone_id: Optional[int] = None
broadcast: str = "detect"
options: str = ""
class InterfaceUpdate(BaseModel):
name: Optional[str] = None
zone_id: Optional[int] = None
broadcast: Optional[str] = None
options: Optional[str] = None
@@ -89,7 +81,8 @@ class InterfaceOut(BaseModel):
id: int
config_id: int
name: str
zone_id: int
zone_id: Optional[int]
broadcast: str
options: str
model_config = {"from_attributes": True}
@@ -97,10 +90,12 @@ class InterfaceOut(BaseModel):
# --- Policy ---
class PolicyCreate(BaseModel):
src_zone_id: int
dst_zone_id: int
src_zone_id: Optional[int] = None
dst_zone_id: Optional[int] = None
policy: str
log_level: str = ""
limit_burst: str = ""
connlimit_mask: str = ""
comment: str = ""
position: int = 0
@@ -110,6 +105,8 @@ class PolicyUpdate(BaseModel):
dst_zone_id: Optional[int] = None
policy: Optional[str] = None
log_level: Optional[str] = None
limit_burst: Optional[str] = None
connlimit_mask: Optional[str] = None
comment: Optional[str] = None
position: Optional[int] = None
@@ -117,10 +114,12 @@ class PolicyUpdate(BaseModel):
class PolicyOut(BaseModel):
id: int
config_id: int
src_zone_id: int
dst_zone_id: int
src_zone_id: Optional[int]
dst_zone_id: Optional[int]
policy: str
log_level: str
limit_burst: str
connlimit_mask: str
comment: str
position: int
@@ -137,6 +136,15 @@ class RuleCreate(BaseModel):
proto: str = ""
dport: 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 = ""
position: int = 0
@@ -150,6 +158,15 @@ class RuleUpdate(BaseModel):
proto: Optional[str] = None
dport: 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
position: Optional[int] = None
@@ -165,42 +182,131 @@ class RuleOut(BaseModel):
proto: str
dport: 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
position: int
model_config = {"from_attributes": True}
# --- Masq ---
class MasqCreate(BaseModel):
# --- Snat ---
class SnatCreate(BaseModel):
source_network: str
out_interface: str
to_address: str = ""
proto: str = ""
port: str = ""
ipsec: str = ""
mark: str = ""
user_group: str = ""
switch_name: str = ""
origdest: str = ""
probability: str = ""
comment: str = ""
class MasqUpdate(BaseModel):
class SnatUpdate(BaseModel):
source_network: Optional[str] = None
out_interface: 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
class MasqOut(BaseModel):
class SnatOut(BaseModel):
id: int
config_id: int
source_network: str
out_interface: str
to_address: str
proto: str
port: str
ipsec: str
mark: str
user_group: str
switch_name: str
origdest: str
probability: str
comment: str
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 ---
class GenerateRequest(BaseModel):
token: Optional[str] = Field(default=None, min_length=1)
class RegenerateTokenOut(BaseModel):
download_token: str
class GenerateOut(BaseModel):
zones: str
interfaces: str
policy: str
rules: str
masq: str
snat: str
hosts: str
params: str

View File

@@ -26,32 +26,83 @@ class ShorewallGenerator:
return "".join(lines)
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:
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)
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):
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)
def rules(self) -> str:
lines = [
self._header("rules"),
"#ACTION".ljust(16) + "SOURCE".ljust(24) + "DEST".ljust(24) + "PROTO".ljust(10) + "DPORT".ljust(10) + "SPORT\n",
"#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):
src = (r.src_zone.name if r.src_zone else "all") + (f":{r.src_ip}" if r.src_ip else "")
dst = (r.dst_zone.name if r.dst_zone else "all") + (f":{r.dst_ip}" if r.dst_ip else "")
lines.append(self._col(r.action, src, dst, r.proto or "-", r.dport or "-", r.sport or "-", width=16))
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)
def masq(self) -> str:
lines = [self._header("masq"), "#INTERFACE".ljust(24) + "SOURCE".ljust(24) + "ADDRESS\n"]
for m in self._config.masq_entries:
lines.append(self._col(m.out_interface, m.source_network, m.to_address or "-", width=24))
def hosts(self) -> str:
lines = [self._header("hosts"), "#ZONE".ljust(16) + "HOSTS\n"]
for h in self._config.host_entries:
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)
def as_json(self) -> dict:
@@ -60,7 +111,9 @@ class ShorewallGenerator:
"interfaces": self.interfaces(),
"policy": self.policy(),
"rules": self.rules(),
"masq": self.masq(),
"snat": self.snat(),
"hosts": self.hosts(),
"params": self.params(),
}
def as_zip(self) -> bytes:
@@ -70,5 +123,7 @@ class ShorewallGenerator:
zf.writestr("interfaces", self.interfaces())
zf.writestr("policy", self.policy())
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()

View File

@@ -4,8 +4,9 @@ sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt<4.0.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

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,75 @@
# Keycloak SSO Integration Design
**Goal:** Replace local username/password auth with Keycloak OIDC using a backend callback flow.
**Architecture:** Backend acts as OIDC confidential client. Browser is redirected to Keycloak, which redirects back to a backend callback endpoint. Backend validates the token, checks group membership, provisions the user, and issues its own httpOnly JWT cookie. Frontend is unchanged except the login page.
**Tech Stack:** authlib (OIDC), FastAPI, Alembic, React
---
## Auth Flow
```
Browser → GET /api/auth/oidc/login
→ backend generates state, stores in short-lived cookie, redirects to Keycloak
Keycloak → user authenticates → redirects to:
GET /api/auth/oidc/callback?code=...&state=...
Backend:
1. Validates state cookie (CSRF protection)
2. Exchanges code for tokens via Keycloak token endpoint
3. Validates ID token signature via Keycloak JWKS (authlib handles this)
4. Checks groups claim for "firewall admins" → 403 if absent
5. Looks up user by keycloak_sub → auto-provisions row if first login
6. Issues httpOnly JWT cookie (same mechanism as before)
7. Redirects browser to /
```
Removed endpoints: `POST /auth/login`, `POST /auth/register`
Kept endpoints: `GET /auth/me`, `POST /auth/logout`
## Data Model
New Alembic migration:
- Add `keycloak_sub VARCHAR(255) UNIQUE` to `users` table
- Make `hashed_password` nullable (always NULL for SSO users; kept for schema stability)
## Configuration
**ConfigMap** (non-secret):
- `KEYCLOAK_URL`: `https://sso.baumann.gr`
- `KEYCLOAK_REALM`: `homelab`
- `KEYCLOAK_CLIENT_ID`: `shorefront`
**Secret** (added to `scripts/create-secrets.sh`):
- `KEYCLOAK_CLIENT_SECRET`
**Redirect URI** (backend callback, registered in Keycloak):
- `https://shorefront.baumann.gr/api/auth/oidc/callback`
## Backend Changes
- Add `authlib` + `httpx` to `requirements.txt`
- Add `keycloak_url`, `keycloak_realm`, `keycloak_client_id`, `keycloak_client_secret` to `Settings`
- Add `keycloak_sub` column to `User` model
- New migration: add `keycloak_sub`, make `hashed_password` nullable
- Replace `backend/app/api/auth.py` with OIDC endpoints:
- `GET /auth/oidc/login` — generate state, redirect to Keycloak
- `GET /auth/oidc/callback` — exchange code, validate token, check group, provision user, set cookie, redirect
- Keep `POST /auth/logout`, `GET /auth/me`
- Remove `hash_password`, `verify_password` from `auth.py`
## Frontend Changes
- `Login.tsx`: replace username/password form with a single "Sign in with SSO" button (`window.location.href = '/api/auth/oidc/login'`)
- All other components unchanged
## Keycloak Manual Setup (pre-deploy)
1. Create client `shorefront`, access type: confidential
2. Set Valid Redirect URIs: `https://shorefront.baumann.gr/api/auth/oidc/callback`
3. Set Web Origins: `https://shorefront.baumann.gr`
4. Add Group Membership mapper on client: include groups in ID token, claim name `groups`
5. Create group `firewall admins`, add users to it

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, {
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) => ({
list: (configId: number) => api.get(`/configs/${configId}/${resource}`),
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 policiesApi = nestedApi('policies')
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
type?: 'text' | 'select' | 'number'
options?: { value: string | number; label: string }[]
placeholder?: string
}
interface Props {
@@ -37,7 +38,13 @@ export default function EntityForm({ open, title, fields, initialValues, onClose
const handleSubmit = async () => {
setSubmitting(true)
try { await onSubmit(values) } finally { setSubmitting(false) }
const submitted = Object.fromEntries(
Object.entries(values).map(([k, v]) => {
const field = fields.find((f) => f.name === k)
return [k, field?.type === 'select' && v === '' ? null : v]
})
)
try { await onSubmit(submitted) } finally { setSubmitting(false) }
}
return (
@@ -67,6 +74,7 @@ export default function EntityForm({ open, title, fields, initialValues, onClose
value={values[f.name] ?? ''}
onChange={(e) => handleChange(f.name, e.target.value)}
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 DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
@@ -18,7 +18,9 @@ interface GeneratedFiles {
interfaces: string
policy: string
rules: string
masq: string
snat: string
hosts: string
params: string
}
interface Props {
@@ -28,13 +30,17 @@ interface Props {
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) {
const [tab, setTab] = useState(0)
const [files, setFiles] = useState<GeneratedFiles | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) setFiles(null)
}, [open])
const handleOpen = async () => {
if (files) return
setLoading(true)

View File

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

View File

@@ -10,16 +10,26 @@ import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Typography from '@mui/material/Typography'
import Breadcrumbs from '@mui/material/Breadcrumbs'
import IconButton from '@mui/material/IconButton'
import InputAdornment from '@mui/material/InputAdornment'
import OutlinedInput from '@mui/material/OutlinedInput'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import Tooltip from '@mui/material/Tooltip'
import AddIcon from '@mui/icons-material/Add'
import BuildIcon from '@mui/icons-material/Build'
import { 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 ----
interface Zone { id: number; name: string; type: string; 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 Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; comment: string; position: number }
interface Masq { id: number; source_network: string; out_interface: string; to_address: string; comment: string }
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 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>
@@ -33,18 +43,26 @@ export default function ConfigDetail() {
const [interfaces, setInterfaces] = useState<Iface[]>([])
const [policies, setPolicies] = useState<Policy[]>([])
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 [editing, setEditing] = useState<AnyEntity | null>(null)
const [generateOpen, setGenerateOpen] = useState(false)
const [downloadToken, setDownloadToken] = useState('')
useEffect(() => {
configsApi.get(configId).then((r) => setConfigName(r.data.name))
configsApi.get(configId).then((r) => {
setConfigName(r.data.name)
setDownloadToken(r.data.download_token)
})
zonesApi.list(configId).then((r) => setZones(r.data))
interfacesApi.list(configId).then((r) => setInterfaces(r.data))
policiesApi.list(configId).then((r) => setPolicies(r.data))
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])
const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name }))
@@ -77,13 +95,15 @@ export default function ConfigDetail() {
{
key: 'zone_id' as const,
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' },
] as Column<AnyEntity>[],
fields: [
{ 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' },
] as FieldDef[],
},
@@ -96,22 +116,26 @@ export default function ConfigDetail() {
{
key: 'src_zone_id' as const,
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,
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: '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' },
] as Column<AnyEntity>[],
fields: [
{ name: 'src_zone_id', label: 'Source Zone', required: true, type: 'select' as const, options: zoneOptions },
{ name: 'dst_zone_id', label: 'Destination Zone', required: true, type: 'select' as const, options: zoneOptions },
{ name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...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: '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: 'position', label: 'Position', type: 'number' as const },
] as FieldDef[],
@@ -135,39 +159,103 @@ export default function ConfigDetail() {
},
{ key: 'proto' as const, label: 'Proto' },
{ key: 'dport' as const, label: 'DPort' },
{ key: 'origdest' as const, label: 'OrigDest' },
{ key: 'position' as const, label: 'Position' },
] as Column<AnyEntity>[],
fields: [
{ name: 'action', label: 'Action', required: true },
{ name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: zoneOptions },
{ name: 'dst_zone_id', label: 'Dest Zone', type: 'select' as const, options: zoneOptions },
{ name: 'src_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: [{ value: '', label: 'all' }, ...zoneOptions] },
{ name: 'src_ip', label: 'Source IP/CIDR' },
{ name: 'dst_ip', label: 'Dest IP/CIDR' },
{ name: 'proto', label: 'Protocol' },
{ name: 'dport', label: 'Dest Port' },
{ name: 'sport', label: 'Source Port' },
{ name: 'proto', label: 'Protocol', placeholder: 'e.g. tcp, udp, icmp' },
{ name: 'dport', label: 'Dest Port(s)' },
{ 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: 'position', label: 'Position', type: 'number' as const },
] as FieldDef[],
},
{
label: 'Masq/NAT',
rows: masq as unknown as AnyEntity[],
setRows: setMasq as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: masqApi,
label: 'SNAT',
rows: snat as unknown as AnyEntity[],
setRows: setSnat as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: snatApi,
columns: [
{ key: 'out_interface' as const, label: 'Out Interface' },
{ key: 'source_network' as const, label: 'Source Network' },
{ key: 'to_address' as const, label: 'To Address' },
{ key: 'comment' as const, label: 'Comment' },
{ key: 'proto' as const, label: 'Proto' },
{ key: 'probability' as const, label: 'Probability' },
] as Column<AnyEntity>[],
fields: [
{ name: 'out_interface', label: 'Out Interface', 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' },
] 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]
@@ -193,6 +281,12 @@ export default function ConfigDetail() {
current.setRows(res.data as any)
}
const handleRegenerate = async () => {
if (!confirm('Regenerate the download token? The old token will stop working.')) return
const res = await configsApi.regenerateToken(configId)
setDownloadToken(res.data.download_token)
}
return (
<Layout title={configName || 'Config Detail'}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
@@ -207,6 +301,31 @@ export default function ConfigDetail() {
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel>Download Token</InputLabel>
<OutlinedInput
label="Download Token"
value={downloadToken}
endAdornment={
<InputAdornment position="end">
<Tooltip title="Copy token">
<IconButton onClick={() => navigator.clipboard.writeText(downloadToken)} edge="end">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
}
inputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: 12 } }}
/>
</FormControl>
<Tooltip title="Regenerate token">
<IconButton onClick={handleRegenerate} color="warning">
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ bgcolor: 'white', borderRadius: 2, border: '1px solid #e2e8f0', overflow: 'hidden' }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
{tabConfig.map((tc) => <Tab key={tc.label} label={tc.label} />)}

View File

@@ -1,41 +1,23 @@
import { useState, FormEvent } from 'react'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import TextField from '@mui/material/TextField'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import Alert from '@mui/material/Alert'
import { authApi } from '../api'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
try {
await authApi.login(username, password)
window.location.href = '/configs'
} catch {
setError('Invalid username or password')
}
}
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#f5f7fa' }}>
<Card sx={{ width: 380, boxShadow: 3 }}>
<CardContent sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} gutterBottom>Shorefront</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>Sign in to manage your Shorewall configs</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box component="form" onSubmit={handleSubmit}>
<TextField fullWidth label="Username" value={username} onChange={(e) => setUsername(e.target.value)} sx={{ mb: 2 }} size="small" required />
<TextField fullWidth label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} sx={{ mb: 3 }} size="small" required />
<Button type="submit" variant="contained" fullWidth>Sign In</Button>
</Box>
<Button
variant="contained"
fullWidth
onClick={() => { window.location.href = '/api/auth/oidc/login' }}
>
Sign in with SSO
</Button>
</CardContent>
</Card>
</Box>

View File

@@ -33,6 +33,31 @@ spec:
key: JWT_SECRET_KEY
- name: DATABASE_URL
value: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
- 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
containers:
- name: backend
image: "{{ .Values.backend.image }}:{{ .Values.containers.version }}"
@@ -60,6 +85,36 @@ spec:
configMapKeyRef:
name: shorefront-config
key: JWT_EXPIRE_MINUTES
- 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
- name: APP_VERSION
valueFrom:
configMapKeyRef:
name: shorefront-config
key: APP_VERSION
ports:
- containerPort: 8000
resources:

View File

@@ -10,3 +10,8 @@ data:
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 }}
APP_VERSION: {{ .Values.containers.version | quote }}

View File

@@ -13,13 +13,6 @@ spec:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 8000
- path: /
pathType: Prefix
backend:

View File

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

View File

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

View File

@@ -35,5 +35,11 @@ ingress:
host: shorefront.baumann.gr
ingressClassName: traefik
keycloak:
url: https://sso.baumann.gr
realm: homelab
clientId: shorefront
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
containers:
version: "0.002"
version: "0.014"

View File

@@ -12,6 +12,7 @@ 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 -
@@ -21,6 +22,7 @@ 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}'."