feat(instances): allow manual unban without ban listing
CI / validate (pull_request) Canceled after 0s
CI / validate (pull_request) Canceled after 0s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user