feat(web): expose milestone 9 administration
This commit is contained in:
+47
-2
@@ -9,16 +9,19 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
|
"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/auth"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
"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/persistence/sqlite"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
|
||||||
)
|
)
|
||||||
@@ -55,6 +58,21 @@ func run(logger *slog.Logger) error {
|
|||||||
var handler http.Handler
|
var handler http.Handler
|
||||||
var lifecycle *instance.LifecycleService
|
var lifecycle *instance.LifecycleService
|
||||||
var backupService *backup.Service
|
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)
|
importService, err := importexport.New(repository, environment("DOGAMA_IMPORTS_ROOT", "/var/lib/dogama/imports/staging"), serversRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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")
|
agentURL, tokenFile := os.Getenv("DOGAMA_AGENT_URL"), os.Getenv("DOGAMA_AGENT_TOKEN_FILE")
|
||||||
if agentURL == "" && tokenFile == "" {
|
if agentURL == "" && tokenFile == "" {
|
||||||
logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled")
|
logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled")
|
||||||
handler, err = web.NewHandlerWithRepositoryAndImports(auth.New(db), repository, importService, logger)
|
|
||||||
} else {
|
} else {
|
||||||
if agentURL == "" || tokenFile == "" {
|
if agentURL == "" || tokenFile == "" {
|
||||||
return errors.New("DOGAMA_AGENT_URL and DOGAMA_AGENT_TOKEN_FILE must be configured together")
|
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")
|
logger.Warn("instance reconciliation incomplete", "event", "lifecycle.reconcile.failed")
|
||||||
}
|
}
|
||||||
cancel()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -102,6 +119,7 @@ func run(logger *slog.Logger) error {
|
|||||||
go runBackupScheduler(ctx, backupService, logger)
|
go runBackupScheduler(ctx, backupService, logger)
|
||||||
}
|
}
|
||||||
go runImportCleanup(ctx, importService, logger)
|
go runImportCleanup(ctx, importService, logger)
|
||||||
|
go runObservabilityScheduler(ctx, auditService, notificationService, logger)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: listenAddress,
|
Addr: listenAddress,
|
||||||
Handler: handler,
|
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) {
|
func runImportCleanup(ctx context.Context, service *importexport.Service, logger *slog.Logger) {
|
||||||
ticker := time.NewTicker(time.Hour)
|
ticker := time.NewTicker(time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|||||||
+12
-5
@@ -4,9 +4,9 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
|||||||
|
|
||||||
## Baseline
|
## Baseline
|
||||||
|
|
||||||
- Current reference: milestone 8 implementation `7f5fa30` after baseline `c820c9c`.
|
- Current reference: milestone 9 working branch after merged milestone 8 baseline `1e226d3`.
|
||||||
- Released SQLite migrations: `0001` through `0008`; never rewrite them.
|
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
|
||||||
- Roadmap milestones 1-8 are implemented.
|
- Roadmap milestones 1-9 are implemented.
|
||||||
|
|
||||||
## Architecture
|
## 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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- 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.
|
- 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.
|
- `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
|
## 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.
|
- Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ tests/integration/
|
|||||||
|
|
||||||
## Initial application development
|
## 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
|
```sh
|
||||||
go run ./cmd/dogama
|
go run ./cmd/dogama
|
||||||
|
|||||||
@@ -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.
|
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
|
## Events and filtering
|
||||||
|
|
||||||
Suggested configurable events:
|
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.
|
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.
|
||||||
|
|||||||
@@ -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
@@ -16,15 +16,18 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
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/auth"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||||
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -58,14 +61,16 @@ var englishMessages = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
repository repository
|
repository repository
|
||||||
lifecycle *instance.LifecycleService
|
lifecycle *instance.LifecycleService
|
||||||
permissions *authorization.Service
|
permissions *authorization.Service
|
||||||
backups *backup.Service
|
backups *backup.Service
|
||||||
imports *importexport.Service
|
imports *importexport.Service
|
||||||
|
audit *audit.Service
|
||||||
|
notifications *notification.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type repository interface {
|
type repository interface {
|
||||||
@@ -76,12 +81,19 @@ type repository interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
Error string
|
Error string
|
||||||
User auth.User
|
User auth.User
|
||||||
GlobalLabels string
|
GlobalLabels string
|
||||||
IsAdmin bool
|
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.
|
// 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)
|
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) {
|
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)
|
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) {
|
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")
|
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if repository != nil {
|
||||||
s.permissions = authorization.New(repository)
|
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("POST /api/v1/admin/users", s.userCreate)
|
||||||
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
|
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
|
||||||
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
|
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("GET /api/v1/instances/{id}/memberships", s.membershipList)
|
||||||
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet)
|
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet)
|
||||||
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete)
|
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 /login", s.loginSubmit)
|
||||||
mux.HandleFunc("POST /logout", s.logout)
|
mux.HandleFunc("POST /logout", s.logout)
|
||||||
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
|
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)
|
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]+)*$`)
|
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)
|
result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID)
|
||||||
if err != nil {
|
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())
|
s.apiProblem(w, 422, "update_failed", err.Error())
|
||||||
return
|
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)
|
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")
|
value, err := s.backups.Create(r.Context(), actor.ID, r.PathValue("id"), "manual")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.queueNotification(r, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "The manual backup failed."})
|
||||||
s.backupProblem(w, err)
|
s.backupProblem(w, err)
|
||||||
return
|
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)
|
s.apiJSON(w, http.StatusCreated, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,9 +933,11 @@ func (s *server) backupRestore(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.backups.Restore(r.Context(), actor.ID, r.PathValue("id"), r.PathValue("backupID")); err != nil {
|
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)
|
s.backupProblem(w, err)
|
||||||
return
|
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"})
|
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)
|
s.authorizationProblem(w, err)
|
||||||
return
|
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)
|
s.apiJSON(w, http.StatusCreated, created)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,9 +1206,19 @@ func (s *server) installationRequestReview(w http.ResponseWriter, r *http.Reques
|
|||||||
s.authorizationProblem(w, err)
|
s.authorizationProblem(w, err)
|
||||||
return
|
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)
|
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 {
|
func (s *server) decodeAPIJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
decoder := json.NewDecoder(r.Body)
|
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) {
|
if errors.Is(err, auth.ErrRateLimited) {
|
||||||
status = http.StatusTooManyRequests
|
status = http.StatusTooManyRequests
|
||||||
w.Header().Set("Retry-After", "60")
|
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)
|
token := s.anonymousCSRF(w, r)
|
||||||
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
|
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
|
||||||
return
|
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 cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil {
|
||||||
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
|
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
|
||||||
_ = s.auth.Revoke(r.Context(), session.Token)
|
_ = 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)
|
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)
|
s.render(w, http.StatusOK, "home.html", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import (
|
|||||||
|
|
||||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
"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/auth"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
"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/persistence/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,6 +136,55 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
|
|||||||
assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity)
|
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) {
|
func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
|
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
|
||||||
body { margin: 0; background: #eef2f7; color: #172033; }
|
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; }
|
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; }
|
||||||
form { display: grid; gap: 1rem; }
|
form { display: grid; gap: 1rem; }
|
||||||
header form { display: block; }
|
header form { display: block; }
|
||||||
|
.inline { display: inline; margin-left: .5rem; }
|
||||||
|
.inline button { padding: .35rem .55rem; }
|
||||||
label { display: grid; gap: .35rem; font-weight: 600; }
|
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; }
|
.warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; }
|
||||||
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
|
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
|
||||||
.error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; }
|
.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; } }
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
{{define "home.html"}}<!doctype html>
|
{{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>
|
<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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user