3 Commits
15 changed files with 1374 additions and 34 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.
+178
View File
@@ -0,0 +1,178 @@
// Package audit stores the deliberately small, redacted security audit trail.
package audit
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Event struct {
ID string `json:"id"`
ActorID string `json:"actor_id"`
ActorLabel string `json:"actor_label"`
InstanceID string `json:"instance_id"`
Action string `json:"action"`
Outcome string `json:"outcome"`
OccurredAt time.Time `json:"occurred_at"`
Summary map[string]string `json:"summary"`
}
type Filter struct {
ActorID, InstanceID, Action, Outcome string
Since, Until time.Time
Limit int
}
type Policy struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
type Service struct {
db *sql.DB
now func() time.Time
}
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
func (s *Service) Record(ctx context.Context, event Event) error {
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
return errors.New("invalid audit event")
}
clean := map[string]string{}
for key, value := range event.Summary {
if allowedSummaryKeys[key] && len(value) <= 200 {
clean[key] = value
}
}
body, _ := json.Marshal(clean)
when := event.OccurredAt
if when.IsZero() {
when = s.now().UTC()
}
var actor, instance any
if event.ActorID != "" {
actor = event.ActorID
}
if event.InstanceID != "" {
instance = event.InstanceID
}
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(id,occurred_at,actor_id,actor_label,instance_id,action,outcome,summary_json) VALUES(?,?,?,?,?,?,?,?)`, randomID(), when.Format(time.RFC3339Nano), actor, event.ActorLabel, instance, event.Action, event.Outcome, string(body))
if err != nil {
return fmt.Errorf("record audit event: %w", err)
}
return nil
}
func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
clauses, args := []string{"1=1"}, []any{}
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
if item.value != "" {
clauses = append(clauses, item.column+"=?")
args = append(args, item.value)
}
}
if !f.Since.IsZero() {
clauses = append(clauses, "occurred_at>=?")
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
}
if !f.Until.IsZero() {
clauses = append(clauses, "occurred_at<?")
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("list audit events: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
var occurred, body string
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
return nil, err
}
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
_ = json.Unmarshal([]byte(body), &e.Summary)
events = append(events, e)
}
return events, rows.Err()
}
func (s *Service) Policy(ctx context.Context) (Policy, error) {
var body string
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='audit_policy'`).Scan(&body)
if err != nil {
return Policy{}, err
}
var p Policy
var raw struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
if err = json.Unmarshal([]byte(body), &raw); err != nil {
return p, err
}
p.RetentionDays, p.MaximumCount = raw.RetentionDays, raw.MaximumCount
return p, nil
}
func (s *Service) SetPolicy(ctx context.Context, p Policy) error {
if p.RetentionDays < 0 || p.RetentionDays > 3650 || p.MaximumCount < 0 || p.MaximumCount > 1000000 {
return errors.New("audit policy is out of bounds")
}
body, _ := json.Marshal(map[string]int{"retention_days": p.RetentionDays, "maximum_count": p.MaximumCount})
_, err := s.db.ExecContext(ctx, `UPDATE system_settings SET value_json=?,revision=revision+1,updated_at=? WHERE key='audit_policy'`, body, s.now().UTC().Format(time.RFC3339Nano))
return err
}
func (s *Service) Purge(ctx context.Context, before time.Time) (int64, error) {
if before.IsZero() || before.After(s.now().UTC()) {
return 0, errors.New("invalid audit purge boundary")
}
result, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE occurred_at < ?`, before.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *Service) RunRetention(ctx context.Context) (int64, error) {
p, err := s.Policy(ctx)
if err != nil {
return 0, err
}
var total int64
if p.RetentionDays > 0 {
n, e := s.Purge(ctx, s.now().UTC().AddDate(0, 0, -p.RetentionDays))
if e != nil {
return 0, e
}
total += n
}
if p.MaximumCount > 0 {
result, e := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE id IN (SELECT id FROM audit_events ORDER BY occurred_at DESC,id DESC LIMIT -1 OFFSET ?)`, p.MaximumCount)
if e != nil {
return total, e
}
n, _ := result.RowsAffected()
total += n
}
return total, nil
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+55
View File
@@ -0,0 +1,55 @@
package audit_test
import (
"context"
"path/filepath"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestRecordFiltersSummaryAndRetention(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()
service := audit.New(db)
old := time.Now().UTC().AddDate(0, 0, -40)
if err := service.Record(ctx, audit.Event{OccurredAt: old, ActorLabel: "admin", Action: "instance.update", Outcome: "allowed", Summary: map[string]string{"target_name": "Palworld", "secret": "must-not-persist"}}); err != nil {
t.Fatal(err)
}
events, err := service.List(ctx, audit.Filter{Action: "instance.update"})
if err != nil || len(events) != 1 {
t.Fatalf("events=%#v err=%v", events, err)
}
if events[0].Summary["target_name"] != "Palworld" || events[0].Summary["secret"] != "" {
t.Fatalf("summary was not allow-listed: %#v", events[0].Summary)
}
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: 30, MaximumCount: 100}); err != nil {
t.Fatal(err)
}
deleted, err := service.RunRetention(ctx)
if err != nil || deleted != 1 {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
}
func TestPolicyAndPurgeBounds(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()
service := audit.New(db)
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: -1}); err == nil {
t.Fatal("negative retention accepted")
}
if _, err := service.Purge(ctx, time.Now().Add(time.Hour)); err == nil {
t.Fatal("future purge accepted")
}
}
+425
View File
@@ -0,0 +1,425 @@
// Package notification manages encrypted channels and bounded asynchronous delivery.
package notification
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
type Channel struct {
ID, Name, Type string
Enabled bool
Events []string
Configured bool
}
type Input struct {
Name, Type string
Enabled bool
Events []string
Config map[string]string
}
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
type Service struct {
db *sql.DB
aead cipher.AEAD
client *http.Client
resolver *net.Resolver
now func() time.Time
}
func New(db *sql.DB, key []byte) (*Service, error) {
if len(key) != 32 {
return nil, errors.New("notification encryption key must be exactly 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
s := &Service{db: db, aead: aead, resolver: net.DefaultResolver, now: time.Now}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = s.dialSafe
s.client = &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return s.validateURL(req.Context(), req.URL)
}}
return s, nil
}
func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, error) {
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
return Channel{}, errors.New("invalid notification channel")
}
if err := validateConfigShape(in.Type, in.Config); err != nil {
return Channel{}, err
}
encrypted, err := s.seal(in.Config)
if err != nil {
return Channel{}, err
}
events, _ := json.Marshal(normalizeEvents(in.Events))
now := s.now().UTC().Format(time.RFC3339Nano)
if id == "" {
id = randomID()
}
_, err = s.db.ExecContext(ctx, `INSERT INTO notification_channels(id,name,type,enabled,encrypted_config,event_filter_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,enabled=excluded.enabled,encrypted_config=excluded.encrypted_config,event_filter_json=excluded.event_filter_json,updated_at=excluded.updated_at`, id, strings.TrimSpace(in.Name), in.Type, in.Enabled, encrypted, string(events), now, now)
if err != nil {
return Channel{}, fmt.Errorf("save notification channel: %w", err)
}
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
}
func (s *Service) List(ctx context.Context) ([]Channel, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Channel
for rows.Next() {
var c Channel
var body string
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(body), &c.Events)
out = append(out, c)
}
return out, rows.Err()
}
func (s *Service) Delete(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
return err
}
func (s *Service) Queue(ctx context.Context, event Event) error {
if !validEvent(event.Type) || len(event.Message) > 1000 {
return errors.New("invalid notification event")
}
payload, _ := json.Marshal(event)
rows, err := s.db.QueryContext(ctx, `SELECT id,event_filter_json FROM notification_channels WHERE enabled=1`)
if err != nil {
return err
}
var channelIDs []string
for rows.Next() {
var id, filter string
if err := rows.Scan(&id, &filter); err != nil {
rows.Close()
return err
}
var events []string
_ = json.Unmarshal([]byte(filter), &events)
if matches(events, event.Type) {
channelIDs = append(channelIDs, id)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
now := s.now().UTC().Format(time.RFC3339Nano)
for _, id := range channelIDs {
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
return err
}
}
return nil
}
func (s *Service) Test(ctx context.Context, id string) error {
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
}
func (s *Service) queueForChannel(ctx context.Context, id string, event Event) error {
var enabled bool
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
return err
}
payload, _ := json.Marshal(event)
now := s.now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
return err
}
func (s *Service) RunDue(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `SELECT d.id,d.attempt,d.payload_redacted,c.type,c.encrypted_config FROM notification_deliveries d JOIN notification_channels c ON c.id=d.channel_id WHERE d.status IN ('queued','retrying') AND d.next_attempt_at<=? ORDER BY d.next_attempt_at LIMIT 20`, s.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
type job struct {
id, typ, payload string
attempt int
encrypted []byte
}
var jobs []job
for rows.Next() {
var j job
if err := rows.Scan(&j.id, &j.attempt, &j.payload, &j.typ, &j.encrypted); err != nil {
rows.Close()
return err
}
jobs = append(jobs, j)
}
rows.Close()
for _, j := range jobs {
config, e := s.open(j.encrypted)
if e == nil {
e = s.deliver(ctx, j.id, j.typ, config, []byte(j.payload))
}
attempt := j.attempt + 1
if e == nil {
if _, updateErr := s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status='succeeded',attempt=?,completed_at=?,last_error_code='' WHERE id=?`, attempt, s.now().UTC().Format(time.RFC3339Nano), j.id); updateErr != nil {
return updateErr
}
} else {
status := "retrying"
if attempt >= 5 {
status = "failed"
}
delay := time.Duration(1<<min(attempt, 6)) * time.Minute
_, _ = s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status=?,attempt=?,next_attempt_at=?,last_error_code=? WHERE id=?`, status, attempt, s.now().UTC().Add(delay).Format(time.RFC3339Nano), errorCode(e), j.id)
}
}
return nil
}
func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]string, payload []byte) error {
if typ == "email" {
host := c["host"]
port := c["port"]
if port == "" {
port = "587"
}
addr := net.JoinHostPort(host, port)
var auth smtp.Auth
if c["username"] != "" {
auth = smtp.PlainAuth("", c["username"], c["password"], host)
}
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
}
u, err := url.Parse(c["url"])
if err != nil {
return err
}
if err = s.validateURL(ctx, u); err != nil {
return err
}
body := payload
if typ == "discord" {
var event Event
_ = json.Unmarshal(payload, &event)
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-DoGaMa-Event-ID", id)
timestamp := strconv.FormatInt(s.now().Unix(), 10)
req.Header.Set("X-DoGaMa-Timestamp", timestamp)
if secret := c["signing_secret"]; secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "." + string(body)))
req.Header.Set("X-DoGaMa-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("remote_status_%d", resp.StatusCode)
}
return nil
}
func (s *Service) validateURL(ctx context.Context, u *url.URL) error {
if u.Scheme != "https" || u.User != nil || u.Hostname() == "" {
return errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", u.Hostname())
if err != nil {
return errors.New("destination_resolution_failed")
}
for _, ip := range ips {
if unsafeIP(ip) {
return errors.New("unsafe_destination")
}
}
return nil
}
func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, errors.New("destination_resolution_failed")
}
dialer := net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips {
if unsafeIP(ip) {
return nil, errors.New("unsafe_destination")
}
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return connection, nil
}
}
return nil, errors.New("delivery_failed")
}
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
connection, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
client, err := smtp.NewClient(connection, host)
if err != nil {
_ = connection.Close()
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("smtp_tls_required")
}
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
}
if auth != nil {
if err := client.Auth(auth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
for _, recipient := range recipients {
if err := client.Rcpt(strings.TrimSpace(recipient)); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(message); err != nil {
_ = w.Close()
return err
}
if err = w.Close(); err != nil {
return err
}
return client.Quit()
}
func unsafeIP(ip netip.Addr) bool {
return !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
}
func (s *Service) seal(config map[string]string) ([]byte, error) {
body, _ := json.Marshal(config)
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return s.aead.Seal(nonce, nonce, body, nil), nil
}
func (s *Service) open(body []byte) (map[string]string, error) {
n := s.aead.NonceSize()
if len(body) < n {
return nil, errors.New("invalid encrypted channel")
}
plain, err := s.aead.Open(nil, body[:n], body[n:], nil)
if err != nil {
return nil, err
}
var out map[string]string
err = json.Unmarshal(plain, &out)
return out, err
}
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
func validEvent(v string) bool {
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
}
func validateConfigShape(typ string, c map[string]string) error {
if typ == "email" {
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
return errors.New("email host, from and to are required")
}
return nil
}
u, err := url.Parse(c["url"])
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil {
return errors.New("an HTTPS webhook URL is required")
}
return nil
}
func normalizeEvents(in []string) []string {
seen := map[string]bool{}
out := []string{}
for _, v := range in {
v = strings.TrimSpace(v)
if validEvent(v) && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func matches(filter []string, event string) bool {
for _, v := range filter {
if v == event || v == "*" {
return true
}
}
return false
}
func errorCode(err error) string {
v := err.Error()
if strings.HasPrefix(v, "remote_status_") {
return v
}
switch v {
case "unsafe_destination", "destination_resolution_failed":
return v
}
return "delivery_failed"
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+84
View File
@@ -0,0 +1,84 @@
package notification_test
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestChannelSecretsAreEncryptedAndWriteOnly(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()
service, err := notification.New(db, bytes.Repeat([]byte{7}, 32))
if err != nil {
t.Fatal(err)
}
channel, err := service.Upsert(ctx, "", notification.Input{Name: "ops", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://example.com/hook", "signing_secret": "highly-sensitive"}})
if err != nil {
t.Fatal(err)
}
var encrypted []byte
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, channel.ID).Scan(&encrypted); err != nil {
t.Fatal(err)
}
if strings.Contains(string(encrypted), "highly-sensitive") {
t.Fatal("secret stored in plaintext")
}
channels, err := service.List(ctx)
if err != nil || len(channels) != 1 || !channels[0].Configured {
t.Fatalf("channels=%#v err=%v", channels, err)
}
}
func TestDeliveryBlocksPrivateWebhookAndRetriesWithRedactedError(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()
service, _ := notification.New(db, bytes.Repeat([]byte{8}, 32))
channel, err := service.Upsert(ctx, "", notification.Input{Name: "unsafe", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://127.0.0.1/hook", "signing_secret": "never-leak"}})
if err != nil {
t.Fatal(err)
}
if err := service.Queue(ctx, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "Operation failed", OperationID: "op-1"}); err != nil {
t.Fatal(err)
}
if err := service.RunDue(ctx); err != nil {
t.Fatal(err)
}
var status, code string
var attempt int
if err := db.QueryRowContext(ctx, `SELECT status,attempt,last_error_code FROM notification_deliveries WHERE channel_id=?`, channel.ID).Scan(&status, &attempt, &code); err != nil {
t.Fatal(err)
}
if status != "retrying" || attempt != 1 || code != "unsafe_destination" || strings.Contains(code, "never-leak") {
t.Fatalf("status=%s attempt=%d code=%q", status, attempt, code)
}
}
func TestRejectsMissingKeyAndInsecureURL(t *testing.T) {
if _, err := notification.New(nil, []byte("short")); err == nil {
t.Fatal("short key accepted")
}
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service, _ := notification.New(db, bytes.Repeat([]byte{9}, 32))
if _, err := service.Upsert(ctx, "", notification.Input{Name: "bad", Type: "discord", Enabled: true, Config: map[string]string{"url": "http://example.com"}}); err == nil {
t.Fatal("insecure URL accepted")
}
}
+5 -5
View File
@@ -24,10 +24,10 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 8 {
t.Fatalf("got %d migrations, want 8", count)
if count != 9 {
t.Fatalf("got %d migrations, want 9", count)
}
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports"} {
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports", "notification_channels", "notification_deliveries", "audit_events"} {
var found int
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&found); err != nil {
t.Fatal(err)
@@ -62,8 +62,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 8 {
t.Fatalf("reopened database has %d migrations, want 8", count)
if count != 9 {
t.Fatalf("reopened database has %d migrations, want 9", count)
}
}
+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}}
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE notification_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
encrypted_config BLOB NOT NULL,
event_filter_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE notification_deliveries (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
payload_redacted TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
next_attempt_at TEXT NOT NULL,
last_error_code TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
occurred_at TEXT NOT NULL,
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
actor_label TEXT NOT NULL,
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
action TEXT NOT NULL,
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
summary_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
INSERT INTO system_settings(key, value_json, revision, updated_at)
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));