feat(instances): allow manual unban without ban listing
CI / validate (pull_request) Canceled after 0s

This commit is contained in:
2026-08-14 13:20:09 +02:00
parent d737c2995d
commit fdb487f34e
8 changed files with 228 additions and 21 deletions
+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 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, banned-player lists and permitted player/announcement actions. Unban requires a fresh adapter `list_bans` result. Update availability is limited to immutable candidates explicitly approved in a template; games without one remain safely unknown and no registry browsing is performed.
- 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, banned-player lists and permitted player/announcement actions. Unban uses a fresh adapter `list_bans` result when available; otherwise, it accepts a bounded manual game identifier for the module to validate. Update availability is limited to immutable candidates explicitly approved in a template; games without one remain safely unknown and no registry browsing is performed.
- 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.
+6 -2
View File
@@ -24,8 +24,12 @@ with the persisted image: it is available, up to date, or safely unknown.
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
stable game IDs. Unban additionally requires `list_bans`: the server validates
the submitted ID against a fresh runtime list before calling `unban_player`.
stable game IDs. `list_bans` is optional for Unban: when available, the server
renders the real banned-player selection and validates the submitted ID against
a fresh runtime list before calling `unban_player`. Without `list_bans`, Unban
renders a manual player-identifier field; DoGaMa validates only bounded,
control-character-free input and leaves game-specific identifier validation to
the module/API.
An unavailable module or game leaves the rest of the page usable and exposes no
fictional controls.
+2 -2
View File
@@ -41,8 +41,8 @@ var messages = map[string]map[string]string{
func init() {
for language, values := range map[string]map[string]string{
"en": {"instance.update_unknown": "Update verification is unavailable.", "instance.update_available": "A verified update is available.", "instance.update_current": "This instance is up to date.", "instance.banned_players": "Banned players", "instance.no_banned_players": "No banned players.", "instance.bans_not_supported": "This module cannot list banned players.", "instance.action_done.instance.update": "Instance updated.", "instance.action_failed.instance.update": "Unable to update the instance."},
"fr": {"instance.update_unknown": "La vérification de mise à jour est indisponible.", "instance.update_available": "Une mise à jour vérifiée est disponible.", "instance.update_current": "Cette instance est à jour.", "instance.banned_players": "Joueurs bannis", "instance.no_banned_players": "Aucun joueur banni.", "instance.bans_not_supported": "Ce module ne peut pas lister les joueurs bannis.", "instance.action_done.instance.update": "Instance mise à jour.", "instance.action_failed.instance.update": "Impossible de mettre l’instance à jour."},
"en": {"instance.update_unknown": "Update verification is unavailable.", "instance.update_available": "A verified update is available.", "instance.update_current": "This instance is up to date.", "instance.banned_players": "Banned players", "instance.no_banned_players": "No banned players.", "instance.player_identifier": "Player identifier", "instance.bans_not_supported": "The banned-player list is unavailable for this server. Enter the identifier expected by the game API.", "instance.action_done.instance.update": "Instance updated.", "instance.action_failed.instance.update": "Unable to update the instance."},
"fr": {"instance.update_unknown": "La vérification de mise à jour est indisponible.", "instance.update_available": "Une mise à jour vérifiée est disponible.", "instance.update_current": "Cette instance est à jour.", "instance.banned_players": "Joueurs bannis", "instance.no_banned_players": "Aucun joueur banni.", "instance.player_identifier": "Identifiant du joueur", "instance.bans_not_supported": "La liste des joueurs bannis n’est pas disponible pour ce serveur. Saisissez l’identifiant attendu par l’API du jeu.", "instance.action_done.instance.update": "Instance mise à jour.", "instance.action_failed.instance.update": "Impossible de mettre l’instance à jour."},
} {
for key, value := range values {
messages[language][key] = value
+43 -12
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"net/url"
"strings"
"unicode"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
@@ -100,22 +101,36 @@ func (s *server) instanceModuleActionForm(w http.ResponseWriter, r *http.Request
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) {
live := s.moduleRuntime.Live(r.Context(), current, false)
if live.Unavailable || !live.Capabilities[capability] {
s.detailRedirect(w, r, false, "players.unban")
return
}
bans, listErr := s.moduleRuntime.ListBans(r.Context(), current)
valid := false
for _, ban := range bans {
if ban.PlayerID == player {
valid = true
break
player := r.FormValue("player_id")
if live.Capabilities["list_bans"] {
if !validGameID(player) {
s.detailRedirect(w, r, false, "players.unban")
return
}
bans, listErr := s.moduleRuntime.ListBans(r.Context(), current)
valid := false
for _, ban := range bans {
if ban.PlayerID == player {
valid = true
break
}
}
if listErr != nil || !valid {
s.detailRedirect(w, r, false, permission)
return
}
} else {
var valid bool
player, valid = validManualPlayerIdentifier(player)
if !valid {
s.detailRedirect(w, r, false, "players.unban")
return
}
}
if listErr != nil || !valid {
s.detailRedirect(w, r, false, permission)
return
}
request = map[string]string{"player_id": player}
} else {
@@ -211,6 +226,22 @@ func validGameID(value string) bool {
return value != "" && len(value) <= 256 && !strings.ContainsAny(value, "\r\n")
}
// validManualPlayerIdentifier accepts the game-defined identifier format
// without assuming a Steam ID. It only bounds and sanitizes browser input
// before the module applies its own game-specific validation.
func validManualPlayerIdentifier(value string) (string, bool) {
value = strings.TrimSpace(value)
if value == "" || len(value) > 256 {
return "", false
}
for _, r := range value {
if unicode.IsControl(r) {
return "", false
}
}
return value, true
}
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"))
+11 -2
View File
@@ -56,7 +56,16 @@ type server struct {
notifications *notification.Service
catalogScan func(context.Context) (catalog.ScanResult, error)
serversRoot string
moduleRuntime *instance.ModuleService
moduleRuntime moduleRuntime
}
// moduleRuntime is the narrow integration boundary used by SSR handlers. It
// keeps HTTP unaware of WASM details while allowing the capability states to
// be exercised without a live game API.
type moduleRuntime interface {
Live(context.Context, instance.StoredInstance, bool) instance.Live
ListBans(context.Context, instance.StoredInstance) ([]instance.Ban, error)
Action(context.Context, instance.StoredInstance, string, string, any) error
}
type completeHandler struct {
http.Handler
@@ -215,7 +224,7 @@ func NewHandlerCompleteWithCatalogAndDeployment(authService *auth.Service, repos
// 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) {
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 moduleRuntime, 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
+160
View File
@@ -50,6 +50,166 @@ func (webLifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwi
return agentwire.InstanceStats{InstanceID: id}, nil
}
type fakeModuleRuntime struct {
live instance.Live
bans []instance.Ban
actionErr error
actionCall int
request map[string]string
}
func (f *fakeModuleRuntime) Live(context.Context, instance.StoredInstance, bool) instance.Live {
return f.live
}
func (f *fakeModuleRuntime) ListBans(context.Context, instance.StoredInstance) ([]instance.Ban, error) {
return f.bans, nil
}
func (f *fakeModuleRuntime) Action(_ context.Context, _ instance.StoredInstance, _ string, _ string, request any) error {
f.actionCall++
f.request, _ = request.(map[string]string)
return f.actionErr
}
func TestInstanceUnbanCapabilityFallback(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)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
DisplayName: "Unban fixture", HostPorts: map[string]int{"game": 8211},
MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7,
})
if err != nil {
t.Fatal(err)
}
const instanceID = "unban-fixture"
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
admin, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
viewer, err := authService.CreateUser(ctx, "viewer", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
users, err := authService.ListUsers(ctx)
if err != nil {
t.Fatal(err)
}
var adminID string
for _, user := range users {
if user.Username == "admin" {
adminID = user.ID
}
}
if err := repository.SetMembership(ctx, adminID, instanceID, viewer.ID, "user"); err != nil {
t.Fatal(err)
}
viewerSession, err := authService.Login(ctx, "viewer", "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
newHandler := func(runtime *fakeModuleRuntime) http.Handler {
handler, err := NewHandlerCompleteWithCatalogDeploymentAndRuntime(authService, repository, nil, nil, nil, audit.New(db), nil, nil, t.TempDir(), runtime, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
return handler
}
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
t.Run("unban absent hides action", func(t *testing.T) {
handler := newHandler(&fakeModuleRuntime{live: instance.Live{Capabilities: map[string]bool{}}})
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
assertStatus(t, page, http.StatusOK)
if strings.Contains(page.Body.String(), "/module/unban") {
t.Fatal("unban action rendered without unban capability")
}
})
t.Run("unavailable runtime hides manual action", func(t *testing.T) {
handler := newHandler(&fakeModuleRuntime{live: instance.Live{Unavailable: true}})
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
assertStatus(t, page, http.StatusOK)
if strings.Contains(page.Body.String(), "/module/unban") {
t.Fatal("unban action rendered while runtime was unavailable")
}
})
t.Run("list bans keeps selection validation", func(t *testing.T) {
runtime := &fakeModuleRuntime{live: instance.Live{Capabilities: map[string]bool{"unban": true, "list_bans": true}, BansChecked: true, Bans: []instance.Ban{{PlayerID: "real-id", DisplayName: "Real player"}}}, bans: []instance.Ban{{PlayerID: "real-id", DisplayName: "Real player"}}}
handler := newHandler(runtime)
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
if !strings.Contains(page.Body.String(), "<select name=\"player_id\"") || strings.Contains(page.Body.String(), "Player identifier") {
t.Fatal("ban selection did not retain list-only UI")
}
invalid := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {"invented-id"}}, adminCookies...)
assertStatus(t, invalid, http.StatusSeeOther)
if runtime.actionCall != 0 {
t.Fatal("unlisted player was sent to module")
}
valid := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {"real-id"}}, adminCookies...)
assertStatus(t, valid, http.StatusSeeOther)
if runtime.actionCall != 1 || runtime.request["player_id"] != "real-id" {
t.Fatalf("listed player action = %#v, calls=%d", runtime.request, runtime.actionCall)
}
})
t.Run("palworld manual fallback validates and audits", func(t *testing.T) {
manifest, err := os.ReadFile("../../modules/palworld-rest/manifest.yaml")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifest), " - unban") || strings.Contains(string(manifest), "list_bans") {
t.Fatal("Palworld fixture no longer represents unban without list_bans")
}
runtime := &fakeModuleRuntime{live: instance.Live{Capabilities: map[string]bool{"unban": true}}}
handler := newHandler(runtime)
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
if !strings.Contains(page.Body.String(), "Player identifier") || strings.Contains(page.Body.String(), "<select name=\"player_id\"") {
t.Fatal("manual unban fallback was not rendered")
}
for _, playerID := range []string{"", strings.Repeat("a", 257), "eos\x1fuser"} {
response := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {playerID}}, adminCookies...)
assertStatus(t, response, http.StatusSeeOther)
}
if runtime.actionCall != 0 {
t.Fatal("invalid manual identifier reached module")
}
csrfDenied := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"player_id": {"eos-user"}}, adminCookies...)
assertStatus(t, csrfDenied, http.StatusForbidden)
viewerDenied := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {viewerSession.CSRFToken}, "player_id": {"eos-user"}}, &http.Cookie{Name: sessionCookie, Value: viewerSession.Token}, &http.Cookie{Name: csrfCookie, Value: viewerSession.CSRFToken})
assertStatus(t, viewerDenied, http.StatusSeeOther)
valid := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {" eos-user "}}, adminCookies...)
assertStatus(t, valid, http.StatusSeeOther)
if runtime.actionCall != 1 || runtime.request["player_id"] != "eos-user" {
t.Fatalf("manual unban action = %#v, calls=%d", runtime.request, runtime.actionCall)
}
events, err := audit.New(db).List(ctx, audit.Filter{Action: "players.unban", InstanceID: instanceID})
if err != nil || len(events) == 0 || events[0].Outcome != "allowed" {
t.Fatalf("unban audit events = %#v, err=%v", events, err)
}
})
}
func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -50,7 +50,7 @@ Every module implements these three exports.
| `kick` | `kick_player(player_id, reason?)` | action result |
| `ban` | `ban_player(player_id, reason?)` | action result |
| `unban` | `unban_player(player_id)` | action result |
| `list_bans` | `list_bans()` | bounded banned-player list with stable player IDs |
| `list_bans` | `list_bans()` | optional bounded banned-player list with stable player IDs |
An optional export cannot exist as an enabled UI action unless manifest, runtime report, template and permission agree.
@@ -79,5 +79,8 @@ Accepts bounded structured diagnostic data. The host redacts and rate-limits it.
- Player actions use stable game IDs, not only display names.
- `list_bans` returns bounded entries with a stable `player_id` and display
name. Hosts must revalidate selections before unbanning.
- `unban` does not require `list_bans`. When the list capability is absent,
hosts may accept a bounded, sanitized manual identifier and pass it to the
module, which remains responsible for game-specific identifier validation.
- Metrics use documented normalized names/units. Unknown game-specific metrics may be omitted rather than smuggled into arbitrary maps in V1.
- All calls are side-effect-free except save, shutdown, announcement and player-action exports.