Compare commits
49 Commits
5ac2f931f8
...
feature/ss
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b9cbfd884 | |||
| 6699bf7421 | |||
| 881f812fa8 | |||
| 593daa17bf | |||
| 0943798399 | |||
| c24c00b307 | |||
| ab181e802f | |||
| b71e1e5989 | |||
| 2e0cda834b | |||
| 29b71b267e | |||
| 3b90373b78 | |||
| 9b15c081b0 | |||
| e9a91a7794 | |||
| d6e3904f0a | |||
| c55d73fd58 | |||
| ad35b00023 | |||
| d28d034a17 | |||
| 4d0164ed02 | |||
| 426fb8fbfd | |||
| d56075a74e | |||
| 9382106e8d | |||
| 390774c79a | |||
| 08dddb7297 | |||
| 02c8f71957 | |||
| 36224cebcd | |||
| 3c259a1862 | |||
| e05e9d5975 | |||
| 3dc97df6cd | |||
| 8b787a99c2 | |||
| 58ef0dec63 | |||
| 21d404229a | |||
| 15f28cb070 | |||
| 686ce911bb | |||
| 1b543ed44a | |||
| 59d9b438a1 | |||
| 388e945343 | |||
| 740983277f | |||
| 6b340f50cb | |||
| aaa6e7def4 | |||
| 2b6cd29413 | |||
| daabafc595 | |||
| 1daa6f6e90 | |||
| f28240c37f | |||
| 95fbe99b61 | |||
| ff4aa155d1 | |||
| 924e51ffaa | |||
| 58f0fd50d8 | |||
| 40113bc634 | |||
| 4c4cdf0a52 |
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env.local
|
||||
|
||||
# Generated shorewall output
|
||||
shorewall/
|
||||
shorewall.tar.gz
|
||||
|
||||
# Secrets
|
||||
secrets
|
||||
secrets/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
25
README.md
25
README.md
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
25
backend/alembic/versions/0002_keycloak_sso.py
Normal file
25
backend/alembic/versions/0002_keycloak_sso.py
Normal 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")
|
||||
20
backend/alembic/versions/0003_rename_masq_to_snat.py
Normal file
20
backend/alembic/versions/0003_rename_masq_to_snat.py
Normal 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")
|
||||
37
backend/alembic/versions/0004_add_hosts_and_params.py
Normal file
37
backend/alembic/versions/0004_add_hosts_and_params.py
Normal 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")
|
||||
21
backend/alembic/versions/0005_interface_zone_nullable.py
Normal file
21
backend/alembic/versions/0005_interface_zone_nullable.py
Normal 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)
|
||||
21
backend/alembic/versions/0006_interface_add_broadcast.py
Normal file
21
backend/alembic/versions/0006_interface_add_broadcast.py
Normal 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")
|
||||
23
backend/alembic/versions/0007_policy_zones_nullable.py
Normal file
23
backend/alembic/versions/0007_policy_zones_nullable.py
Normal 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)
|
||||
23
backend/alembic/versions/0008_policy_add_limit_connlimit.py
Normal file
23
backend/alembic/versions/0008_policy_add_limit_connlimit.py
Normal 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")
|
||||
35
backend/alembic/versions/0009_rules_add_missing_columns.py
Normal file
35
backend/alembic/versions/0009_rules_add_missing_columns.py
Normal 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)
|
||||
34
backend/alembic/versions/0010_snat_add_missing_columns.py
Normal file
34
backend/alembic/versions/0010_snat_add_missing_columns.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
39
backend/alembic/versions/0012_config_add_download_token.py
Normal file
39
backend/alembic/versions/0012_config_add_download_token.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""add download_token to configs
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-03-01
|
||||
"""
|
||||
import secrets
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, column
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"configs",
|
||||
sa.Column("download_token", sa.String(64), nullable=False, server_default="''"),
|
||||
)
|
||||
# Backfill existing rows with unique tokens
|
||||
configs = table("configs", column("id", sa.Integer), column("download_token", sa.String(64)))
|
||||
conn = op.get_bind()
|
||||
for row in conn.execute(sa.select(configs.c.id)):
|
||||
conn.execute(
|
||||
configs.update()
|
||||
.where(configs.c.id == row.id)
|
||||
.values(download_token=secrets.token_urlsafe(32))
|
||||
)
|
||||
# Remove the DB-level default — ORM model provides Python-level default
|
||||
op.alter_column("configs", "download_token", server_default=None)
|
||||
op.create_index("ix_configs_download_token", "configs", ["download_token"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_configs_download_token", table_name="configs")
|
||||
op.drop_column("configs", "download_token")
|
||||
@@ -1,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()
|
||||
|
||||
FIREWALL_ADMINS_GROUP = "firewall admins"
|
||||
|
||||
@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")
|
||||
|
||||
@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(
|
||||
username=body.username,
|
||||
email=body.email,
|
||||
hashed_password=hash_password(body.password),
|
||||
keycloak_sub=sub,
|
||||
email=email,
|
||||
username=username,
|
||||
hashed_password=None,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@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)
|
||||
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")
|
||||
|
||||
@@ -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
64
backend/app/api/hosts.py
Normal 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()
|
||||
@@ -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
64
backend/app/api/params.py
Normal 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
64
backend/app/api/snat.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
77
docs/plans/2026-03-01-config-download-token-design.md
Normal file
77
docs/plans/2026-03-01-config-download-token-design.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Config Download Token Design
|
||||
|
||||
**Date:** 2026-03-01
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Downloading a generated config ZIP from the command line requires extracting an httpOnly OIDC
|
||||
session cookie from the browser, which is fragile and not scriptable. Users need a stable,
|
||||
per-config token they can embed in automation scripts.
|
||||
|
||||
## Solution
|
||||
|
||||
Add a `download_token` field to each `Config`. The existing generate endpoint accepts this token
|
||||
in the POST body as an alternative to OIDC cookie auth, allowing unauthenticated-but-authorized
|
||||
downloads.
|
||||
|
||||
## Data Model
|
||||
|
||||
- Add `download_token: str` column to `configs` table.
|
||||
- Value: `secrets.token_urlsafe(32)` — 32 bytes of URL-safe random data (43 characters).
|
||||
- Generated automatically on config creation.
|
||||
- Stored as plaintext (the token is low-value; it only grants read access to a single config's
|
||||
generated output).
|
||||
- Alembic migration backfills existing configs with auto-generated tokens.
|
||||
|
||||
## API Changes
|
||||
|
||||
### Modified: `POST /api/configs/{id}/generate`
|
||||
|
||||
Accepts an optional JSON body:
|
||||
|
||||
```json
|
||||
{ "token": "..." }
|
||||
```
|
||||
|
||||
Auth logic (either is sufficient):
|
||||
1. Valid OIDC `access_token` cookie + `owner_id` match
|
||||
2. `token` in body matches `config.download_token` (no owner filter needed)
|
||||
|
||||
Error responses:
|
||||
- No cookie and no/wrong token → 401
|
||||
- Valid token but wrong config ID → 404
|
||||
|
||||
Example curl usage:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://host/api/configs/1/generate?format=zip" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token": "abc..."}' -o shorewall.zip
|
||||
```
|
||||
|
||||
### New: `POST /api/configs/{id}/regenerate-token`
|
||||
|
||||
- OIDC-protected, owner-only.
|
||||
- Generates a new `secrets.token_urlsafe(32)`, saves it to the config, returns it.
|
||||
- Response: `{ "download_token": "..." }`
|
||||
- Non-owner → 403.
|
||||
|
||||
## Schema Changes
|
||||
|
||||
- `ConfigOut` gains `download_token: str`.
|
||||
- New `GenerateRequest` Pydantic model: `token: Optional[str] = None`.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
On the Config Detail page header area (above the tabs):
|
||||
|
||||
- Read-only text field showing `download_token`.
|
||||
- Copy-to-clipboard icon button.
|
||||
- "Regenerate" button that calls the new endpoint and updates the displayed value.
|
||||
|
||||
## Migration
|
||||
|
||||
New Alembic migration `0012_config_add_download_token.py`:
|
||||
- Add `download_token` column with `server_default=''`.
|
||||
- Backfill with `secrets.token_urlsafe(32)` for all existing rows via a data migration step.
|
||||
506
docs/plans/2026-03-01-config-download-token.md
Normal file
506
docs/plans/2026-03-01-config-download-token.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# Config Download Token Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a per-config `download_token` field that allows downloading the generated ZIP without OIDC auth by POSTing the token in the request body.
|
||||
|
||||
**Architecture:** A `download_token` (random 43-char URL-safe string) is added to the `Config` model and auto-generated on creation. The existing `POST /configs/{id}/generate` endpoint accepts an optional JSON body; if the token matches, access is granted regardless of OIDC session. A new `POST /configs/{id}/regenerate-token` endpoint (OIDC-only) replaces the token. The frontend shows the token on the Config Detail page with copy and regenerate buttons.
|
||||
|
||||
**Tech Stack:** Python `secrets.token_urlsafe`, FastAPI optional body with `Optional` dependency, SQLAlchemy, Alembic, React + MUI.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Alembic migration — add `download_token` to configs
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/alembic/versions/0012_config_add_download_token.py`
|
||||
|
||||
**Step 1: Create the migration file**
|
||||
|
||||
```python
|
||||
"""add download_token to configs
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-03-01
|
||||
"""
|
||||
import secrets
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, column
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"configs",
|
||||
sa.Column("download_token", sa.String(64), nullable=False, server_default=""),
|
||||
)
|
||||
# Backfill existing rows with unique tokens
|
||||
configs = table("configs", column("id", sa.Integer), column("download_token", sa.String(64)))
|
||||
conn = op.get_bind()
|
||||
for row in conn.execute(sa.select(configs.c.id)):
|
||||
conn.execute(
|
||||
configs.update()
|
||||
.where(configs.c.id == row.id)
|
||||
.values(download_token=secrets.token_urlsafe(32))
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("configs", "download_token")
|
||||
```
|
||||
|
||||
**Step 2: Verify migration runs (if you have a local DB)**
|
||||
|
||||
```bash
|
||||
cd backend && alembic upgrade head
|
||||
```
|
||||
|
||||
Expected: no errors, `configs` table gains a `download_token` column with non-empty values.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/alembic/versions/0012_config_add_download_token.py
|
||||
git commit -m "feat: migration 0012 — add download_token to configs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SQLAlchemy model — add `download_token` field
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models.py:21-39`
|
||||
|
||||
**Step 1: Add the field to the `Config` model**
|
||||
|
||||
In `backend/app/models.py`, add this import at the top alongside existing imports:
|
||||
|
||||
```python
|
||||
import secrets
|
||||
```
|
||||
|
||||
Then add `download_token` to the `Config` class after `updated_at`:
|
||||
|
||||
```python
|
||||
download_token: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
default=lambda: secrets.token_urlsafe(32),
|
||||
)
|
||||
```
|
||||
|
||||
The `default=lambda: secrets.token_urlsafe(32)` ensures new configs created via SQLAlchemy get a token automatically.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models.py
|
||||
git commit -m "feat: add download_token field to Config model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Pydantic schemas — expose token and add request body
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas.py`
|
||||
|
||||
**Step 1: Add `download_token` to `ConfigOut`**
|
||||
|
||||
In `backend/app/schemas.py`, update `ConfigOut` (currently lines 29-38) to add:
|
||||
|
||||
```python
|
||||
class ConfigOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
owner_id: int
|
||||
download_token: str # ← add this line
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
```
|
||||
|
||||
**Step 2: Add `GenerateRequest` body schema**
|
||||
|
||||
Add this new class near the bottom of `backend/app/schemas.py`, before `GenerateOut`:
|
||||
|
||||
```python
|
||||
# --- Generate request ---
|
||||
class GenerateRequest(BaseModel):
|
||||
token: Optional[str] = None
|
||||
```
|
||||
|
||||
`Optional` is already imported at line 3.
|
||||
|
||||
**Step 3: Add `RegenerateTokenOut` response schema**
|
||||
|
||||
```python
|
||||
class RegenerateTokenOut(BaseModel):
|
||||
download_token: str
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas.py
|
||||
git commit -m "feat: add download_token to ConfigOut and GenerateRequest schema"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: API — modify generate endpoint and add regenerate-token endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/configs.py`
|
||||
|
||||
**Step 1: Update imports**
|
||||
|
||||
At the top of `backend/app/api/configs.py`, add `Optional` to the imports:
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from app import models, schemas
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.shorewall_generator import ShorewallGenerator
|
||||
import io
|
||||
import secrets
|
||||
```
|
||||
|
||||
**Step 2: Replace the `generate_config` endpoint**
|
||||
|
||||
Replace the entire `generate_config` function (lines 79-115) with:
|
||||
|
||||
```python
|
||||
@router.post("/{config_id}/generate")
|
||||
def generate_config(
|
||||
config_id: int,
|
||||
format: str = "json",
|
||||
body: Optional[schemas.GenerateRequest] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[models.User] = Depends(get_current_user),
|
||||
):
|
||||
token = body.token if body else None
|
||||
|
||||
# Determine access: OIDC session or matching download token
|
||||
if current_user is not None:
|
||||
# Authenticated via OIDC — enforce owner check
|
||||
config = (
|
||||
db.query(models.Config)
|
||||
.filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Config not found")
|
||||
elif token:
|
||||
# Token auth — no owner filter, just match token
|
||||
config = (
|
||||
db.query(models.Config)
|
||||
.filter(models.Config.id == config_id, models.Config.download_token == token)
|
||||
.first()
|
||||
)
|
||||
if not config:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
# Eagerly load relationships
|
||||
config = (
|
||||
db.query(models.Config)
|
||||
.options(
|
||||
selectinload(models.Config.zones),
|
||||
selectinload(models.Config.interfaces).selectinload(models.Interface.zone),
|
||||
selectinload(models.Config.policies).selectinload(models.Policy.src_zone),
|
||||
selectinload(models.Config.policies).selectinload(models.Policy.dst_zone),
|
||||
selectinload(models.Config.rules).selectinload(models.Rule.src_zone),
|
||||
selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
|
||||
selectinload(models.Config.snat_entries),
|
||||
selectinload(models.Config.host_entries).selectinload(models.Host.zone),
|
||||
selectinload(models.Config.params),
|
||||
)
|
||||
.filter(models.Config.id == config_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
generator = ShorewallGenerator(config)
|
||||
|
||||
if format == "zip":
|
||||
zip_bytes = generator.as_zip()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(zip_bytes),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename={config.name}-shorewall.zip"},
|
||||
)
|
||||
|
||||
return generator.as_json()
|
||||
```
|
||||
|
||||
**Important:** `get_current_user` currently raises a 401 if no cookie is present. You need to make it return `None` instead of raising when there is no cookie, so the generate endpoint can fall back to token auth. See Task 5 for that change — do Task 5 before testing this.
|
||||
|
||||
**Step 3: Add the `regenerate_token` endpoint**
|
||||
|
||||
Add this after `generate_config`:
|
||||
|
||||
```python
|
||||
@router.post("/{config_id}/regenerate-token", response_model=schemas.RegenerateTokenOut)
|
||||
def regenerate_token(
|
||||
config_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
config = _get_config_or_404(config_id, db, current_user)
|
||||
config.download_token = secrets.token_urlsafe(32)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return {"download_token": config.download_token}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/configs.py
|
||||
git commit -m "feat: token auth on generate endpoint and regenerate-token endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Auth — make `get_current_user` optional for the generate endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/auth.py`
|
||||
|
||||
The `generate_config` endpoint needs to accept requests with no OIDC cookie (token-only auth). Currently `get_current_user` raises 401 when the cookie is missing. Add an `optional` variant that returns `None` instead.
|
||||
|
||||
**Step 1: Read current `get_current_user`**
|
||||
|
||||
Open `backend/app/auth.py` and find the `get_current_user` function. It currently looks roughly like:
|
||||
|
||||
```python
|
||||
async def get_current_user(
|
||||
access_token: Optional[str] = Cookie(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> models.User:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, ...)
|
||||
...
|
||||
return user
|
||||
```
|
||||
|
||||
**Step 2: Add `get_optional_user` that returns `None` when unauthenticated**
|
||||
|
||||
Add this function after `get_current_user`:
|
||||
|
||||
```python
|
||||
async def get_optional_user(
|
||||
access_token: Optional[str] = Cookie(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Optional[models.User]:
|
||||
if not access_token:
|
||||
return None
|
||||
try:
|
||||
return await get_current_user(access_token=access_token, db=db)
|
||||
except HTTPException:
|
||||
return None
|
||||
```
|
||||
|
||||
**Step 3: Update `generate_config` to use `get_optional_user`**
|
||||
|
||||
In `backend/app/api/configs.py`, update the import and the dependency in `generate_config`:
|
||||
|
||||
```python
|
||||
from app.auth import get_current_user, get_optional_user
|
||||
```
|
||||
|
||||
And change the dependency in `generate_config`:
|
||||
|
||||
```python
|
||||
current_user: Optional[models.User] = Depends(get_optional_user),
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/auth.py backend/app/api/configs.py
|
||||
git commit -m "feat: add get_optional_user for unauthenticated generate access"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend API client — add `regenerateToken`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api.ts:31-41`
|
||||
|
||||
**Step 1: Add `regenerateToken` to `configsApi`**
|
||||
|
||||
In `frontend/src/api.ts`, update the `configsApi` object:
|
||||
|
||||
```typescript
|
||||
export const configsApi = {
|
||||
list: () => api.get('/configs'),
|
||||
create: (data: object) => api.post('/configs', data),
|
||||
get: (id: number) => api.get(`/configs/${id}`),
|
||||
update: (id: number, data: object) => api.put(`/configs/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/configs/${id}`),
|
||||
generate: (id: number, format: 'json' | 'zip' = 'json') =>
|
||||
api.post(`/configs/${id}/generate?format=${format}`, null, {
|
||||
responseType: format === 'zip' ? 'blob' : 'json',
|
||||
}),
|
||||
regenerateToken: (id: number) =>
|
||||
api.post<{ download_token: string }>(`/configs/${id}/regenerate-token`),
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api.ts
|
||||
git commit -m "feat: add regenerateToken to configsApi"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — display token with copy and regenerate on Config Detail
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/routes/ConfigDetail.tsx`
|
||||
|
||||
**Step 1: Add state for `downloadToken`**
|
||||
|
||||
In `ConfigDetail.tsx`, add a state variable near the other `useState` calls (around line 32):
|
||||
|
||||
```typescript
|
||||
const [downloadToken, setDownloadToken] = useState('')
|
||||
```
|
||||
|
||||
**Step 2: Load token in `useEffect`**
|
||||
|
||||
The existing `useEffect` already fetches the config name. Update it to also set the token:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
configsApi.get(configId).then((r) => {
|
||||
setConfigName(r.data.name)
|
||||
setDownloadToken(r.data.download_token)
|
||||
})
|
||||
// ... rest unchanged
|
||||
}, [configId])
|
||||
```
|
||||
|
||||
**Step 3: Add the token UI block**
|
||||
|
||||
Add these MUI imports at the top of the file (alongside existing imports):
|
||||
|
||||
```typescript
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import OutlinedInput from '@mui/material/OutlinedInput'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import RefreshIcon from '@mui/icons-material/Refresh'
|
||||
```
|
||||
|
||||
Add a `handleRegenerate` function after `handleDelete`:
|
||||
|
||||
```typescript
|
||||
const handleRegenerate = async () => {
|
||||
if (!confirm('Regenerate the download token? The old token will stop working.')) return
|
||||
const res = await configsApi.regenerateToken(configId)
|
||||
setDownloadToken(res.data.download_token)
|
||||
}
|
||||
```
|
||||
|
||||
In the JSX, insert this block between the breadcrumbs/generate-button row and the tabs card (i.e., after the closing `</Box>` of the top row, before the `<Box sx={{ bgcolor: 'white' ...`):
|
||||
|
||||
```tsx
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<FormControl size="small" sx={{ flex: 1 }}>
|
||||
<InputLabel>Download Token</InputLabel>
|
||||
<OutlinedInput
|
||||
label="Download Token"
|
||||
value={downloadToken}
|
||||
readOnly
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title="Copy token">
|
||||
<IconButton onClick={() => navigator.clipboard.writeText(downloadToken)} edge="end">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
}
|
||||
inputProps={{ style: { fontFamily: 'monospace', fontSize: 12 } }}
|
||||
/>
|
||||
</FormControl>
|
||||
<Tooltip title="Regenerate token">
|
||||
<IconButton onClick={handleRegenerate} color="warning">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/routes/ConfigDetail.tsx
|
||||
git commit -m "feat: show download token with copy and regenerate on Config Detail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Manual end-to-end verification
|
||||
|
||||
**Step 1: Rebuild and restart**
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
**Step 2: Verify token appears in UI**
|
||||
|
||||
Log in, open a config. You should see a "Download Token" field with a 43-character token.
|
||||
|
||||
**Step 3: Test copy button**
|
||||
|
||||
Click the copy icon — paste somewhere to confirm the token was copied.
|
||||
|
||||
**Step 4: Test ZIP download with token**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost/api/configs/1/generate?format=zip" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token": "<paste-token>"}' \
|
||||
-o /tmp/shorewall.zip
|
||||
file /tmp/shorewall.zip # should say: Zip archive data
|
||||
```
|
||||
|
||||
**Step 5: Test invalid token returns 401**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
"http://localhost/api/configs/1/generate?format=zip" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token": "wrong"}'
|
||||
# Expected: 401
|
||||
```
|
||||
|
||||
**Step 6: Test regenerate**
|
||||
|
||||
Click "Regenerate" in the UI, confirm the old token stops working and the new one works.
|
||||
|
||||
**Step 7: Commit any fixups found during testing**
|
||||
75
docs/plans/2026-03-01-keycloak-sso-design.md
Normal file
75
docs/plans/2026-03-01-keycloak-sso-design.md
Normal 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
|
||||
584
docs/plans/2026-03-01-keycloak-sso.md
Normal file
584
docs/plans/2026-03-01-keycloak-sso.md
Normal 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
4112
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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×top=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} />)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,7 +10,7 @@ spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ""
|
||||
storageClassName: nfs
|
||||
nfs:
|
||||
server: {{ .Values.nfs.server }}
|
||||
path: {{ .Values.nfs.path }}
|
||||
|
||||
@@ -8,8 +8,7 @@ metadata:
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ""
|
||||
volumeName: shorefront-postgres-pv
|
||||
storageClassName: nfs
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.nfs.storage }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}'."
|
||||
|
||||
Reference in New Issue
Block a user