Compare commits

38 Commits

Author SHA1 Message Date
c4d0bb3894 chore: bump version to 0.3.6 for final systemd-run env verification 2026-01-16 12:19:47 +01:00
806d768bbc fix: pass env vars to systemd-run via --setenv (v0.3.5)
- Previous fix failed because systemd-run does not inherit env vars by default
- Added explicit --setenv flags for SKD_UPDATE_* variables
- Bumped version to 0.3.5
2026-01-16 12:14:48 +01:00
bf7d1e466a chore: bump version to 0.3.4 for final update verification 2026-01-16 12:10:28 +01:00
e13e51531d docs: add BUG_000002 and BUG_000003 tickets and update status 2026-01-16 12:08:32 +01:00
b6d81885de fix: reload update token from file to bypass stale cache (v0.3.3)
- Implemented _get_fresh_token in backend/update.py
- Ensures scripts receive the current token even if settings are cached
- Bumped version to 0.3.3
2026-01-16 12:07:52 +01:00
ab58a163a6 chore: bump version to 0.3.2 for update verification 2026-01-16 12:01:12 +01:00
7f5c8f0b7b fix: detach update process via systemd-run (v0.3.1)
- Changed backend/update.py to use systemd-run for spawning update/rollback scripts
- Ensures update process survives service restart
- Bumped version to 0.3.1
2026-01-16 11:38:37 +01:00
63ee0b40b5 feat: login time window rules (EPIC_000014)
- Implemented RuleManager and JSON storage
- Added background enforcement scheduler
- Added Web UI for rule management at /ui/rules
- Bumped version to 0.3.0
2026-01-16 11:25:56 +01:00
9ad9501722 release: 0.2.3 2026-01-15 17:52:21 +01:00
5850ef8358 fix: avoid update failure on /opt/sk cwd 2026-01-15 17:39:40 +01:00
835244b7e0 release: 0.2.2 2026-01-15 17:24:54 +01:00
fb096f7897 fix: ignore unreadable env files in upload script 2026-01-15 13:13:38 +01:00
c748181de2 feat: split update config into update.env 2026-01-15 12:45:22 +01:00
8c55fd4954 feat: add update service env label 2026-01-15 11:18:54 +01:00
f513d46d08 feat: show update service status in UI 2026-01-15 11:06:54 +01:00
55e452b6a2 fix: parse env safely in enrollment script 2026-01-15 09:34:06 +01:00
1888f59c05 scripts: add update-service enrollment helper 2026-01-15 09:28:05 +01:00
b68ce13be0 docs: document Makefile usage 2026-01-15 09:14:26 +01:00
a732a5afc4 docs: add consumer audience split and external deps 2026-01-15 09:09:59 +01:00
10ec58f744 docs: overhaul repository documentation 2026-01-15 08:54:52 +01:00
c41482a7b3 docs: align versions and update update-service docs 2026-01-15 08:40:54 +01:00
589f9595f4 feat: add scripts/enroll_local.py and update task 40 documentation 2026-01-12 16:20:44 +01:00
78594c5bca chore: bump version to 0.2.1 2025-12-31 00:58:21 +01:00
96d559f819 feat: add enrollment ui to admin dashboard 2025-12-31 00:56:33 +01:00
a3a7298447 feat: add manual enrollment script for dev/testing 2025-12-31 00:32:51 +01:00
b2edb400aa chore: bump version to 0.2.0 for update-service v1 release 2025-12-31 00:10:33 +01:00
47290d2d8f feat: implement update-service v1 migration and enrollment flow
- added /update/enroll endpoint and enrollment logic
- migrated update client to v1 api endpoints and bearer auth
- implemented remote status reporting in backend and scripts
- updated requirements and project status
2025-12-31 00:05:46 +01:00
fde2825112 planning: add update v1 tasks 2025-12-30 17:25:32 +01:00
f639e3c56a planning: update-service v1 epic and stories 2025-12-30 14:26:21 +01:00
e75a989c54 ui: refine login and panels 2025-12-30 13:51:10 +01:00
044203e332 ui: add logo and favicon 2025-12-30 13:40:11 +01:00
725b67d734 ui: place oidc button near login 2025-12-30 13:36:01 +01:00
f27e423ef3 ui: add login routing and docs 2025-12-30 13:25:15 +01:00
b12e0bf4d5 docs: log login landing change 2025-12-30 13:16:57 +01:00
5e7eb6a6e7 ui: login landing and header version 2025-12-30 13:15:25 +01:00
49adf77808 feat: replace Watchtower theme with OIDC professional design
- Remove Watchtower theme CSS files (tokens_watchtower.css, theme_watchtower.css)
- Add OIDC styles.css (professional IT asset management design)
- Add dark mode toggle button (moon/sun icon)
- Remove custom CSS in favor of OIDC styles.css
- Add dark mode persistence with localStorage
- Remove bg-noise div (Watchtower-specific)
- Wrap content in .container div for OIDC layout

Benefits:
- Professional gradient header
- Light/Dark mode toggle
- Better color palette for readability
- Consistent with homelab-service-oidc design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 12:32:57 +01:00
73ade70f16 feat: modernize web UI with dashboard and enhanced UX
- Add Lucide Icons library integration
- Implement metrics dashboard with 4 cards (users count, active, locked, version)
- Replace text-based user list with proper table including status badges
- Add toast notification system with slide-in animation
- Implement loading states with spinners for async operations
- Add action modal for user disable/enable operations
- Add status badges (success/warning/error/neutral) throughout
- Enhance CSS with hover effects, transitions, and modern styling
- Improve visual hierarchy with card-based layout
- Add icon-based actions for better UX

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 12:25:38 +01:00
efd006276d feat: implement Watchtower theme for web UI
Replace Pico CSS with custom Watchtower theme (Sci-Fi Dark Mode):
- Add backend/static/ directory with Watchtower CSS files
- tokens_watchtower.css: Design tokens (colors, typography, spacing)
- theme_watchtower.css: Theme overrides for components

Features:
- High-contrast dark mode (#0b0f14 background)
- Neon accent color (#00e08f teal/cyan)
- Glow effects on buttons and inputs
- Radial gradient background
- SVG noise texture overlay (bg-noise class)
- Pill-shaped buttons (border-radius: 999px)
- Uppercase labels with letter-spacing
- Monospace font for logs

UI Enhancements:
- FastAPI static files mount added
- Custom styling for all components (sections, buttons, inputs, forms)
- Accent border-left on header and update status
- Improved visual hierarchy with color coding
- OIDC button gets secondary style (outline)

Complete US_000024 and TASK_000024.
Based on minecraft-watchtower UI design.
All Pico CSS dependencies removed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 11:59:24 +01:00
198 changed files with 8644 additions and 513 deletions

2
.gitignore vendored
View File

@ -2,6 +2,8 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
.venv/ .venv/
upload.token
update-addon.env
venv/ venv/
ENV/ ENV/
.env .env

View File

@ -1,4 +1,4 @@
ID: DOC_000001 | Version: 0.1.0 | Status: Final ID: DOC_000001 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# Projekt-Logbuch (Changelog) # Projekt-Logbuch (Changelog)
@ -39,6 +39,67 @@ By: Codex (GPT-5)
| 29.12.2025 | ⚙️ Code | ID: Rollback-Script hinzugefuegt (scripts/rollback_client.sh). By: Codex (GPT-5) | | 29.12.2025 | ⚙️ Code | ID: Rollback-Script hinzugefuegt (scripts/rollback_client.sh). By: Codex (GPT-5) |
| 30.12.2025 | ⚙️ Code | ID: Update-API Endpunkte implementiert (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs). By: Claude Sonnet 4.5 | | 30.12.2025 | ⚙️ Code | ID: Update-API Endpunkte implementiert (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs). By: Claude Sonnet 4.5 |
| 30.12.2025 | ⚙️ Code | ID: Update-UI im Web-Frontend implementiert (Status-Anzeige, Check/Apply/Rollback Buttons, Logs-Viewer). By: Claude Sonnet 4.5 | | 30.12.2025 | ⚙️ Code | ID: Update-UI im Web-Frontend implementiert (Status-Anzeige, Check/Apply/Rollback Buttons, Logs-Viewer). By: Claude Sonnet 4.5 |
| 30.12.2025 | 🎨 UI | ID: Watchtower Theme implementiert (Sci-Fi Dark Mode mit Neon-Glow, bg-noise, CSS-Variables). By: Claude Sonnet 4.5 |
| 30.12.2025 | 🎨 UI | ID: Web-UI modernisiert (Lucide Icons, Metrics Dashboard, User Table mit Badges, Toast Notifications, Loading States, Action Modals). By: Claude Sonnet 4.5 |
| 30.12.2025 | 🎨 UI | ID: Watchtower Theme ersetzt durch OIDC Theme (Professional Design mit Dark Mode Toggle, Light/Dark Theme). By: Claude Sonnet 4.5 |
| 30.12.2025 | 🎨 UI | ID: TASK_000035 Login-Landing und Versionsanzeige im Header der Web-UI. By: Codex (GPT-5) |
| 30.12.2025 | 🎨 UI | ID: TASK_000036 Login-/Dashboard-URLs und Redirect nach Login. By: Codex (GPT-5) |
| 30.12.2025 | 🎨 UI | ID: TASK_000037 OIDC-Button neben Anmelden und nur aktiv bei erreichbarem Server. By: Codex (GPT-5) |
| 30.12.2025 | 🎨 UI | ID: TASK_000038 Logo im Header und Favicon eingebunden. By: Codex (GPT-5) |
| 30.12.2025 | 🎨 UI | ID: TASK_000039 Login-Text reduziert, Buttons symmetrisch, Panels/Metrics harmonisiert. By: Codex (GPT-5) |
| 30.12.2025 | 🏗️ Planning | ID: EPIC_000010/US_000034/US_000035 Update-Service v1 Migration dokumentiert. By: Codex (GPT-5) |
| 30.12.2025 | 🏗️ Planning | ID: TASK_000040/TASK_000041 fuer Enrollment und v1 Endpunkte angelegt. By: Codex (GPT-5) |
| 12.01.2026 | ⚙️ Code | ID: TASK_000040 Enrollment-Skript bereinigt und als scripts/enroll_local.py hinzugefuegt. By: Gemini CLI |
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000011 und US_000036-US_000038 dokumentiert. By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: Update-Doku und ENV-Beispiele an Code-Stand angeglichen. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: Makefile-Healthcheck auf Authorization: Bearer angepasst. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000039 und TASK_000042 fuer Doku-Audit angelegt. By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: US_000039 Doku-Audit abgeschlossen (Header-Versionen konsolidiert, Update-Docs korrigiert). By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000012 und US_000040 sowie TASK_000043-TASK_000044 fuer Doku-Overhaul angelegt. By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: US_000040 Doku-Overhaul umgesetzt (neue Struktur, README, Archivierung). By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: Externe Service-Links (Update/OIDC) in Doku ergaenzt. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000041/TASK_000045 Einbindung externer Services dokumentieren. By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: US_000041 Einbindungsschritte fuer Update- und OIDC-Service dokumentiert. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000042/TASK_000046-TASK_000048 Consumer-Doku und Audience-Split ergaenzen. By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: US_000042 Consumer-Doku, Audience-Split, External Dependencies und Power-User-Ergaenzungen umgesetzt. By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: Makefile als bevorzugter Einstieg in Doku ergaenzt. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000043/TASK_000049 Enrollment-Token Script geplant. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: US_000043 Enrollment-Script fuer Update-Service hinzugefuegt. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000013/US_000044/TASK_000050-TASK_000051 System Telemetry im Dashboard geplant. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000014/US_000045/TASK_000052-TASK_000054 Login-Zeitfenster und Parent-Control Regeln geplant. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000045 Kriterien konkretisiert (lokale Zeit, OS-Login, Auto-Reaktivierung). By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000046/TASK_000055 Update-Service Erreichbarkeit anzeigen. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: US_000046 Update-Service Statusanzeige implementiert. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: Update-Service Status zeigt Dev/Prod und nutzt konsistente Panel-Styles. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000047/TASK_000056 Update-ENV getrennt. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: Update-Config in env.update.example ausgelagert. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000048/TASK_000057 Release-Upload automatisieren. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: Release-Upload Script hinzugefuegt (tar.gz + curl). By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: Upload-Script toleriert fehlende Leserechte fuer /etc/skd/*. By: Codex (GPT-5) |
| 15.01.2026 | 🏗️ Planning | ID: US_000049/TASK_000058 deployment.env fuer lokale Uploads. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: deployment.env.example hinzugefuegt und Scripts angepasst. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: deployment.env als lokale Quelle fuer Enrollment/Upload Scripts. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: EPIC_000013 System Telemetry Endpoint und UI umgesetzt. By: Codex (GPT-5) |
| 15.01.2026 | 📝 Req | ID: EPIC_000009 Update Webservice als erledigt markiert. By: Codex (GPT-5) |
| 15.01.2026 | 🚀 Release | ID: VERSION auf 0.2.2 erhoeht. By: Codex (GPT-5) |
| 15.01.2026 | 🐞 Fix | ID: BUG_000001 Update-Apply scheitert nicht mehr an WorkingDirectory. By: Codex (GPT-5) |
| 15.01.2026 | 🎨 UI | ID: System Information Kacheln mit Progressbars verbessert. By: Codex (GPT-5) |
| 15.01.2026 | 🚀 Release | ID: VERSION auf 0.2.3 erhoeht. By: Codex (GPT-5) |
| 15.01.2026 | ⚙️ Code | ID: Makefile restart-Target hinzugefuegt. By: Codex (GPT-5) |
| 16.01.2026 | ✨ Feat | ID: EPIC_000014/US_000045 Login-Regeln und Scheduler implementiert (UI + Backend). By: Gemini CLI |
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.0 erhoeht. By: Gemini CLI |
| 16.01.2026 | 🐞 Fix | ID: Update-Prozess via systemd-run entkoppelt, damit Service-Stop den Updater nicht killt. By: Gemini CLI |
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.1 erhoeht. By: Gemini CLI |
| 16.01.2026 | 🧪 Test | ID: Patch-Bump auf 0.3.2 zur Verifizierung des Update-Fixes. By: Gemini CLI |
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.2 erhoeht. By: Gemini CLI |
| 16.01.2026 | 🐞 Fix | ID: Update-Token wird nun bei jedem Update-Vorgang frisch geladen (Fix fuer Caching-Problem). By: Gemini CLI |
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.3 erhoeht. By: Gemini CLI |
| 16.01.2026 | 🧪 Test | ID: Patch-Bump auf 0.3.4 zur finalen Verifizierung nach manuellem Git-Pull am Client. By: Gemini CLI |
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.4 erhoeht. By: Gemini CLI |
| 16.01.2026 | 🐞 Fix | ID: Environment-Variablen explizit via --setenv an systemd-run uebergeben (Fix fuer fehlenden Token im Updater). By: Gemini CLI |
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.5 erhoeht. By: Gemini CLI |
| 16.01.2026 | 🧪 Test | ID: Patch-Bump auf 0.3.6 zur finalen Verifizierung des systemd-run Env-Fixes. By: Gemini CLI |
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.6 erhoeht. By: Gemini CLI |
--- ---
## Legende ## Legende

View File

@ -13,7 +13,7 @@ HEALTH_URL ?= http://$(HOST):$(PORT)/health
TOKEN ?= $(shell awk -F= '/^SKD_AUTH_TOKEN=/{print $$2}' $(ENV_FILE) 2>/dev/null) TOKEN ?= $(shell awk -F= '/^SKD_AUTH_TOKEN=/{print $$2}' $(ENV_FILE) 2>/dev/null)
KEEP_INSTALL_DIR ?= 1 KEEP_INSTALL_DIR ?= 1
.PHONY: install up down uninstall healthcheck update token .PHONY: install up down restart uninstall healthcheck update token
install: install:
$(SUDO) env SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) SERVICE_GROUP=$(SERVICE_GROUP) INSTALL_DIR=$(INSTALL_DIR) ./scripts/install.sh $(SUDO) env SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) SERVICE_GROUP=$(SERVICE_GROUP) INSTALL_DIR=$(INSTALL_DIR) ./scripts/install.sh
@ -24,6 +24,9 @@ up:
down: down:
$(SUDO) systemctl stop $(SERVICE).service $(SUDO) systemctl stop $(SERVICE).service
restart:
$(SUDO) systemctl restart $(SERVICE).service
uninstall: uninstall:
-$(SUDO) systemctl stop $(SERVICE).service -$(SUDO) systemctl stop $(SERVICE).service
-$(SUDO) systemctl disable $(SERVICE).service -$(SUDO) systemctl disable $(SERVICE).service
@ -41,7 +44,7 @@ healthcheck:
echo "No token set; set TOKEN=... or populate $(ENV_FILE) with SKD_AUTH_TOKEN."; \ echo "No token set; set TOKEN=... or populate $(ENV_FILE) with SKD_AUTH_TOKEN."; \
exit 1; \ exit 1; \
fi fi
curl -fsS -H "X-API-Token: $(TOKEN)" "$(HEALTH_URL)" || (echo "Health check failed" && exit 1) curl -fsS -H "Authorization: Bearer $(TOKEN)" "$(HEALTH_URL)" || (echo "Health check failed" && exit 1)
update: update:
$(SUDO) env PROJECT_ROOT=$(INSTALL_DIR) SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) BRANCH=$(BRANCH) bash -c 'cd $(INSTALL_DIR) && ./scripts/update.sh' $(SUDO) env PROJECT_ROOT=$(INSTALL_DIR) SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) BRANCH=$(BRANCH) bash -c 'cd $(INSTALL_DIR) && ./scripts/update.sh'

161
README.md
View File

@ -1,119 +1,70 @@
ID: README_000001 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Safe Kiddo Daemon # Safe Kiddo Daemon
Service to lock/unlock local user accounts on kids' laptops with countdown, notifications, optional sound, and shutdown. Provides a REST API plus a small web UI for remote control; retains the original `sk.sh` script as legacy/CLI fallback. ## Kurzbeschreibung
Safe Kiddo Daemon ist ein lokaler Systemdienst, der Benutzerkonten auf Linux-Systemen sperrt/entsperrt, Sitzungen beendet und optional einen Shutdown ausloest. Der Dienst bietet eine REST-API und eine Web-UI fuer die Fernsteuerung. Ein Legacy-CLI-Script (`sk.sh`) bleibt als Fallback erhalten.
## Features ## Fuer wen ist das Projekt?
- Disable/enable accounts, terminate sessions, optionally trigger shutdown. 1. Endnutzer (Consumers): Nutzen die Web-Oberflaeche ohne technische Details.
- Desktop notifications and optional sound during countdown. 2. Technische Einsteiger: Installieren und starten den Dienst.
- Login-protected API with minimal web UI (PAM auth for root users, bearer token for calls). 3. Power-User: Konfigurieren, automatisieren und betreiben den Dienst.
- Systemd-managed service, virtualenv-based deploy, remote update script. 4. Entwickler/Professionals: Verstehen Architektur, Interna und Erweiterungspunkte.
- Dry-run mode to validate flows without touching accounts.
## Quick Start (Local/Target Device) ## Was es NICHT ist
- Kein Cloud-Service und keine zentrale Benutzerverwaltung.
- Kein Ersatz fuer Mobile-Device-Management (MDM).
- Kein plattformuebergreifendes Kontrollen-System; Fokus ist Linux und lokale Accounts.
## Hauptfunktionen
- Sperren/Entsperren lokaler Nutzerkonten inkl. Session-Management und optionalem Shutdown.
- Benachrichtigungen und optionaler Sound waehrend Countdown.
- PAM-Login (immer aktiv) und optionaler OIDC-Login.
- Systemd-Service mit Installations- und Update-Skripten.
- Update-Client-Integration (Enrollment, Manifest, Apply, Rollback, Logs).
- Dry-Run-Modus zum sicheren Testen.
## Quickstart (5 Minuten)
```bash ```bash
git clone <repo> /opt/sk # 1) Repo holen
sudo mkdir -p /opt/sk
sudo git clone ssh://git@git.wlkns.org:2222/stephan/kiddo /opt/sk
# Hinweis: verwende hier die Repo-URL deiner Instanz
cd /opt/sk cd /opt/sk
./scripts/install.sh
# 2) Installieren (legt User, env und Systemd-Unit an)
make install
# 3) Status pruefen
sudo systemctl status skd.service sudo systemctl status skd.service
# 4) Login testen (PAM)
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"root","password":"example-password"}' \
http://localhost/login
``` ```
Then open `http://localhost/` and log in via PAM (default) to start quickly. Danach die Web-UI unter `http://localhost/` oeffnen und anmelden.
## Configuration ## Dokumentation nach Zielgruppe
Set in `/etc/skd/env` (see `env.example`): - Endnutzer (Consumers): `docs/FOR_USERS.md`
- PAM-Login ist immer aktiv. OIDC wird zusaetzlich angeboten, wenn konfiguriert. - Technische Einsteiger: `docs/GETTING_STARTED.md`
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens/cookies (set a strong value). - Power-User: `docs/USAGE.md` und `docs/CONFIGURATION.md`
- `SKD_TOKEN_TTL_SECONDS`: token lifetime (default 900s). - Entwickler/Professionals: `docs/ARCHITECTURE.md`, `docs/DEVELOPMENT.md`, `docs/DEPLOYMENT.md`
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in (used for PAM and as an allowlist for OIDC claims). - Gemeinsame Referenz: `docs/FAQ.md`, `docs/TROUBLESHOOTING.md`
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (PAM only, default `sudo`).
- `SKD_AUTH_PAM_SERVICE`: PAM service name; Ubuntu/Debian uses `/etc/pam.d/skd` (created by `scripts/install.sh`), other distros may prefer `login` or `sshd`.
- `SKD_OIDC_*`: `ISSUER`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI`, `SCOPES` to point at your OIDC provider; set `SKD_SESSION_COOKIE_SECURE=true` for HTTPS.
- OIDC dynamic registration helper: `scripts/register_oidc_client.sh` (requires `OIDC_INITIAL_ACCESS_TOKEN` and `SKD_OIDC_ISSUER`; uses `SKD_OIDC_REDIRECT_URI` for the redirect). Run once during setup if your provider issues initial access tokens for client creation.
- `SKD_ALLOWED_USERS`: optional comma list to limit manageable accounts (must exist on the system).
- `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults.
- `SKD_DRY_RUN=true` to test without real account changes or shutdown.
- `SKD_SOUND_PLAYER`/`SKD_SOUND_FILE`, `SKD_NOTIFY_SEND_PATH` if defaults differ.
- Update client:
- `SKD_UPDATE_URL` (default `https://update.wlkns.org`)
- `SKD_UPDATE_TOKEN` (API token for update service)
- `SKD_UPDATE_INTERVAL` (seconds; default 3600)
- `SKD_UPDATE_STATUS_URL` (default `https://update.wlkns.org/status`)
- `SKD_UPDATE_STATUS_FILE` (default `/var/lib/skd/update_status.json`)
- `SKD_UPDATE_LOG_FILE` (default `/var/lib/skd/update_logs.jsonl`)
Notes:
- `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist.
## OIDC Setup ## Externe Services
OIDC ist optional. PAM bleibt immer verfuegbar; `SKD_AUTH_MODE` ist optional. - Update-Service (intern): https://git.wlkns.org/stephan/update-webservice
1. Issuer muss der externen URL des Providers entsprechen (TLS trust erforderlich). - OIDC-Service (intern): https://git.wlkns.org/stephan/oicd
2. OIDC Client registrieren (DCR), z.B.: Einbindung und Konfiguration: `docs/DEPLOYMENT.md` und `docs/CONFIGURATION.md`.
```bash
export SKD_OIDC_ISSUER="https://auth.example.org"
export SKD_OIDC_REDIRECT_URI="https://<device-host>/login/oidc/callback"
export OIDC_INITIAL_ACCESS_TOKEN="<initial-access-token>"
./scripts/register_oidc_client.sh
```
3. Danach in `/etc/skd/env` setzen:
```
SKD_AUTH_MODE=oidc
SKD_OIDC_ISSUER=...
SKD_OIDC_CLIENT_ID=...
SKD_OIDC_CLIENT_SECRET=...
SKD_OIDC_REDIRECT_URI=...
SKD_OIDC_SCOPES=openid profile email
SKD_SESSION_COOKIE_SECURE=true
```
Hinweise:
- Redirect-URI muss exakt sein (keine Wildcards).
- Bei Host/Port-Aenderung neu registrieren und neue Credentials setzen.
- Allowlist fuer OIDC: `SKD_AUTH_ALLOWED_USERS` prueft `preferred_username`, `email` oder `sub`.
## Running ## Badges
- Service: managed by systemd; `./scripts/install.sh` writes the unit dynamically to `/etc/systemd/system/skd.service` with the current repo path and restarts it (runs as root for PAM). Derzeit keine offiziellen Badges, da im Repository keine CI, Coverage, Release oder Docker-Pipeline definiert ist.
- Manual run: `./scripts/run.sh` (uses `.venv`, defaults to `0.0.0.0:80`).
- Login (PAM): `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost/login`
- Login (OIDC): open `http://localhost/login/oidc/start` → provider → redirected back with session cookie set.
- Health: `curl -H "Authorization: Bearer <token>" http://localhost/health`
## OIDC Validation & Fallbacks ## Hinweise
- Validierungsschritte: `docs/oidc-validation.md` (State, Token-Exchange, Claims, Cookie). - Abweichung von der Zielstruktur: Die OpenAPI-Spezifikation des Update-Services bleibt unter `docs/architecture/openapi.yaml` und `docs/architecture/openapi/` erhalten.
- Falls Discovery/JWKS nicht verfuegbar: OIDC deaktivieren und PAM nutzen. - Altdokumente liegen unter `docs/_archive/` und sind nicht geloescht.
- Falls DCR nicht verfuegbar: Client manuell im IdP anlegen und `SKD_OIDC_*` setzen. - Konfigurationsdateien: `/etc/skd/env` (Core) und `/etc/skd/update.env` (Update-Service).
- Bei Self-Signed TLS: CA im System trusten oder in Dev PAM nutzen.
## API (Bearer token via `/login`)
- `GET /users` → `[{user, logged_in, account_locked}]` (manageable system users; excludes root)
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
- `POST /users/{name}/enable`
- `GET /health`
- `GET /me` (returns current user + auth mode when a session/bearer token is present)
Example:
```bash
token=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost/login | jq -r .token)
curl -X POST -H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d '{"countdown":90,"sound":true}' \
http://localhost/users/child1/disable
```
## Web UI
Served at `/`. Nutze den Button „Login via OIDC“ (setzt Session-Cookie) oder das PAM-Formular, falls OIDC deaktiviert; danach werden verfügbare System-User angezeigt und Aktionen senden Token/Cookies automatisch.
## Updates
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
- Manual: `git pull && source .venv/bin/activate && pip install -r backend/requirements.txt && sudo systemctl restart skd`
## Deployment (zip/SSH)
- Quick copy: create `sk_deploy.zip` (already in repo root) and unzip on target under `/opt/sk`, then refresh venv deps and restart service.
- Scripted deploy: edit `deploy_hosts.yml` (host/user/port/install_dir/service user/group), then run `./scripts/deploy.sh <host-name>`; accepts JSON configs too. Requires SSH access and `sudo` on target.
- After deploy on target: `sudo -u skd /opt/sk/.venv/bin/pip install -r /opt/sk/backend/requirements.txt && sudo systemctl restart skd.service`
## Security Hardening
- Restrict access to API/Web UI to LAN/VPN; firewall the port.
- Set a strong `SKD_AUTH_SECRET`; rotate tokens by changing the secret.
- Create dedicated `skd` user/group; no login shell.
- Configure sudoers minimally: allow `skd` to run `usermod -L/-U`, `pkill -KILL -u`, `shutdown now`, and sound/notify binaries if needed (no full passwordless sudo).
- Consider mTLS or IP allowlisting for added protection.
## Legacy Script ## Legacy Script
`sk.sh` remains for direct SSH use. Plan to replace its logic with API-backed helpers; keep it as emergency fallback. Das Script `sk.sh` bleibt als CLI-Fallback. Details und Beispiele: `docs/USAGE.md`.

View File

@ -1 +1 @@
0.1.0 0.3.6

View File

@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

15
assets/branding/logo.svg Normal file
View File

@ -0,0 +1,15 @@
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<!-- Middle Block: Teal, 19px Height -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<!-- Wordmark -->
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<!-- The Underline: Teal, aligned like V8 -->
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -1,11 +1,13 @@
import asyncio
import logging import logging
from typing import List from typing import List
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from backend import actions from backend import actions, enforcer, rules
from backend.actions import ActionError from backend.actions import ActionError
from backend.auth import ( from backend.auth import (
authenticate_admin_user, authenticate_admin_user,
@ -16,19 +18,26 @@ from backend.auth import (
list_manageable_users, list_manageable_users,
) )
from backend.models import ( from backend.models import (
AccessRule,
ActionRequest, ActionRequest,
ActionResponse, ActionResponse,
EnrollRequest,
EnrollResponse,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
RuleSet,
UpdateActionResponse, UpdateActionResponse,
UpdateCheckResponse, UpdateCheckResponse,
UpdateLogEntry, UpdateLogEntry,
UpdateServiceStatus,
UpdateStatus, UpdateStatus,
SystemMetrics,
UserStatus, UserStatus,
) )
from backend.oidc import OIDCClient, OIDCError from backend.oidc import OIDCClient, OIDCError
from backend.settings import Settings, get_settings from backend.settings import Settings, get_settings
from backend import update from backend import update
from backend import system_metrics
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -37,9 +46,16 @@ logging.basicConfig(
logger = logging.getLogger("skd") logger = logging.getLogger("skd")
app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0") app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
app.mount("/assets", StaticFiles(directory="assets"), name="assets")
templates = Jinja2Templates(directory="backend/templates") templates = Jinja2Templates(directory="backend/templates")
@app.on_event("startup")
async def startup_event():
asyncio.create_task(enforcer.enforcement_loop())
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient: def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
if not settings.oidc_enabled: if not settings.oidc_enabled:
raise HTTPException( raise HTTPException(
@ -230,12 +246,56 @@ def enable_user(
) )
@app.get("/rules", response_model=RuleSet, dependencies=[Depends(get_current_admin)])
def get_rules(settings: Settings = Depends(get_settings)) -> RuleSet:
manager = rules.RuleManager(settings)
return manager.load_rules()
@app.post("/rules", dependencies=[Depends(get_current_admin)])
def set_rule(
rule: AccessRule,
settings: Settings = Depends(get_settings),
) -> dict:
manager = rules.RuleManager(settings)
manager.set_rule(rule)
return {"status": "ok"}
@app.delete("/rules/{username}", dependencies=[Depends(get_current_admin)])
def delete_rule(
username: str,
settings: Settings = Depends(get_settings),
) -> dict:
manager = rules.RuleManager(settings)
manager.delete_rule(username)
return {"status": "ok"}
@app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)]) @app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)])
def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus: def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
status_data = update.get_status(settings) status_data = update.get_status(settings)
return UpdateStatus(**status_data) return UpdateStatus(**status_data)
@app.post("/update/enroll", response_model=EnrollResponse, dependencies=[Depends(get_current_admin)])
def update_enroll(
payload: EnrollRequest | None = Body(default=None),
settings: Settings = Depends(get_settings),
) -> EnrollResponse:
if payload and payload.enroll_token:
settings.update_enroll_token = payload.enroll_token
try:
update.enroll(settings)
return EnrollResponse(enrolled=True, message="Enrollment successful")
except Exception as exc:
logger.exception("Enrollment failed")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Enrollment failed: {str(exc)}",
) from exc
@app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)]) @app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)])
def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse: def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse:
try: try:
@ -291,6 +351,26 @@ def update_logs(settings: Settings = Depends(get_settings), limit: int = 200) ->
) from exc ) from exc
@app.get("/update/service-status", response_model=UpdateServiceStatus, dependencies=[Depends(get_current_admin)])
def update_service_status(settings: Settings = Depends(get_settings)) -> UpdateServiceStatus:
data = update.get_service_status(settings)
return UpdateServiceStatus(**data)
@app.get("/system/metrics", response_model=SystemMetrics, dependencies=[Depends(get_current_admin)])
def system_metrics_status() -> SystemMetrics:
data = system_metrics.get_system_metrics()
return SystemMetrics(**data)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@app.get("/login", response_class=HTMLResponse)
@app.get("/dashboard", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse: def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse("index.html", {"request": request})
@app.get("/ui/rules", response_class=HTMLResponse)
def rules_ui(request: Request) -> HTMLResponse:
return templates.TemplateResponse("rules.html", {"request": request})

71
backend/enforcer.py Normal file
View File

@ -0,0 +1,71 @@
import asyncio
import logging
from backend import actions
from backend.auth import is_account_locked, list_manageable_users
from backend.rules import RuleManager
from backend.settings import get_settings
logger = logging.getLogger(__name__)
async def check_and_enforce_rules():
"""Iterate all manageable users and enforce time window rules."""
settings = get_settings()
manager = RuleManager(settings)
# We only care about users explicitly managed via settings or rules.
target_users = list_manageable_users(settings)
for user in target_users:
try:
allowed = manager.is_login_allowed(user)
rule = manager.get_rule(user)
if allowed:
# If user has a rule with auto-reenable, ensure unlocked
# We check rule existence because "allowed" is also true for users with NO rules.
# But for users with no rules, we don't want to auto-unlock randomly (maybe manual lock?).
# US says: "Given a rule allows automatic reactivation ... Then account is automatically activated"
# So we only auto-unlock if a rule EXISTS and explicitly asks for it.
if rule and rule.auto_reenable and is_account_locked(user):
logger.info("Auto-enabling user %s (Time window started)", user)
actions.enable_user(user)
continue
# If we are here, access is DENIED.
# Check if logged in
logged_in_users = actions.list_logged_in_users()
is_logged_in = user in logged_in_users
if is_logged_in:
logger.warning("User %s is logged in during forbidden time. Enforcing logout.", user)
# Warn and shutdown
actions.disable_user(
user,
countdown=60,
sound=True,
message="Time limit reached. Shutdown in 60s.",
)
else:
# Not logged in. Ensure account is locked to prevent login.
if not is_account_locked(user):
logger.info("Locking user %s (Time window ended)", user)
# disable_user locks the account. We pass countdown=0 but since not logged in, it won't matter much.
actions.disable_user(user, countdown=0, sound=False)
except Exception:
logger.exception("Error enforcing rules for user %s", user)
async def enforcement_loop():
logger.info("Starting enforcement loop")
while True:
try:
await check_and_enforce_rules()
except Exception:
logger.exception("Error in enforcement loop")
# Run every minute
await asyncio.sleep(60)

View File

@ -1,8 +1,36 @@
from typing import List, Optional from enum import Enum
from typing import List, Optional, Dict
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class DayOfWeek(str, Enum):
MONDAY = "mon"
TUESDAY = "tue"
WEDNESDAY = "wed"
THURSDAY = "thu"
FRIDAY = "fri"
SATURDAY = "sat"
SUNDAY = "sun"
class TimeWindow(BaseModel):
days: List[DayOfWeek]
start_time: str = Field(..., pattern=r"^\d{2}:\d{2}$", description="HH:MM format")
end_time: str = Field(..., pattern=r"^\d{2}:\d{2}$", description="HH:MM format")
class AccessRule(BaseModel):
user: str
enabled: bool = True
auto_reenable: bool = False
allowed_windows: List[TimeWindow] = []
class RuleSet(BaseModel):
rules: Dict[str, AccessRule] = {} # Keyed by username for O(1) lookup
class ActionRequest(BaseModel): class ActionRequest(BaseModel):
countdown: Optional[int] = Field(default=None, ge=0, description="Seconds for countdown") countdown: Optional[int] = Field(default=None, ge=0, description="Seconds for countdown")
sound: Optional[bool] = Field(default=None, description="Play sound alongside notification") sound: Optional[bool] = Field(default=None, description="Play sound alongside notification")
@ -41,6 +69,16 @@ class UpdateStatus(BaseModel):
last_status: str last_status: str
last_error: Optional[str] = None last_error: Optional[str] = None
last_timestamp: Optional[str] = None last_timestamp: Optional[str] = None
enrolled: bool = False
class EnrollRequest(BaseModel):
enroll_token: Optional[str] = None
class EnrollResponse(BaseModel):
enrolled: bool
message: str
class UpdateCheckResponse(BaseModel): class UpdateCheckResponse(BaseModel):
@ -63,3 +101,23 @@ class UpdateLogEntry(BaseModel):
version: Optional[str] = None version: Optional[str] = None
error: Optional[str] = None error: Optional[str] = None
device_id: Optional[str] = None device_id: Optional[str] = None
class UpdateServiceStatus(BaseModel):
url: str
reachable: bool
status_code: Optional[int] = None
error: Optional[str] = None
checked_url: str
environment: str
class SystemMetrics(BaseModel):
cpu_percent: float
ram_total_mb: float
ram_used_percent: float
gpu_vram_total_mb: Optional[float] = None
gpu_vram_used_percent: Optional[float] = None
gpu_present: bool = False
net_rx_mbps: float
net_tx_mbps: float

91
backend/rules.py Normal file
View File

@ -0,0 +1,91 @@
import json
import logging
import os
from datetime import datetime
from typing import Optional
from backend.models import RuleSet, AccessRule, DayOfWeek
from backend.settings import get_settings, Settings
logger = logging.getLogger(__name__)
class RuleManager:
def __init__(self, settings: Optional[Settings] = None):
self.settings = settings or get_settings()
self.rules_file = self.settings.rules_file
def load_rules(self) -> RuleSet:
if not os.path.exists(self.rules_file):
return RuleSet()
try:
with open(self.rules_file, "r", encoding="utf-8") as f:
data = json.load(f)
return RuleSet(**data)
except (json.JSONDecodeError, OSError) as e:
logger.error("Failed to load rules from %s: %s", self.rules_file, e)
return RuleSet()
def save_rules(self, rules: RuleSet) -> None:
try:
os.makedirs(os.path.dirname(self.rules_file), exist_ok=True)
with open(self.rules_file, "w", encoding="utf-8") as f:
f.write(rules.model_dump_json(indent=2))
except OSError as e:
logger.error("Failed to save rules to %s: %s", self.rules_file, e)
raise
def get_rule(self, user: str) -> Optional[AccessRule]:
rules = self.load_rules()
return rules.rules.get(user)
def set_rule(self, rule: AccessRule) -> None:
rules = self.load_rules()
rules.rules[rule.user] = rule
self.save_rules(rules)
def delete_rule(self, user: str) -> None:
rules = self.load_rules()
if user in rules.rules:
del rules.rules[user]
self.save_rules(rules)
def is_login_allowed(self, user: str, now: Optional[datetime] = None) -> bool:
"""
Check if user is allowed to login at the given time (or now).
Returns True if no rule exists (or disabled) or if time is within an allowed window.
Returns False if a rule exists and current time is outside all allowed windows.
"""
rule = self.get_rule(user)
if not rule or not rule.enabled:
# No rule or rule disabled -> Default Allow
return True
if not rule.allowed_windows:
# Rule enabled but no windows -> Deny All
return False
if now is None:
now = datetime.now()
# Map weekday to Enum
weekday_map = {
0: DayOfWeek.MONDAY,
1: DayOfWeek.TUESDAY,
2: DayOfWeek.WEDNESDAY,
3: DayOfWeek.THURSDAY,
4: DayOfWeek.FRIDAY,
5: DayOfWeek.SATURDAY,
6: DayOfWeek.SUNDAY,
}
current_day = weekday_map[now.weekday()]
current_time_str = now.strftime("%H:%M")
for window in rule.allowed_windows:
if current_day in window.days:
# Handle simple range start <= now <= end
# (Overnight windows assumed to be split by user into two windows)
if window.start_time <= current_time_str <= window.end_time:
return True
return False

View File

@ -40,7 +40,13 @@ class Settings:
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5")) self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true" self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true"
self.update_url: str = os.getenv("SKD_UPDATE_URL", "https://update.wlkns.org") self.update_url: str = os.getenv("SKD_UPDATE_URL", "https://update.wlkns.org")
self.update_token: str = os.getenv("SKD_UPDATE_TOKEN", "") self.update_service_url: str = os.getenv("SKD_UPDATE_SERVICE_URL", "https://update.wlkns.org")
self.update_project_id: str = os.getenv("SKD_UPDATE_PROJECT_ID", "safe-kiddo-control")
self.update_enroll_token: str = os.getenv("SKD_UPDATE_ENROLL_TOKEN", "")
self.update_token_file: str = os.getenv(
"SKD_UPDATE_TOKEN_FILE", "/var/lib/skd/update_token"
)
self.update_token: str = self._load_update_token()
self.update_interval: int = int(os.getenv("SKD_UPDATE_INTERVAL", "3600")) self.update_interval: int = int(os.getenv("SKD_UPDATE_INTERVAL", "3600"))
self.update_status_url: str = os.getenv( self.update_status_url: str = os.getenv(
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status" "SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status"
@ -51,6 +57,9 @@ class Settings:
self.update_log_file: str = os.getenv( self.update_log_file: str = os.getenv(
"SKD_UPDATE_LOG_FILE", "/var/lib/skd/update_logs.jsonl" "SKD_UPDATE_LOG_FILE", "/var/lib/skd/update_logs.jsonl"
) )
self.rules_file: str = os.getenv(
"SKD_RULES_FILE", "/var/lib/skd/rules.json"
)
# Paths/tools # Paths/tools
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send") self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay") self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
@ -63,6 +72,18 @@ class Settings:
def _parse_list(value: str) -> List[str]: def _parse_list(value: str) -> List[str]:
return [item for item in (part.strip() for part in value.split(",")) if item] return [item for item in (part.strip() for part in value.split(",")) if item]
def _load_update_token(self) -> str:
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
if env_token:
return env_token
if os.path.exists(self.update_token_file):
try:
with open(self.update_token_file, "r", encoding="utf-8") as f:
return f.read().strip()
except OSError:
pass
return ""
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:

928
backend/static/styles.css Executable file
View File

@ -0,0 +1,928 @@
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
:root {
--bg-main: #f5f7fa;
--bg-panel: #ffffff;
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--text-main: #2d3748;
--text-secondary: #718096;
--text-light: #a0aec0;
--border-main: #e2e8f0;
--primary: #667eea;
--primary-hover: #5568d3;
--success: #48bb78;
--success-hover: #38a169;
--warning: #ed8936;
--danger: #f56565;
--danger-hover: #e53e3e;
--info: #4299e1;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
}
/* Dark Mode Theme */
body.dark-mode {
--bg-main: #1a202c;
--bg-panel: #2d3748;
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
--text-main: #f7fafc;
--text-secondary: #cbd5e0;
--text-light: #a0aec0;
--border-main: #4a5568;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--bg-main);
color: var(--text-main);
line-height: 1.6;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
border: none;
cursor: pointer;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 999;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.theme-toggle svg {
width: 24px;
height: 24px;
stroke: white;
fill: none;
}
.theme-toggle .sun-icon {
display: none;
}
body.dark-mode .theme-toggle .moon-icon {
display: none;
}
body.dark-mode .theme-toggle .sun-icon {
display: block;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.hidden {
display: none !important;
}
.login-landing {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: radial-gradient(circle at 20% 10%, rgba(102, 126, 234, 0.15), transparent 45%),
radial-gradient(circle at 80% 20%, rgba(72, 187, 120, 0.12), transparent 40%),
var(--bg-main);
}
.login-card {
width: min(520px, 100%);
background: var(--bg-panel);
border: 1px solid var(--border-main);
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-lg);
}
.login-brand {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.login-brand i {
width: 40px;
height: 40px;
color: var(--primary);
}
.login-brand h1 {
font-size: 1.6rem;
margin-bottom: 4px;
}
.login-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: center;
}
.login-actions button {
width: 100%;
}
/* Header - Keep the gradient but more professional */
header {
background: var(--bg-header);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
header p {
font-size: 1rem;
opacity: 0.95;
font-weight: 400;
}
.header-main {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
align-items: flex-start;
}
.header-main i {
width: 32px;
height: 32px;
}
.header-logo {
height: 34px;
width: auto;
display: block;
}
.header-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.header-version {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
}
/* Statistics Dashboard - More visual interest */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-panel);
padding: 24px;
border-radius: 12px;
box-shadow: var(--shadow-md);
border-left: 4px solid var(--primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: var(--primary);
opacity: 0.05;
border-radius: 50%;
transform: translate(30%, -30%);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-left-width: 6px;
}
.stat-card h3 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
position: relative;
z-index: 1;
}
/* Controls - Clean but distinctive */
.controls {
background: var(--bg-panel);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
border: 1px solid var(--border-main);
}
/* Inputs - More refined */
input, select {
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
font-family: inherit;
}
input:focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input::placeholder {
color: var(--text-light);
}
.search-box {
flex: 1;
min-width: 250px;
}
/* Buttons - Keep gradient feel but professional */
button {
background: var(--primary);
color: white;
border: none;
padding: 11px 20px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
button:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button:active {
transform: translateY(0);
}
button.secondary {
background: var(--success);
}
button.secondary:hover {
background: var(--success-hover);
}
button.danger {
background: var(--danger);
}
button.danger:hover {
background: var(--danger-hover);
}
/* Table Container - More polished */
.table-container {
background: var(--bg-panel);
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
border: 1px solid var(--border-main);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 16px;
text-align: left;
}
th {
background: var(--bg-main);
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border-main);
}
tbody tr {
border-bottom: 1px solid var(--border-main);
transition: background-color 0.15s ease;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--bg-main);
}
td {
color: var(--text-main);
}
td strong {
color: var(--primary);
font-weight: 600;
}
/* Status Badges - More colorful but professional */
.status-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-available {
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
color: #22543d;
}
.status-in_use {
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
color: #2c5282;
}
.status-maintenance {
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
color: #7c2d12;
}
.status-retired {
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
color: #2d3748;
}
.status-lost {
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
color: #742a2a;
}
/* Modal - Elegant overlay */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: var(--bg-panel);
border-radius: 16px;
padding: 32px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid var(--border-main);
}
.modal-header h2 {
color: var(--text-main);
font-size: 1.5rem;
font-weight: 700;
}
.close-btn {
background: var(--border-main);
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
width: 36px;
height: 36px;
line-height: 1;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--primary);
color: white;
transform: rotate(90deg);
}
/* Form - Clean and accessible */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
}
textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
}
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 28px;
padding-top: 20px;
border-top: 2px solid var(--border-main);
}
/* Loading & Empty States */
.loading, .empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.4;
stroke: var(--text-secondary);
}
.empty-state h3 {
color: var(--text-main);
margin-bottom: 8px;
font-weight: 600;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.action-buttons button {
padding: 7px 14px;
font-size: 0.8rem;
}
/* Import Zone - Visual and inviting */
.import-zone {
border: 3px dashed var(--border-main);
border-radius: 12px;
padding: 48px;
text-align: center;
margin: 20px 0;
transition: all 0.3s ease;
cursor: pointer;
background: var(--bg-main);
}
.import-zone:hover, .import-zone.drag-over {
border-color: var(--primary);
background: var(--bg-panel);
transform: scale(1.02);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
}
.import-zone.processing {
border-color: var(--success);
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
}
.import-zone svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
stroke: var(--primary);
}
.import-zone h3 {
color: var(--text-main);
font-weight: 600;
margin-bottom: 8px;
font-size: 1.125rem;
}
.import-zone p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.file-input {
display: none;
}
/* Import Results */
.import-results {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
border-left: 4px solid;
box-shadow: var(--shadow-sm);
}
.import-results.success {
background: #c6f6d5;
border-color: var(--success);
color: #22543d;
}
.import-results.error {
background: #fed7d7;
border-color: var(--danger);
color: #742a2a;
}
/* Progress Bar - More visual */
.progress-bar {
width: 100%;
height: 10px;
background: var(--border-main);
border-radius: 8px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 12px;
}
header {
padding: 24px 20px;
}
header h1 {
font-size: 1.5rem;
}
.stats {
grid-template-columns: 1fr;
gap: 12px;
}
.controls {
flex-direction: column;
padding: 16px;
}
.search-box {
width: 100%;
}
button {
width: 100%;
}
.theme-toggle {
bottom: 20px;
right: 20px;
width: 48px;
height: 48px;
}
table {
font-size: 0.85rem;
}
th, td {
padding: 12px 8px;
}
.action-buttons {
flex-direction: column;
}
.action-buttons button {
width: 100%;
}
.modal-content {
padding: 24px 20px;
}
.login-card {
padding: 24px;
}
.header-meta {
align-items: flex-start;
}
.login-actions {
grid-template-columns: 1fr;
}
}
/* NEW STYLES FOR ENHANCED UI */
.error-card {
background-color: var(--danger);
color: white;
border-left-color: var(--danger-hover);
}
.error-card h3 {
color: white;
}
.loading-card {
text-align: center;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stat-card details {
margin-top: 16px;
}
.stat-card summary {
cursor: pointer;
font-weight: 600;
color: var(--text-main);
margin-bottom: 8px;
}
.stat-card summary:hover {
color: var(--primary);
}
.flags-list {
list-style-type: none;
padding-left: 8px;
font-size: 0.9rem;
}
.flags-list li {
margin-bottom: 6px;
padding-left: 16px;
position: relative;
}
.flags-list li::before {
content: '›';
position: absolute;
left: 0;
color: var(--primary);
font-weight: bold;
}
.value.level-high {
color: var(--danger);
}
.value.level-medium {
color: var(--warning);
}
.value.level-low {
color: var(--success);
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: var(--bg-panel);
padding: 24px;
border-radius: 12px;
box-shadow: var(--shadow-md);
border-left: 4px solid var(--primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.metric-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: var(--primary);
opacity: 0.05;
border-radius: 50%;
transform: translate(30%, -30%);
}
.metric-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-left-width: 6px;
}
.metric-card .label {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.metric-card .value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
position: relative;
z-index: 1;
}
.metric-bar {
height: 6px;
background: var(--border-main);
border-radius: 999px;
overflow: hidden;
margin-top: 10px;
}
.metric-bar-fill {
height: 100%;
width: 0%;
background: var(--primary);
transition: width 0.4s ease;
}
.metric-bar-fill.info {
background: var(--info);
}
.metric-bar-fill.success {
background: var(--success);
}
.metric-bar-fill.warning {
background: var(--warning);
}
.panel-section {
background: var(--bg-panel);
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-main);
margin-bottom: 24px;
}
.panel-section h3 {
margin-bottom: 12px;
}

139
backend/system_metrics.py Normal file
View File

@ -0,0 +1,139 @@
import time
import shutil
import subprocess
from typing import Any, Dict, Optional, Tuple
_last_cpu: Optional[Tuple[float, float]] = None
_last_net: Optional[Tuple[float, float, float]] = None
def _read_cpu_times() -> Tuple[float, float]:
with open("/proc/stat", "r", encoding="utf-8") as handle:
line = handle.readline()
parts = line.strip().split()
if not parts or parts[0] != "cpu":
return 0.0, 0.0
values = [float(p) for p in parts[1:]]
total = sum(values)
idle = values[3] if len(values) > 3 else 0.0
return total, idle
def _cpu_percent() -> float:
global _last_cpu
total, idle = _read_cpu_times()
if _last_cpu is None:
_last_cpu = (total, idle)
return 0.0
last_total, last_idle = _last_cpu
_last_cpu = (total, idle)
delta_total = total - last_total
delta_idle = idle - last_idle
if delta_total <= 0:
return 0.0
return max(0.0, min(100.0, (delta_total - delta_idle) / delta_total * 100.0))
def _read_meminfo() -> Dict[str, float]:
data: Dict[str, float] = {}
with open("/proc/meminfo", "r", encoding="utf-8") as handle:
for line in handle:
key, value = line.split(":", 1)
parts = value.strip().split()
if not parts:
continue
data[key] = float(parts[0])
return data
def _read_net_bytes() -> Tuple[float, float]:
rx_total = 0.0
tx_total = 0.0
with open("/proc/net/dev", "r", encoding="utf-8") as handle:
for line in handle:
if ":" not in line:
continue
iface, stats = line.split(":", 1)
iface = iface.strip()
if iface == "lo":
continue
fields = stats.split()
if len(fields) < 16:
continue
rx_total += float(fields[0])
tx_total += float(fields[8])
return rx_total, tx_total
def _net_mbps() -> Tuple[float, float]:
global _last_net
now = time.time()
rx, tx = _read_net_bytes()
if _last_net is None:
_last_net = (now, rx, tx)
return 0.0, 0.0
last_time, last_rx, last_tx = _last_net
_last_net = (now, rx, tx)
delta_t = now - last_time
if delta_t <= 0:
return 0.0, 0.0
rx_mbps = (rx - last_rx) * 8.0 / (delta_t * 1_000_000.0)
tx_mbps = (tx - last_tx) * 8.0 / (delta_t * 1_000_000.0)
return max(0.0, rx_mbps), max(0.0, tx_mbps)
def _gpu_metrics() -> Dict[str, Any]:
if not shutil.which("nvidia-smi"):
return {
"gpu_vram_total_mb": None,
"gpu_vram_used_percent": None,
"gpu_present": False,
}
try:
output = subprocess.check_output(
[
"nvidia-smi",
"--query-gpu=memory.total,memory.used",
"--format=csv,noheader,nounits",
],
text=True,
).strip()
if not output:
raise ValueError("empty nvidia-smi output")
total_str, used_str = output.split(",", 1)
total_mb = float(total_str.strip())
used_mb = float(used_str.strip())
used_percent = 0.0 if total_mb == 0 else used_mb / total_mb * 100.0
return {
"gpu_vram_total_mb": total_mb,
"gpu_vram_used_percent": used_percent,
"gpu_present": True,
}
except Exception:
return {
"gpu_vram_total_mb": None,
"gpu_vram_used_percent": None,
"gpu_present": False,
}
def get_system_metrics() -> Dict[str, Any]:
meminfo = _read_meminfo()
total_kb = meminfo.get("MemTotal", 0.0)
available_kb = meminfo.get("MemAvailable", 0.0)
used_kb = max(0.0, total_kb - available_kb)
ram_total_mb = total_kb / 1024.0
ram_used_percent = 0.0 if total_kb == 0 else used_kb / total_kb * 100.0
cpu_percent = _cpu_percent()
rx_mbps, tx_mbps = _net_mbps()
gpu = _gpu_metrics()
return {
"cpu_percent": cpu_percent,
"ram_total_mb": ram_total_mb,
"ram_used_percent": ram_used_percent,
"net_rx_mbps": rx_mbps,
"net_tx_mbps": tx_mbps,
**gpu,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regelverwaltung - Safe Kiddo</title>
<link rel="stylesheet" href="/static/styles.css" />
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg" />
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.rule-editor {
display: grid;
grid-template-columns: 250px 1fr;
gap: 1.5rem;
align-items: start;
}
.user-list {
background: var(--bg-panel);
border: 1px solid var(--border-main);
border-radius: var(--radius-sm);
overflow: hidden;
}
.user-item {
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border-main);
display: flex;
justify-content: space-between;
align-items: center;
}
.user-item:last-child { border-bottom: none; }
.user-item:hover { background: rgba(255,255,255,0.05); }
.user-item.active { background: rgba(var(--primary-rgb), 0.1); border-left: 3px solid var(--primary); }
.editor-panel {
background: var(--bg-panel);
border: 1px solid var(--border-main);
border-radius: var(--radius-sm);
padding: 1.5rem;
}
.window-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.window-item {
background: var(--bg-main);
border: 1px solid var(--border-main);
padding: 0.75rem;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.day-badges {
display: flex;
gap: 0.25rem;
}
.day-badge {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-panel);
border: 1px solid var(--border-main);
color: var(--text-muted);
}
.day-badge.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.time-range {
font-family: var(--font-mono);
font-weight: 600;
}
.remove-btn {
margin-left: auto;
color: var(--color-error);
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
}
.day-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.day-checkbox {
display: none;
}
.day-label {
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-main);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
user-select: none;
}
.day-checkbox:checked + .day-label {
background: var(--primary);
color: white;
border-color: var(--primary);
}
</style>
</head>
<body>
<!-- Dark Mode Toggle -->
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<div class="header-main">
<h1>
<i data-lucide="clock"></i>
Regelverwaltung
</h1>
</div>
<div class="header-meta">
<a href="/dashboard" class="button secondary small">
<i data-lucide="arrow-left"></i> Zurück zum Dashboard
</a>
</div>
</header>
<div class="rule-editor" id="ruleEditor">
<!-- User List -->
<div class="user-list" id="userList">
<div style="padding: 1rem; text-align: center; color: var(--text-muted);">
Lade Nutzer...
</div>
</div>
<!-- Editor Panel -->
<div class="editor-panel hidden" id="editorPanel">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2 id="selectedUserTitle" style="margin: 0;">Benutzer</h2>
<div style="display: flex; gap: 0.5rem;">
<button class="danger small" id="deleteRulesBtn">Regeln löschen</button>
<button class="primary small" id="saveRulesBtn">Speichern</button>
</div>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="ruleEnabled" checked>
<label for="ruleEnabled">Regelwerk aktiv</label>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="autoReenable">
<label for="autoReenable">Automatisch entsperren (wenn Zeitfenster beginnt)</label>
</div>
<h3 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Erlaubte Zeitfenster</h3>
<div class="window-list" id="windowList">
<!-- Windows go here -->
</div>
<button class="secondary small mt-1" onclick="openAddWindowModal()">
<i data-lucide="plus"></i> Zeitfenster hinzufügen
</button>
</div>
<div class="editor-panel" id="emptyState" style="text-align: center; padding: 3rem;">
<i data-lucide="user" style="width: 48px; height: 48px; color: var(--text-muted); margin-bottom: 1rem;"></i>
<p class="text-muted">Wähle einen Benutzer aus, um Regeln zu bearbeiten.</p>
</div>
</div>
</div>
<!-- Add Window Modal -->
<div class="modal" id="addWindowModal">
<div class="modal-content">
<div class="modal-header">
<h3>Zeitfenster hinzufügen</h3>
<button class="secondary small" onclick="closeAddWindowModal()">
<i data-lucide="x"></i>
</button>
</div>
<form id="addWindowForm">
<div class="form-group">
<label>Wochentage</label>
<div class="day-selector">
<label><input type="checkbox" class="day-checkbox" value="mon"><span class="day-label">Mo</span></label>
<label><input type="checkbox" class="day-checkbox" value="tue"><span class="day-label">Di</span></label>
<label><input type="checkbox" class="day-checkbox" value="wed"><span class="day-label">Mi</span></label>
<label><input type="checkbox" class="day-checkbox" value="thu"><span class="day-label">Do</span></label>
<label><input type="checkbox" class="day-checkbox" value="fri"><span class="day-label">Fr</span></label>
<label><input type="checkbox" class="day-checkbox" value="sat"><span class="day-label">Sa</span></label>
<label><input type="checkbox" class="day-checkbox" value="sun"><span class="day-label">So</span></label>
</div>
</div>
<div class="grid">
<div class="form-group">
<label>Start</label>
<input type="time" id="startTime" required>
</div>
<div class="form-group">
<label>Ende</label>
<input type="time" id="endTime" required>
</div>
</div>
<div class="modal-actions">
<button type="button" class="secondary" onclick="closeAddWindowModal()">Abbrechen</button>
<button type="submit">Hinzufügen</button>
</div>
</form>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
lucide.createIcons();
// State
let users = [];
let currentRules = {}; // Keyed by username
let selectedUser = null;
let currentWindows = []; // Temp storage for editor
// API
async function api(path, options = {}) {
const token = sessionStorage.getItem('skdToken');
if (!token) window.location.href = '/login';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options.headers || {})
};
const res = await fetch(path, { ...options, headers });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}"></i>
<span>${message}</span>
`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Init
async function init() {
try {
// Load users and rules in parallel
const [usersData, rulesData] = await Promise.all([
api('/users'),
api('/rules')
]);
users = usersData.map(u => u.user);
currentRules = rulesData.rules || {};
renderUserList();
} catch (err) {
showToast('Fehler beim Laden: ' + err.message, 'error');
}
}
function renderUserList() {
const list = document.getElementById('userList');
if (users.length === 0) {
list.innerHTML = '<div style="padding:1rem;">Keine Nutzer gefunden.</div>';
return;
}
list.innerHTML = users.map(u => {
const hasRule = !!currentRules[u];
return `
<div class="user-item ${selectedUser === u ? 'active' : ''}" onclick="selectUser('${u}')">
<div>
<strong>${u}</strong>
${hasRule ? '<i data-lucide="check" style="width:14px; height:14px; margin-left:4px; color:var(--color-success)"></i>' : ''}
</div>
<i data-lucide="chevron-right" style="width:16px; height:16px; color:var(--text-muted)"></i>
</div>
`;
}).join('');
lucide.createIcons();
}
function selectUser(user) {
selectedUser = user;
renderUserList(); // Update active state
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('selectedUserTitle').textContent = user;
const rule = currentRules[user] || { enabled: true, auto_reenable: false, allowed_windows: [] };
document.getElementById('ruleEnabled').checked = rule.enabled;
document.getElementById('autoReenable').checked = rule.auto_reenable;
currentWindows = [...(rule.allowed_windows || [])];
renderWindows();
}
function renderWindows() {
const container = document.getElementById('windowList');
if (currentWindows.length === 0) {
container.innerHTML = '<div class="text-muted" style="font-size:0.9rem;">Keine Zeitfenster definiert. Login ist standardmäßig <strong>nicht erlaubt</strong> (wenn Regelwerk aktiv).</div>';
return;
}
const dayMap = { mon:'Mo', tue:'Di', wed:'Mi', thu:'Do', fri:'Fr', sat:'Sa', sun:'So' };
const allDays = ['mon','tue','wed','thu','fri','sat','sun'];
container.innerHTML = currentWindows.map((win, idx) => {
const badges = allDays.map(d => `
<span class="day-badge ${win.days.includes(d) ? 'active' : ''}">${dayMap[d]}</span>
`).join('');
return `
<div class="window-item">
<div class="day-badges">${badges}</div>
<div class="time-range">
${win.start_time} - ${win.end_time}
</div>
<button class="remove-btn" onclick="removeWindow(${idx})">
<i data-lucide="trash-2" style="width:16px; height:16px;"></i>
</button>
</div>
`;
}).join('');
lucide.createIcons();
}
function removeWindow(index) {
currentWindows.splice(index, 1);
renderWindows();
}
function openAddWindowModal() {
document.getElementById('addWindowForm').reset();
// Default checkboxes
document.querySelectorAll('.day-checkbox').forEach(cb => {
if (['mon','tue','wed','thu','fri'].includes(cb.value)) cb.checked = true;
else cb.checked = false;
});
document.getElementById('addWindowModal').classList.add('active');
}
function closeAddWindowModal() {
document.getElementById('addWindowModal').classList.remove('active');
}
document.getElementById('addWindowForm').addEventListener('submit', (e) => {
e.preventDefault();
const days = Array.from(document.querySelectorAll('.day-checkbox:checked')).map(cb => cb.value);
if (days.length === 0) {
showToast('Bitte mindestens einen Wochentag wählen.', 'error');
return;
}
const start = document.getElementById('startTime').value;
const end = document.getElementById('endTime').value;
if (start >= end) {
showToast('Endzeit muss nach Startzeit liegen.', 'error');
return;
}
currentWindows.push({ days, start_time: start, end_time: end });
currentWindows.sort((a, b) => a.start_time.localeCompare(b.start_time));
closeAddWindowModal();
renderWindows();
});
document.getElementById('saveRulesBtn').addEventListener('click', async () => {
if (!selectedUser) return;
const rule = {
user: selectedUser,
enabled: document.getElementById('ruleEnabled').checked,
auto_reenable: document.getElementById('autoReenable').checked,
allowed_windows: currentWindows
};
try {
await api('/rules', {
method: 'POST',
body: JSON.stringify(rule)
});
currentRules[selectedUser] = rule;
showToast('Regeln gespeichert', 'success');
renderUserList();
} catch (err) {
showToast('Speichern fehlgeschlagen: ' + err.message, 'error');
}
});
document.getElementById('deleteRulesBtn').addEventListener('click', async () => {
if (!selectedUser || !confirm(`Regeln für ${selectedUser} wirklich löschen?\nDer Nutzer hat dann uneingeschränkten Zugriff.`)) return;
try {
await api(`/rules/${selectedUser}`, { method: 'DELETE' });
delete currentRules[selectedUser];
showToast('Regeln gelöscht', 'success');
selectUser(selectedUser); // Refresh view (defaults)
} catch (err) {
showToast('Löschen fehlgeschlagen: ' + err.message, 'error');
}
});
// Dark Mode (Shared)
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
init();
</script>
</body>
</html>

View File

@ -70,20 +70,73 @@ def get_status(settings: Settings) -> Dict[str, Any]:
"last_status": data.get("status", "unknown"), "last_status": data.get("status", "unknown"),
"last_error": data.get("error") or None, "last_error": data.get("error") or None,
"last_timestamp": data.get("timestamp"), "last_timestamp": data.get("timestamp"),
"enrolled": bool(settings.update_token),
} }
def enroll(settings: Settings) -> str:
if not settings.update_enroll_token:
raise ValueError("No enrollment token provided in settings")
enroll_url = f"{settings.update_service_url}/v1/enroll"
payload = {
"project_id": settings.update_project_id,
"client_id": os.uname().nodename,
"software_id": "safe-kiddo",
"enroll_token": settings.update_enroll_token,
}
with httpx.Client(timeout=10.0) as client:
response = client.post(enroll_url, json=payload)
response.raise_for_status()
data = response.json()
token = data.get("token")
if not token:
raise ValueError("Enrollment response did not contain a token")
# Save token
token_path = Path(settings.update_token_file)
_ensure_parent(token_path)
token_path.write_text(token, encoding="utf-8")
# Update settings object for immediate use
settings.update_token = token
return token
def _parse_version(value: str) -> List[int]: def _parse_version(value: str) -> List[int]:
return [int(part) for part in value.split(".")] return [int(part) for part in value.split(".")]
def _get_fresh_token(settings: Settings) -> str:
"""Always reload token from file/env to avoid stale cache."""
# Env var takes precedence
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
if env_token:
return env_token
# Fallback to file
token_file = Path(settings.update_token_file)
if token_file.exists():
try:
return token_file.read_text(encoding="utf-8").strip()
except OSError:
pass
return settings.update_token # Fallback to cached value
def check_update(settings: Settings) -> Dict[str, Any]: def check_update(settings: Settings) -> Dict[str, Any]:
headers = {} token = _get_fresh_token(settings)
if settings.update_token: if not token:
headers["Authorization"] = f"Bearer {settings.update_token}" raise ValueError("Client is not enrolled (missing update token)")
headers = {"Authorization": f"Bearer {token}"}
manifest_url = (
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/manifest"
)
with httpx.Client(timeout=10.0) as client: with httpx.Client(timeout=10.0) as client:
response = client.get(settings.update_url, headers=headers) response = client.get(manifest_url, headers=headers)
response.raise_for_status() response.raise_for_status()
manifest = response.json() manifest = response.json()
@ -109,21 +162,88 @@ def check_update(settings: Settings) -> Dict[str, Any]:
} }
def report_status(
settings: Settings,
status: str,
version: str,
error: str | None = None,
duration_ms: int | None = None,
) -> None:
token = _get_fresh_token(settings)
if not token:
return
report_url = (
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/status"
)
payload = {
"project_id": settings.update_project_id,
"version": version,
"status": status,
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"client_id": os.uname().nodename,
}
if error:
payload["error_code"] = error
payload["reason"] = error
if duration_ms is not None:
payload["duration_ms"] = duration_ms
try:
headers = {"Authorization": f"Bearer {token}"}
with httpx.Client(timeout=10.0) as client:
client.post(report_url, json=payload, headers=headers).raise_for_status()
except Exception:
# We don't want to crash if status reporting fails
pass
def _run_async(script_path: Path, settings: Settings) -> None: def _run_async(script_path: Path, settings: Settings) -> None:
env = os.environ.copy() # Explicitly gather the env vars we need to pass
env["SKD_UPDATE_STATUS_FILE"] = settings.update_status_file env_vars = {
env["SKD_UPDATE_LOG_FILE"] = settings.update_log_file "SKD_UPDATE_SERVICE_URL": settings.update_service_url,
subprocess.Popen([str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) "SKD_UPDATE_PROJECT_ID": settings.update_project_id,
"SKD_UPDATE_TOKEN": _get_fresh_token(settings),
"SKD_UPDATE_STATUS_FILE": settings.update_status_file,
"SKD_UPDATE_LOG_FILE": settings.update_log_file,
}
# Construct systemd-run command with --setenv for each variable
cmd = [
"systemd-run",
"--unit=skd-update",
"--collect",
"--description=Safe Kiddo Update Process",
]
for key, val in env_vars.items():
if val: # Only pass if not empty
cmd.append(f"--setenv={key}={val}")
cmd.append(str(script_path))
# We don't pass 'env' parameter to Popen because systemd-run ignores it
# for the target process (it only uses it for itself, but we use --setenv).
subprocess.Popen(
cmd,
cwd="/",
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def start_update(settings: Settings, version: str | None = None) -> None: def start_update(settings: Settings, version: str | None = None) -> None:
_write_status(settings, "in_progress", version or _read_version()) current_version = version or _read_version()
_write_status(settings, "in_progress", current_version)
report_status(settings, "in_progress", current_version)
script = _project_root() / "scripts" / "update_client.sh" script = _project_root() / "scripts" / "update_client.sh"
_run_async(script, settings) _run_async(script, settings)
def start_rollback(settings: Settings) -> None: def start_rollback(settings: Settings) -> None:
_write_status(settings, "in_progress", _read_version()) current_version = _read_version()
_write_status(settings, "in_progress", current_version)
report_status(settings, "in_progress", current_version)
script = _project_root() / "scripts" / "rollback_client.sh" script = _project_root() / "scripts" / "rollback_client.sh"
_run_async(script, settings) _run_async(script, settings)
@ -140,3 +260,45 @@ def get_logs(settings: Settings, limit: int = 200) -> List[Dict[str, Any]]:
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
return entries return entries
def get_service_status(settings: Settings) -> Dict[str, Any]:
base_url = settings.update_service_url.rstrip("/")
if not base_url:
return {
"url": "",
"reachable": False,
"status_code": None,
"error": "update service url not configured",
"checked_url": "",
"environment": "unknown",
}
env = "prod"
lowered = base_url.lower()
if "://dev." in lowered or lowered.startswith("dev."):
env = "dev"
elif "://staging." in lowered or lowered.startswith("staging."):
env = "staging"
check_url = base_url
try:
with httpx.Client(timeout=3.0) as client:
response = client.get(check_url)
return {
"url": base_url,
"reachable": True,
"status_code": response.status_code,
"error": None,
"checked_url": check_url,
"environment": env,
}
except Exception as exc:
return {
"url": base_url,
"reachable": False,
"status_code": None,
"error": str(exc),
"checked_url": check_url,
"environment": env,
}

79
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,79 @@
ID: DOC_000012 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Architektur
## Systemuebersicht
Safe Kiddo Daemon ist ein FastAPI-basierter Service, der lokale Systemkonten verwaltet. Er kombiniert:
- API und Web-UI (FastAPI + Jinja2 Templates)
- Authentifizierung (PAM und optional OIDC)
- Systemaktionen via sudo/usermod/pkill/shutdown
- Update-Client-Integration (externes Update-Service-Backend)
## Externe Abhaengigkeiten
- Update-Service (intern): https://git.wlkns.org/stephan/update-webservice
- OIDC-Service (intern): https://git.wlkns.org/stephan/oicd
## Integrationspunkte
### Update-Service
Die Update-Integration nutzt v1-Endpunkte des Update-Services:
- `POST /v1/enroll` (Enrollment fuer Langzeit-Token)
- `GET /v1/projects/{project_id}/manifest`
- `POST /v1/projects/{project_id}/status`
Der Langzeit-Token wird lokal in `SKD_UPDATE_TOKEN_FILE` gespeichert und fuer Manifest/Status als Bearer-Token verwendet.
### OIDC-Service
OIDC nutzt Discovery unter `/.well-known/openid-configuration` basierend auf `SKD_OIDC_ISSUER`.
Der Login-Flow tauscht einen Code gegen ein ID-Token (RS256) und validiert es gegen JWKS.
## Module und Verantwortlichkeiten
- `backend/app.py`: API-Routing, Web-UI-Endpunkte, Update-Endpunkte.
- `backend/auth.py`: PAM-Login, JWT-Handling, Auth-Guards, Allowlists.
- `backend/oidc.py`: OIDC Discovery, Token-Exchange, JWT-Validierung.
- `backend/actions.py`: Systemaktionen (lock/unlock, notify, sound, shutdown).
- `backend/update.py`: Update-Enrollment, Manifest-Check, Update/Rollback-Start, Status/Logs.
- `backend/settings.py`: Zentrale ENV-Konfiguration.
- `backend/templates/` + `backend/static/`: Web-UI.
- `scripts/*.sh`: Installation, Deployment, Update-Client, Rollback, OIDC-Registration.
## Daten- und Kontrollfluss
### Login und Auth
1. `POST /login` authentifiziert via PAM.
2. JWT wird erstellt und als Cookie oder Bearer-Token genutzt.
3. Schutz aller Admin-Endpunkte via `get_current_admin`.
### OIDC-Flow (optional)
1. `GET /login/oidc/start` generiert State und leitet zum IdP.
2. Callback `GET /login/oidc/callback` validiert State, tauscht Code gegen ID-Token.
3. ID-Token wird gegen JWKS geprueft, Username extrahiert, Session gesetzt.
### Benutzeraktionen
1. `POST /users/{username}/disable` ruft `actions.disable_user`.
2. Systemaktionen: `usermod -L`, optional notify/sound, `pkill`, optional `shutdown`.
3. `POST /users/{username}/enable` fuehrt `usermod -U` aus.
### Update-Flow
1. `POST /update/enroll` schreibt Langzeit-Token in `SKD_UPDATE_TOKEN_FILE`.
2. `POST /update/check` ruft Manifest beim Update-Service ab.
3. `POST /update/apply` startet `scripts/update_client.sh` asynchron.
4. `POST /update/rollback` startet `scripts/rollback_client.sh` asynchron.
5. Status/Logs werden lokal in Dateien geschrieben und optional an den Update-Service gemeldet.
## Designentscheidungen und Tradeoffs
- **Root-Run**: Service laeuft als root, da PAM und Systemkommandos Root erfordern.
- **JWT + Cookie**: Einfache lokale Auth; keine externe Session-Datenbank.
- **OIDC optional**: OIDC ist optional, PAM bleibt als Fallback aktiv.
- **Update als Script**: Update/Backup/Swap via Bash-Skripte fuer einfache Ops, Tradeoff: weniger granularer Fehler-Handling.
## Erweiterungspunkte
- **Auth**: Weitere Auth-Mechanismen koennen in `backend/auth.py` integriert werden.
- **UI**: Templates unter `backend/templates/` und CSS in `backend/static/`.
- **Update-Client**: Anpassung der Update-Strategie in `scripts/update_client.sh`.
- **Notifications/Sound**: Konfigurierbar per `SKD_NOTIFY_SEND_PATH`, `SKD_SOUND_PLAYER`, `SKD_SOUND_FILE`.
## Spezifikationen
- Update-Service OpenAPI: `docs/architecture/openapi.yaml` und `docs/architecture/openapi/`.
## Weitere Dokumente
- Entwicklung: `docs/DEVELOPMENT.md`
- Deployment: `docs/DEPLOYMENT.md`

91
docs/CONFIGURATION.md Normal file
View File

@ -0,0 +1,91 @@
ID: DOC_000011 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Konfiguration
## Speicherort
Die Konfiguration erfolgt per ENV-Dateien:
- `/etc/skd/env` (Core-App, Vorlage: `env.example`)
- `/etc/skd/update.env` (Update-Service fuer Laufzeit, Vorlage: `env.update.example`)
- `update-addon.env` (lokal fuer Upload/Enrollment, Vorlage: `update-addon.env.example`)
## Authentifizierung
- `SKD_AUTH_MODE` (default `pam`): `pam` oder `oidc`. Ungueltige Werte fallen auf `pam` zurueck. Hinweis: Der Wert wird aktuell nicht zur Erzwingung genutzt; OIDC ist aktiv, sobald die OIDC-Variablen gesetzt sind.
- `SKD_AUTH_SECRET` (default `change-me-secret`): HMAC-Secret fuer JWTs.
- `SKD_TOKEN_TTL_SECONDS` (default `900`): Token-Laufzeit in Sekunden.
- `SKD_AUTH_ALLOWED_USERS` (default leer): Kommagetrennte Liste erlaubter Admin-User (gilt fuer PAM und OIDC).
- `SKD_AUTH_ALLOWED_GROUPS` (default `sudo`): Erlaubte Gruppen fuer PAM-Login.
- `SKD_AUTH_PAM_SERVICE` (default `login`, auf Debian/Ubuntu via `install.sh` auf `skd` gesetzt).
## OIDC
OIDC ist optional und zusaetzlich zu PAM.
- `SKD_OIDC_ISSUER`
- `SKD_OIDC_CLIENT_ID`
- `SKD_OIDC_CLIENT_SECRET`
- `SKD_OIDC_REDIRECT_URI` (default `http://localhost:8000/login/oidc/callback`)
- `SKD_OIDC_SCOPES` (default `openid profile email`)
- `SKD_SESSION_COOKIE_SECURE` (default `false`): Setze `true` fuer HTTPS.
- `SKD_OIDC_STATE_COOKIE_NAME` (default `skd_oidc_state`)
Referenz: OIDC-Service (intern) https://git.wlkns.org/stephan/oicd
### OIDC-Einbindung (Kurz)
1. Issuer setzen (muss der externen URL des IdP entsprechen):
- `SKD_OIDC_ISSUER=https://auth.example.org`
2. Client registrieren (DCR), falls der IdP es erlaubt:
```bash
export SKD_OIDC_ISSUER="https://auth.example.org"
export SKD_OIDC_REDIRECT_URI="https://kiddo.example.org/login/oidc/callback"
export OIDC_INITIAL_ACCESS_TOKEN="example-token"
./scripts/register_oidc_client.sh
```
3. Client-Credentials in `/etc/skd/env` setzen:
```
SKD_OIDC_CLIENT_ID=example-client-id
SKD_OIDC_CLIENT_SECRET=example-client-secret
SKD_OIDC_REDIRECT_URI=https://kiddo.example.org/login/oidc/callback
SKD_SESSION_COOKIE_SECURE=true
```
Hinweis: OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind.
## Session/Benutzerverwaltung
- `SKD_SESSION_COOKIE_NAME` (default `skd_session`)
- `SKD_ALLOWED_USERS` (default leer): Optionales Allowlist fuer verwaltbare System-User.
## Aktionen (Countdown/Notify/Sound)
- `SKD_DEFAULT_COUNTDOWN` (default `60` Sekunden)
- `SKD_DEFAULT_SOUND` (default `false`)
- `SKD_NOTIFY_TIMEOUT` (default `5` Sekunden)
- `SKD_NOTIFY_SEND_PATH` (default `notify-send`)
- `SKD_SOUND_PLAYER` (default `paplay`)
- `SKD_SOUND_FILE` (default `/usr/share/sounds/freedesktop/stereo/dialog-warning.oga`)
## Update-Client
- `SKD_UPDATE_SERVICE_URL` (default `https://update.wlkns.org`)
- `SKD_UPDATE_PROJECT_ID` (default `safe-kiddo-control`)
- `SKD_UPDATE_ENROLL_TOKEN` (optional; fuer `/update/enroll`)
- `SKD_UPDATE_TOKEN` (optional; alternativ per Datei)
- `SKD_UPDATE_TOKEN_FILE` (default `/var/lib/skd/update_token`)
- `SKD_UPDATE_STATUS_FILE` (default `/var/lib/skd/update_status.json`)
- `SKD_UPDATE_LOG_FILE` (default `/var/lib/skd/update_logs.jsonl`)
- `SKD_UPDATE_INTERVAL` (default `3600`): Hinweis: wird aktuell nur eingelesen, aber nicht automatisch genutzt.
- `SKD_UPDATE_UPLOAD_TOKEN` (optional; fuer Release-Upload)
- `SKD_UPDATE_UPLOAD_TOKEN_FILE` (optional; z.B. `/etc/skd/update.upload.token`)
## Dry-Run
- `SKD_DRY_RUN` (default `false`): Keine echten System-Aktionen, nur Logging.
## Tuning und Betrieb (Power-User)
- `SKD_TOKEN_TTL_SECONDS`: kuerzere Tokens reduzieren Risiko, laengere Tokens reduzieren Login-Haeufigkeit.
- `SKD_DEFAULT_COUNTDOWN`: steuert Nutzerwarnung vor Sperre/Shutdown.
- `SKD_NOTIFY_TIMEOUT`: Dauer der Desktop-Benachrichtigung.
- `SKD_SESSION_COOKIE_SECURE=true`: zwingend bei HTTPS, sonst Login-Cookies unsicher.
## Hinweise
- `scripts/install.sh` erstellt `/etc/skd/env` und setzt Default-Werte fuer PAM/Allowed-User.
- `scripts/install.sh` erstellt `/etc/skd/update.env` fuer Update-Service Variablen.
- Aenderungen in `/etc/skd/env` erfordern einen Service-Restart (`sudo systemctl restart skd.service`).
## Weitere Dokumente
- Nutzung/Automation: `docs/USAGE.md`
- Deployment: `docs/DEPLOYMENT.md`
- Architektur: `docs/ARCHITECTURE.md`

133
docs/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,133 @@
ID: DOC_000014 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Deployment
## Lokale Installation (systemd)
`./scripts/install.sh` fuehrt folgende Schritte aus:
- legt Service-User/Group an
- kopiert das Projekt nach `/opt/sk`
- erstellt/aktualisiert `.venv`
- erstellt `/etc/skd/env` aus `env.example`
- erstellt `/etc/skd/update.env` aus `env.update.example`
- schreibt eine systemd-Unit nach `/etc/systemd/system/skd.service`
Beispiel:
```bash
make install
sudo systemctl status skd.service
```
## Manuelles Starten
```bash
./scripts/run.sh
```
## Makefile Targets (Ops)
```bash
make install
make up
make down
make restart
make update
make uninstall
```
## Remote-Deploy (SSH)
`./scripts/deploy.sh` packt das Repo und deployt es auf einen Zielhost.
Konfiguration in `deploy_hosts.yml`.
Beispiel:
```bash
./scripts/deploy.sh kid-laptop
```
## Deployment via ZIP
Im Repo liegt `sk_deploy.zip`. Dieses Archiv kann auf den Zielhost kopiert und nach `/opt/sk` entpackt werden.
Anschliessend Abhaengigkeiten installieren und Service neu starten:
```bash
sudo -u skd /opt/sk/.venv/bin/pip install -r /opt/sk/backend/requirements.txt
sudo systemctl restart skd.service
```
## Update des Services
`./scripts/update.sh` zieht den Branch neu und fuehrt einen harten Reset aus.
Wichtig: Das Script nutzt `git reset --hard origin/main`.
```bash
sudo ./scripts/update.sh
```
## Update-Client (Remote Update Service)
Die Update-API startet `scripts/update_client.sh` bzw. `scripts/rollback_client.sh`.
Wichtige ENV-Variablen:
- `SKD_UPDATE_SERVICE_URL`
- `SKD_UPDATE_PROJECT_ID`
- `SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE`
Voraussetzungen auf dem Host:
- `curl`, `tar`, `sha256sum`, `python3`, `systemctl`
Referenz: Update-Service (intern) https://git.wlkns.org/stephan/update-webservice
### Update-Service einbinden
1. Service-URL und Projekt setzen:
```
SKD_UPDATE_SERVICE_URL=https://update.wlkns.org
SKD_UPDATE_PROJECT_ID=safe-kiddo-control
```
2. Enrollment-Token besorgen (vom Update-Service-Admin) und einen Langzeit-Token erzeugen:
- Option A: Ueber lokale API
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"enroll_token\":\"example-enroll-token\"}" \
http://localhost/update/enroll
```
- Option B: Direkter Enrollment-Client
```bash
./scripts/manual_enroll.py --url "https://update.wlkns.org" --project "safe-kiddo-control" --token "example-enroll-token"
```
3. Token-Datei pruefen:
```
sudo cat /var/lib/skd/update_token
```
Hinweis: Der Update-Check ist erst moeglich, wenn der Langzeit-Token gespeichert wurde.
Enrollment-Tools:
- `scripts/manual_enroll.py`: Enrollment direkt gegen den Update-Service, schreibt Token in `SKD_UPDATE_TOKEN_FILE`.
- `scripts/enroll_local.py`: Enrollment ueber die lokale API (`/update/enroll`), benoetigt Admin-Session; `--token` setzen (Default-Token ist nur Prototyp-Altlast).
- `scripts/enroll_update_service.sh`: Holt den Langzeit-Token per curl vom Update-Service (liest `update-addon.env`).
Beispiel (curl-Script):
```bash
sudo ./scripts/enroll_update_service.sh --enroll-token "enroll_example"
```
## Release-Upload (Dev/Prod)
Fuer Dev/Prod Uploads kann ein Release-Archiv (tar.gz) automatisiert gebaut und hochgeladen werden.
Beispiel:
```bash
./scripts/upload_release.sh --profile dev
```
Token-Quelle:
- `update-addon.env` (z.B. `DEV_UPDATE_UPLOAD_TOKEN_FILE=./upload.token`).
## Lokale update-addon.env
Fuer lokale Tests kann eine `update-addon.env` im Repo genutzt werden (gitignored).
Beispiel:
```bash
cp update-addon.env.example update-addon.env
```
Lokale Status/Logs:
- `SKD_UPDATE_STATUS_FILE` (default `/var/lib/skd/update_status.json`)
- `SKD_UPDATE_LOG_FILE` (default `/var/lib/skd/update_logs.jsonl`)
## Backup/Restore
- Bei Apply wird `/opt/sk` nach `/opt/sk_backup_1.2.3_1700000000` verschoben (Beispiel).
- Rollback nutzt das letzte Backup (`/opt/sk_backup_*`).
## Weitere Dokumente
- Konfiguration: `docs/CONFIGURATION.md`
- Nutzung: `docs/USAGE.md`
- Troubleshooting: `docs/TROUBLESHOOTING.md`

87
docs/DEVELOPMENT.md Normal file
View File

@ -0,0 +1,87 @@
ID: DOC_000013 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Development
## Repository-Struktur (Kurz)
- `backend/`: FastAPI-App, Auth, Update-Logik, Templates, Static Assets.
- `scripts/`: Install/Deploy/Update/Helper-Skripte.
- `systemd/`: Beispiel-Unit.
- `docs/architecture/`: OpenAPI-Spezifikation fuer Update-Service.
- `docs/`: Dokumentation.
- `assets/`: Branding und Design.
- `sk.sh`: Legacy-CLI.
## Lokales Setup
```bash
./scripts/create_venv.sh
source .venv/bin/activate
./scripts/run.sh
```
Standard: `0.0.0.0:80`. Fuer andere Ports:
```bash
HOST=127.0.0.1 PORT=8000 ./scripts/run.sh
```
## Release-Upload (Dev/Prod)
Das Update-Artefakt wird als tar.gz gebaut und ueber den Update-Service hochgeladen.
Das Script nutzt `VERSION` und laedt ein vollstaendiges Release (kein Delta).
Vorbereitung:
```bash
cp update-addon.env.example update-addon.env
```
Beispiel (Dev):
```bash
./scripts/upload_release.sh --profile dev
```
## Tests und Lint
Im Repo sind keine automatisierten Tests enthalten. Verfuegbare Checks:
- Bash-Syntax: `bash -n sk.sh`
- ShellCheck: `shellcheck sk.sh`
## Coding Conventions
- Bash 4+, `set -euo pipefail` in neuen Skripten.
- Python: FastAPI-Patterns, klare Modultrennung (Auth, Actions, Update, OIDC).
## Beitrag und Workflow
- Arbeite mit Feature-Branches.
- Aktualisiere `VERSION`, Doku-Header und `CHANGELOG.md` gemaess SOP.
- PRs sollten Verhalten, Risiken und manuelle Tests beschreiben.
## CI/CD
Aktuell keine CI/CD-Pipeline im Repository definiert.
## Legacy/Interna
- `SKD_UPDATE_URL` und `SKD_UPDATE_STATUS_URL` sind in `backend/settings.py` noch vorhanden, werden aber im aktuellen Code nicht genutzt.
## Externe Abhaengigkeiten (mit Quelle und Zweck)
### Python-Libraries (requirements.txt)
- FastAPI: https://fastapi.tiangolo.com/ (Web-API und Routing)
- Uvicorn: https://www.uvicorn.org/ (ASGI-Server)
- Pydantic: https://docs.pydantic.dev/ (Datenmodelle und Validierung)
- Jinja2: https://jinja.palletsprojects.com/ (HTML-Templates)
- PyJWT: https://pyjwt.readthedocs.io/ (JWT-Erstellung und -Validierung)
- python-pam: https://pypi.org/project/python-pam/ (PAM-Authentifizierung)
- httpx: https://www.python-httpx.org/ (HTTP-Client fuer Update/OIDC)
- cryptography: https://cryptography.io/ (Krypto-Abhaengigkeit fuer JWT)
- PyYAML (optional): https://pyyaml.org/ (YAML-Parsing in `scripts/deploy.sh`)
### System-Tools
- systemd: https://www.freedesktop.org/software/systemd/man/systemd.html (Service-Management)
- Linux-PAM: https://www.linux-pam.org/ (System-Authentifizierung)
- usermod/pkill/shutdown: https://man7.org/linux/man-pages/ (Account- und Session-Management)
- curl/tar/sha256sum/rsync/git/ssh: https://man7.org/linux/man-pages/ (Install/Update/Deploy)
- jq (optional): https://stedolan.github.io/jq/ (JSON-Parsing in Beispielen)
- notify-send: https://developer.gnome.org/libnotify/ (Desktop-Benachrichtigungen)
- paplay/aplay: https://www.freedesktop.org/wiki/Software/PulseAudio/ und https://alsa-project.org/ (Sound)
### Externe Services
- Update-Service (intern): https://git.wlkns.org/stephan/update-webservice (Manifest/Status/Enrollment)
- OIDC-Service (intern): https://git.wlkns.org/stephan/oicd (Login via OIDC)
## Weitere Dokumente
- Architektur: `docs/ARCHITECTURE.md`
- Deployment: `docs/DEPLOYMENT.md`

26
docs/FAQ.md Normal file
View File

@ -0,0 +1,26 @@
ID: DOC_000015 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# FAQ
## Braucht der Dienst Root-Rechte?
Ja. PAM-Authentifizierung und Systemkommandos (usermod/pkill/shutdown) erfordern Root.
## Kann ich OIDC ohne PAM nutzen?
OIDC ist optional und zusaetzlich zu PAM. PAM bleibt als Fallback aktiv.
## Wie aendere ich den Port?
- Lokaler Run: `PORT=8000 ./scripts/run.sh`
- Systemd: Unit-Datei in `/etc/systemd/system/skd.service` anpassen und Service neu starten.
## Gibt es einen Docker-Container?
Nein, im Repository ist kein Docker-Setup enthalten.
## Wo liegen Logs?
- systemd: `journalctl -u skd.service`
- Update-Status/Logs: siehe `SKD_UPDATE_STATUS_FILE` und `SKD_UPDATE_LOG_FILE`.
## Weitere Dokumente
- Einstieg: `docs/GETTING_STARTED.md`
- Nutzung: `docs/USAGE.md`
- Troubleshooting: `docs/TROUBLESHOOTING.md`

37
docs/FOR_USERS.md Normal file
View File

@ -0,0 +1,37 @@
ID: DOC_000017 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Fuer Nutzerinnen und Nutzer
## Worum geht es?
Safe Kiddo Daemon hilft dabei, lokale Benutzerkonten auf einem Familien- oder Schulgeraet zu sperren und wieder freizugeben. Ziel ist, klare Nutzungszeiten durchzusetzen und sicherzustellen, dass nach einer Sperrung keine Sitzung offen bleibt.
## Welche Probleme loest es?
- Ein Konto soll zu bestimmten Zeiten nicht nutzbar sein.
- Offene Sitzungen sollen beendet werden, wenn ein Konto gesperrt wird.
- Eltern/Betreuende wollen den Zustand zentral sehen und verwalten.
## Typische Anwendungsfaelle
- Abendliche Nutzungszeit endet, der Account wird gesperrt.
- Bei Verstoessen gegen Regeln wird ein Konto kurzzeitig deaktiviert.
- Eine Sitzung bleibt offen und muss beendet werden.
## Wie wird es bedient?
Die Bedienung erfolgt ueber eine einfache Web-Oberflaeche im lokalen Netzwerk.
Dort kann eine berechtigte Person:
- Konten sperren oder freigeben.
- Den aktuellen Status sehen.
## Grenzen und Sicherheit
- Die Sperrung betrifft nur lokale Konten auf dem Geraet.
- Wenn kein berechtigter Zugang vorhanden ist, kann die Web-Oberflaeche nicht genutzt werden.
- Das System kann den Rechner im Bedarfsfall herunterfahren, um offene Sitzungen zu beenden.
## Was tun, wenn etwas schiefgeht?
- Wenn die Web-Oberflaeche nicht erreichbar ist, die betreuende Person informieren.
- Wenn das Konto unerwartet gesperrt wurde, nicht weiter experimentieren, sondern nachfragen.
- Bei wiederholten Problemen soll der Betreiber die technische Fehlerbehebung pruefen.
## Weitere Informationen (fuer Betreiber)
- Einstieg: `docs/GETTING_STARTED.md`
- Hilfe bei Problemen: `docs/TROUBLESHOOTING.md`

69
docs/GETTING_STARTED.md Normal file
View File

@ -0,0 +1,69 @@
ID: DOC_000009 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Getting Started
## Ziel
Schneller Einstieg fuer neue Nutzer: Installation, erster Login und erste Aktion.
## Voraussetzungen
- Linux-System mit systemd.
- Root-Zugriff (PAM, usermod, shutdown).
- Python 3, curl, tar, sha256sum (fuer Update-Client-Skripte).
- Optional: notify-send (Benachrichtigungen), paplay/aplay (Sound).
## Schnellstart
```bash
# 1) Repo installieren
sudo mkdir -p /opt/sk
sudo git clone ssh://git@git.wlkns.org:2222/stephan/kiddo /opt/sk
# Hinweis: verwende hier die Repo-URL deiner Instanz
cd /opt/sk
# 2) Installation (legt Service-User, env, systemd-Unit an)
make install
# 3) Service pruefen
sudo systemctl status skd.service
# 4) Login (PAM)
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"root","password":"..."}' \
http://localhost/login
```
## Erster API-Test
```bash
token=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"root","password":"example-password"}' \
http://localhost/login | jq -r .token)
curl -s -H "Authorization: Bearer $token" http://localhost/me
```
Hinweis: `jq` ist optional; ohne jq das Token manuell aus der JSON-Antwort lesen.
## Makefile-Kurzbefehle
```bash
make install # Installation (systemd, env, venv)
make up # Service starten
make down # Service stoppen
make restart # Service neu starten
make update # Code-Update (git reset --hard origin/main)
make healthcheck TOKEN=... # Healthcheck mit Bearer-Token
```
## Naechste Schritte
- Konfiguration anpassen: `docs/CONFIGURATION.md`
- API und Web-UI nutzen: `docs/USAGE.md`
- Deployment und Updates: `docs/DEPLOYMENT.md`
## Typische Einsteigerfehler
- Service startet, aber Port 80 ist bereits belegt (loese den Konflikt oder nutze einen anderen Port).
- Login scheitert, weil `SKD_AUTH_ALLOWED_USERS`/`SKD_AUTH_ALLOWED_GROUPS` den Nutzer nicht erlauben.
- OIDC wird erwartet, ist aber nicht aktiv (Issuer/Client-ID/Secret fehlen).
- Token wird nicht gesendet (fehlender `Authorization: Bearer` Header).
## Weitere Dokumente
- Endnutzer-Sicht: `docs/FOR_USERS.md`
- FAQ: `docs/FAQ.md`
- Troubleshooting: `docs/TROUBLESHOOTING.md`

42
docs/TROUBLESHOOTING.md Normal file
View File

@ -0,0 +1,42 @@
ID: DOC_000016 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Troubleshooting
## Service startet nicht
- Status pruefen: `sudo systemctl status skd.service`
- Logs: `sudo journalctl -u skd.service -n 200 --no-pager`
- Port 80 belegt? Test: `sudo ss -ltnp | grep ':80'`
## 401/403 bei API-Aufrufen
- Bearer-Token fehlt oder abgelaufen.
- Nutzer nicht in `SKD_AUTH_ALLOWED_USERS` oder `SKD_AUTH_ALLOWED_GROUPS`.
- `SKD_AUTH_SECRET` geaendert? Tokens muessen neu erzeugt werden.
## OIDC-Login fehlschlaegt
- `SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, `SKD_OIDC_CLIENT_SECRET` gesetzt?
- Redirect-URI exakt registriert?
- Netzwerkzugriff auf Discovery/JWKS moeglich?
## Benachrichtigung/Sound fehlt
- `notify-send` fehlt: `sudo apt install libnotify-bin`
- Sound-Player fehlt: `paplay` oder `aplay` installieren.
- `SKD_NOTIFY_SEND_PATH` oder `SKD_SOUND_PLAYER` falsch gesetzt.
## Update-Check meldet "Client is not enrolled"
- `SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE` fehlt.
- Enrollment ueber `/update/enroll` oder `scripts/manual_enroll.py` durchfuehren.
## Update-Apply fehlschlaegt
- Update-Service nicht erreichbar oder Token ungueltig.
- Prüfe `SKD_UPDATE_STATUS_FILE` und `SKD_UPDATE_LOG_FILE`.
- Hinweis: `update_client.sh` wird asynchron gestartet und stdout/stderr werden verworfen.
## Rollback meldet "no backup found"
- Es existiert kein `/opt/sk_backup_*` vom vorherigen Update.
- Rollback erst nach mindestens einem erfolgreichen Update moeglich.
## Weitere Dokumente
- Einstieg: `docs/GETTING_STARTED.md`
- Konfiguration: `docs/CONFIGURATION.md`
- Deployment: `docs/DEPLOYMENT.md`

103
docs/USAGE.md Normal file
View File

@ -0,0 +1,103 @@
ID: DOC_000010 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# Usage
## Authentifizierung
- PAM-Login: `POST /login` mit Benutzername/Passwort. Liefert JWT und setzt Session-Cookie.
- OIDC-Login: `GET /login/oidc/start` startet Flow, Callback setzt Session-Cookie.
- Alle geschuetzten Endpunkte akzeptieren `Authorization: Bearer $TOKEN` oder Session-Cookie.
### Beispiel: Login und Token nutzen
```bash
token=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"root","password":"example-password"}' \
http://localhost/login | jq -r .token)
curl -s -H "Authorization: Bearer $token" http://localhost/me
```
Hinweis: `jq` ist optional; ohne jq das Token manuell aus der JSON-Antwort lesen.
Login ist nur fuer erlaubte Nutzer moeglich (siehe `SKD_AUTH_ALLOWED_USERS` und `SKD_AUTH_ALLOWED_GROUPS`).
## Web-UI
- Aufruf: `http://localhost/`
- Login per PAM oder OIDC (wenn konfiguriert).
- Aktionen: Benutzer sperren/entsperren, Update-Status, Update-Check, Apply/Rollback.
## Automatisierung (Power-User)
Die API kann in Skripten oder Zeitplaenen genutzt werden, z.B. fuer regelmaessige Sperrungen.
Beispiel (cron, taeglich 21:00 sperren):
```bash
0 21 * * * curl -s -X POST -H "Authorization: Bearer $TOKEN" http://localhost/users/child1/disable
```
Hinweis: Token sicher speichern (z.B. Root-Only Datei) und regelmaessig rotieren.
## API-Endpunkte (Auszug)
### Health und Identitaet
- `GET /health` (ohne Auth)
- `GET /me`
### Benutzerverwaltung
- `GET /users`
- `POST /users/{username}/disable` mit JSON `{countdown?, sound?, message?}`
- `POST /users/{username}/enable`
Beispiel (disable):
```bash
curl -X POST -H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d '{"countdown":90,"sound":true,"message":"Bitte speichern"}' \
http://localhost/users/child1/disable
```
### Update-API (lokal)
Alle Update-Endpunkte erfordern Admin-Auth.
- `GET /update/status`
- `GET /update/service-status` (zeigt URL, Erreichbarkeit und Dev/Prod-Umgebung)
- `POST /update/enroll` (optional Body: `{ "enroll_token": "..." }`)
- `POST /update/check`
- `POST /update/apply` (optional Body: `{ "version": "x.y.z" }`)
- `POST /update/rollback`
- `GET /update/logs?limit=200`
### System-API (lokal)
- `GET /system/metrics` (CPU %, RAM, GPU VRAM, Netzwerk Mbps)
Beispiel (Enrollment):
```bash
ENROLL_TOKEN="example-enroll-token"
curl -X POST -H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "{\"enroll_token\":\"${ENROLL_TOKEN}\"}" \
http://localhost/update/enroll
```
Beispiel (Update-Check):
```bash
curl -X POST -H "Authorization: Bearer $token" http://localhost/update/check
```
Hinweis: Ohne gespeichertes Update-Token liefert der Check einen Fehler.
## Healthcheck per Makefile
```bash
make healthcheck TOKEN="$token"
```
## Weitere Dokumente
- Konfiguration: `docs/CONFIGURATION.md`
- Deployment: `docs/DEPLOYMENT.md`
- Troubleshooting: `docs/TROUBLESHOOTING.md`
## CLI-Fallback (sk.sh)
Das Legacy-Script arbeitet direkt auf dem Host und benoetigt Root-Rechte.
Aufruf:
```bash
sudo ./sk.sh USERNAME disable|enable [countdown] [sound] [countdown_time_in_seconds]
```
Beispiel:
```bash
sudo ./sk.sh demo_user disable countdown sound 90
```

View File

@ -0,0 +1,93 @@
ID: DOC_000006 | Version: 0.2.3 | Status: Draft
Archived – superseded by new documentation.
By: Codex (GPT-5)
# Admin Token Operations
## Purpose
This document describes how operators create and manage pre-shared enrollment tokens for clients.
## Pre-Shared Token Creation
Operators generate a single-use enrollment token and share it out-of-band with the client.
Recommended properties:
- Single-use only
- Short TTL (e.g., 24h)
- Scoped to `project_id` and optional `client_id`/`software_id`
## Admin Interfaces
We provide both an Admin API and a CLI tool for token operations. A frontend will be added later.
### Admin User and Access
- An admin user must exist to operate token workflows.
- Initial access uses a local admin token.
- Later, admin auth will be integrated with the OIDC service.
### CLI and Admin API Capabilities
- Create enrollment tokens
- List token metadata (no plaintext output)
- Revoke tokens
- Export a token as a file for client installation
### Local Admin Token (Initial Phase)
- Admin requests must include `Authorization: Bearer <ADMIN_TOKEN>`.
- The admin token is stored locally (e.g., `.env`) and never committed.
Example `.env` (local only):
```
ADMIN_TOKEN=change-me-please
```
Minimal flow (first token):
1) Set `ADMIN_TOKEN` in `.env`.
2) Call `POST /v1/admin/enrollment-tokens` with the bearer token.
3) Export the returned one-time token to a file and hand it to the client.
## Admin API (Draft)
All admin endpoints are authenticated. Initial auth is local; later OIDC.
Base path:
- `/v1/admin`
Endpoints:
- `POST /v1/admin/enrollment-tokens`
- Create a pre-shared enrollment token.
- Request: `project_id`, optional `client_id`, optional `software_id`, optional `expires_at`.
- Response: token metadata + one-time plaintext token.
- `GET /v1/admin/enrollment-tokens`
- List token metadata (never return plaintext tokens).
- Supports filtering by `project_id`, `client_id`, `status` (active/used/expired).
- `POST /v1/admin/enrollment-tokens/{token_id}/revoke`
- Revoke a token (marks as revoked or sets `used_at`/`revoked_at`).
- `GET /v1/admin/enrollment-tokens/{token_id}/export`
- Export the one-time token to a file download (single use).
## CLI (Draft)
Example commands (names can be adjusted):
- `update-service admin token create --project <id> [--client <id>] [--software <id>] [--expires <iso8601>]`
- `update-service admin token list --project <id> [--status active|used|expired|revoked]`
- `update-service admin token revoke --id <token_id>`
- `update-service admin token export --id <token_id> --out ./enroll-token.txt`
Example format:
```
enroll_<random_32_bytes>
```
## Storage and Safety
- Store only a hash of the enrollment token (never plaintext).
- Track `created_at`, `expires_at`, and `used_at`.
- Deny enrollment if `expires_at` is exceeded or `used_at` is set.
## Rotation and Revocation
- Revoke enrollment tokens by invalidating their stored hash.
- Issue a new enrollment token if the previous one expires or is leaked.
## Distribution
Preferred channels:
- One-time install code (copy/paste)
- QR code
- Encrypted file included in an install bundle
## Audit Expectations
- Log token creation and enrollment usage for traceability.

View File

@ -0,0 +1,45 @@
ID: DOC_000008 | Version: 0.2.3 | Status: Draft
Archived – superseded by new documentation.
# Client Quickstart
## Goal
Enroll a client, store the long-term token, fetch the manifest, and report status.
## 1) Get a Pre-Shared Token
Request a one-time enrollment token from an admin/operator.
## 2) Enroll and Receive Long-Term Token
```
curl -X POST https://update.wlkns.org/v1/enroll \
-H "Content-Type: application/json" \
-d '{
"project_id": "safe-kiddo-control",
"client_id": "kiddo-001",
"software_id": "kiddo-agent",
"enroll_token": "<pre_shared_token>"
}'
```
Store the returned token locally (file or secret store). Example:
```
echo "<long_term_token>" > ./update-token.txt
```
## 3) Fetch Manifest
```
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
https://update.wlkns.org/v1/projects/safe-kiddo-control/manifest
```
## 4) Report Status
```
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
-H "Content-Type: application/json" \
-d '{"project_id":"safe-kiddo-control","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
https://update.wlkns.org/v1/projects/safe-kiddo-control/status
```
## Notes
- All endpoints require `Authorization: Bearer <token>` except `/v1/enroll`.
- Status values: `success`, `failed`, `in_progress`.

View File

@ -1,4 +1,5 @@
ID: DOC_000003 | Version: 0.1.0 | Status: Draft ID: DOC_000003 | Version: 0.2.3 | Status: Draft
Archived – superseded by new documentation.
By: Codex (GPT-5) By: Codex (GPT-5)
# OIDC End-to-End Validation (Kiddo) # OIDC End-to-End Validation (Kiddo)

View File

@ -0,0 +1,137 @@
ID: DOC_000005 | Version: 0.2.3 | Status: Draft
Archived – superseded by new documentation.
By: Codex (GPT-5)
# Third-Party API Guide
## Purpose
This document explains how third-party services integrate with the Update Webservice: obtaining tokens, fetching manifests, downloading artifacts, and reporting status.
## Quick Start (First Client)
1) Request a pre-shared enrollment token from an admin/operator.
2) Enroll once to obtain a long-term token.
3) Store the long-term token locally and use it for all API calls.
## Base URLs
- Production: `https://update.wlkns.org`
- Staging: `https://staging.update.wlkns.org`
All endpoints are versioned under `/v1`.
## Authentication
All endpoints require `Authorization: Bearer <token>`.
### Enrollment (Pre-Shared Token -> Long-Term Token)
Clients obtain a long-term token by exchanging a pre-shared token provided by an admin/operator.
Request (example):
```
POST /v1/enroll
{
"project_id": "<project>",
"client_id": "<client>",
"software_id": "<software>",
"enroll_token": "<pre_shared_token>"
}
```
Response (example):
```
200 OK
{
"token": "<long_term_token>",
"scope": "read_manifest report_status",
"expires_at": "<iso8601 or null>"
}
```
Notes:
- Enrollment tokens are single-use and must be invalidated after a successful exchange.
- If the token is invalid or reused, the server responds with `unauthorized` or `invalid_payload`.
- Enrollment does not require an existing bearer token.
- If the client is already enrolled, the server responds with `already_enrolled` (HTTP 409).
## Client API (Read + Report)
### Get Manifest
```
GET /v1/projects/{project_id}/manifest
```
Response:
```
{
"version": "0.1.2",
"artifact_url": "https://update.wlkns.org/v1/projects/<project_id>/releases/0.1.2/artifact",
"sha256": "<hex>",
"sig_url": "<optional>"
}
```
Required scope: `read_manifest`
### Download Artifact
```
GET /v1/projects/{project_id}/releases/{version}/artifact
```
Required scope: `read_manifest`
### Report Status
```
POST /v1/projects/{project_id}/status
{
"project_id": "<project_id>",
"version": "<semver>",
"status": "success|failed|in_progress",
"timestamp": "<iso8601>",
"client_id": "<optional>",
"duration_ms": "<optional>",
"error_code": "<optional>"
}
```
Required scope: `report_status`
## Release API (Upload)
### Upload Release
```
POST /v1/projects/{project_id}/releases
Content-Type: multipart/form-data
```
Required scope: `upload_release`
Required fields:
- `version` (SemVer)
- `artifact` (file)
- `sha256` (hex)
Optional fields:
- `sig_url` or inline signature
- `key_id`
## Error Codes
Common error codes:
`unauthorized`, `rate_limited`, `not_found`, `invalid_payload`, `version_invalid`,
`version_exists`, `checksum_mismatch`, `signature_missing`, `signature_invalid`,
`payload_too_large`, `status_invalid`
## Rate Limits
Limits are tiered by scope. See `docs/architecture/openapi/paths/limits.yaml` for current values.
## Examples
Fetch manifest:
```
curl -H "Authorization: Bearer $TOKEN" \
https://update.wlkns.org/v1/projects/$PROJECT_ID/manifest
```
Report status:
```
curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
https://update.wlkns.org/v1/projects/$PROJECT_ID/status
```

View File

@ -1,4 +1,5 @@
ID: DOC_000006 | Version: 0.1.0 | Status: Draft ID: DOC_000006 | Version: 0.2.3 | Status: Draft
Archived – superseded by new documentation.
By: Codex (GPT-5) By: Codex (GPT-5)
# Update API (Kiddo Backend) # Update API (Kiddo Backend)
@ -8,6 +9,16 @@ Definiert interne API-Endpunkte fuer Update-Status, Check, Apply, Rollback und L
## Endpoints ## Endpoints
### POST /update/enroll
Body (optional):
```json
{ "enroll_token": "<one-time-token>" }
```
Antwort:
```json
{ "enrolled": true, "message": "Enrollment successful" }
```
### GET /update/status ### GET /update/status
Antwort: Antwort:
```json ```json
@ -15,7 +26,8 @@ Antwort:
"current_version": "0.1.2", "current_version": "0.1.2",
"last_status": "success|failed|unknown", "last_status": "success|failed|unknown",
"last_error": "<optional>", "last_error": "<optional>",
"last_timestamp": "2025-12-28T12:34:56Z" "last_timestamp": "2025-12-28T12:34:56Z",
"enrolled": true
} }
``` ```
@ -51,10 +63,18 @@ Antwort:
Antwort: Antwort:
```json ```json
[ [
{"timestamp":"2025-12-28T12:34:56Z","status":"success","message":"updated to 0.1.2"} {
"timestamp": "2025-12-28T12:34:56Z",
"status": "success",
"message": "updated to 0.1.2",
"version": "0.1.2",
"device_id": "kiddo-001",
"error": null
}
] ]
``` ```
## Notes ## Notes
- Alle Endpunkte erfordern Auth (Session/Bearer). - Alle Endpunkte erfordern Auth (Session-Cookie oder `Authorization: Bearer <token>`).
- Apply/Rollback starten async; UI pollt /update/status. - Apply/Rollback starten async; UI pollt /update/status.
- `/update/enroll` speichert das Update-Token lokal (siehe `SKD_UPDATE_TOKEN_FILE`).

View File

@ -1,4 +1,5 @@
ID: DOC_000004 | Version: 0.1.0 | Status: Draft ID: DOC_000004 | Version: 0.2.3 | Status: Draft
Archived – superseded by new documentation.
By: Codex (GPT-5) By: Codex (GPT-5)
# Client Update Flow (Kiddo) # Client Update Flow (Kiddo)
@ -18,7 +19,8 @@ Beispiel:
``` ```
## Flow (High Level) ## Flow (High Level)
1. Manifest abrufen (auth optional via Bearer Token). 0. Falls kein Token vorhanden ist: Enrollment durchfuehren (Pre-Shared Token -> Langzeit-Token).
1. Manifest abrufen (auth via Bearer Token).
2. `artifact_url` herunterladen. 2. `artifact_url` herunterladen.
3. SHA256 pruefen (Signatur optional). 3. SHA256 pruefen (Signatur optional).
4. In Staging-Verzeichnis entpacken. 4. In Staging-Verzeichnis entpacken.
@ -37,13 +39,14 @@ Beispiel:
## Security Notes ## Security Notes
- Artefakte muessen checksum-verifiziert sein. - Artefakte muessen checksum-verifiziert sein.
- Token-Handling ueber `SKD_UPDATE_TOKEN`. - Token-Handling ueber `SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE`.
- Enrollment nutzt einen Pre-Shared Token und speichert das Langzeit-Token lokal.
## Constraints ## Constraints
- Update-Service ist extern (update.wlkns.org). - Update-Service ist extern (update.wlkns.org).
- Service muss als root stoppen/starten koennen. - Service muss als root stoppen/starten koennen.
## Status Reporting ## Status Reporting
- Status wird per HTTP POST an `https://update.wlkns.org/status` gemeldet. - Status wird per HTTP POST an `${SKD_UPDATE_SERVICE_URL}/v1/projects/${SKD_UPDATE_PROJECT_ID}/status` gemeldet.
- Schema siehe `docs/update-status.md`. - Schema siehe `docs/update-status.md`.
- Lokaler Status/Logs liegen unter `/var/lib/skd` (konfigurierbar via ENV). - Lokaler Status/Logs liegen unter `/var/lib/skd` (konfigurierbar via ENV).

View File

@ -0,0 +1,30 @@
ID: DOC_000005 | Version: 0.2.3 | Status: Draft
Archived – superseded by new documentation.
By: Codex (GPT-5)
# Update Status Reporting
## Purpose
Definiert das Status-Schema fuer Update-Resultate und den Uebertragungsweg.
## Status Schema (JSON)
```json
{
"project_id": "safe-kiddo-control",
"client_id": "<hostname>",
"version": "0.2.1",
"status": "success|failed|in_progress",
"timestamp": "2025-12-28T12:34:56Z",
"duration_ms": 1234,
"error_code": "<optional>",
"reason": "<optional>"
}
```
## Transport
- HTTP POST an `${SKD_UPDATE_SERVICE_URL}/v1/projects/${SKD_UPDATE_PROJECT_ID}/status`
- Auth: Bearer Token (`SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE`)
## Notes
- Statusmeldungen sind best-effort; Fehler beim Senden blockieren kein Update.
- Reporting wird nur gesendet, wenn ein Update-Token vorhanden ist.

View File

@ -0,0 +1,57 @@
openapi: 3.0.3
info:
title: Update Webservice API
version: 0.2.1
servers:
- url: https://update.wlkns.org
- url: https://staging.update.wlkns.org
security:
- bearerAuth: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Manifest:
$ref: './openapi/schemas/manifest.yaml'
StatusReport:
$ref: './openapi/schemas/status-report.yaml'
UploadResponse:
$ref: './openapi/schemas/upload-response.yaml'
EnrollRequest:
$ref: './openapi/schemas/enroll-request.yaml'
EnrollResponse:
$ref: './openapi/schemas/enroll-response.yaml'
EnrollmentToken:
$ref: './openapi/schemas/enrollment-token.yaml'
EnrollmentTokenCreateRequest:
$ref: './openapi/schemas/enrollment-token-create-request.yaml'
EnrollmentTokenCreateResponse:
$ref: './openapi/schemas/enrollment-token-create-response.yaml'
Error:
$ref: './openapi/schemas/error.yaml'
Limits:
$ref: './openapi/schemas/limits.yaml'
LimitsPolicy:
$ref: './openapi/schemas/limits-policy.yaml'
paths:
/v1/enroll:
$ref: './openapi/paths/enroll.yaml'
/v1/admin/enrollment-tokens:
$ref: './openapi/paths/admin-enrollment-tokens.yaml'
/v1/admin/enrollment-tokens/{token_id}/revoke:
$ref: './openapi/paths/admin-enrollment-tokens-revoke.yaml'
/v1/admin/enrollment-tokens/{token_id}/export:
$ref: './openapi/paths/admin-enrollment-tokens-export.yaml'
/v1/projects/{project_id}/manifest:
$ref: './openapi/paths/manifest.yaml'
/v1/projects/{project_id}/releases/{version}/artifact:
$ref: './openapi/paths/artifact.yaml'
/v1/projects/{project_id}/status:
$ref: './openapi/paths/status.yaml'
/v1/projects/{project_id}/releases:
$ref: './openapi/paths/releases.yaml'
/v1/limits:
$ref: './openapi/paths/limits.yaml'

View File

@ -0,0 +1,31 @@
get:
summary: Export enrollment token
x-auth-scopes: [admin]
parameters:
- name: token_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Token file
content:
text/plain:
schema:
type: string
example: enroll_6f3d2c...
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,30 @@
post:
summary: Revoke enrollment token
x-auth-scopes: [admin]
parameters:
- name: token_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Revoked
content:
application/json:
schema:
$ref: '../schemas/enrollment-token.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,69 @@
get:
summary: List enrollment tokens
x-auth-scopes: [admin]
parameters:
- name: project_id
in: query
required: false
schema:
type: string
- name: client_id
in: query
required: false
schema:
type: string
- name: status
in: query
required: false
schema:
type: string
enum: [active, used, expired, revoked]
responses:
'200':
description: Token list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '../schemas/enrollment-token.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
post:
summary: Create enrollment token
x-auth-scopes: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/enrollment-token-create-request.yaml'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '../schemas/enrollment-token-create-response.yaml'
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,58 @@
get:
summary: Download artifact
x-auth-scopes: [read_manifest]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: version
in: path
required: true
schema:
type: string
responses:
'200':
description: Artifact tar.gz
content:
application/gzip:
schema:
type: string
format: binary
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
not_found:
value:
code: not_found
message: Artifact not found
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,65 @@
post:
summary: Enroll client and issue long-term token
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/enroll-request.yaml'
examples:
enroll:
value:
project_id: demo
client_id: device-42
software_id: kiosk
enroll_token: enroll_6f3d2c...
responses:
'200':
description: Enrollment successful
content:
application/json:
schema:
$ref: '../schemas/enroll-response.yaml'
examples:
issued:
value:
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
scope: read_manifest report_status
expires_at: 2026-12-30T10:00:00Z
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Invalid or expired enrollment token
'409':
description: Conflict
x-error-codes: [already_enrolled]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
already_enrolled:
value:
code: already_enrolled
message: Client already enrolled

View File

@ -0,0 +1,48 @@
get:
summary: Get service limits
x-auth-scopes: [read_manifest]
responses:
'200':
description: Limits
content:
application/json:
schema:
$ref: '../schemas/limits-policy.yaml'
examples:
medium:
value:
tier: medium
limits:
upload_max_artifact_size_bytes_soft: 1073741824
upload_max_artifact_size_bytes_hard: 2147483648
read_max_requests_per_minute_soft: 300
read_max_requests_per_minute_hard: 600
upload_max_requests_per_minute_soft: 6
upload_max_requests_per_minute_hard: 12
report_max_requests_per_minute_soft: 120
report_max_requests_per_minute_hard: 240
burst_requests_per_minute: 1200
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,47 @@
get:
summary: Get active manifest
x-auth-scopes: [read_manifest]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Manifest
content:
application/json:
schema:
$ref: '../schemas/manifest.yaml'
examples:
default:
value:
version: 1.2.3
artifact_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,126 @@
post:
summary: Upload release
x-auth-scopes: [upload_release]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- version
- sha256
- artifact
properties:
version:
type: string
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
example: 1.2.3
sha256:
type: string
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url:
type: string
format: uri
description: Optional reference to a detached signature
signature:
type: string
format: binary
description: Detached signature file (optional alternative to sig_url)
key_id:
type: string
description: Public key identifier for signature verification
artifact:
type: string
format: binary
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '../schemas/upload-response.yaml'
examples:
created:
value:
version: 1.2.3
manifest_url: https://update.wlkns.org/v1/projects/demo/manifest
active: true
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'409':
description: Conflict
x-error-codes: [version_exists]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
version_exists:
value:
code: version_exists
message: Version already exists
'413':
description: Payload Too Large
x-error-codes: [payload_too_large]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
payload_too_large:
value:
code: payload_too_large
message: Artifact exceeds size limit
'422':
description: Unprocessable Entity (invalid checksum/signature/version)
x-error-codes: [checksum_mismatch, signature_invalid, signature_missing, version_invalid]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
checksum_mismatch:
value:
code: checksum_mismatch
message: SHA256 does not match artifact
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,84 @@
post:
summary: Report update status
x-auth-scopes: [report_status]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/status-report.yaml'
examples:
success:
value:
project_id: demo
version: 1.2.3
status: success
timestamp: 2025-12-28T10:15:30Z
client_id: device-42
duration_ms: 2450
failure:
value:
project_id: demo
version: 1.2.3
status: failed
timestamp: 2025-12-28T10:15:30Z
client_id: device-42
reason: checksum_mismatch
error_code: checksum_mismatch
responses:
'202':
description: Accepted
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'422':
description: Unprocessable Entity (invalid version or status)
x-error-codes: [version_invalid, status_invalid]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
version_invalid:
value:
code: version_invalid
message: Version does not match SemVer
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,20 @@
type: object
required:
- project_id
- client_id
- software_id
- enroll_token
properties:
project_id:
type: string
example: demo
client_id:
type: string
example: device-42
software_id:
type: string
example: kiosk
enroll_token:
type: string
description: Pre-shared, single-use enrollment token
example: enroll_6f3d2c...

View File

@ -0,0 +1,19 @@
type: object
required:
- token
- scope
properties:
token:
type: string
description: Long-term bearer token for client requests
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
scope:
type: string
description: Space-delimited scopes
example: read_manifest report_status
expires_at:
type: string
format: date-time
nullable: true
description: Null for non-expiring tokens
example: 2026-12-30T10:00:00Z

View File

@ -0,0 +1,20 @@
type: object
required:
- project_id
properties:
project_id:
type: string
example: demo
client_id:
type: string
nullable: true
example: device-42
software_id:
type: string
nullable: true
example: kiosk
expires_at:
type: string
format: date-time
nullable: true
example: 2026-12-30T10:00:00Z

View File

@ -0,0 +1,11 @@
type: object
required:
- token
- token_meta
properties:
token:
type: string
description: One-time plaintext enrollment token
example: enroll_6f3d2c...
token_meta:
$ref: './enrollment-token.yaml'

View File

@ -0,0 +1,39 @@
type: object
required:
- id
- project_id
- status
- created_at
properties:
id:
type: string
example: tok_123
project_id:
type: string
example: demo
client_id:
type: string
nullable: true
example: device-42
software_id:
type: string
nullable: true
example: kiosk
status:
type: string
enum: [active, used, expired, revoked]
example: active
expires_at:
type: string
format: date-time
nullable: true
example: 2026-12-30T10:00:00Z
created_at:
type: string
format: date-time
example: 2025-12-30T10:00:00Z
used_at:
type: string
format: date-time
nullable: true
example: 2025-12-30T10:15:00Z

View File

@ -0,0 +1,15 @@
type: object
required:
- code
- message
properties:
code:
type: string
description: Error code (e.g., unauthorized, invalid_payload, already_enrolled)
example: unauthorized
message:
type: string
example: Missing or invalid token
details:
type: object
additionalProperties: true

View File

@ -0,0 +1,10 @@
type: object
required:
- tier
- limits
properties:
tier:
type: string
enum: [small, medium, large]
limits:
$ref: './limits.yaml'

View File

@ -0,0 +1,39 @@
type: object
required:
- upload_max_artifact_size_bytes_soft
- upload_max_artifact_size_bytes_hard
- read_max_requests_per_minute_soft
- read_max_requests_per_minute_hard
- upload_max_requests_per_minute_soft
- upload_max_requests_per_minute_hard
- report_max_requests_per_minute_soft
- report_max_requests_per_minute_hard
- burst_requests_per_minute
properties:
upload_max_artifact_size_bytes_soft:
type: integer
default: 1073741824
upload_max_artifact_size_bytes_hard:
type: integer
default: 2147483648
read_max_requests_per_minute_soft:
type: integer
default: 300
read_max_requests_per_minute_hard:
type: integer
default: 600
upload_max_requests_per_minute_soft:
type: integer
default: 6
upload_max_requests_per_minute_hard:
type: integer
default: 12
report_max_requests_per_minute_soft:
type: integer
default: 120
report_max_requests_per_minute_hard:
type: integer
default: 240
burst_requests_per_minute:
type: integer
default: 1200

View File

@ -0,0 +1,24 @@
type: object
required:
- version
- artifact_url
- sha256
properties:
version:
type: string
description: SemVer string (e.g., 1.2.3)
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
example: 1.2.3
artifact_url:
type: string
format: uri
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
sha256:
type: string
description: Hex-encoded SHA256
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url:
type: string
format: uri
nullable: true
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature

View File

@ -0,0 +1,42 @@
type: object
required:
- project_id
- version
- status
- timestamp
properties:
project_id:
type: string
example: demo
version:
type: string
example: 1.2.3
status:
type: string
enum: [success, failed, in_progress]
example: success
timestamp:
type: string
format: date-time
example: 2025-12-28T10:15:30Z
reason:
type: string
example: checksum_mismatch
client_id:
type: string
example: device-42
client_version:
type: string
example: 1.2.2
device_type:
type: string
example: kiosk
update_channel:
type: string
example: stable
duration_ms:
type: integer
example: 2450
error_code:
type: string
example: checksum_mismatch

View File

@ -0,0 +1,16 @@
type: object
required:
- version
- manifest_url
properties:
version:
type: string
example: 1.2.3
manifest_url:
type: string
format: uri
example: https://update.wlkns.org/v1/projects/demo/manifest
active:
type: boolean
description: True if release is active
example: true

View File

@ -1,25 +0,0 @@
ID: DOC_000005 | Version: 0.1.0 | Status: Draft
By: Codex (GPT-5)
# Update Status Reporting
## Purpose
Definiert das Status-Schema fuer Update-Resultate und den Uebertragungsweg.
## Status Schema (JSON)
```json
{
"device_id": "<hostname>",
"version": "0.1.2",
"status": "success|failed",
"error": "<optional message>",
"timestamp": "2025-12-28T12:34:56Z"
}
```
## Transport
- HTTP POST an `https://update.wlkns.org/status`
- Auth: Bearer Token (`SKD_UPDATE_TOKEN`)
## Notes
- Statusmeldungen sind best-effort; Fehler beim Senden blockieren kein Update.

View File

@ -21,12 +21,5 @@ SKD_OIDC_STATE_COOKIE_NAME=skd_oidc_state
SKD_DEFAULT_COUNTDOWN=60 SKD_DEFAULT_COUNTDOWN=60
SKD_DEFAULT_SOUND=false SKD_DEFAULT_SOUND=false
SKD_NOTIFY_TIMEOUT=5 SKD_NOTIFY_TIMEOUT=5
# Update client configuration
SKD_UPDATE_URL=https://update.wlkns.org
SKD_UPDATE_TOKEN=
SKD_UPDATE_INTERVAL=3600
SKD_UPDATE_STATUS_URL=https://update.wlkns.org/status
SKD_UPDATE_STATUS_FILE=/var/lib/skd/update_status.json
SKD_UPDATE_LOG_FILE=/var/lib/skd/update_logs.jsonl
# Set to true to test without performing real system changes # Set to true to test without performing real system changes
SKD_DRY_RUN=false SKD_DRY_RUN=false

11
env.update.example Normal file
View File

@ -0,0 +1,11 @@
# Copy to /etc/skd/update.env for update-service configuration
SKD_UPDATE_SERVICE_URL=https://update.wlkns.org
SKD_UPDATE_PROJECT_ID=safe-kiddo-control
SKD_UPDATE_ENROLL_TOKEN=
SKD_UPDATE_TOKEN=
SKD_UPDATE_TOKEN_FILE=/var/lib/skd/update_token
SKD_UPDATE_INTERVAL=3600
SKD_UPDATE_STATUS_FILE=/var/lib/skd/update_status.json
SKD_UPDATE_LOG_FILE=/var/lib/skd/update_logs.jsonl
SKD_UPDATE_UPLOAD_TOKEN=
SKD_UPDATE_UPLOAD_TOKEN_FILE=/etc/skd/update.upload.token

View File

@ -1,4 +1,4 @@
ID: AGENTS_000001 | Version: 0.1.0 | Status: Draft ID: AGENTS_000001 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5) By: Codex (GPT-5)
# Repository Guidelines # Repository Guidelines

View File

@ -1,4 +1,4 @@
ID: STATUS_000001 | Version: 0.1.0 | Status: Final ID: STATUS_000001 | Version: 0.3.6 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# Projekt-Status # Projekt-Status
@ -10,14 +10,21 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
✅ Stabilization ✅ Stabilization
## Aktueller Fokus ## Aktueller Fokus
1. Client-Update-Mechanik planen (EPIC_000008). 1. OIDC-Validierung abschliessen (EPIC_000003 / US_000025).
2. Dokumentierter Ist-Zustand der Module. 2. Client-Update-Mechanik pflegen (EPIC_000008).
3. Pflege der Anforderungen bei neuen Features. 3. Anforderungen bei neuen Features sauber dokumentieren.
## Projekt-Tagebuch (Kurz, optional) ## Projekt-Tagebuch (Kurz, optional)
| Datum | Typ | Beschreibung | | Datum | Typ | Beschreibung |
|---|---|---| |---|---|---|
| 28.12.2025 | 🏗️ Planning | Anforderungen als Epics und Stories dokumentiert. | | 28.12.2025 | 🏗️ Planning | Anforderungen als Epics und Stories dokumentiert. |
| 15.01.2026 | 📝 Req | Doku und ENV-Beispiele an Code-Stand angepasst. |
| 15.01.2026 | 📝 Req | Doku-Overhaul mit neuer Struktur und Archivierung. |
| 15.01.2026 | 📝 Req | Externe Service-Links in der Doku ergaenzt. |
| 15.01.2026 | 📝 Req | Makefile als Einstieg in Doku aufgenommen. |
| 15.01.2026 | ⚙️ Code | Makefile restart-Target hinzugefuegt. |
| 16.01.2026 | ✨ Feat | Login-Zeitfenster (Rules & Scheduler) implementiert (US_000045). |
| 16.01.2026 | 🐞 Fix | Update-Prozess entkoppelt (systemd-run), damit er beim Service-Stop weiterlaeuft. |
## Epic-Backlog (Uebersicht) ## Epic-Backlog (Uebersicht)
### EPIC_000001: Legacy CLI Account Control (sk.sh) ### EPIC_000001: Legacy CLI Account Control (sk.sh)
@ -54,8 +61,8 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
- [x] TASK_000010: Serve UI template - [x] TASK_000010: Serve UI template
- [x] US_000022: Web-UI Aktionen ausfuehren - [x] US_000022: Web-UI Aktionen ausfuehren
- [x] TASK_000022: UI login and actions - [x] TASK_000022: UI login and actions
- [ ] US_000024: Watchtower Theme fuer Web-UI (zurueckgestellt) - [x] US_000024: Watchtower Theme fuer Web-UI
- [ ] TASK_000024: Apply Watchtower theme (zurueckgestellt) - [x] TASK_000024: Apply Watchtower theme
### EPIC_000005: Automation Scripts ### EPIC_000005: Automation Scripts
- [x] US_000011: Virtualenv und Abhaengigkeiten erstellen - [x] US_000011: Virtualenv und Abhaengigkeiten erstellen
@ -102,11 +109,61 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
- [x] TASK_000033: UI update logs view - [x] TASK_000033: UI update logs view
- [x] US_000033: Rollback im Web-UI anstossen - [x] US_000033: Rollback im Web-UI anstossen
- [x] TASK_000034: UI rollback action - [x] TASK_000034: UI rollback action
- [x] US_000043: Enrollment-Token per Script abrufen
- [x] TASK_000049: Script fuer Enrollment-Flow erstellen
- [x] US_000046: Update-Service Erreichbarkeit anzeigen
- [x] TASK_000055: Endpoint und UI fuer Update-Service Status
- [x] US_000047: Update-Config auslagern
- [x] TASK_000056: Update-ENV separieren
- [x] US_000048: Release-Upload automatisieren
- [x] TASK_000057: Script fuer Release-Upload erstellen
- [x] US_000049: Lokale deployment.env fuer Update-Uploads
- [x] TASK_000058: deployment.env Beispiel und Script-Anpassungen
### EPIC_000009: Update Webservice (External Team) ### EPIC_000009: Update Webservice (External Team)
- [ ] US_000026: Client bezieht Updates (Pull) - [x] US_000026: Client bezieht Updates (Pull)
- [ ] US_000027: Client verifiziert und wendet Updates an - [x] US_000027: Client verifiziert und wendet Updates an
- [ ] US_000028: Client meldet Update-Status - [x] US_000028: Client meldet Update-Status
### EPIC_000010: Update-Service v1 Migration (Major Release)
- [x] US_000034: Enrollment fuer Langzeit-Token
- [x] TASK_000040: Enrollment-Flow implementieren
- [x] US_000035: v1 Update-Endpoints und Status-Schema
- [x] TASK_000041: v1 Endpunkte im Update-Client umstellen
### EPIC_000011: Documentation and Configuration Alignment
- [x] US_000036: Doku-Versionen auf VERSION synchronisieren
- [x] US_000037: Update-API-Doku mit /update/enroll abgleichen
- [x] US_000038: ENV-Beispiele und Healthcheck-Auth angleichen
- [x] US_000039: Doku-Audit fuer verbleibende Abweichungen
- [x] TASK_000042: Doku-Audit verbleibender Dateien
### EPIC_000012: Dokumentations-Overhaul
- [x] US_000040: Doku-Struktur und Inhalte erstellen
- [x] TASK_000043: Neue Doku-Dateien erstellen und verlinken
- [x] TASK_000044: Altdoku archivieren
- [x] US_000041: Einbindung externer Services dokumentieren
- [x] TASK_000045: Einbindung externer Services dokumentieren
- [x] US_000042: Consumer-Perspektive und Audience-Split ergaenzen
- [x] TASK_000046: Consumer-Doku erstellen (FOR_USERS.md)
- [x] TASK_000047: README und Audience-Split anpassen
- [x] TASK_000048: External Dependencies und Audience-Ergaenzungen
### EPIC_000013: System Telemetry im Dashboard
- [x] US_000044: Systemmetriken im Dashboard anzeigen
- [x] TASK_000050: Backend-Endpoint fuer Systemmetriken
- [x] TASK_000051: UI-Kacheln im Dashboard (System Information)
### EPIC_000014: Login-Zeitfenster und Parent-Control Regeln
- [x] US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
- [x] TASK_000052: Regelmodell und Speicherung definieren
- [x] TASK_000053: Login-Pruefung und Enforcement
- [x] TASK_000054: Admin-UI fuer Regeln und Scheduler
## Fehler / Bugs (History)
- [x] BUG_000001: Update-Apply scheitert an WorkingDirectory
- [x] BUG_000002: Update-Prozess wird beim Service-Stop gekillt
- [x] BUG_000003: Veralteter Update-Token durch Caching
## Offene Risiken / Abhaengigkeiten ## Offene Risiken / Abhaengigkeiten
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn). - Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
@ -116,4 +173,4 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
## Naechste Schritte ## Naechste Schritte
- Anforderungen beim naechsten Feature-Start erweitern. - Anforderungen beim naechsten Feature-Start erweitern.
- OIDC-Validierung abschliessen und US_000025 auf Done setzen. - OIDC-Validierung abschliessen und US_000025 auf Done setzen.
- PR vorbereiten: feature/oidc-validation (Summary, Risiko, Testschritte). - Doku-Audit fuer weitere Abweichungen priorisieren (wenn Zeitfenster frei).

View File

@ -1,4 +1,4 @@
ID: PROJECT_STATUS_TEMPLATE | Version: 0.1.0 | Status: Draft ID: PROJECT_STATUS_TEMPLATE | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5) By: Codex (GPT-5)
# 📊 Projekt-Status (Template) # 📊 Projekt-Status (Template)

View File

@ -0,0 +1,729 @@
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
:root {
--bg-main: #f5f7fa;
--bg-panel: #ffffff;
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--text-main: #2d3748;
--text-secondary: #718096;
--text-light: #a0aec0;
--border-main: #e2e8f0;
--primary: #667eea;
--primary-hover: #5568d3;
--success: #48bb78;
--success-hover: #38a169;
--warning: #ed8936;
--danger: #f56565;
--danger-hover: #e53e3e;
--info: #4299e1;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
}
/* Dark Mode Theme */
body.dark-mode {
--bg-main: #1a202c;
--bg-panel: #2d3748;
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
--text-main: #f7fafc;
--text-secondary: #cbd5e0;
--text-light: #a0aec0;
--border-main: #4a5568;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--bg-main);
color: var(--text-main);
line-height: 1.6;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
border: none;
cursor: pointer;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 999;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.theme-toggle svg {
width: 24px;
height: 24px;
stroke: white;
fill: none;
}
.theme-toggle .sun-icon {
display: none;
}
body.dark-mode .theme-toggle .moon-icon {
display: none;
}
body.dark-mode .theme-toggle .sun-icon {
display: block;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Header - Keep the gradient but more professional */
header {
background: var(--bg-header);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: var(--shadow-md);
}
header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
header p {
font-size: 1rem;
opacity: 0.95;
font-weight: 400;
}
/* Statistics Dashboard - More visual interest */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-panel);
padding: 24px;
border-radius: 12px;
box-shadow: var(--shadow-md);
border-left: 4px solid var(--primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: var(--primary);
opacity: 0.05;
border-radius: 50%;
transform: translate(30%, -30%);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-left-width: 6px;
}
.stat-card h3 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
position: relative;
z-index: 1;
}
/* Controls - Clean but distinctive */
.controls {
background: var(--bg-panel);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
border: 1px solid var(--border-main);
}
/* Inputs - More refined */
input, select {
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
font-family: inherit;
}
input:focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input::placeholder {
color: var(--text-light);
}
.search-box {
flex: 1;
min-width: 250px;
}
/* Buttons - Keep gradient feel but professional */
button {
background: var(--primary);
color: white;
border: none;
padding: 11px 20px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
button:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button:active {
transform: translateY(0);
}
button.secondary {
background: var(--success);
}
button.secondary:hover {
background: var(--success-hover);
}
button.danger {
background: var(--danger);
}
button.danger:hover {
background: var(--danger-hover);
}
/* Table Container - More polished */
.table-container {
background: var(--bg-panel);
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
border: 1px solid var(--border-main);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 16px;
text-align: left;
}
th {
background: var(--bg-main);
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border-main);
}
tbody tr {
border-bottom: 1px solid var(--border-main);
transition: background-color 0.15s ease;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--bg-main);
}
td {
color: var(--text-main);
}
td strong {
color: var(--primary);
font-weight: 600;
}
/* Status Badges - More colorful but professional */
.status-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-available {
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
color: #22543d;
}
.status-in_use {
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
color: #2c5282;
}
.status-maintenance {
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
color: #7c2d12;
}
.status-retired {
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
color: #2d3748;
}
.status-lost {
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
color: #742a2a;
}
/* Modal - Elegant overlay */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: var(--bg-panel);
border-radius: 16px;
padding: 32px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid var(--border-main);
}
.modal-header h2 {
color: var(--text-main);
font-size: 1.5rem;
font-weight: 700;
}
.close-btn {
background: var(--border-main);
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
width: 36px;
height: 36px;
line-height: 1;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--primary);
color: white;
transform: rotate(90deg);
}
/* Form - Clean and accessible */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
}
textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
}
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 28px;
padding-top: 20px;
border-top: 2px solid var(--border-main);
}
/* Loading & Empty States */
.loading, .empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.4;
stroke: var(--text-secondary);
}
.empty-state h3 {
color: var(--text-main);
margin-bottom: 8px;
font-weight: 600;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.action-buttons button {
padding: 7px 14px;
font-size: 0.8rem;
}
/* Import Zone - Visual and inviting */
.import-zone {
border: 3px dashed var(--border-main);
border-radius: 12px;
padding: 48px;
text-align: center;
margin: 20px 0;
transition: all 0.3s ease;
cursor: pointer;
background: var(--bg-main);
}
.import-zone:hover, .import-zone.drag-over {
border-color: var(--primary);
background: var(--bg-panel);
transform: scale(1.02);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
}
.import-zone.processing {
border-color: var(--success);
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
}
.import-zone svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
stroke: var(--primary);
}
.import-zone h3 {
color: var(--text-main);
font-weight: 600;
margin-bottom: 8px;
font-size: 1.125rem;
}
.import-zone p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.file-input {
display: none;
}
/* Import Results */
.import-results {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
border-left: 4px solid;
box-shadow: var(--shadow-sm);
}
.import-results.success {
background: #c6f6d5;
border-color: var(--success);
color: #22543d;
}
.import-results.error {
background: #fed7d7;
border-color: var(--danger);
color: #742a2a;
}
/* Progress Bar - More visual */
.progress-bar {
width: 100%;
height: 10px;
background: var(--border-main);
border-radius: 8px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 12px;
}
header {
padding: 24px 20px;
}
header h1 {
font-size: 1.5rem;
}
.stats {
grid-template-columns: 1fr;
gap: 12px;
}
.controls {
flex-direction: column;
padding: 16px;
}
.search-box {
width: 100%;
}
button {
width: 100%;
}
.theme-toggle {
bottom: 20px;
right: 20px;
width: 48px;
height: 48px;
}
table {
font-size: 0.85rem;
}
th, td {
padding: 12px 8px;
}
.action-buttons {
flex-direction: column;
}
.action-buttons button {
width: 100%;
}
.modal-content {
padding: 24px 20px;
}
}
/* NEW STYLES FOR ENHANCED UI */
.error-card {
background-color: var(--danger);
color: white;
border-left-color: var(--danger-hover);
}
.error-card h3 {
color: white;
}
.loading-card {
text-align: center;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stat-card details {
margin-top: 16px;
}
.stat-card summary {
cursor: pointer;
font-weight: 600;
color: var(--text-main);
margin-bottom: 8px;
}
.stat-card summary:hover {
color: var(--primary);
}
.flags-list {
list-style-type: none;
padding-left: 8px;
font-size: 0.9rem;
}
.flags-list li {
margin-bottom: 6px;
padding-left: 16px;
position: relative;
}
.flags-list li::before {
content: '›';
position: absolute;
left: 0;
color: var(--primary);
font-weight: bold;
}
.value.level-high {
color: var(--danger);
}
.value.level-medium {
color: var(--warning);
}
.value.level-low {
color: var(--success);
}

View File

@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V2 App Icon (Teal Optimized) -->
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
<g transform="translate(120, 96) scale(8)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@ -0,0 +1,9 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V12 App Icon (Teal) -->
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
<g transform="translate(120, 96) scale(8)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1,15 @@
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<!-- Middle Block: Teal, 19px Height -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<!-- Wordmark -->
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<!-- The Underline: Teal, aligned like V8 -->
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -0,0 +1,16 @@
<svg width="340" height="60" viewBox="0 0 340 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V12: ALL TEAL -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<rect x="188" y="27" width="6" height="6" rx="1" fill="#4B5563"/>
<!-- AI: Teal -->
<text x="206" y="42" fill="#0EA5A4" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">AI</text>
<!-- Underline: Teal -->
<rect x="56" y="48" width="186" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B

View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Client Administration</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>OIDC Clients</h1>
<p>Manage OIDC clients - Logged in as: <strong>{{ admin_user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
{{ message }}
</div>
{% endif %}
<div class="controls">
<a href="/admin/client/create" style="text-decoration: none;">
<button class="secondary">Create New Client</button>
</a>
<a href="/admin/users" style="text-decoration: none;">
<button>Manage Users</button>
</a>
<a href="/admin/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Client ID</th>
<th>Client Name</th>
<th>Redirect URIs</th>
<th>Allowed Scopes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr>
<td><strong>{{ client.id }}</strong></td>
<td><code>{{ client.client_id }}</code></td>
<td>{{ client.client_name }}</td>
<td>
<ul>
{% for uri in client.get_redirect_uris() %}
<li>{{ uri }}</li>
{% endfor %}
</ul>
</td>
<td>{{ client.get_allowed_scopes()|join(', ') }}</td>
<td>
<div class="action-buttons">
<a href="/admin/client/{{ client.id }}/edit" style="text-decoration: none;">
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
</a>
<form method="POST" action="/admin/client/{{ client.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete client {{ client.client_name }}?');">
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Client</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Create New OIDC Client</h1>
<p>Add a new client application to the system</p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="client_name">Client Name</label>
<input type="text" id="client_name" name="client_name" placeholder="My Awesome App" required autofocus>
</div>
<div class="form-group">
<label for="client_id">Client ID</label>
<input type="text" id="client_id" name="client_id" placeholder="leave blank to auto-generate" >
</div>
<div class="form-group">
<label for="client_secret">Client Secret</label>
<input type="text" id="client_secret" name="client_secret" placeholder="leave blank to auto-generate">
</div>
<div class="form-group">
<label for="redirect_uris">Redirect URIs (one per line)</label>
<textarea id="redirect_uris" name="redirect_uris" rows="3" placeholder="https://app.example.com/callback" required></textarea>
</div>
<div class="form-group">
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
<input type="text" id="allowed_scopes" name="allowed_scopes" value="openid, profile, email" placeholder="e.g. openid, profile, email">
</div>
<div class="form-actions">
<a href="/admin/clients">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Create Client</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New User</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Create New User</h1>
<p>Add a new user to the system</p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Enter username" required autofocus>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="user@example.com" required>
</div>
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="John Doe" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role" required>
<option value="user" selected>User</option>
<option value="admin">Admin</option>
<option value="moderator">Moderator</option>
<option value="readonly">Read-Only</option>
</select>
</div>
<div class="form-group">
<label for="permissions">Permissions (comma-separated)</label>
<input type="text" id="permissions" name="permissions" placeholder="e.g. read:data, write:data">
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
Common permissions: read:data, write:data, manage:users, manage:settings
</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin">
Admin User
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_active" checked>
Account Active
</label>
</div>
<div class="form-actions">
<a href="/admin/users">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Create User</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Administration</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>User Administration</h1>
<p>Manage OIDC users - Logged in as: <strong>{{ admin_user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
{{ message }}
</div>
{% endif %}
<div class="stats">
<div class="stat-card">
<h3>Total Users</h3>
<div class="value">{{ total_users }}</div>
</div>
<div class="stat-card">
<h3>Active Users</h3>
<div class="value value.level-low">{{ active_users }}</div>
</div>
<div class="stat-card">
<h3>Admin Users</h3>
<div class="value">{{ admin_users }}</div>
</div>
<div class="stat-card">
<h3>Inactive Users</h3>
<div class="value value.level-medium">{{ inactive_users }}</div>
</div>
</div>
<div class="controls">
<a href="/admin/analytics" style="text-decoration: none;">
<button>📊 Analytics</button>
</a>
<a href="/admin/clients" style="text-decoration: none;">
<button>Manage Clients</button>
</a>
<a href="/admin/user/create" style="text-decoration: none;">
<button class="secondary">Create New User</button>
</a>
<a href="/admin/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Permissions</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td><strong>{{ user.id }}</strong></td>
<td>{{ user.username }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
{% if user.is_active %}
<span class="status-badge status-available">Active</span>
{% else %}
<span class="status-badge status-retired">Inactive</span>
{% endif %}
</td>
<td>
{% if user.role == 'admin' %}
<span class="status-badge status-in_use">{{ user.role|capitalize }}</span>
{% elif user.role == 'moderator' %}
<span class="status-badge status-available">{{ user.role|capitalize }}</span>
{% elif user.role == 'readonly' %}
<span class="status-badge status-retired">{{ user.role|capitalize }}</span>
{% else %}
<span class="status-badge">{{ user.role|capitalize }}</span>
{% endif %}
</td>
<td style="font-size: 0.85rem;">
{% if user.get_permissions()|length > 0 %}
{{ user.get_permissions()|join(', ') }}
{% else %}
<em style="color: var(--text-secondary);">None</em>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<div class="action-buttons">
<a href="/admin/user/{{ user.id }}/edit" style="text-decoration: none;">
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
</a>
{% if user.is_active %}
<form method="POST" action="/admin/user/{{ user.id }}/deactivate" style="display: inline;">
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Deactivate</button>
</form>
{% else %}
<form method="POST" action="/admin/user/{{ user.id }}/activate" style="display: inline;">
<button type="submit" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Activate</button>
</form>
{% endif %}
{% if not user.is_admin or admin_count > 1 %}
<form method="POST" action="/admin/user/{{ user.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete user {{ user.username }}?');">
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
</form>
{% endif %}
<a href="/admin/user/{{ user.id }}/tokens" style="text-decoration: none;">
<button type="button" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Tokens</button>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Client - {{ client.client_name }}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Edit OIDC Client</h1>
<p>Modify details for client: <strong>{{ client.client_name }}</strong></p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="client_name">Client Name</label>
<input type="text" id="client_name" name="client_name" value="{{ client.client_name }}" required>
</div>
<div class="form-group">
<label for="client_id">Client ID</label>
<input type="text" id="client_id" name="client_id" value="{{ client.client_id }}" readonly>
</div>
<div class="form-group">
<label for="new_client_secret">New Client Secret (leave empty to keep current)</label>
<input type="text" id="new_client_secret" name="new_client_secret" placeholder="Optional: Set new secret">
</div>
<div class="form-group">
<label for="redirect_uris">Redirect URIs (one per line)</label>
<textarea id="redirect_uris" name="redirect_uris" rows="3" required>{{ client.get_redirect_uris()|join('\n') }}</textarea>
</div>
<div class="form-group">
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
<input type="text" id="allowed_scopes" name="allowed_scopes" value="{{ client.get_allowed_scopes()|join(', ') }}" placeholder="e.g. openid, profile, email">
</div>
<div class="form-actions">
<a href="/admin/clients">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit User - {{ user.username }}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Edit User</h1>
<p>Modify user details for: <strong>{{ user.username }}</strong></p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ user.username }}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" value="{{ user.email }}" required>
</div>
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" value="{{ user.name }}" required>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %}>
Admin User
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %}>
Account Active
</label>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role" required>
<option value="user" {% if user.role == 'user' %}selected{% endif %}>User</option>
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
<option value="moderator" {% if user.role == 'moderator' %}selected{% endif %}>Moderator</option>
<option value="readonly" {% if user.role == 'readonly' %}selected{% endif %}>Read-Only</option>
</select>
</div>
<div class="form-group">
<label for="permissions">Permissions (comma-separated)</label>
<input type="text" id="permissions" name="permissions" value="{{ user.get_permissions()|join(', ') }}" placeholder="e.g. read:data, write:data, manage:users">
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
Common permissions: read:data, write:data, manage:users, manage:settings
</small>
</div>
<div class="form-group">
<label for="new_password">New Password (leave empty to keep current)</label>
<input type="password" id="new_password" name="new_password" placeholder="Optional: Set new password">
</div>
<div class="form-actions">
<a href="/admin/users">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Admin Login</h1>
<p>User Administration Access</p>
</header>
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Admin Username</label>
<input type="text" id="username" name="username" placeholder="Enter admin username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required>
</div>
<button type="submit" style="width: 100%; margin-top: 8px;">Admin Login</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Home</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Tokens</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Access Tokens for {{ target_user.username }}</h1>
<p>Logged in as admin: <strong>{{ admin_user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
{{ message }}
</div>
{% endif %}
{% if new_token %}
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
New Token (copy now): <code>{{ new_token }}</code>
</div>
{% endif %}
{% if error %}
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
{{ error }}
</div>
{% endif %}
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
<h2>Create Initial Access Token</h2>
<form method="POST">
<div class="form-group">
<label>Scope</label>
<input type="text" name="scope" value="dcr:register">
</div>
<div class="form-group">
<label>TTL (days)</label>
<input type="number" name="ttl_days" value="365" min="1" max="3650">
</div>
<button type="submit">Create Token</button>
<a href="/admin/users" style="margin-left: 8px;">Back</a>
</form>
</div>
<div class="table-container" style="margin-top: 24px;">
<table>
<thead>
<tr>
<th>Token</th>
<th>Scope</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
<td>{{ token.scope }}</td>
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if token.revoked %}
<span class="status-badge status-retired">Revoked</span>
{% elif token.is_valid %}
<span class="status-badge status-available">Valid</span>
{% else %}
<span class="status-badge status-retired">Expired</span>
{% endif %}
</td>
<td>
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="revoke">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
</form>
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="update">
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
</form>
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block;">
<input type="hidden" name="action" value="delete">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC IdP - Change Password</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Change Password</h1>
<p>Update your account security</p>
</header>
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
{% if success %}
<div class="import-results success">
<strong>Success:</strong> {{ success }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Your username" required autofocus>
</div>
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" placeholder="Enter current password" required>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" placeholder="Min. 8 characters" required minlength="8">
</div>
<div class="form-group">
<label for="new_password_confirm">Confirm New Password</label>
<input type="password" id="new_password_confirm" name="new_password_confirm" placeholder="Repeat new password" required>
</div>
<button type="submit" style="width: 100%; margin-top: 8px;">Update Password</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>👤 User Dashboard</h1>
<p>Logged in as: <strong>{{ user.username }}</strong></p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
<h2 style="color: var(--text-main); margin-bottom: 20px;">Your Information</h2>
<div class="form-group">
<label>Username</label>
<input type="text" value="{{ user.username }}" readonly>
</div>
<div class="form-group">
<label>Email</label>
<input type="text" value="{{ user.email }}" readonly>
</div>
<div class="form-group">
<label>Full Name</label>
<input type="text" value="{{ user.name }}" readonly>
</div>
<div class="form-group">
<label>Role</label>
<input type="text" value="{{ user.role }}" readonly>
</div>
<div class="form-group">
<label>Permissions</label>
<input type="text" value="{{ user.get_permissions()|join(', ') }}" readonly>
</div>
<div class="form-group">
<label>Account Status</label>
<input type="text" value="{% if user.is_active %}Active{% else %}Inactive{% endif %}" readonly>
</div>
</div>
<div class="controls" style="margin-top: 24px;">
<a href="/my-sessions" style="text-decoration: none;">
<button>📊 My Sessions</button>
</a>
<a href="/change-password" style="text-decoration: none;">
<button>🔑 Change Password</button>
</a>
<a href="/my-tokens" style="text-decoration: none;">
<button>🎟️ My Tokens</button>
</a>
<a href="/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC Identity Provider</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>🔐 OIDC Identity Provider</h1>
<p>Secure authentication server for your services</p>
</header>
<div class="modal-content" style="max-width: 700px; margin: 0 auto;">
<h2 style="color: var(--text-main); margin-bottom: 20px;">Welcome</h2>
<p style="color: var(--text-secondary); line-height: 1.6;">
This is an OpenID Connect (OIDC) Identity Provider that enables secure authentication
for your applications using industry-standard protocols.
</p>
</div>
<div class="controls" style="margin-top: 32px; justify-content: center;">
<a href="/login" style="text-decoration: none;">
<button>🔑 Login</button>
</a>
<a href="/register" style="text-decoration: none;">
<button class="secondary">📝 Register</button>
</a>
</div>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<p style="color: var(--text-secondary); font-size: 0.9rem;">
Administrators: <a href="/admin/login" style="color: var(--primary); text-decoration: none;">Access admin panel</a>
</p>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC IdP - Login</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>🔐 Homelab OIDC Login</h1>
<p>Secure authentication for your homelab services</p>
</header>
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
{% if success %}
<div class="import-results success">
<strong>Success:</strong> {{ success }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Enter your username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter your password" required>
</div>
<button type="submit" style="width: 100%; margin-top: 8px;">Sign In</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<p style="color: var(--text-secondary); margin-bottom: 12px;">Don't have an account?</p>
<a href="/register" style="color: var(--primary); text-decoration: none; font-weight: 600;">Create new account →</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
// Load saved theme
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC IdP - Registration</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Create New Account</h1>
<p>Join your homelab authentication system</p>
</header>
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Choose a username" required autofocus>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="your.email@homelab.local" required>
</div>
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="John Doe" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Min. 8 characters" required minlength="8">
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" placeholder="Repeat your password" required>
</div>
<button type="submit" class="secondary" style="width: 100%; margin-top: 8px;">Create Account</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<p style="color: var(--text-secondary); margin-bottom: 12px;">Already have an account?</p>
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Active Sessions</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>My Active Sessions</h1>
<p>{{ user.name }} ({{ user.email }})</p>
</header>
<div style="margin-bottom: 24px;">
<a href="/dashboard" style="text-decoration: none;">
<button>Back to Dashboard</button>
</a>
<a href="/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
<!-- Summary Stats -->
<div class="analytics-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px;">
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Active Sessions</h3>
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_active_sessions }}</div>
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">Currently active</div>
</div>
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Applications</h3>
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_clients }}</div>
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">You're using</div>
</div>
</div>
<!-- Active Sessions -->
<h2 style="margin-bottom: 20px;">Active Sessions</h2>
{% if active_sessions %}
{% set current_client = namespace(value='') %}
{% for session in active_sessions %}
{% if session.client_name != current_client.value %}
{% set current_client.value = session.client_name %}
{% if not loop.first %}
</div>
{% endif %}
<div class="client-section" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px; margin-bottom: 16px;">
<h3 style="margin-bottom: 16px;">{{ session.client_name }}</h3>
{% endif %}
<div class="session-item" style="padding: 16px; background: var(--bg-primary); border-radius: 6px; margin-bottom: 12px;">
<div class="session-info" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div>
<strong>Session</strong>
</div>
<span class="status-badge status-available" style="padding: 4px 12px; background: #10b981; color: white; border-radius: 4px; font-size: 12px;">Active</span>
</div>
<div class="session-meta" style="font-size: 14px; color: var(--text-secondary);">
Created: {{ session.created_at.strftime('%Y-%m-%d %H:%M:%S') }} |
Expires: {{ session.expires_at.strftime('%Y-%m-%d %H:%M:%S') }}
</div>
</div>
{% if loop.last %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div class="import-results" style="background: var(--bg-secondary); padding: 20px; border-radius: 8px;">
No active sessions. Log in to an application to see sessions here.
</div>
{% endif %}
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
// Auto-refresh every 30 seconds
setTimeout(function() {
location.reload();
}, 30000);
</script>
</body>
</html>

View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Tokens</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Initial Access Tokens</h1>
<p>Logged in as: <strong>{{ user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
{{ message }}
</div>
{% endif %}
{% if new_token %}
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
New Token (copy now): <code>{{ new_token }}</code>
</div>
{% endif %}
{% if error %}
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
{{ error }}
</div>
{% endif %}
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
<h2>Create Initial Access Token</h2>
<form method="POST">
<div class="form-group">
<label>Scope</label>
<input type="text" name="scope" value="dcr:register">
</div>
<div class="form-group">
<label>TTL (days)</label>
<input type="number" name="ttl_days" value="365" min="1" max="3650">
</div>
<button type="submit">Create Token</button>
<a href="/dashboard" style="margin-left: 8px;">Back</a>
</form>
</div>
<div class="table-container" style="margin-top: 24px;">
<table>
<thead>
<tr>
<th>Token</th>
<th>Scope</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
<td>{{ token.scope }}</td>
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if token.revoked %}
<span class="status-badge status-retired">Revoked</span>
{% elif token.is_valid %}
<span class="status-badge status-available">Valid</span>
{% else %}
<span class="status-badge status-retired">Expired</span>
{% endif %}
</td>
<td>
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="revoke">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
</form>
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="update">
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
</form>
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block;">
<input type="hidden" name="action" value="delete">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@ -1,4 +1,4 @@
ID: SOP_000001 | Version: 0.1.0 | Status: Draft ID: SOP_000001 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5) By: Codex (GPT-5)
# Onboarding: Arbeitsweise im Sound Architect Projekt # Onboarding: Arbeitsweise im Sound Architect Projekt

View File

@ -1,4 +1,4 @@
ID: SETUP_000005 | Version: 0.1.0 | Status: Draft ID: SETUP_000005 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5) By: Codex (GPT-5)
# 🧬 SETUP_GUIDE: Phase 0 - Project Genesis # 🧬 SETUP_GUIDE: Phase 0 - Project Genesis

View File

@ -1,4 +1,4 @@
ID: PROMPT_000008 | Version: 0.1.0 | Status: Draft ID: PROMPT_000008 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5) By: Codex (GPT-5)
# Requirements Engineer Prompt # Requirements Engineer Prompt

View File

@ -0,0 +1,26 @@
ID: BUG_000001 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5)
# BUG_000001: Update scheitert wegen WorkingDirectory /opt/sk
## Beschreibung
Beim Update-Apply bricht `scripts/update_client.sh` ab, wenn `/opt/sk` verschoben wird,
weil der Prozess selbst im Verzeichnis arbeitet (WorkingDirectory=/opt/sk).
Das fuehrt zu einem Abbruch ohne Abschlussstatus (status bleibt `in_progress`).
## Schritte zur Reproduktion
1. `skd.service` laeuft mit `WorkingDirectory=/opt/sk`.
2. Update ueber UI oder `POST /update/apply` starten.
3. Script stoppt Service und versucht `mv /opt/sk ...`.
4. Fehler wegen busy CWD, Script bricht ab, Service bleibt gestoppt.
## Erwartetes Verhalten
Update-Prozess laeuft aus einem neutralen CWD (z.B. `/`), und Fehler werden als `failed`
in Status/Logs vermerkt.
## Ist-Verhalten
Update bleibt im Status `in_progress`, Service bleibt gestoppt.
## Fix-Idee
- `subprocess.Popen(..., cwd="/")` fuer Update-Client.
- Fehlertrap in `scripts/update_client.sh` fuer `failed` Status.

View File

@ -0,0 +1,16 @@
ID: BUG_000002 | Status: Fixed | Severity: High
By: Gemini CLI
# BUG_000002: Update-Prozess wird beim Stoppen des Services beendet
## Beschreibung
Der Update-Prozess (`scripts/update_client.sh`) wird vom Backend-Service gestartet. Da er in derselben Systemd-CGroup wie der `skd.service` läuft, beendet Systemd den Updater sofort, wenn der Service für das Update gestoppt wird.
## Ursache
Prozesse, die direkt via `subprocess.Popen` aus einer Systemd-Unit gestartet werden, gehören zur selben Unit und werden beim Stoppen mit beendet (SIGTERM/SIGKILL).
## Fix
Umstellung des Start-Mechanismus in `backend/update.py` auf `systemd-run --unit=skd-update --collect`. Dies entkoppelt den Prozess in eine eigene transiente Unit.
## Verifizierung
- Update von v0.3.1 auf v0.3.2 verlief erfolgreich, ohne dass der Prozess abgewürgt wurde.

View File

@ -0,0 +1,16 @@
ID: BUG_000003 | Status: Fixed | Severity: Medium
By: Gemini CLI
# BUG_000003: Veralteter Update-Token durch Caching in Settings
## Beschreibung
Wenn ein Gerät registriert (Enrollment) wird, wird das Token in einer Datei gespeichert. Die `Settings`-Klasse lädt das Token jedoch nur einmal beim Start des Services. Nachfolgende Update-Versuche nutzen ein leeres oder veraltetes Token aus dem Cache, was zu 401/403 Fehlern beim Manifest-Download führt.
## Ursache
Die `Settings`-Instanz wird via `@lru_cache` in `backend/settings.py` gehalten und nicht aktualisiert, wenn sich Dateien auf der Festplatte ändern.
## Fix
Einführung der Hilfsfunktion `_get_fresh_token(settings)` in `backend/update.py`, die das Token bei jedem kritischen Vorgang (Check, Report, Update) frisch von der Festplatte oder aus der Environment lädt.
## Verifizierung
- Update auf v0.3.3 nutzt nun erfolgreich das Token, auch wenn der Service seit dem Enrollment nicht neu gestartet wurde.

View File

@ -1,4 +1,4 @@
ID: EPIC_000001 | Version: 0.1.0 | Status: Final ID: EPIC_000001 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000001: Legacy CLI Account Control (sk.sh) # EPIC_000001: Legacy CLI Account Control (sk.sh)

View File

@ -1,4 +1,4 @@
ID: EPIC_000002 | Version: 0.1.0 | Status: Final ID: EPIC_000002 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000002: Backend API Service # EPIC_000002: Backend API Service

View File

@ -1,4 +1,4 @@
ID: EPIC_000003 | Version: 0.1.0 | Status: Final ID: EPIC_000003 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000003: Authentication & Sessions # EPIC_000003: Authentication & Sessions

View File

@ -1,4 +1,4 @@
ID: EPIC_000004 | Version: 0.1.0 | Status: Final ID: EPIC_000004 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000004: Web UI # EPIC_000004: Web UI

View File

@ -1,4 +1,4 @@
ID: EPIC_000005 | Version: 0.1.0 | Status: Final ID: EPIC_000005 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000005: Automation Scripts # EPIC_000005: Automation Scripts

View File

@ -1,4 +1,4 @@
ID: EPIC_000006 | Version: 0.1.0 | Status: Final ID: EPIC_000006 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000006: Systemd & Deployment Artifacts # EPIC_000006: Systemd & Deployment Artifacts

View File

@ -1,4 +1,4 @@
ID: EPIC_000007 | Version: 0.1.0 | Status: Final ID: EPIC_000007 | Version: 0.2.3 | Status: Final
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000007: Documentation & Runbook # EPIC_000007: Documentation & Runbook

View File

@ -1,4 +1,4 @@
ID: EPIC_000008 | Version: 0.1.0 | Status: Draft ID: EPIC_000008 | Version: 0.2.3 | Status: Draft
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000008: Client-Side Update Mechanism # EPIC_000008: Client-Side Update Mechanism
@ -44,3 +44,7 @@ Ermoegliche einen robusten Client-Update-Flow mit Verifikation und Rollback.
- US_000031: Update im Web-UI anstossen - US_000031: Update im Web-UI anstossen
- US_000032: Update-Logs im Web-UI anzeigen - US_000032: Update-Logs im Web-UI anzeigen
- US_000033: Rollback im Web-UI anstossen - US_000033: Rollback im Web-UI anstossen
- US_000043: Enrollment-Token per Script abrufen
- US_000046: Update-Service Erreichbarkeit anzeigen
- US_000048: Release-Upload automatisieren
- US_000049: Lokale deployment.env fuer Update-Uploads

View File

@ -1,4 +1,4 @@
ID: EPIC_000009 | Version: 0.1.0 | Status: Draft ID: EPIC_000009 | Version: 0.2.3 | Status: Done
By: Codex (GPT-5) By: Codex (GPT-5)
# EPIC_000009: Update Webservice (External Team) # EPIC_000009: Update Webservice (External Team)

View File

@ -0,0 +1,45 @@
ID: EPIC_000010 | Version: 0.2.3 | Status: Done
By: Codex (GPT-5)
# EPIC_000010: Update-Service v1 Migration (Major Release)
## Beschreibung
Migration des Update-Clients auf den neuen v1 Update-Service mit verpflichtender Authentifizierung
und Enrollment-Flow fuer Langzeit-Tokens. Diese Umstellung ist ein Major Release.
## Ziel / Business Value
Sicheres, standardisiertes Update-Management mit verpflichtender Auth und nachvollziehbarem Status-Reporting.
## Mission Statement
Stelle sicher, dass der Client die v1 Endpunkte nutzen kann, inkl. Enrollment und
neuem Status-Schema.
## Business Value & Metriken
- Security: Auth ist obligatorisch fuer alle Requests.
- Erfolgsmetrik: 100% der Clients koennen per v1 manifest/artifact/status arbeiten.
## In-Scope (Kiddo Team)
- Enrollment-Flow fuer Langzeit-Token (mit Pre-Shared Token).
- Update-Client auf v1 Endpunkte umstellen.
- Status-Payload auf v1 Schema umstellen.
- Migration-Notiz/Docs fuer Client-Dev.
## Out-of-Scope
- Betrieb/Hosting des Update-Services.
- Ausgabe/Verwaltung von Pre-Shared Tokens auf Server-Seite.
## High-Level Akzeptanzkriterien
- Auth ist Pflicht (Bearer Token) fuer Manifest, Artifact und Status.
- Enrollment liefert Langzeit-Token, der lokal gespeichert wird.
- v1 Endpunkte werden genutzt:
- GET /v1/projects/{project_id}/manifest
- GET /v1/projects/{project_id}/releases/{version}/artifact
- POST /v1/projects/{project_id}/status
## Technische Constraints & Risiken
- Major Release: Rollout-Strategie und Backward Compatibility klaeren.
- Token-Handling und sichere lokale Speicherung.
## Zugeordnete User Stories
- US_000034: Enrollment fuer Langzeit-Token
- US_000035: v1 Update-Endpoints und Status-Schema

View File

@ -0,0 +1,43 @@
ID: EPIC_000011 | Version: 0.2.3 | Status: Done
By: Codex (GPT-5)
# EPIC_000011: Documentation and Configuration Alignment
## Beschreibung
Konsolidierung der Dokumentation, Beispiel-Konfigurationen und Ops-Hinweise mit dem
aktuellen Code-Stand, inklusive Version-Synchronisierung mit der zentralen VERSION.
## Ziel / Business Value
Reduziert Integrationsfehler, sorgt fuer konsistente Bedienung und verringert Support-Aufwand.
## Mission Statement
Stelle sicher, dass Dokumente, Beispiele und Automationshinweise den realen API- und
Konfigurationsstand widerspiegeln.
## Business Value & Metriken
- Weniger Fehlkonfigurationen durch korrekte ENV-Keys und Auth-Header.
- Erfolgsmetrik: 0 bekannte Abweichungen zwischen Code und Doku in den Update/Health-Flows.
## In-Scope (Kiddo Team)
- Doku-Versionen auf VERSION ziehen.
- Update-API Doku inkl. /update/enroll und Response-Feldern aktualisieren.
- ENV-Beispiele und Makefile-Healthcheck auf aktuelle Auth-Mechanik angleichen.
## Out-of-Scope
- Funktionale Aenderungen am Auth-Flow im Backend.
- Erweiterte Validierung durch Integrationstests.
## High-Level Akzeptanzkriterien
- Doku-Header nutzen die aktuelle VERSION.
- Update-API-Doku listet alle implementierten Update-Endpunkte.
- ENV-Beispiele nutzen die im Code verwendeten Update-Variablen.
- Healthcheck-Beispiel nutzt Authorization: Bearer.
## Technische Constraints & Risiken
- Dokumentation darf keine falschen Defaults oder veraltete Endpunkte nennen.
- Audit kann weitere Abweichungen aufdecken und Folgetickets noetig machen.
## Zugeordnete User Stories
- US_000036: Doku-Versionen auf VERSION synchronisieren
- US_000037: Update-API-Doku mit /update/enroll und Response-Feldern abgleichen
- US_000038: ENV-Beispiele und Makefile-Healthcheck an Auth/Update-Config angleichen

View File

@ -0,0 +1,44 @@
ID: EPIC_000012 | Version: 0.2.3 | Status: Done
By: Codex (GPT-5)
# EPIC_000012: Dokumentations-Overhaul
## Beschreibung
Komplette, konsistente Dokumentation fuer Einsteiger, Admins und Entwickler, inkl. Archivierung
veralteter Inhalte und einer klaren Doku-Struktur.
## Ziel / Business Value
Schnelleres Onboarding, weniger Fehlkonfigurationen und klare Betriebsvorgaenge.
## Mission Statement
Stelle eine professionelle, konsistente und vollstaendige Doku bereit, die sich direkt am
Code und den Skripten orientiert.
## Business Value & Metriken
- Onboarding ohne Rueckfragen fuer neue Entwickler.
- Betriebssicherheit durch klare Runbooks.
- Erfolgsmetrik: 0 offene Doku-Abweichungen im Audit.
## In-Scope (Kiddo Team)
- Neue Doku-Struktur und konsistente Inhalte.
- README auf Einsteigerpfad + Links auf neue Dokus.
- Altdokumente archivieren, nicht loeschen.
## Out-of-Scope
- Funktionale Code-Aenderungen (nur Doku).
- Neue Automatisierungen/CI-Pipelines.
## High-Level Akzeptanzkriterien
- README erfuellt definierte Anforderungen.
- Doku-Struktur gem. Ziel (GETTING_STARTED/USAGE/CONFIGURATION/ARCHITECTURE/DEVELOPMENT/DEPLOYMENT/FAQ/TROUBLESHOOTING).
- Altdoku in docs/_archive/ mit Archiv-Header.
- Interne Links funktionieren und keine inhaltlichen Widersprueche.
## Technische Constraints & Risiken
- Keine spekulativen Inhalte; nur dokumentieren, was im Repo belegt ist.
- Sprachvorgabe: Deutsch, ASCII wo moeglich.
## Zugeordnete User Stories
- US_000040: Doku-Struktur und Inhalte erstellen
- US_000041: Einbindung externer Services dokumentieren
- US_000042: Consumer-Perspektive und Audience-Split ergaenzen

View File

@ -0,0 +1,38 @@
ID: EPIC_000013 | Version: 0.2.3 | Status: Done
By: Codex (GPT-5)
# EPIC_000013: System Telemetry im Dashboard
## Beschreibung
Anzeige von Systemmetriken (CPU, RAM, GPU, Netzwerk) fuer Operatoren im Web-Dashboard.
## Ziel / Business Value
Schneller Ueberblick ueber Systemzustand und Last ohne externe Tools.
## Mission Statement
Stelle aktuelle Telemetriedaten im Dashboard bereit, aktualisiert in kurzen Intervallen.
## Business Value & Metriken
- Operatoren erkennen Engpaesse sofort.
- Erfolgsmetrik: Metriken aktualisieren sich alle 5 Sekunden im UI.
## In-Scope
- API liefert aktuelle CPU/RAM/GPU/Netzwerkwerte.
- Web-UI zeigt Kacheln in einem "System Information" Container.
## Out-of-Scope
- Langzeit-Historie oder Trend-Analysen.
- Alerting oder Threshold-Management.
## High-Level Akzeptanzkriterien
- CPU als Gesamt-% angezeigt.
- RAM: Total und Usage %.
- GPU: VRAM Total und Usage % (falls GPU vorhanden).
- Netzwerk: aktuelle RX/TX in Mbps.
## Technische Constraints & Risiken
- GPU-Metriken sind hardware-/driver-abhaengig.
- Netzwerkwerte benoetigen Delta-Berechnung ueber Zeitfenster.
## Zugeordnete User Stories
- US_000044: Systemmetriken im Dashboard anzeigen

Some files were not shown because too many files have changed in this diff Show More