This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user