feat(web): expose milestone 9 administration

This commit is contained in:
2026-08-07 20:53:01 +00:00
parent 3fbaa65eea
commit 349d34ce20
9 changed files with 587 additions and 29 deletions
+47 -2
View File
@@ -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()
+12 -5
View File
@@ -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.
+1 -1
View File
@@ -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
+12 -1
View File
@@ -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 03650 retention days and 01,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.
+257
View File
@@ -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)
}
+183 -16
View File
@@ -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)
}
+51
View File
@@ -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()
+10 -3
View File
@@ -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; } }
+14 -1
View File
@@ -1,3 +1,16 @@
{{define "home.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>{{if .IsAdmin}}<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>{{end}}</main></body></html>{{end}}
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header>
<main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>
{{if .IsAdmin}}
<section><h2>Notification channels</h2><p>Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.</p>
<ul>{{range .Channels}}<li><strong>{{.Name}}</strong> — {{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">Send test</button></form>
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></li>{{else}}<li>No channel configured.</li>{{end}}</ul>
<form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><div class="grid"><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
<section><h2>Audit retention</h2><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p>Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form>
<form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></section>
<section><h2>Recent audit events</h2><form method="get" action="/"><div class="grid"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label></div><button type="submit">Filter audit</button></form>
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event.</td></tr>{{end}}</tbody></table></div></section>
<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>
{{end}}</main></body></html>{{end}}