From 0be1f2e547e4442b23d5f9450974a89d490b2304 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 16 Feb 2026 17:17:42 +0100 Subject: [PATCH] =?UTF-8?q?Phase=201=20=E2=80=94=20Authentication,=20Persi?= =?UTF-8?q?stence=20&=20Settings=20Page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK.md | 464 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 315 insertions(+), 149 deletions(-) diff --git a/TASK.md b/TASK.md index bc967e6..71d1d15 100644 --- a/TASK.md +++ b/TASK.md @@ -1,174 +1,340 @@ -# TASK: Bug fixes from v0.6.2 code scan +# TASK: Phase 1 — Authentication, Persistence & Settings Page -## Context +**Version target:** 0.7.0 +**Repo:** `deploy-felhom-compose` (controller) -Comprehensive code scan of felhom-controller v0.6.2 found 4 minor bugs across templates, shell scripts, and Go code. None are critical, but all should be fixed for correctness. +## Overview -**Current state:** Controller v0.6.2 running on demo-felhom.eu. +Three workstreams in this phase: +1. Implement login/logout authentication for the controller dashboard +2. Persist DB validation results across container restarts +3. Add "Beállítások" (Settings) page with config display and password change -All changes in this task are in the **deploy-felhom-compose** repo only. +All user-editable state is stored in a single `settings.json` file at: +`/opt/docker/felhom-controller/data/settings.json` + +This path is already bind-mounted into the container (the `data/` dir holds `restic-password` etc.). +The controller.yaml config remains the source of truth for operator-provisioned values; +`settings.json` holds customer-modifiable overrides. --- -## Bug 1: Missing `require_arg` for `--hdd-path` in docker-setup.sh +## 1. settings.json — Shared Persistence Layer -**File:** `scripts/docker-setup.sh` - -**Problem:** The `--hdd-path` flag parsing doesn't use `require_arg` validation like all other flags do. Under `set -u`, if `--hdd-path` is the last argument and has no value, `$2` is unbound and the script crashes with a cryptic bash error instead of a friendly message. - -**Current code** (in the argument parsing `while` loop): - -```bash ---hdd-path) HDD_PATH="$2"; shift 2 ;; +### 1.1 Create `internal/settings/settings.go` +``` +File: controller/internal/settings/settings.go ``` -**Fix:** Add `require_arg` call, matching the pattern used by all other flags: - -```bash ---hdd-path) - require_arg "$1" "${2:-}" - HDD_PATH="$2"; shift 2 ;; -``` - -**Verification:** Search for other flags in the same `while` loop — they all use `require_arg`. Confirm `require_arg` is defined earlier in the script (it is). - ---- - -## Bug 2: Implicit `event` variable in `stackAction()` (layout.html) - -**File:** `controller/internal/web/templates/layout.html` - -**Problem:** The `stackAction` JavaScript function references `event.currentTarget` to get the clicked button, but `event` is never passed as a parameter. It relies on the implicit global `window.event` object, which is non-standard and deprecated. Works in Chrome/Firefox today but is not guaranteed. - -**Current code:** - -```javascript -async function stackAction(name, action) { - const btn = event.currentTarget; -``` - -**Fix — Step 1:** Change the function signature to accept `event`: - -```javascript -async function stackAction(event, name, action) { - const btn = event.currentTarget; -``` - -**Fix — Step 2:** Update ALL `onclick` call sites in the same file that call `stackAction` to pass `event` as the first argument. Search for `stackAction(` in the template — each call looks like: - -```html -onclick="stackAction('{{.Name}}', 'start')" -``` - -Change each to: - -```html -onclick="stackAction(event, '{{.Name}}', 'start')" -``` - -There are multiple call sites (start, stop, restart buttons in the stacks section). Update **all** of them. - -**Verification:** Search the entire file for `stackAction(` — every call site must pass `event` as the first argument. No other functions in the codebase call `stackAction`. - ---- - -## Bug 3: Missing separator in page title (layout.html) - -**File:** `controller/internal/web/templates/layout.html` - -**Problem:** The `` tag concatenates `.Title` and "Felhom.eu" with no separator, rendering as e.g. `"VezérlőpultFelhom.eu"` instead of `"Vezérlőpult — Felhom.eu"`. - -**Current code:** - -```html -<title>{{.Title}}Felhom.eu -``` - -**Fix:** - -```html -{{.Title}} — Felhom.eu -``` - -Uses em dash (U+2014) with spaces on both sides. This is a single-character change in the template. - -**Edge case:** If `.Title` is empty, the title becomes ` — Felhom.eu` (leading space + dash). Check if any handler sets an empty `.Title`. If so, consider using a conditional: - -```html -{{if .Title}}{{.Title}} — {{end}}Felhom.eu -``` - -Check all handlers that call `render()` or `renderTemplate()` — if every handler always sets a non-empty `.Title`, the simple fix (without conditional) is fine. - ---- - -## Bug 4: `nextPruneLabel` edge case on Sunday before 4am (funcmap.go) - -**File:** `controller/internal/web/funcmap.go` - -**Problem:** The `nextPruneLabel` function calculates when the next weekly prune (Sunday 4:00) will occur. On Sunday before 4am, `daysUntilSunday` computes to 0, but the function returns the date in `"2006-01-02"` format instead of `"ma"` (Hungarian for "today"). Every other "today" scenario in the codebase uses the `"ma"` label. - -**Current code:** - +Define the settings struct and load/save logic: ```go -daysUntilSunday := (7 - int(now.Weekday())) % 7 -if daysUntilSunday == 0 && now.Hour() >= 4 { - daysUntilSunday = 7 +type Settings struct { + mu sync.RWMutex `json:"-"` + + // Auth + PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml + + // Notification preferences (Phase 2 — define struct now, leave empty) + Notifications *NotificationPrefs `json:"notifications,omitempty"` + + // Cached state + DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` +} + +type NotificationPrefs struct { + // placeholder for Phase 2 +} + +type DBValidationCache struct { + ValidatedAt string `json:"validated_at"` // RFC3339 + TableCount int `json:"table_count"` + HasHeader bool `json:"has_header"` + Error string `json:"error,omitempty"` } -next := time.Date(now.Year(), now.Month(), now.Day()+daysUntilSunday, 4, 0, 0, 0, now.Location()) -return next.Format("2006-01-02") ``` -The logic breakdown: -- Sunday, hour >= 4: `daysUntilSunday` = 0 → set to 7 (next week). Correct. -- Sunday, hour < 4: `daysUntilSunday` = 0 → stays 0, returns today's date as `"2006-01-02"`. Should return `"ma"`. -- Any other day: `daysUntilSunday` > 0 → returns future date. Correct. +**Behavior:** +- `Load(path string) (*Settings, error)` — reads file, returns empty Settings{} if file doesn't exist (not an error) +- `Save() error` — atomic write (write to `.tmp`, rename). Path stored internally from Load. +- Use `sync.RWMutex` for concurrent access (backup goroutine writes validations, web reads them) +- Log on every save: `[DEBUG] Settings saved to ` -**Fix:** +**File location:** The controller's docker-compose.yml already mounts `./data:/opt/docker/felhom-controller/data`. +The settings file path should be passed to the Settings manager on init. -```go -daysUntilSunday := (7 - int(now.Weekday())) % 7 -if daysUntilSunday == 0 { - if now.Hour() >= 4 { - daysUntilSunday = 7 // Already ran today, next week - } else { - return "ma" // Today (Sunday), hasn't run yet - } -} -next := time.Date(now.Year(), now.Month(), now.Day()+daysUntilSunday, 4, 0, 0, 0, now.Location()) -return next.Format("2006-01-02") -``` - -**Verification:** Mentally walk through all cases: -- Monday–Saturday: `daysUntilSunday` is 1–6, returns future date ✓ -- Sunday 03:00: returns `"ma"` ✓ -- Sunday 04:00: `daysUntilSunday` = 7, returns next Sunday ✓ -- Sunday 23:00: `daysUntilSunday` = 7, returns next Sunday ✓ +**Testing:** On startup, if file doesn't exist, log `[INFO] No settings.json found, using defaults`. Do NOT create the file until something actually needs saving. --- -## Build & Deploy +## 2. Authentication (Login / Logout) -After all fixes, commit and deploy as v0.6.3: +### 2.1 Password resolution -```bash -# 1. Commit -cd /e/git/deploy-felhom-compose -git add -A && git commit -m "fix: require_arg for --hdd-path, explicit event in stackAction, title separator, nextPruneLabel Sunday edge case" && git push +The effective password hash is determined with this priority: +1. `settings.json` → `password_hash` (customer changed it) +2. `controller.yaml` → `web.password_hash` (operator provisioned) +3. Empty string → no auth required (current testing behavior) -# 2. Build (only needed for bugs 2-4 which affect the controller binary/templates) -ssh kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && ./build.sh 0.6.3 --push" - -# 3. Deploy to demo node -ssh kisfenyo@192.168.0.162 "docker pull gitea.dooplex.hu/admin/felhom-controller:0.6.3 && cd /opt/docker && docker compose up -d" - -# 4. Verify -ssh kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 5" +On startup, log which source is active: +``` +[INFO] Auth: using password from settings.json +[INFO] Auth: using password from controller.yaml +[INFO] Auth: no password configured — dashboard is open ``` -## Post-deploy checklist +### 2.2 Session management -- [ ] Page title shows separator: "Vezérlőpult — Felhom.eu" (check browser tab) -- [ ] Stack start/stop/restart buttons still work (Bug 2 didn't break onclick handlers) -- [ ] `docker-setup.sh --hdd-path` without value shows friendly error (test locally) -- [ ] Backup page shows "ma" on Sunday before 4am (only testable at that time, or adjust system clock) \ No newline at end of file +Use a **signed session cookie** approach (not just `"authenticated"` string): +- Generate a random 32-byte session secret on startup (store in memory only, not persisted — restarts invalidate sessions, which is fine) +- Cookie name: `felhom_session` +- Cookie value: `.` — HMAC of expiry timestamp with session secret +- `HttpOnly: true`, `SameSite: Strict`, `Secure: false` (local network, self-signed certs) +- `MaxAge: 7 days` (configurable later) +- `Path: /` + +### 2.3 Routes + +Add to `internal/web/server.go` routing: + +| Method | Path | Auth? | Handler | +|--------|-------------|-------|--------------------| +| GET | `/login` | No | Show login form | +| POST | `/login` | No | Validate + set cookie + redirect to `/` | +| GET/POST | `/logout` | No | Clear cookie + redirect to `/login` | + +**Auth middleware** (wrap all routes except `/login`, `/logout`, `/style.css`, `/assets/*`, `/api/*`): +- Check `felhom_session` cookie → validate HMAC + check expiry +- If invalid/missing → redirect to `/login` (for browser) or return 401 (for API) +- If no password configured → pass through (no auth) + +### 2.4 Login page template +``` +File: controller/internal/web/templates/login.html +``` + +- Standalone page (no sidebar), same dark theme as rest of dashboard +- Felhom logo centered at top +- Single password field + "Bejelentkezés" button +- On error: "Hibás jelszó" message (red, below form) +- Customer name shown below logo (from config) +- No username field — single-user system + +### 2.5 Logout + +- `GET /logout` or `POST /logout` → set cookie MaxAge=-1 → redirect to `/login` +- Add logout link to sidebar bottom (near version display): +``` + | Kijelentkezés +``` + Only show "Kijelentkezés" if auth is enabled. + +### 2.6 Edge cases + +- If password_hash is empty in both sources → no auth, no login page, no logout link +- If user is on a page and session expires → next request redirects to `/login`, after login redirect back to original page (use `?next=/backups` query param) +- Cookie cleared on logout must work even if server secret rotated (clear by MaxAge=-1) + +--- + +## 3. DB Validation Persistence + +### 3.1 Problem + +After container restart, the backup page "Érvényesítés" column shows "–" for all databases until the next backup cycle runs validation. The v0.6.2 cross-check helps once RefreshCache runs, but the initial load after restart still shows no data. + +### 3.2 Solution + +**On validation completion** (`internal/backup/dbdump.go` → `ValidateDump`): +- After successful validation, save result to `settings.json` via settings package: +```go + settings.SetDBValidation("immich-postgres.sql", DBValidationCache{ + ValidatedAt: time.Now().Format(time.RFC3339), + TableCount: 60, + HasHeader: true, + }) + // SetDBValidation acquires write lock, updates map, calls Save() +``` + +**On startup / RefreshCache** (`internal/backup/backup.go`): +- Load cached validations from `settings.json` +- For each dump file that exists on disk AND has a cached validation: + - Use the cached validation data + - Set `DumpValidation.Valid = true/false` based on cached result + - Set `DumpValidation.Message` to include cached info: e.g., `"60 tábla (utolsó: 08:04)"` +- The next actual validation run overwrites the cache with fresh data + +**Important:** The cache is keyed by dump filename (e.g., `immich-postgres.sql`). If a dump file no longer exists, its cached validation is ignored (stale data cleanup). + +### 3.3 Template update + +No template changes needed — the existing 4-branch guard from v0.6.2 already handles showing validation status correctly. The only difference is now the data will be populated from cache on startup instead of being zero-valued. + +--- + +## 4. "Beállítások" (Settings) Page + +### 4.1 Sidebar menu item + +Add "Beállítások" as the last menu item, visually separated from the main navigation — placed at the bottom of the sidebar, just above the version/logout section. Use a gear/cog icon (⚙ or SVG). + +Sidebar order: +``` +Vezérlőpult +Alkalmazások +Biztonsági mentés +Rendszermonitor +─── (spacer / flex-grow) ─── +⚙ Beállítások ← new, pinned to bottom +v0.7.0 | Kijelentkezés +``` + +### 4.2 Route + +| Method | Path | Auth? | Handler | +|--------|-------------|-------|---------------------| +| GET | `/settings` | Yes | Show settings page | +| POST | `/settings/password` | Yes | Change password | + +### 4.3 Settings page layout + +The page has two sections: + +#### Section A: "Rendszer konfiguráció" (System Configuration) — Read-only + +Display key values from `controller.yaml` in a clean info-grid. These are read-only — the customer can see what's configured but can't change it here. + +| Label (Hungarian) | Source in controller.yaml | Display format | +|--------------------------|----------------------------------|----------------| +| Ügyfél azonosító | `customer.id` | `demo-felhom` | +| Ügyfél neve | `customer.name` | `Demo Ügyfél` | +| Domain | `customer.domain` | `demo-felhom.eu` | +| Alkalmazás sablon forrás | `git.repo_url` | URL (truncated) | +| Sablon szinkronizálás | `git.sync_interval` | `15m` | +| Biztonsági mentés | `backup.enabled` | ✅ Aktív / ❌ Inaktív | +| Mentés ütemezés | `backup.db_dump_schedule` + `backup.restic_schedule` | `02:30 / 03:00` | +| Monitoring | `monitoring.enabled` | ✅ Aktív / ❌ Inaktív | +| Healthchecks URL | `monitoring.healthchecks_base` | URL or "–" | +| Hub jelentés | `hub.enabled` (if exists) | ✅ Aktív / ❌ Inaktív / "–" | +| Controller verzió | built-in Version constant | `0.7.0` | + +Use green checkmark / red X styling for boolean states, consistent with the backup page. + +#### Section B: "Jelszó módosítás" (Change Password) — Editable + +Only shown if auth is enabled (password_hash is set). If no auth, show an info message: +"A jelszavas védelem nincs beállítva. Kérd az üzemeltetőt a beállításhoz." + +Form fields: +- Jelenlegi jelszó (current password) — required +- Új jelszó (new password) — required, min 8 chars +- Új jelszó megerősítése (confirm new password) — required, must match + +**POST `/settings/password` handler:** +1. Validate current password against effective hash (bcrypt compare) +2. Validate new password: min 8 chars, both fields match +3. Generate bcrypt hash (cost 10) for new password +4. Save to `settings.json` → `password_hash` +5. Invalidate current session (regenerate session secret so all cookies become invalid) +6. Redirect to `/login` with success flash message: "Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval." + +**Error handling:** +- Wrong current password → "Hibás jelenlegi jelszó" (stay on page) +- Passwords don't match → "A két jelszó nem egyezik" +- Too short → "A jelszónak legalább 8 karakter hosszúnak kell lennie" +- Show errors inline, don't clear form + +### 4.4 Template +``` +File: controller/internal/web/templates/settings.html +``` + +Follow the same card-based layout as the backup page. Two cards: +1. "Rendszer konfiguráció" — info-grid with labels + values +2. "Jelszó módosítás" — form card + +--- + +## 5. Implementation Order + +Follow this sequence to keep each step testable: + +### Step 1: settings.json package +- Create `internal/settings/settings.go` with Settings struct, Load/Save +- Add settings instance to the controller's main app struct +- Load on startup, log result +- **Test:** Start controller, check logs for settings load message + +### Step 2: Authentication +- Add session secret generation on startup +- Add auth middleware +- Add login.html template +- Add login/logout handlers +- Add logout link to sidebar +- Wire up routes in server.go +- Set `web.password_hash` in controller.yaml on demo to test +- **Test:** Navigate to dashboard → redirected to /login → enter password → dashboard loads → /logout → back to /login + +### Step 3: DB validation persistence +- After ValidateDump completes, save results to settings.json +- On RefreshCache, load cached validations for initial display +- **Test:** Deploy apps with DBs → trigger backup → check Érvényesítés column shows data → restart container → check column still shows data + +### Step 4: Settings page +- Add settings.html template +- Add settings route + handler +- Add sidebar menu item with bottom-pinning +- Implement password change POST handler +- **Test:** Open /settings → see config values → change password → re-login with new password + +### Step 5: Cleanup & version bump +- Update CONTEXT.md +- Bump version to 0.7.0 +- Build + deploy + verify on demo-felhom.eu + +--- + +## 6. Files to Create / Modify + +### New files: +- `controller/internal/settings/settings.go` — Settings persistence +- `controller/internal/web/templates/login.html` — Login page +- `controller/internal/web/templates/settings.html` — Settings page + +### Modified files: +- `controller/internal/web/server.go` — Add auth middleware, new routes (/login, /logout, /settings, /settings/password), session management, settings page handler +- `controller/internal/web/templates/` sidebar partial or base template — Add "Beállítások" menu item at bottom, logout link +- `controller/internal/backup/backup.go` — Load cached validations in RefreshCache +- `controller/internal/backup/dbdump.go` — Save validation results to settings.json +- `controller/internal/config/config.go` — Possibly add data_dir path helper +- `controller/cmd/controller/main.go` — Initialize settings, pass to web server and backup manager + +--- + +## 7. Design Decisions & Notes + +### Why settings.json (not SQLite)? +- Single file, human-debuggable, easy to backup/restore +- Tiny data volume (password hash + a few validation entries) +- No query needs — just load/save whole struct +- Customers or operators can inspect/reset it easily + +### Why not modify controller.yaml for password changes? +- controller.yaml is operator-provisioned config, risky to programmatically rewrite YAML +- settings.json is a clean override layer: operator sets initial password in yaml, customer changes it in json +- If settings.json is deleted, system falls back to controller.yaml password (recovery path) + +### Session secret is ephemeral (memory only) +- Container restart = all sessions invalidated = users must re-login +- This is acceptable and actually desirable for security +- No need to persist session state + +### Notifications (Phase 2 prep) +- The Settings struct includes a `Notifications` field placeholder +- Phase 2 will add: email relay via k3s (similar to contact-mailer pattern), notification preferences UI +- The relay approach: customer controller sends HTTP POST to a central notification API on k3s, which handles Resend delivery. Avoids storing Resend API keys on customer hardware. +- This keeps secrets centralized and customer nodes lightweight. + +### Settings page scope — what goes where +- "Beállítások" = actual settings (things the user can configure or needs to know about their setup) +- "Rendszermonitor" = live system state (hostname, uptime, CPU, RAM, disk, Docker containers) +- No overlap — config is static, monitoring is dynamic \ No newline at end of file