feat(instances): bind SSR detail to WASM runtime
CI / validate (pull_request) Failing after 5m38s

This commit is contained in:
2026-08-14 12:51:16 +02:00
parent 7c8a7e676a
commit 0f5f9310c5
8 changed files with 386 additions and 13 deletions
+1
View File
@@ -17,6 +17,7 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
FROM gcr.io/distroless/static-debian12:latest@sha256:a9fcaedd4c9b59e12dd65d954f0b5044f19b0647a8a3712e77205df9e7b102cd AS dogama
WORKDIR /var/lib/dogama
COPY --from=build /out/dogama /usr/local/bin/dogama
COPY --from=build /src/modules /usr/share/dogama/modules
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/dogama"]
+3 -2
View File
@@ -46,6 +46,7 @@ func run(logger *slog.Logger) error {
databasePath := environment("DOGAMA_DATABASE_PATH", "dogama.db")
serversRoot := environment("DOGAMA_SERVERS_ROOT", "/srv/game-servers")
templatesRoot := environment("DOGAMA_TEMPLATES_ROOT", "/var/lib/dogama/templates")
modulesRoot := environment("DOGAMA_MODULES_ROOT", "/usr/share/dogama/modules")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -119,7 +120,7 @@ func run(logger *slog.Logger) error {
}
cancel()
}
handler, err = web.NewHandlerCompleteWithCatalogAndDeployment(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, func(ctx context.Context) (catalog.ScanResult, error) {
handler, err = web.NewHandlerCompleteWithCatalogDeploymentAndRuntime(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, func(ctx context.Context) (catalog.ScanResult, error) {
result, scanErr := catalog.ScanDir(templatesRoot)
if scanErr != nil {
return result, scanErr
@@ -128,7 +129,7 @@ func run(logger *slog.Logger) error {
return result, syncErr
}
return result, nil
}, serversRoot, logger)
}, serversRoot, instance.NewModuleService(repository, repository, modulesRoot), logger)
if err != nil {
return err
}
+1 -1
View File
@@ -75,7 +75,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
## Known limitations and debt
- 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 Dashboard now links to an SSR instance detail page through opaque registry IDs. It uses existing lifecycle and backup services for CSRF-protected start, stop, manual backup and permission-gated restore. Live module calls, player lists and secret reveal remain unavailable until module runtime binding is integrated with the web process.
- The Dashboard links to an SSR instance detail page through opaque registry IDs. It uses existing lifecycle and backup services for CSRF-protected actions and a validated sandboxed WASM facade for declared live server info, metrics, player lists and permitted player/announcement actions. The player password can be explicitly revealed only by a global administrator with CSRF and no-store response; no banned-player list or automatic update discovery exists.
- Template configuration targets are applied during deployment: container environment and argv are included in the signed agent plan; INI changes are applied atomically after an optional restore and before start. Instance secrets are encrypted outside preview JSON.
- 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.
- The two DoGaMa services run as root inside their container namespaces for bind-mount portability. Risk is bounded with read-only image filesystems, all capabilities dropped, `no-new-privileges`, no Docker socket in the main application and a private typed agent API; rootless Docker and user-namespace remapping remain host-level deployment choices.
+22 -8
View File
@@ -4,10 +4,11 @@
Select an instance from the Dashboard to open `/instances/<opaque-instance-id>`.
The identifier is a registry identifier, never a filesystem path. The page
shows the pinned game artwork, instance description, lifecycle state, configured
maximum-player value, and a masked indication of whether a player password is
configured. Password values and module credentials are not rendered by the
page.
shows pinned artwork, lifecycle state and, when a validated active WASM module
is reachable, only its declared live server information, metrics and players.
Live player count and maximum are labelled live; a template `max_players` value
is labelled configured when no live metric is available. The page never renders
module credentials or ordinary secret values.
Administrators and members with the matching permissions can start, stop and
create a manual backup. Actions submit server-rendered CSRF-protected forms and
@@ -18,10 +19,20 @@ browser confirmation, and continues to use the safety-backup restore workflow.
The Update control remains disabled until the existing digest-aware update
preview has a verified candidate; DoGaMa never invents an available update.
Likewise, server functions are intentionally unavailable in the detail page
until an activated module runtime is wired to the web process. The page must not
present Kick, Ban, Unban or Announcement controls merely because a template
mentions them.
Server controls are capability- and permission-gated: Announcement, Kick, Ban
and Unban appear only when the active adapter declares the corresponding
capability and the user has its backend-enforced permission. Player actions use
the stable game ID returned by the live player list. There is intentionally no
banned-player list: adapters do not expose a normalized `list_bans` contract.
An unavailable module or game leaves the rest of the page usable and exposes no
fictional controls.
The player `server_password` is never loaded into the normal page. A global
administrator can reveal it only through a CSRF-protected POST response with
`Cache-Control: no-store`; the reveal is audited without the value. DoGaMa has
no separate recent-password-confirmation primitive, so the existing authenticated
administrator session is the most restrictive available flow. `admin_password`
and module credentials cannot be revealed through this UI.
## Resources
@@ -72,6 +83,9 @@ The full update sequence and rollback behavior are normative in `docs/domain/ins
The V1 API requires an explicit candidate tag and SHA-256 image digest plus confirmation. A required `pre_update` backup must finish before replacement. Readiness is bounded by the template timeout; failed replacement, start or readiness restores the prior image/configuration plan when rollback is enabled, without restoring player data.
There is no automatic check for an available update: DoGaMa does not consult a
registry without an explicitly supplied, verified tag-and-digest candidate.
## Game-container labels, users and tags
Administrators can define global labels for game-server containers and instance-specific overrides, one `key=value` per line. Empty lines are ignored and only the first `=` separates the key. Instance labels override global labels; DoGaMa's technical labels always win. Both `dogama.*` and `io.dogama.*` are reserved.
+213
View File
@@ -0,0 +1,213 @@
package instance
// This file is the application boundary between persisted instances and WASM
// integrations. It deliberately owns all manifest/artifact/secrets handling;
// HTTP handlers only use the typed methods below.
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/module"
"gopkg.in/yaml.v3"
)
var ErrModuleUnavailable = errors.New("module unavailable")
type ModuleService struct {
secrets SecretRepository
catalog catalog.Repository
root string
}
func NewModuleService(secrets SecretRepository, repository catalog.Repository, root string) *ModuleService {
return &ModuleService{secrets: secrets, catalog: repository, root: filepath.Clean(root)}
}
type ServerInfo struct {
Name string `json:"name"`
GameVersion string `json:"game_version"`
Description string `json:"description"`
WorldID string `json:"world_id"`
}
type Metrics struct {
CurrentPlayers int `json:"current_players"`
MaxPlayers int `json:"max_players"`
}
type Player struct {
PlayerID string `json:"player_id"`
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
PingMS float64 `json:"ping_ms"`
}
type Live struct {
Capabilities map[string]bool
ServerInfo *ServerInfo
Metrics *Metrics
Players []Player
Unavailable bool
}
type moduleManifest struct {
ID string `yaml:"id"`
Runtime struct {
Type string `yaml:"type"`
ABI string `yaml:"abi"`
} `yaml:"runtime"`
Compatibility struct {
ManagerAPI string `yaml:"manager_api"`
ModuleAPI string `yaml:"module_api"`
} `yaml:"compatibility"`
Capabilities []string `yaml:"capabilities"`
Permissions struct {
Network struct {
PortIDs []string `yaml:"port_ids"`
HTTPMethods []string `yaml:"http_methods"`
} `yaml:"network"`
} `yaml:"permissions"`
Limits struct {
MemoryMB uint32 `yaml:"memory_mb"`
TimeoutMS int `yaml:"timeout_ms"`
MaxResponseBytes int `yaml:"max_response_bytes"`
MaxConcurrentCalls int `yaml:"max_concurrent_calls"`
} `yaml:"limits"`
Configuration []struct {
ID string `yaml:"id"`
Type string `yaml:"type"`
} `yaml:"configuration"`
Artifacts struct {
WASM string `yaml:"wasm"`
SHA256 string `yaml:"sha256"`
} `yaml:"artifacts"`
}
func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*module.Runtime, moduleManifest, error) {
if s == nil || s.secrets == nil || s.catalog == nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
snapshot, err := s.catalog.Get(ctx, value.Preview.Template.ID, value.Preview.Template.Version)
if err != nil || snapshot.Template.Integration == nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
integration := snapshot.Template.Integration
if integration.ModuleID == "" || strings.Contains(integration.ModuleID, "/") || strings.Contains(integration.ModuleID, "..") {
return nil, moduleManifest{}, ErrModuleUnavailable
}
body, err := os.ReadFile(filepath.Join(s.root, integration.ModuleID, "manifest.yaml"))
if err != nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
var manifest moduleManifest
if yaml.Unmarshal(body, &manifest) != nil || manifest.ID != integration.ModuleID || manifest.Runtime.Type != "wasm" || manifest.Runtime.ABI != module.ABI || manifest.Compatibility.ManagerAPI == "" || manifest.Compatibility.ModuleAPI == "" {
return nil, moduleManifest{}, ErrModuleUnavailable
}
var port int
for _, p := range value.Preview.Ports {
if p.ID == integration.PortID && p.Purpose == "integration" {
port = p.ContainerPort
break
}
}
if port == 0 || !contains(manifest.Permissions.Network.PortIDs, integration.PortID) {
return nil, moduleManifest{}, ErrModuleUnavailable
}
methods := map[string]bool{}
for _, method := range manifest.Permissions.Network.HTTPMethods {
if method == "GET" || method == "POST" {
methods[method] = true
} else {
return nil, moduleManifest{}, ErrModuleUnavailable
}
}
if len(methods) == 0 || manifest.Artifacts.WASM == "" || filepath.Base(manifest.Artifacts.WASM) != manifest.Artifacts.WASM {
return nil, moduleManifest{}, ErrModuleUnavailable
}
config, secrets := map[string]string{}, map[string]string{}
stored, err := s.secrets.LoadInstanceSecrets(ctx, value.ID)
if err != nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
for _, field := range manifest.Configuration {
if field.ID == "" {
return nil, moduleManifest{}, ErrModuleUnavailable
}
if field.Type == "secret" {
if v := stored[field.ID]; v != "" {
secrets[field.ID] = v
}
} else if v := value.Preview.Configuration[field.ID]; v != "" {
config[field.ID] = v
}
}
wasm, err := os.ReadFile(filepath.Join(s.root, integration.ModuleID, manifest.Artifacts.WASM))
if err != nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
r, err := module.New(ctx, wasm, manifest.Artifacts.SHA256, manifest.Capabilities, module.Limits{MemoryMB: manifest.Limits.MemoryMB, Timeout: durationMS(manifest.Limits.TimeoutMS), MaxResponseBytes: manifest.Limits.MaxResponseBytes, MaxConcurrentCall: manifest.Limits.MaxConcurrentCalls}, module.Binding{InstanceID: value.ID, ContainerPort: port, AllowedMethods: methods, Configuration: config, Secrets: secrets})
if err != nil {
return nil, moduleManifest{}, fmt.Errorf("%w: invalid integration", ErrModuleUnavailable)
}
return r, manifest, nil
}
func durationMS(v int) time.Duration { return time.Duration(v) * time.Millisecond }
func contains(values []string, needle string) bool {
for _, v := range values {
if v == needle {
return true
}
}
return false
}
func (s *ModuleService) Live(ctx context.Context, value StoredInstance, players bool) Live {
r, manifest, err := s.runtime(ctx, value)
if err != nil {
return Live{Unavailable: true}
}
live := Live{Capabilities: map[string]bool{}}
for _, c := range manifest.Capabilities {
live.Capabilities[c] = true
}
if live.Capabilities["server_info"] {
var out ServerInfo
if r.Call(ctx, "get_server_info", struct{}{}, &out) == nil {
live.ServerInfo = &out
}
}
if live.Capabilities["metrics"] {
var out Metrics
if r.Call(ctx, "get_metrics", struct{}{}, &out) == nil {
live.Metrics = &out
}
}
if players && live.Capabilities["player_list"] {
var out struct {
Players []Player `json:"players"`
}
if r.Call(ctx, "list_players", struct{}{}, &out) == nil {
live.Players = out.Players
}
}
return live
}
func (s *ModuleService) Action(ctx context.Context, value StoredInstance, capability, operation string, request any) error {
r, manifest, err := s.runtime(ctx, value)
if err != nil || !contains(manifest.Capabilities, capability) {
return ErrModuleUnavailable
}
var out struct {
Accepted bool `json:"accepted"`
}
if err := r.Call(ctx, operation, request, &out); err != nil || !out.Accepted {
return ErrModuleUnavailable
}
return nil
}
+113
View File
@@ -1,10 +1,13 @@
package web
import (
"html/template"
"net/http"
"net/url"
"strings"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
// instanceDetailPage is intentionally SSR-only: the opaque registry ID is the
@@ -49,10 +52,120 @@ func (s *server) instanceDetailPage(w http.ResponseWriter, r *http.Request) {
detail.PasswordSet = setting.Configured
}
}
if s.moduleRuntime != nil {
detail.Live = s.moduleRuntime.Live(r.Context(), current, s.permissions.Require(r.Context(), user, id, authorization.PermissionPlayersView) == nil)
detail.ModuleAvailable = !detail.Live.Unavailable
detail.CanAnnounce = detail.Live.Capabilities["announcement"] && s.permissions.Require(r.Context(), user, id, authorization.PermissionAnnouncementsSend) == nil
detail.CanKick = detail.Live.Capabilities["kick"] && s.permissions.Require(r.Context(), user, id, authorization.PermissionPlayersKick) == nil
detail.CanBan = detail.Live.Capabilities["ban"] && s.permissions.Require(r.Context(), user, id, authorization.PermissionPlayersBan) == nil
detail.CanUnban = detail.Live.Capabilities["unban"] && s.permissions.Require(r.Context(), user, id, authorization.PermissionPlayersUnban) == nil
}
data := pageData{Title: localized(s.language(r, user.Language), "instance.title"), Language: s.language(r, user.Language), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "instances", InstanceDetail: detail}
s.render(w, http.StatusOK, "instance-detail.html", data)
}
func (s *server) instanceModuleActionForm(w http.ResponseWriter, r *http.Request) {
if !s.parseForm(w, r) || !s.validCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
user, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
id := r.PathValue("id")
current, err := s.repository.GetInstance(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
if s.moduleRuntime == nil {
s.detailRedirect(w, r, false, "instance.module")
return
}
path := r.URL.Path
capability, operation, permission := "", "", ""
request := any(nil)
if strings.HasSuffix(path, "/announcement") {
capability, operation, permission = "announcement", "send_announcement", authorization.PermissionAnnouncementsSend
message := r.FormValue("message")
if len(message) == 0 || len(message) > 1000 {
s.detailRedirect(w, r, false, "announcement.send")
return
}
request = map[string]string{"message": message}
} else if strings.HasSuffix(path, "/unban") {
capability, operation, permission = "unban", "unban_player", authorization.PermissionPlayersUnban
player := r.FormValue("player_id")
if !validGameID(player) {
s.detailRedirect(w, r, false, "players.unban")
return
}
request = map[string]string{"player_id": player}
} else {
if strings.HasSuffix(path, "/kick") {
capability, operation, permission = "kick", "kick_player", authorization.PermissionPlayersKick
} else {
capability, operation, permission = "ban", "ban_player", authorization.PermissionPlayersBan
}
player, reason := r.FormValue("player_id"), r.FormValue("reason")
if !validGameID(player) || len(reason) > 1000 {
s.detailRedirect(w, r, false, permission)
return
}
live := s.moduleRuntime.Live(r.Context(), current, true)
found := false
for _, p := range live.Players {
if p.PlayerID == player || p.UserID == player {
found = true
break
}
}
if !found {
s.detailRedirect(w, r, false, permission)
return
}
request = map[string]string{"player_id": player, "reason": reason}
}
if s.moduleRuntime == nil || s.permissions.Require(r.Context(), user, id, permission) != nil {
s.detailRedirect(w, r, false, permission)
return
}
s.detailRedirect(w, r, s.moduleRuntime.Action(r.Context(), current, capability, operation, request) == nil, permission)
}
func validGameID(value string) bool {
return value != "" && len(value) <= 256 && !strings.ContainsAny(value, "\r\n")
}
func (s *server) instancePasswordRevealForm(w http.ResponseWriter, r *http.Request) {
if !s.parseForm(w, r) || !s.validCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
user, err := s.currentUser(r)
if err != nil || user.Role != "admin" {
s.problem(w, http.StatusForbidden, "Forbidden.")
return
}
store, ok := s.repository.(instance.SecretRepository)
if !ok {
s.problem(w, http.StatusNotFound, "Unavailable.")
return
}
values, err := store.LoadInstanceSecrets(r.Context(), r.PathValue("id"))
password := values["server_password"]
if err != nil || password == "" {
s.problem(w, http.StatusNotFound, "Unavailable.")
return
}
w.Header().Set("Cache-Control", "no-store")
s.recordAudit(r, user, "instance.password.reveal", "allowed", map[string]string{"target_id": r.PathValue("id")})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<!doctype html><title>Server password</title><p>" + template.HTMLEscapeString(password) + "</p>"))
}
func detailBusy(state string) bool {
switch state {
case "installing", "starting", "stopping", "updating", "update", "backup", "restore":
+32 -1
View File
@@ -56,6 +56,7 @@ type server struct {
notifications *notification.Service
catalogScan func(context.Context) (catalog.ScanResult, error)
serversRoot string
moduleRuntime *instance.ModuleService
}
type completeHandler struct {
http.Handler
@@ -119,6 +120,11 @@ type instanceDetailPage struct {
Message string
MessageError bool
ModuleAvailable bool
Live instance.Live
CanAnnounce bool
CanKick bool
CanBan bool
CanUnban bool
}
type deploymentPage struct {
@@ -203,6 +209,16 @@ func NewHandlerCompleteWithCatalogAndDeployment(authService *auth.Service, repos
return h, nil
}
// NewHandlerCompleteWithCatalogDeploymentAndRuntime adds the validated WASM
// integration facade to the SSR server without exposing it to HTTP handlers.
func NewHandlerCompleteWithCatalogDeploymentAndRuntime(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), serversRoot string, runtime *instance.ModuleService, logger *slog.Logger) (http.Handler, error) {
h, err := NewHandlerCompleteWithCatalogAndDeployment(authService, repository, lifecycle, backupService, importService, auditService, notificationService, scanner, serversRoot, logger)
if err == nil {
h.(*completeHandler).server.moduleRuntime = runtime
}
return h, err
}
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)
}
@@ -328,6 +344,11 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("POST /instances/{id}/stop", s.instanceDetailActionForm)
mux.HandleFunc("POST /instances/{id}/backup", s.instanceDetailActionForm)
mux.HandleFunc("POST /instances/{id}/backups/{backupID}/restore", s.instanceDetailRestoreForm)
mux.HandleFunc("POST /instances/{id}/module/announcement", s.instanceModuleActionForm)
mux.HandleFunc("POST /instances/{id}/module/kick", s.instanceModuleActionForm)
mux.HandleFunc("POST /instances/{id}/module/ban", s.instanceModuleActionForm)
mux.HandleFunc("POST /instances/{id}/module/unban", s.instanceModuleActionForm)
mux.HandleFunc("POST /instances/{id}/password/reveal", s.instancePasswordRevealForm)
mux.HandleFunc("GET /", s.home)
return &completeHandler{Handler: s.enforceWebAccess(s.securityHeaders(s.auditRequests(mux))), server: s}, nil
}
@@ -378,7 +399,7 @@ func (s *server) auditRequests(next http.Handler) http.Handler {
summary["target_id"] = id
}
instanceID := ""
if status < 400 && strings.HasPrefix(r.URL.Path, "/api/v1/instances/") {
if status < 400 && (strings.HasPrefix(r.URL.Path, "/api/v1/instances/") || strings.HasPrefix(r.URL.Path, "/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})
@@ -408,6 +429,16 @@ func auditAction(method, path string) string {
return "backup.create"
case strings.HasPrefix(path, "/instances/") && strings.Contains(path, "/backups/") && strings.HasSuffix(path, "/restore"):
return "backup.restore"
case strings.HasSuffix(path, "/module/announcement"):
return "announcement.send"
case strings.HasSuffix(path, "/module/kick"):
return "players.kick"
case strings.HasSuffix(path, "/module/ban"):
return "players.ban"
case strings.HasSuffix(path, "/module/unban"):
return "players.unban"
case strings.HasSuffix(path, "/password/reveal"):
return "instance.password.reveal"
case path == "/api/v1/imports":
return "import.create"
case strings.HasSuffix(path, "/update"):
File diff suppressed because one or more lines are too long