diff --git a/catalog/vrising/module/README.md b/catalog/vrising/module/README.md index 4741673..0bc0176 100644 --- a/catalog/vrising/module/README.md +++ b/catalog/vrising/module/README.md @@ -1,11 +1,9 @@ # V Rising RCON module This template-local adapter uses the documented Source RCON interface of the -V Rising dedicated server. The official server documentation lists only -`announce` and `announcerestart`; the adapter currently exposes only bounded -connectivity/status until command behavior is validated end-to-end on a real -server. It does not claim announcement, player, save, shutdown, kick, ban or -unban support. +V Rising dedicated server. It exposes bounded `announce` and `shutdown` +operations. Player listing, save, kick, ban and unban are intentionally not +advertised without real-server RCON validation. Build from the repository root: diff --git a/catalog/vrising/module/manifest.yaml b/catalog/vrising/module/manifest.yaml index 42651fd..8e5cfb7 100644 --- a/catalog/vrising/module/manifest.yaml +++ b/catalog/vrising/module/manifest.yaml @@ -17,7 +17,9 @@ compatibility: manager_api: ">=1.0.0 <2.0.0" module_api: ">=1.0.0 <2.0.0" -capabilities: [] +capabilities: + - announcement + - graceful_shutdown permissions: network: @@ -49,4 +51,4 @@ configuration: artifacts: wasm: module.wasm - sha256: "d0b34c7724309e18f3e6e474886a6d4cf992cfaf9109415d20e46ff54fd9ab69" + sha256: "bdafc0de4c517288c208bd0f1cb1d212858a54f9939a8e305621d32105f81f5c" diff --git a/catalog/vrising/module/module.wasm b/catalog/vrising/module/module.wasm index 552ec00..50aa546 100644 Binary files a/catalog/vrising/module/module.wasm and b/catalog/vrising/module/module.wasm differ diff --git a/catalog/vrising/module/src/main.go b/catalog/vrising/module/src/main.go index e647249..f0d2835 100644 --- a/catalog/vrising/module/src/main.go +++ b/catalog/vrising/module/src/main.go @@ -136,7 +136,7 @@ func authFailure(result authResult) *moduleError { return rconFailure(errors.New(string(result))) } -var capabilities = []string{"announcement"} +var capabilities = []string{"announcement", "graceful_shutdown"} //go:wasmexport initialize func initialize(_, _ uint32, outPtr, outCap uint32) int32 { @@ -194,5 +194,17 @@ func sendAnnouncement(inPtr, inLen, outPtr, outCap uint32) int32 { } return result(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure) } + +//go:wasmexport shutdown +func shutdown(_, _, outPtr, outCap uint32) int32 { + password, failure := credentials() + if failure == nil { + command := execute(tcp, password, "shutdown") + if command != "" { + failure = rconFailure(errors.New(string(command))) + } + } + return result(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure) +} func utf8Valid(value string) bool { return strings.ToValidUTF8(value, "") == value } func main() {} diff --git a/catalog/vrising/module/src/rcon_test.go b/catalog/vrising/module/src/rcon_test.go index b071100..2a010c8 100644 --- a/catalog/vrising/module/src/rcon_test.go +++ b/catalog/vrising/module/src/rcon_test.go @@ -82,6 +82,20 @@ func TestExecuteHandlesMultiPacketResponse(t *testing.T) { } } +func TestExecuteShutdownUsesOfficialCommand(t *testing.T) { + exchange := fakeRCON(t, func(request []byte) []byte { + packets, err := parseRCONPackets(request) + if err != nil || len(packets) != 2 || packets[1].body != "shutdown" { + t.Errorf("unexpected shutdown request: %#v %v", packets, err) + return nil + } + return append(packetBytes(t, 1, rconAuthReply, ""), packetBytes(t, 2, rconCommandOut, "accepted")...) + }) + if result := execute(exchange, "secret", "shutdown"); result != "" { + t.Fatal(result) + } +} + func TestRCONRejectsMalformedAndOversizedPackets(t *testing.T) { for _, raw := range [][]byte{{1, 2}, make([]byte, maxRCONResponse+1), packetBytes(t, 1, rconAuthReply, "x")[:12]} { if _, err := parseRCONPackets(raw); !errors.Is(err, errRCONMalformed) { diff --git a/catalog/vrising/template.yaml b/catalog/vrising/template.yaml index d40a607..e0a40b0 100644 --- a/catalog/vrising/template.yaml +++ b/catalog/vrising/template.yaml @@ -71,7 +71,9 @@ container: publish: false required: true -capabilities: [] +capabilities: + - announcement + - graceful_shutdown storage: mounts: diff --git a/docs/PROJECT-STATE.md b/docs/PROJECT-STATE.md index 7e671c5..61e5ea6 100644 --- a/docs/PROJECT-STATE.md +++ b/docs/PROJECT-STATE.md @@ -84,7 +84,7 @@ Read this compact operational baseline before starting a milestone. Open detaile - Session policy is stored in the existing `system_settings` table. `created_at` remains the absolute lifetime anchor, `last_seen_at` tracks inactivity with writes no more often than every five minutes, and logout/deactivation/expiry revoke server-side session rows. - The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth for administrator-owned customizations. Bundled templates are copied only when their destination files are absent; after every local scan, the current bundled immutable snapshots are synchronized into SQLite and selected for new deployments while older snapshots remain available for existing instances, audit and diagnostics. - Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only. -- V Rising is the first template-scoped TCP RCON module. Its manifest currently declares only verified connectivity/status; command operations are not advertised until validated end-to-end against a real server. Go/WASI reactor modules use `-buildmode=c-shared` and initialize through `_initialize`; the V Rising success path uses concrete results to avoid Go 1.26 WASI reactor nil-interface traps. Wazero is pinned at v1.12.0. +- V Rising is the first template-scoped TCP RCON module. Its manifest declares the bounded `announcement` and `graceful_shutdown` operations, implemented as `announce ` and `shutdown`; players/save/kick/ban/unban remain unadvertised until a real server confirms RCON support. Go/WASI reactor modules use `-buildmode=c-shared` and initialize through `_initialize`; the V Rising success path uses concrete results to avoid Go 1.26 WASI reactor nil-interface traps. Wazero is pinned at v1.12.0. - Module TCP access is instance-scoped and template-bound: the guest supplies no destination, only bounded bytes; the host pins the instance network address and declared integration port, enforces deadlines and response limits, and rejects arbitrary/unsafe destinations. - Published port selection is generic: templates own container ports and protocols, while administrators choose host ports at deployment; TCP and UDP may reuse a host number because Docker treats those bindings independently. diff --git a/internal/backup/ownership_test.go b/internal/backup/ownership_test.go new file mode 100644 index 0000000..462de3a --- /dev/null +++ b/internal/backup/ownership_test.go @@ -0,0 +1,58 @@ +package backup + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance" +) + +func TestApplyRuntimeOwnershipUsesTargetIdentity(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("changing ownership to a distinct UID requires root") + } + root := t.TempDir() + file := filepath.Join(root, "save.dat") + if err := os.WriteFile(file, []byte("save"), 0o640); err != nil { + t.Fatal(err) + } + preview := instance.Preview{DockerUser: instance.DockerUser{Mode: instance.DockerUserDoGaMa}, DockerUserValue: "1234:1234"} + if err := applyRuntimeOwnership(root, preview); err != nil { + t.Fatal(err) + } + for _, name := range []string{root, file} { + info, err := os.Stat(name) + if err != nil { + t.Fatal(err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || uint32(stat.Uid) != 1234 || uint32(stat.Gid) != 1234 { + t.Fatalf("%s ownership = %v", name, info.Sys()) + } + } +} + +func TestApplyRuntimeOwnershipDoesNotOverrideImageUser(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "save.dat") + if err := os.WriteFile(file, []byte("save"), 0o640); err != nil { + t.Fatal(err) + } + before, err := os.Stat(file) + if err != nil { + t.Fatal(err) + } + preview := instance.Preview{DockerUser: instance.DockerUser{Mode: instance.DockerUserImage}, DockerUserValue: "1234:1234"} + if err := applyRuntimeOwnership(root, preview); err != nil { + t.Fatal(err) + } + after, err := os.Stat(file) + if err != nil { + t.Fatal(err) + } + if before.Sys().(*syscall.Stat_t).Uid != after.Sys().(*syscall.Stat_t).Uid || before.Sys().(*syscall.Stat_t).Gid != after.Sys().(*syscall.Stat_t).Gid { + t.Fatal("image-defined ownership was changed") + } +} diff --git a/internal/backup/service.go b/internal/backup/service.go index 27d7b5d..1437784 100644 --- a/internal/backup/service.go +++ b/internal/backup/service.go @@ -17,6 +17,7 @@ import ( "path" "path/filepath" "sort" + "strconv" "strings" "time" @@ -615,6 +616,10 @@ func (s *Service) restoreArchive(archive string, current instance.StoredInstance rollback() return ErrIntegrity } + if err := applyRuntimeOwnership(staged, current.Preview); err != nil { + rollback() + return err + } previous := live + ".dogama-previous-" + manifest.BackupID if err := os.Rename(live, previous); err != nil { rollback() @@ -635,6 +640,37 @@ func (s *Service) restoreArchive(archive string, current instance.StoredInstance return nil } +// applyRuntimeOwnership deliberately ignores tar ownership metadata. The +// effective Docker user is part of the target instance preview and is the +// only ownership source accepted for a DoGaMa-managed container. Image-owned +// templates retain ownership chosen by their image entrypoint. +func applyRuntimeOwnership(root string, preview instance.Preview) error { + if preview.DockerUser.Mode == instance.DockerUserImage { + return nil + } + parts := strings.Split(preview.DockerUserValue, ":") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return ErrInvalidState + } + uid, err := strconv.ParseUint(parts[0], 10, 32) + if err != nil { + return ErrInvalidState + } + gid, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { + return ErrInvalidState + } + return filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.Mode()&os.ModeSymlink != 0 { + return ErrUnsafePath + } + return os.Chown(path, int(uid), int(gid)) + }) +} + func (s *Service) applyRetention(ctx context.Context, instanceID string, count int) error { candidates, err := s.repository.RetentionCandidates(ctx, instanceID, count) if err != nil { diff --git a/internal/web/server.go b/internal/web/server.go index 3dd6846..9b38b38 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -2353,6 +2353,13 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) { if s.audit != nil { data.AuditPolicy, _ = s.audit.Policy(r.Context()) } + if err := s.populateAdminUsers(r, &data); err != nil { + s.problem(w, http.StatusInternalServerError, "The users are unavailable.") + return + } + if s.repository != nil { + data.TemplateRepositories, _ = s.repository.ListTemplateRepositories(r.Context()) + } if settings, ok := s.repository.(interface { GetWebAccessPolicy(context.Context) (webaccess.Policy, error) }); ok { diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 97d78a3..48da767 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -70,6 +70,34 @@ if (deploymentForm) { }); } +const adminTabs = document.querySelectorAll('.app-main > nav.tabs a[href^="#"]'); +if (adminTabs.length) { + const panels = [...document.querySelectorAll('.settings-section[id]')]; + const selectAdminTab = (requested, updateHash) => { + const valid = panels.some((panel) => panel.id === requested); + const selected = valid ? requested : (panels[0]?.id || ""); + panels.forEach((panel) => { panel.hidden = panel.id !== selected; }); + adminTabs.forEach((tab) => { + const active = tab.getAttribute("href") === `#${selected}`; + tab.setAttribute("aria-selected", String(active)); + tab.tabIndex = active ? 0 : -1; + }); + if (updateHash && selected && window.location.hash !== `#${selected}`) history.replaceState(null, "", `#${selected}`); + }; + adminTabs.forEach((tab, index) => { + tab.setAttribute("role", "tab"); + tab.addEventListener("click", (event) => { event.preventDefault(); selectAdminTab(tab.hash.slice(1), true); }); + tab.addEventListener("keydown", (event) => { + if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return; + event.preventDefault(); + const next = (index + (event.key === "ArrowRight" ? 1 : -1) + adminTabs.length) % adminTabs.length; + adminTabs[next].focus(); selectAdminTab(adminTabs[next].hash.slice(1), true); + }); + }); + selectAdminTab(window.location.hash.slice(1), false); + window.addEventListener("hashchange", () => selectAdminTab(window.location.hash.slice(1), false)); +} + // The backend, not a timer, is the source for this history. A 403 simply // means the signed-in user is not an administrator and leaves no technical // data in the DOM. diff --git a/internal/web/template_repositories.go b/internal/web/template_repositories.go index 15be81a..100ab80 100644 --- a/internal/web/template_repositories.go +++ b/internal/web/template_repositories.go @@ -9,7 +9,17 @@ import ( ) func (s *server) templateRepositoriesPage(w http.ResponseWriter, r *http.Request) { - s.templateRepositoriesRender(w, r, http.StatusOK, templaterepo.Input{}, "") + data, ok := s.adminPageData(w, r, "Administration", "administration") + if !ok { + return + } + entries, err := s.repository.ListTemplateRepositories(r.Context()) + if err != nil { + s.problem(w, http.StatusInternalServerError, message("error.internal")) + return + } + data.TemplateRepositories = entries + s.render(w, http.StatusOK, "settings.html", data) } func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Request) { @@ -26,7 +36,7 @@ func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Req s.templateRepositoriesRender(w, r, http.StatusUnprocessableEntity, in, localized(s.language(r, actor.Language), "template_repositories.duplicate_error")) return } - http.Redirect(w, r, "/administration/template-repositories", http.StatusSeeOther) + http.Redirect(w, r, "/administration#repositories", http.StatusSeeOther) } func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Request) { @@ -42,7 +52,7 @@ func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Req s.problem(w, http.StatusInternalServerError, message("error.internal")) return } - http.Redirect(w, r, "/administration/template-repositories", http.StatusSeeOther) + http.Redirect(w, r, "/administration#repositories", http.StatusSeeOther) } func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Request, status int, in templaterepo.Input, formError string) { @@ -56,5 +66,9 @@ func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Reque return } data.TemplateRepositories, data.TemplateRepositoryInput, data.Error = entries, in, formError - s.render(w, status, "template-repositories.html", data) + if err := s.populateAdminUsers(r, &data); err != nil { + s.problem(w, http.StatusInternalServerError, "The users are unavailable.") + return + } + s.render(w, status, "settings.html", data) } diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index 5003d2d..328dc1b 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -27,8 +27,8 @@

-