Phase 1 — Authentication, Persistence & Settings Page

This commit is contained in:
2026-02-16 17:17:42 +01:00
parent af98d4f48f
commit 0be1f2e547
+315 -149
View File
@@ -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 `<title>` 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</title>
```
**Fix:**
```html
<title>{{.Title}} — Felhom.eu</title>
```
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
<title>{{if .Title}}{{.Title}} — {{end}}Felhom.eu</title>
```
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 <path>`
**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:
- MondaySaturday: `daysUntilSunday` is 16, 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)
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: `<expiry_unix>.<hmac_sha256_hex>` — 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):
```
<version> | 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