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 @@
-