diff --git a/cmd/dogama/main.go b/cmd/dogama/main.go index d5dd40f..f0a0fa1 100644 --- a/cmd/dogama/main.go +++ b/cmd/dogama/main.go @@ -9,16 +9,19 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" "time" catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web" ) @@ -55,6 +58,21 @@ func run(logger *slog.Logger) error { var handler http.Handler var lifecycle *instance.LifecycleService var backupService *backup.Service + auditService := audit.New(db) + var notificationService *notification.Service + if keyFile := os.Getenv("DOGAMA_MASTER_KEY_FILE"); keyFile != "" { + key, keyErr := os.ReadFile(keyFile) + if keyErr != nil { + return errors.New("read encryption key file") + } + key = bytes.TrimSpace(key) + notificationService, keyErr = notification.New(db, key) + if keyErr != nil { + return keyErr + } + } else { + logger.Warn("notification channel configuration disabled: DOGAMA_MASTER_KEY_FILE is unset", "event", "notification.disabled") + } importService, err := importexport.New(repository, environment("DOGAMA_IMPORTS_ROOT", "/var/lib/dogama/imports/staging"), serversRoot) if err != nil { return err @@ -62,7 +80,6 @@ func run(logger *slog.Logger) error { agentURL, tokenFile := os.Getenv("DOGAMA_AGENT_URL"), os.Getenv("DOGAMA_AGENT_TOKEN_FILE") if agentURL == "" && tokenFile == "" { logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled") - handler, err = web.NewHandlerWithRepositoryAndImports(auth.New(db), repository, importService, logger) } else { if agentURL == "" || tokenFile == "" { return errors.New("DOGAMA_AGENT_URL and DOGAMA_AGENT_TOKEN_FILE must be configured together") @@ -90,8 +107,8 @@ func run(logger *slog.Logger) error { logger.Warn("instance reconciliation incomplete", "event", "lifecycle.reconcile.failed") } cancel() - handler, err = web.NewHandlerWithLifecycleAndBackup(auth.New(db), repository, agent, backupService, importService, logger) } + handler, err = web.NewHandlerComplete(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, logger) if err != nil { return err } @@ -102,6 +119,7 @@ func run(logger *slog.Logger) error { go runBackupScheduler(ctx, backupService, logger) } go runImportCleanup(ctx, importService, logger) + go runObservabilityScheduler(ctx, auditService, notificationService, logger) server := &http.Server{ Addr: listenAddress, Handler: handler, @@ -128,6 +146,33 @@ func run(logger *slog.Logger) error { } } +func runObservabilityScheduler(ctx context.Context, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + lastPurgeDay := "" + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + if notificationService != nil { + if err := notificationService.RunDue(ctx); err != nil { + logger.Warn("notification delivery run incomplete", "event", "notification.scheduler.failed") + } + } + day := now.UTC().Format("2006-01-02") + if day != lastPurgeDay { + if deleted, err := auditService.RunRetention(ctx); err != nil { + logger.Warn("audit retention incomplete", "event", "audit.retention.failed") + } else if deleted > 0 { + _ = auditService.Record(ctx, audit.Event{ActorLabel: "system", Action: "audit.retention.purge", Outcome: "allowed", Summary: map[string]string{"deleted_count": strconv.FormatInt(deleted, 10)}}) + } + lastPurgeDay = day + } + } + } +} + func runImportCleanup(ctx context.Context, service *importexport.Service, logger *slog.Logger) { ticker := time.NewTicker(time.Hour) defer ticker.Stop() diff --git a/docs/PROJECT-STATE.md b/docs/PROJECT-STATE.md index 0c7218c..18198b1 100644 --- a/docs/PROJECT-STATE.md +++ b/docs/PROJECT-STATE.md @@ -4,9 +4,9 @@ Read this compact operational baseline before starting a milestone. Open detaile ## Baseline -- Current reference: milestone 8 implementation `7f5fa30` after baseline `c820c9c`. -- Released SQLite migrations: `0001` through `0008`; never rewrite them. -- Roadmap milestones 1-8 are implemented. +- Current reference: milestone 9 working branch after merged milestone 8 baseline `1e226d3`. +- Released SQLite migrations: `0001` through `0009`; never rewrite them. +- Roadmap milestones 1-9 are implemented. ## Architecture @@ -28,6 +28,9 @@ Read this compact operational baseline before starting a milestone. Open detaile - Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback. - Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback. - Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement. +- Encrypted write-only SMTP, generic HTTPS webhook and Discord channels with event filters, queued test delivery, bounded retry and redacted terminal errors. +- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures. +- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement. ## Durable decisions @@ -44,10 +47,14 @@ Read this compact operational baseline before starting a milestone. Open detaile - The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields. - Update candidates are explicit `tag@sha256:digest` references. Mutable tags alone are rejected; automatic updates remain disabled. - Mod configuration is data-only. Provider commands, scripts and arbitrary download URLs are forbidden. +- Notification configuration is unavailable unless `DOGAMA_MASTER_KEY_FILE` contains exactly 32 bytes; ciphertext is authenticated AES-GCM and secrets are never returned by list APIs. +- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result. +- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds. ## Known limitations and debt -- Notification channels, audit delivery/retention UI and release hardening remain roadmap work. +- Release hardening remains roadmap work. +- Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows. - The web interface is intentionally modest; several advanced workflows are API-first. - Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution. - `staticcheck`, `golangci-lint` and Python specification dependencies may not be installed on every development host; report missing tooling rather than silently skipping or installing it. @@ -61,5 +68,5 @@ Read this compact operational baseline before starting a milestone. Open detaile ## Next known work -- Roadmap milestone 9: notifications and light audit trail. +- Roadmap milestone 10: security hardening, end-to-end tests, contributor documentation and release packaging. - Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements. diff --git a/docs/contributing/development.md b/docs/contributing/development.md index abd3411..a9419b5 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -28,7 +28,7 @@ tests/integration/ ## Initial application development -The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL` and `DOGAMA_AGENT_TOKEN_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. Run it with: +The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL`, `DOGAMA_AGENT_TOKEN_FILE` and `DOGAMA_MASTER_KEY_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. The master-key file must contain exactly 32 bytes and enables encrypted notification-channel configuration; audit remains available without it. Run it with: ```sh go run ./cmd/dogama diff --git a/docs/operations/notifications-and-audit.md b/docs/operations/notifications-and-audit.md index ab62d0c..52988df 100644 --- a/docs/operations/notifications-and-audit.md +++ b/docs/operations/notifications-and-audit.md @@ -10,6 +10,14 @@ Administrators configure channels entirely in the UI: Channel secrets are encrypted and write-only. A test action sends a clearly marked test message and reports a redacted result. +The main application reads the 32-byte authenticated-encryption key from +`DOGAMA_MASTER_KEY_FILE`. Without that external key, audit remains available +but channel configuration and delivery are disabled. Generic and Discord +webhooks require HTTPS; resolution, redirects and every resolved address reject +loopback, private, link-local, multicast and unspecified networks. Generic +webhooks carry `X-DoGaMa-Event-ID`, `X-DoGaMa-Timestamp` and, when a signing +secret is configured, an HMAC-SHA256 `X-DoGaMa-Signature`. + ## Events and filtering Suggested configurable events: @@ -58,5 +66,8 @@ Entries are compact and use allow-listed structured summaries. Player IDs may be Audit retention defaults to 30 days and is globally administrator-configurable. An optional maximum count prevents unbounded growth. Purge runs daily and records one aggregate audit event, not an event per deleted row. Unlimited retention requires an explicit warning and displays database usage. Administrators may manually purge by date with confirmation. -Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy. +The V1 bounds are 0–3650 retention days and 0–1,000,000 entries; zero means +unlimited. The viewer returns at most 200 entries per request and supports time, +actor, instance, action and outcome filters through the administration API. +Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy. diff --git a/internal/web/admin_observability.go b/internal/web/admin_observability.go new file mode 100644 index 0000000..cf6a34a --- /dev/null +++ b/internal/web/admin_observability.go @@ -0,0 +1,257 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + "time" + + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification" +) + +func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) { + var user auth.User + var ok bool + if api { + user, ok = s.requireAPIUser(w, r, true) + } else { + var err error + user, err = s.currentUser(r) + ok = err == nil && user.Role == "admin" + if ok { + session, e := r.Cookie(sessionCookie) + ok = e == nil && s.parseForm(w, r) && s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) + } + } + if !ok { + return auth.User{}, false + } + if time.Since(user.AuthenticatedAt) > 10*time.Minute { + if api { + s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.") + } else { + s.problem(w, http.StatusForbidden, "Recent authentication is required.") + } + return auth.User{}, false + } + return user, true +} +func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) { + if s.audit == nil { + return + } + if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil { + s.logger.Warn("audit recording failed", "event", "audit.record.failed") + } +} + +func (s *server) auditList(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAPIUser(w, r, true); !ok { + return + } + f := audit.Filter{ActorID: r.URL.Query().Get("actor_id"), InstanceID: r.URL.Query().Get("instance_id"), Action: r.URL.Query().Get("action"), Outcome: r.URL.Query().Get("outcome")} + events, err := s.audit.List(r.Context(), f) + if err != nil { + s.apiProblem(w, 500, "audit_unavailable", "Audit events are unavailable.") + return + } + s.apiJSON(w, 200, map[string]any{"events": events}) +} +func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAPIUser(w, r, true); !ok { + return + } + p, err := s.audit.Policy(r.Context()) + if err != nil { + s.apiProblem(w, 500, "audit_unavailable", "Audit policy is unavailable.") + return + } + s.apiJSON(w, 200, p) +} +func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, true) + if !ok { + return + } + var p audit.Policy + if !s.decodeAPIJSON(w, r, &p) { + return + } + if err := s.audit.SetPolicy(r.Context(), p); err != nil { + s.recordAudit(r, actor, "audit.policy.update", "failed", map[string]string{"reason_code": "invalid_policy"}) + s.apiProblem(w, 422, "invalid_policy", "The audit policy is invalid.") + return + } + s.recordAudit(r, actor, "audit.policy.update", "allowed", nil) + s.apiJSON(w, 200, p) +} +func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, true) + if !ok { + return + } + var in struct { + Before string `json:"before"` + Confirm bool `json:"confirm"` + } + if !s.decodeAPIJSON(w, r, &in) || !in.Confirm { + s.apiProblem(w, 422, "confirmation_required", "Purge confirmation is required.") + return + } + before, err := time.Parse(time.RFC3339, in.Before) + if err != nil { + s.apiProblem(w, 422, "invalid_boundary", "The purge boundary is invalid.") + return + } + n, err := s.audit.Purge(r.Context(), before) + if err != nil { + s.apiProblem(w, 422, "purge_failed", "The audit purge failed.") + return + } + s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)}) + s.apiJSON(w, 200, map[string]int64{"deleted_count": n}) +} + +func (s *server) notificationList(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAPIUser(w, r, true); !ok { + return + } + values, err := s.notifications.List(r.Context()) + if err != nil { + s.apiProblem(w, 500, "channels_unavailable", "Notification channels are unavailable.") + return + } + s.apiJSON(w, 200, map[string]any{"channels": values}) +} +func (s *server) notificationCreate(w http.ResponseWriter, r *http.Request) { + s.notificationUpsert(w, r, "") +} +func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) { + s.notificationUpsert(w, r, r.PathValue("id")) +} +func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) { + actor, ok := s.requireRecentAdmin(w, r, true) + if !ok { + return + } + var in notification.Input + if !s.decodeAPIJSON(w, r, &in) { + return + } + value, err := s.notifications.Upsert(r.Context(), id, in) + if err != nil { + s.recordAudit(r, actor, "notification.channel.update", "failed", map[string]string{"channel_type": in.Type, "reason_code": "invalid_channel"}) + s.apiProblem(w, 422, "invalid_channel", "The notification channel is invalid.") + return + } + s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type}) + status := http.StatusOK + if id == "" { + status = http.StatusCreated + } + s.apiJSON(w, status, value) +} +func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, true) + if !ok { + return + } + if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil { + s.apiProblem(w, 500, "delete_failed", "The channel could not be deleted.") + return + } + s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")}) + w.WriteHeader(http.StatusNoContent) +} +func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, true) + if !ok { + return + } + if !s.requireEmptyBody(w, r) { + return + } + if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil { + s.apiProblem(w, 404, "channel_not_found", "The channel was not found.") + return + } + s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"}) + s.apiJSON(w, 202, map[string]string{"status": "queued"}) +} + +func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, false) + if !ok { + return + } + config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")} + value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config}) + if err != nil { + s.problem(w, 422, "Invalid notification channel.") + return + } + s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type}) + http.Redirect(w, r, "/", http.StatusSeeOther) +} +func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, false) + if !ok { + return + } + if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil { + s.problem(w, 404, "Notification channel not found.") + return + } + s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"}) + http.Redirect(w, r, "/", http.StatusSeeOther) +} +func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, false) + if !ok { + return + } + if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil { + s.problem(w, 500, "Notification channel could not be deleted.") + return + } + s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")}) + http.Redirect(w, r, "/", http.StatusSeeOther) +} +func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, false) + if !ok { + return + } + days, _ := strconv.Atoi(r.FormValue("retention_days")) + maximum, _ := strconv.Atoi(r.FormValue("maximum_count")) + if err := s.audit.SetPolicy(r.Context(), audit.Policy{RetentionDays: days, MaximumCount: maximum}); err != nil { + s.problem(w, 422, "Invalid audit policy.") + return + } + s.recordAudit(r, actor, "audit.policy.update", "allowed", nil) + http.Redirect(w, r, "/", http.StatusSeeOther) +} +func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireRecentAdmin(w, r, false) + if !ok { + return + } + if r.FormValue("confirm") != "yes" { + s.problem(w, 422, "Purge confirmation is required.") + return + } + before, err := time.Parse("2006-01-02", r.FormValue("before")) + if err != nil { + s.problem(w, 422, "Invalid purge date.") + return + } + n, err := s.audit.Purge(r.Context(), before) + if err != nil { + s.problem(w, 422, "Audit purge failed.") + return + } + s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)}) + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/web/server.go b/internal/web/server.go index 7a56bf4..c19f215 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -16,15 +16,18 @@ import ( "net/http" "regexp" "strconv" + "strings" "time" catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification" ) const ( @@ -58,14 +61,16 @@ var englishMessages = map[string]string{ } type server struct { - auth *auth.Service - templates *template.Template - logger *slog.Logger - repository repository - lifecycle *instance.LifecycleService - permissions *authorization.Service - backups *backup.Service - imports *importexport.Service + auth *auth.Service + templates *template.Template + logger *slog.Logger + repository repository + lifecycle *instance.LifecycleService + permissions *authorization.Service + backups *backup.Service + imports *importexport.Service + audit *audit.Service + notifications *notification.Service } type repository interface { @@ -76,12 +81,19 @@ type repository interface { } type pageData struct { - Title string - CSRFToken string - Error string - User auth.User - GlobalLabels string - IsAdmin bool + Title string + CSRFToken string + Error string + User auth.User + GlobalLabels string + IsAdmin bool + Channels []notification.Channel + AuditEvents []audit.Event + AuditPolicy audit.Policy + AuditActor string + AuditInstance string + AuditAction string + AuditOutcome string } // NewHandler constructs the complete HTTP application. @@ -109,16 +121,25 @@ func NewHandlerWithLifecycleAndBackup(authService *auth.Service, repository repo return newHandlerWithImports(authService, repository, instance.NewLifecycleService(repository, agent), backupService, importService, logger) } +// NewHandlerComplete enables the milestone-nine administration services. +func NewHandlerComplete(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) { + return newHandlerServices(authService, repository, lifecycle, backupService, importService, auditService, notificationService, logger) +} + func newHandler(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, logger *slog.Logger) (http.Handler, error) { return newHandlerWithImports(authService, repository, lifecycle, backupService, nil, logger) } func newHandlerWithImports(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) { + return newHandlerServices(authService, repository, lifecycle, backupService, importService, nil, nil, logger) +} + +func newHandlerServices(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) { templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html") if err != nil { return nil, err } - s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService} + s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, notifications: notificationService} if repository != nil { s.permissions = authorization.New(repository) } @@ -135,6 +156,19 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif mux.HandleFunc("POST /api/v1/admin/users", s.userCreate) mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet) mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut) + if auditService != nil { + mux.HandleFunc("GET /api/v1/admin/audit", s.auditList) + mux.HandleFunc("GET /api/v1/admin/audit-policy", s.auditPolicyGet) + mux.HandleFunc("PUT /api/v1/admin/audit-policy", s.auditPolicyPut) + mux.HandleFunc("POST /api/v1/admin/audit/purge", s.auditPurge) + } + if notificationService != nil { + mux.HandleFunc("GET /api/v1/admin/notification-channels", s.notificationList) + mux.HandleFunc("POST /api/v1/admin/notification-channels", s.notificationCreate) + mux.HandleFunc("PUT /api/v1/admin/notification-channels/{id}", s.notificationUpdate) + mux.HandleFunc("DELETE /api/v1/admin/notification-channels/{id}", s.notificationDelete) + mux.HandleFunc("POST /api/v1/admin/notification-channels/{id}/test", s.notificationTest) + } mux.HandleFunc("GET /api/v1/instances/{id}/memberships", s.membershipList) mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet) mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete) @@ -176,8 +210,107 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif mux.HandleFunc("POST /login", s.loginSubmit) mux.HandleFunc("POST /logout", s.logout) mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm) + mux.HandleFunc("POST /admin/notification-channels", s.notificationForm) + mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm) + mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm) + mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm) + mux.HandleFunc("POST /admin/audit-purge", s.auditPurgeForm) mux.HandleFunc("GET /", s.home) - return s.securityHeaders(mux), nil + return s.securityHeaders(s.auditRequests(mux)), nil +} + +type auditResponseWriter struct { + http.ResponseWriter + status int +} + +func (w *auditResponseWriter) WriteHeader(status int) { + if w.status == 0 { + w.status = status + } + w.ResponseWriter.WriteHeader(status) +} +func (w *auditResponseWriter) Write(body []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + return w.ResponseWriter.Write(body) +} + +func (s *server) auditRequests(next http.Handler) http.Handler { + if s.audit == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + action := auditAction(r.Method, r.URL.Path) + if action == "" { + next.ServeHTTP(w, r) + return + } + actor, _ := s.currentUser(r) + wrapped := &auditResponseWriter{ResponseWriter: w} + next.ServeHTTP(wrapped, r) + status := wrapped.status + if status == 0 { + status = http.StatusOK + } + outcome := "allowed" + if status == http.StatusUnauthorized || status == http.StatusForbidden { + outcome = "denied" + } else if status >= 400 { + outcome = "failed" + } + summary := map[string]string{} + if id := r.PathValue("id"); id != "" { + summary["target_id"] = id + } + instanceID := "" + if status < 400 && strings.HasPrefix(r.URL.Path, "/api/v1/instances/") { + instanceID = r.PathValue("id") + } + _ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, InstanceID: instanceID, Action: action, Outcome: outcome, Summary: summary}) + }) +} + +func auditAction(method, path string) string { + if method == http.MethodGet || strings.HasPrefix(path, "/api/v1/admin/audit") || strings.HasPrefix(path, "/api/v1/admin/notification") || strings.HasPrefix(path, "/admin/audit") || strings.HasPrefix(path, "/admin/notification") { + return "" + } + switch { + case path == "/api/v1/admin/users": + return "user.create" + case strings.Contains(path, "/memberships/") && strings.Contains(path, "/permissions/"): + return "permission.override.change" + case strings.Contains(path, "/memberships/"): + return "membership.change" + case strings.Contains(path, "installation-requests") && strings.HasSuffix(path, "/review"): + return "installation_request.review" + case strings.Contains(path, "/backups/") && strings.HasSuffix(path, "/restore"): + return "backup.restore" + case strings.HasSuffix(path, "/backups"): + return "backup.create" + case path == "/api/v1/imports": + return "import.create" + case strings.HasSuffix(path, "/update"): + return "instance.update" + case strings.Contains(path, "configuration-revisions") && strings.HasSuffix(path, "/rollback"): + return "configuration.rollback" + case strings.HasSuffix(path, "/start"): + return "instance.start" + case strings.HasSuffix(path, "/stop"): + return "instance.stop" + case strings.HasSuffix(path, "/restart"): + return "instance.restart" + case strings.HasSuffix(path, "/install"): + return "instance.create" + case strings.HasPrefix(path, "/api/v1/instances/") && method == http.MethodDelete: + return "instance.delete" + case strings.Contains(path, "container-configuration") || strings.HasSuffix(path, "/mods"): + return "instance.configuration.change" + case strings.Contains(path, "game-container-labels"): + return "security.configuration.change" + } + return "" } var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) @@ -498,9 +631,11 @@ func (s *server) instanceUpdate(w http.ResponseWriter, r *http.Request) { } result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID) if err != nil { + s.queueNotification(r, notification.Event{Type: "update.failed", Title: "Update failed", Message: "The instance update failed.", InstanceName: current.Preview.DisplayName}) s.apiProblem(w, 422, "update_failed", err.Error()) return } + s.queueNotification(r, notification.Event{Type: "update.completed", Title: "Update completed", Message: "The instance update completed.", InstanceName: current.Preview.DisplayName}) s.apiJSON(w, 200, result) } @@ -768,9 +903,11 @@ func (s *server) backupCreate(w http.ResponseWriter, r *http.Request) { } value, err := s.backups.Create(r.Context(), actor.ID, r.PathValue("id"), "manual") if err != nil { + s.queueNotification(r, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "The manual backup failed."}) s.backupProblem(w, err) return } + s.queueNotification(r, notification.Event{Type: "backup.completed", Title: "Backup completed", Message: "The manual backup completed.", OperationID: value.ID}) s.apiJSON(w, http.StatusCreated, value) } @@ -796,9 +933,11 @@ func (s *server) backupRestore(w http.ResponseWriter, r *http.Request) { return } if err := s.backups.Restore(r.Context(), actor.ID, r.PathValue("id"), r.PathValue("backupID")); err != nil { + s.queueNotification(r, notification.Event{Type: "restore.failed", Title: "Restore failed", Message: "The backup restore failed."}) s.backupProblem(w, err) return } + s.queueNotification(r, notification.Event{Type: "restore.completed", Title: "Restore completed", Message: "The backup restore completed."}) s.apiJSON(w, http.StatusOK, map[string]string{"state": "restored"}) } @@ -1033,6 +1172,7 @@ func (s *server) installationRequestCreate(w http.ResponseWriter, r *http.Reques s.authorizationProblem(w, err) return } + s.queueNotification(r, notification.Event{Type: "installation_request.required", Title: "Installation request submitted", Message: "An installation request requires administrator review."}) s.apiJSON(w, http.StatusCreated, created) } @@ -1066,9 +1206,19 @@ func (s *server) installationRequestReview(w http.ResponseWriter, r *http.Reques s.authorizationProblem(w, err) return } + s.queueNotification(r, notification.Event{Type: "installation_request.completed", Title: "Installation request reviewed", Message: "An installation request was reviewed."}) s.apiJSON(w, http.StatusOK, reviewed) } +func (s *server) queueNotification(r *http.Request, event notification.Event) { + if s.notifications == nil { + return + } + if err := s.notifications.Queue(r.Context(), event); err != nil { + s.logger.Warn("notification queue failed", "event", "notification.queue.failed") + } +} + func (s *server) decodeAPIJSON(w http.ResponseWriter, r *http.Request, target any) bool { r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) decoder := json.NewDecoder(r.Body) @@ -1198,11 +1348,19 @@ func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) { if errors.Is(err, auth.ErrRateLimited) { status = http.StatusTooManyRequests w.Header().Set("Retry-After", "60") + if s.audit != nil { + _ = s.audit.Record(r.Context(), audit.Event{ActorLabel: "anonymous", Action: "auth.login.blocked", Outcome: "denied", Summary: map[string]string{"reason_code": "rate_limited"}}) + } } token := s.anonymousCSRF(w, r) s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")}) return } + if s.audit != nil { + if actor, actorErr := s.auth.Authenticate(r.Context(), session.Token); actorErr == nil { + _ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: "auth.login", Outcome: "allowed"}) + } + } if cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil { if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil { _ = s.auth.Revoke(r.Context(), session.Token) @@ -1260,6 +1418,15 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) { data.GlobalLabels = instance.FormatLabels(labels) } } + if s.notifications != nil { + data.Channels, _ = s.notifications.List(r.Context()) + } + if s.audit != nil { + data.AuditActor, data.AuditInstance = r.URL.Query().Get("actor_id"), r.URL.Query().Get("instance_id") + data.AuditAction, data.AuditOutcome = r.URL.Query().Get("action"), r.URL.Query().Get("outcome") + data.AuditEvents, _ = s.audit.List(r.Context(), audit.Filter{ActorID: data.AuditActor, InstanceID: data.AuditInstance, Action: data.AuditAction, Outcome: data.AuditOutcome, Limit: 50}) + data.AuditPolicy, _ = s.audit.Policy(r.Context()) + } } s.render(w, http.StatusOK, "home.html", data) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 549536a..2a5d865 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -17,11 +17,13 @@ import ( catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance" + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite" ) @@ -134,6 +136,55 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) { assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity) } +func TestNotificationAndAuditAdministration(t *testing.T) { + ctx := context.Background() + db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + repository := sqlite.NewRepository(db) + authService := auth.New(db) + if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil { + t.Fatal(err) + } + session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234") + if err != nil { + t.Fatal(err) + } + auditService := audit.New(db) + notificationService, err := notification.New(db, bytes.Repeat([]byte{3}, 32)) + if err != nil { + t.Fatal(err) + } + handler, err := NewHandlerComplete(authService, repository, nil, nil, nil, auditService, notificationService, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + cookie := &http.Cookie{Name: sessionCookie, Value: session.Token} + payload, _ := json.Marshal(map[string]any{"name": "operations", "type": "webhook", "enabled": true, "events": []string{"backup.failed"}, "config": map[string]string{"url": "https://example.com/hook", "signing_secret": "do-not-return"}}) + created := jsonMethodRequest(t, handler, http.MethodPost, "/api/v1/admin/notification-channels", payload, cookie, session.CSRFToken) + assertStatus(t, created, http.StatusCreated) + if strings.Contains(created.Body.String(), "do-not-return") { + t.Fatal("channel secret returned") + } + listed := request(t, handler, http.MethodGet, "/api/v1/admin/notification-channels", []*http.Cookie{cookie}) + assertStatus(t, listed, http.StatusOK) + if strings.Contains(listed.Body.String(), "do-not-return") || !strings.Contains(listed.Body.String(), "operations") { + t.Fatalf("unsafe channel response: %s", listed.Body.String()) + } + auditResponse := request(t, handler, http.MethodGet, "/api/v1/admin/audit", []*http.Cookie{cookie}) + assertStatus(t, auditResponse, http.StatusOK) + if !strings.Contains(auditResponse.Body.String(), "notification.channel.update") { + t.Fatalf("missing audit event: %s", auditResponse.Body.String()) + } + home := request(t, handler, http.MethodGet, "/", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}}) + assertStatus(t, home, http.StatusOK) + if !strings.Contains(home.Body.String(), "Notification channels") || !strings.Contains(home.Body.String(), "Recent audit events") { + t.Fatal("administration UI sections missing") + } +} + func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) { ctx := context.Background() root := t.TempDir() diff --git a/internal/web/static/app.v1.css b/internal/web/static/app.v1.css index 77fd0cf..d25b55b 100644 --- a/internal/web/static/app.v1.css +++ b/internal/web/static/app.v1.css @@ -1,12 +1,19 @@ :root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; } body { margin: 0; background: #eef2f7; color: #172033; } -main { width: min(32rem, calc(100% - 2rem)); margin: 8vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; } +main { width: min(64rem, calc(100% - 2rem)); margin: 4vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; } +section { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #d0d5dd; } header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; } form { display: grid; gap: 1rem; } header form { display: block; } +.inline { display: inline; margin-left: .5rem; } +.inline button { padding: .35rem .55rem; } label { display: grid; gap: .35rem; font-weight: 600; } -input, textarea, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; } +input, textarea, select, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; } +.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: .6rem; text-align: left; border-bottom: 1px solid #d0d5dd; white-space: nowrap; } +.danger { background: #b42318; } .warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; } button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; } .error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; } -@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } } +@media (max-width: 40rem) { main { padding: 1rem; margin: 1rem; width: auto; } header { padding: 1rem; } .grid { grid-template-columns: 1fr; } } +@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input, textarea, select { background: #111827; color: #e5e7eb; } } diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html index 21c57c1..8d2e0bb 100644 --- a/internal/web/templates/home.html +++ b/internal/web/templates/home.html @@ -1,3 +1,16 @@ {{define "home.html"}} {{.Title}} · DoGaMa -
{{msg "brand"}}

{{msg "dashboard.title"}}

{{msg "dashboard.signed_in"}} {{.User.Username}}.

{{msg "dashboard.ready"}}

{{if .IsAdmin}}

Game-container labels

One label per line. Available variables: {{`{{game.name}}`}}, {{`{{game.id}}`}}, {{`{{game.icon_url}}`}}, {{`{{instance.name}}`}}, {{`{{instance.id}}`}}, {{`{{instance.slug}}`}}, {{`{{server.name}}`}}.

{{end}}
{{end}} +
{{msg "brand"}}
+

{{msg "dashboard.title"}}

{{msg "dashboard.signed_in"}} {{.User.Username}}.

{{msg "dashboard.ready"}}

+{{if .IsAdmin}} +

Notification channels

Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.

+ +
+

Audit retention

Zero means unlimited and may grow the database indefinitely.

+
+

Recent audit events

+
{{range .AuditEvents}}{{else}}{{end}}
TimeActorActionOutcomeInstance
{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}{{.ActorLabel}}{{.Action}}{{.Outcome}}{{.InstanceID}}
No audit event.
+

Game-container labels

One label per line. Available variables: {{`{{game.name}}`}}, {{`{{game.id}}`}}, {{`{{game.icon_url}}`}}, {{`{{instance.name}}`}}, {{`{{instance.id}}`}}, {{`{{instance.slug}}`}}, {{`{{server.name}}`}}.

+{{end}}
{{end}}