Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8507a3c6b6 | ||
|
|
a665585dbe | ||
|
|
972d5ef156 | ||
|
|
da0492c348 | ||
|
|
8d282fd63d | ||
|
|
4ed3c8ae58 | ||
|
|
1bb9d20f88 | ||
|
|
8eff892903 | ||
|
|
62f2300a46 | ||
|
|
d39df2e7b9 | ||
|
|
fb1f91f162 | ||
|
|
9a66aeccb3 | ||
|
|
c2a480331f | ||
|
|
e2b34fed80 | ||
|
|
e2beebfd6c | ||
|
|
368ae918a8 | ||
|
|
51b6e5da34 | ||
|
|
35c11aff6d | ||
|
|
736423da75 | ||
|
|
75d5504729 | ||
|
|
3433c9b6dc | ||
|
|
dee4b692da | ||
|
|
fbbe35ad24 |
@@ -17,7 +17,6 @@ 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"]
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ Report reproducible problems in the [Gitea issue tracker](https://git.zaynet.fr/
|
||||
|
||||
## License
|
||||
|
||||
No repository-wide license file is currently present, so no general redistribution license is asserted here. The Palworld reference module has its own [license](modules/palworld-rest/LICENSE). A project-wide license must be added by the owner before public distribution.
|
||||
No repository-wide license file is currently present, so no general redistribution license is asserted here. The Palworld reference module has its own [license](catalog/palworld/module/LICENSE). A project-wide license must be added by the owner before public distribution.
|
||||
|
||||
### Web access and languages
|
||||
|
||||
|
||||
+1
-1
@@ -5,5 +5,5 @@ import "embed"
|
||||
|
||||
// Files contains built-in templates and their packaged assets.
|
||||
//
|
||||
//go:embed */template.yaml */assets/*
|
||||
//go:embed */template.yaml */assets/* */module/* */module/*/*
|
||||
var Files embed.FS
|
||||
|
||||
@@ -5,17 +5,19 @@ This template demonstrates every important V1 contract: official image, public U
|
||||
## Verified upstream facts
|
||||
|
||||
- Pocketpair publishes the official image and example Compose deployment at [pocketpairjp/palworld-dedicated-server-docker](https://github.com/pocketpairjp/palworld-dedicated-server-docker).
|
||||
- The current official example at specification time uses `ghcr.io/pocketpairjp/palserver:v1.0.2.101103`, UDP 8211, `/pal/Package/Pal/Saved` and the packaged `helper.sh` pattern.
|
||||
- Pocketpair publishes versioned image tags and a `latest` tag for the current official `ghcr.io/pocketpairjp/palserver` release. The official template uses `latest` for normal pulls and new deployments, with UDP 8211, `/pal/Package/Pal/Saved` and the packaged `helper.sh` pattern.
|
||||
- The [official requirements](https://docs.palworldgame.com/0.7.3/getting-started/requirements/) specify four or more CPU cores, 16 GB memory with over 32 GB recommended for stability, and UDP 8211. The template's 8 GB minimum is an explicit lower bootable boundary mentioned upstream, not a stability recommendation; the UI must warn below 16 GB.
|
||||
- The [configuration reference](https://docs.palworldgame.com/settings-and-operation/configuration/) documents `AdminPassword`, `RESTAPIEnabled`, `RESTAPIPort`, server name/password and maximum players.
|
||||
- The [REST API documentation](https://docs.palworldgame.com/category/rest-api/) documents information, players, metrics, announce, save, shutdown and moderation operations. REST API credentials and port must remain private.
|
||||
|
||||
The image tag and upstream API may change. Catalog maintainers must verify and release a new immutable template version; existing instances remain pinned.
|
||||
The upstream API may change. A template snapshot version remains independent from the game-server image tag; existing instances remain pinned to their chosen configuration and DoGaMa does not automatically replace running containers.
|
||||
|
||||
## Reference limitations
|
||||
|
||||
- The template ships separate local logo and horizontal artwork assets. The source URL and attribution remain in the template so the cached raster can be audited without loading third-party content in the UI.
|
||||
- The schema's storage sizes are conservative product defaults because upstream specifies SSD performance but not a fixed disk-size requirement.
|
||||
- Local hosted-world migration may require player identity conversion. The generic V1 importer detects structure and warns; it does not silently convert identities.
|
||||
- The checked-in module manifest is a source example. It becomes installable only after `module.wasm` is built and its real SHA-256 replaces the all-zero placeholder.
|
||||
|
||||
- `module/` contains the complete Palworld REST adapter: its manifest, source,
|
||||
fixture, license and compiled `module.wasm`. It is declared by
|
||||
`template.yaml` and is loaded from the template snapshot, not a global module
|
||||
registry. Rebuild it with the command in `module/README.md`.
|
||||
|
||||
@@ -31,8 +31,11 @@ From the repository root, using Go 1.25 or newer:
|
||||
```sh
|
||||
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build \
|
||||
-trimpath -buildmode=c-shared \
|
||||
-o modules/palworld-rest/module.wasm ./modules/palworld-rest/src
|
||||
sha256sum modules/palworld-rest/module.wasm
|
||||
-o catalog/palworld/module/module.wasm ./catalog/palworld/module/src
|
||||
sha256sum catalog/palworld/module/module.wasm
|
||||
```
|
||||
|
||||
The checksum must exactly match `manifest.yaml`. Repository tests instantiate the real artifact under wazero and exercise it against a bounded fake Palworld transport.
|
||||
The checksum must exactly match `manifest.yaml`. The Palworld template declares
|
||||
`module/manifest.yaml`; DoGaMa discovers the artifact from that template-local
|
||||
bundle without a game-specific registry. Repository tests instantiate the real
|
||||
artifact under wazero and exercise it against a bounded fake Palworld transport.
|
||||
@@ -1,6 +1,6 @@
|
||||
schema_version: 1
|
||||
id: palworld-official
|
||||
version: 1.1.1
|
||||
version: 1.1.3
|
||||
|
||||
source:
|
||||
type: official
|
||||
@@ -29,7 +29,7 @@ requirements:
|
||||
|
||||
container:
|
||||
image: ghcr.io/pocketpairjp/palserver
|
||||
tag: v1.0.2.101103
|
||||
tag: latest
|
||||
entrypoint:
|
||||
- /pal/helper.sh
|
||||
user_mode: image
|
||||
@@ -41,7 +41,6 @@ container:
|
||||
assets:
|
||||
- source: assets/helper.sh
|
||||
destination: /pal/helper.sh
|
||||
sha256: 52e58fe4e22654d0d312fe2bb6195db61dad5ffce78e0440a951d33d20f2c36f
|
||||
read_only: true
|
||||
stop_timeout_seconds: 120
|
||||
ports:
|
||||
@@ -187,6 +186,9 @@ integration:
|
||||
port_id: rest_api
|
||||
required: false
|
||||
|
||||
module:
|
||||
path: module/manifest.yaml
|
||||
|
||||
backup:
|
||||
strategy: online_save
|
||||
source_mounts:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# V Rising RCON module
|
||||
|
||||
This template-local adapter uses the documented Source RCON interface of the
|
||||
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:
|
||||
|
||||
```sh
|
||||
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared \
|
||||
-trimpath -ldflags='-s -w' \
|
||||
-o catalog/vrising/module/module.wasm ./catalog/vrising/module/src
|
||||
sha256sum catalog/vrising/module/module.wasm
|
||||
```
|
||||
|
||||
Copy the printed digest to `manifest.yaml`. The adapter limits each Source
|
||||
RCON packet to 64 KiB and an aggregated response to 256 KiB. Its host TCP
|
||||
exchange is pinned to the instance's `rcon` integration port and uses the
|
||||
module's 10-second deadline.
|
||||
@@ -0,0 +1,54 @@
|
||||
schema_version: 1
|
||||
id: vrising-rcon
|
||||
name: V Rising RCON adapter
|
||||
version: 1.0.0
|
||||
description: Source RCON adapter for documented V Rising connectivity and announcements.
|
||||
license: Apache-2.0
|
||||
homepage: https://github.com/StunlockStudios/vrising-dedicated-server-instructions
|
||||
|
||||
game_ids:
|
||||
- vrising
|
||||
|
||||
runtime:
|
||||
type: wasm
|
||||
abi: dogama:game-module@1.0.0
|
||||
|
||||
compatibility:
|
||||
manager_api: ">=1.0.0 <2.0.0"
|
||||
module_api: ">=1.0.0 <2.0.0"
|
||||
|
||||
capabilities:
|
||||
- announcement
|
||||
- graceful_shutdown
|
||||
|
||||
permissions:
|
||||
network:
|
||||
scope: instance_only
|
||||
protocols:
|
||||
- tcp
|
||||
port_ids:
|
||||
- rcon
|
||||
|
||||
limits:
|
||||
memory_mb: 32
|
||||
timeout_ms: 10000
|
||||
max_response_bytes: 262144
|
||||
max_concurrent_calls: 2
|
||||
|
||||
configuration:
|
||||
- id: rcon_enabled
|
||||
type: boolean
|
||||
required: true
|
||||
description: Existing V Rising RCON enabled setting.
|
||||
- id: rcon_port
|
||||
type: integer
|
||||
required: true
|
||||
description: Existing V Rising RCON port; it must match the declared integration port.
|
||||
- id: rcon_password
|
||||
type: secret
|
||||
required: true
|
||||
description: Existing V Rising RCON password secret.
|
||||
|
||||
artifacts:
|
||||
wasm: module.wasm
|
||||
sha256: "bdafc0de4c517288c208bd0f1cb1d212858a54f9939a8e305621d32105f81f5c"
|
||||
Binary file not shown.
@@ -0,0 +1,210 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport dogama_host tcp_exchange
|
||||
func hostTCPExchange(requestPtr, requestLen, responsePtr, responseCap uint32) int32
|
||||
|
||||
//go:wasmimport dogama_host get_secret
|
||||
func hostGetSecret(keyPtr, keyLen, valuePtr, valueCap uint32) int32
|
||||
|
||||
//go:wasmimport dogama_host get_config
|
||||
func hostGetConfig(keyPtr, keyLen, valuePtr, valueCap uint32) int32
|
||||
|
||||
var allocations [][]byte
|
||||
|
||||
//go:wasmexport dogama_alloc
|
||||
func dogamaAlloc(size uint32) uint32 {
|
||||
if size == 0 {
|
||||
size = 1
|
||||
}
|
||||
value := make([]byte, size)
|
||||
allocations = append(allocations, value)
|
||||
return uint32(uintptr(unsafe.Pointer(&value[0])))
|
||||
}
|
||||
|
||||
type moduleError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
type envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error *moduleError `json:"error,omitempty"`
|
||||
}
|
||||
type tcpRequest struct {
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
func bytesAt(ptr, size uint32) []byte {
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
|
||||
}
|
||||
func output(outPtr, outCap uint32, value any) int32 {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil || len(encoded) > int(outCap) {
|
||||
return -1
|
||||
}
|
||||
copy(bytesAt(outPtr, uint32(len(encoded))), encoded)
|
||||
return int32(len(encoded))
|
||||
}
|
||||
func result(outPtr, outCap uint32, data any, failure *moduleError) int32 {
|
||||
if failure == nil {
|
||||
return output(outPtr, outCap, struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}{OK: true, Data: data})
|
||||
}
|
||||
return output(outPtr, outCap, envelope{OK: false, Data: data, Error: failure})
|
||||
}
|
||||
|
||||
func boundValue(secret bool, key string) (string, bool) {
|
||||
keyBytes, buffer := []byte(key), make([]byte, 4096)
|
||||
var size int32
|
||||
if secret {
|
||||
size = hostGetSecret(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer)))
|
||||
} else {
|
||||
size = hostGetConfig(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer)))
|
||||
}
|
||||
if size < 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(buffer[:size]), true
|
||||
}
|
||||
|
||||
func tcp(body []byte) ([]byte, transportResult) {
|
||||
encoded, _ := json.Marshal(tcpRequest{Body: body})
|
||||
response := make([]byte, maxRCONResponse)
|
||||
size := hostTCPExchange(uint32(uintptr(unsafe.Pointer(&encoded[0]))), uint32(len(encoded)), uint32(uintptr(unsafe.Pointer(&response[0]))), uint32(len(response)))
|
||||
if size >= 0 {
|
||||
return response[:size], transportResult("")
|
||||
}
|
||||
switch size {
|
||||
case -3:
|
||||
return nil, transportResult("rcon connection refused")
|
||||
case -4:
|
||||
return nil, transportResult("rcon timeout")
|
||||
case -5:
|
||||
return nil, transportResult("rcon response too large")
|
||||
}
|
||||
return nil, transportResult("rcon transport failure")
|
||||
}
|
||||
|
||||
func credentials() (string, *moduleError) {
|
||||
enabled, enabledOK := boundValue(false, "rcon_enabled")
|
||||
port, portOK := boundValue(false, "rcon_port")
|
||||
password, passwordOK := boundValue(true, "rcon_password")
|
||||
if !enabledOK || !portOK || !passwordOK || enabled != "true" || port != "9878" || password == "" {
|
||||
return "", &moduleError{Code: "invalid_configuration", Message: "V Rising RCON must be enabled and have a password."}
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
func rconFailure(err error) *moduleError {
|
||||
switch {
|
||||
case errors.Is(err, errRCONUnauthorized):
|
||||
return &moduleError{Code: "unauthorized", Message: "V Rising rejected the configured RCON password."}
|
||||
case strings.Contains(err.Error(), "timeout"):
|
||||
return &moduleError{Code: "timeout", Message: "V Rising RCON did not respond before the deadline.", Retryable: true}
|
||||
case strings.Contains(err.Error(), "refused"):
|
||||
return &moduleError{Code: "unreachable", Message: "V Rising is running but RCON is not accepting connections yet.", Retryable: true}
|
||||
case strings.Contains(err.Error(), "large"):
|
||||
return &moduleError{Code: "invalid_response", Message: "V Rising sent an oversized RCON response."}
|
||||
default:
|
||||
return &moduleError{Code: "invalid_response", Message: "V Rising returned an invalid RCON response."}
|
||||
}
|
||||
}
|
||||
|
||||
func authFailure(result authResult) *moduleError {
|
||||
if result == authOK {
|
||||
return nil
|
||||
}
|
||||
if result == authUnauthorized {
|
||||
return &moduleError{Code: "unauthorized", Message: "V Rising rejected the configured RCON password."}
|
||||
}
|
||||
return rconFailure(errors.New(string(result)))
|
||||
}
|
||||
|
||||
var capabilities = []string{"announcement", "graceful_shutdown"}
|
||||
|
||||
//go:wasmexport initialize
|
||||
func initialize(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
_, failure := credentials()
|
||||
return result(outPtr, outCap, map[string]any{"module_id": "vrising-rcon", "module_version": "1.0.0", "api_version": "1.0.0", "capabilities": capabilities}, failure)
|
||||
}
|
||||
|
||||
//go:wasmexport test_connection
|
||||
func testConnection(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
password, failure := credentials()
|
||||
if failure == nil {
|
||||
failure = authFailure(authenticate(tcp, password))
|
||||
}
|
||||
return result(outPtr, outCap, map[string]any{"connected": failure == nil}, failure)
|
||||
}
|
||||
|
||||
//go:wasmexport get_server_status
|
||||
func getServerStatus(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
password, failure := credentials()
|
||||
if failure == nil {
|
||||
failure = authFailure(authenticate(tcp, password))
|
||||
}
|
||||
status := "ready"
|
||||
if failure != nil {
|
||||
status = "starting"
|
||||
if failure.Code == "unauthorized" || failure.Code == "invalid_configuration" {
|
||||
status = "degraded"
|
||||
}
|
||||
}
|
||||
return result(outPtr, outCap, map[string]any{"status": status}, failure)
|
||||
}
|
||||
|
||||
type messageRequest struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func decodeRequest(inPtr, inLen uint32, value any) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(bytesAt(inPtr, inLen)))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value) == nil
|
||||
}
|
||||
|
||||
//go:wasmexport send_announcement
|
||||
func sendAnnouncement(inPtr, inLen, outPtr, outCap uint32) int32 {
|
||||
var request messageRequest
|
||||
if !decodeRequest(inPtr, inLen, &request) || request.Message == "" || len(request.Message) > 1000 || !utf8Valid(request.Message) {
|
||||
return result(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "The announcement is invalid."})
|
||||
}
|
||||
password, failure := credentials()
|
||||
if failure == nil {
|
||||
command := execute(tcp, password, "announce "+request.Message)
|
||||
if command != "" {
|
||||
failure = rconFailure(errors.New(string(command)))
|
||||
}
|
||||
}
|
||||
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() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
rconAuth uint32 = 3
|
||||
rconAuthReply uint32 = 2
|
||||
rconCommand uint32 = 2
|
||||
rconCommandOut uint32 = 0
|
||||
maxRCONPacket = 64 << 10
|
||||
maxRCONResponse = 256 << 10
|
||||
)
|
||||
|
||||
type rconError string
|
||||
|
||||
func (e rconError) Error() string { return string(e) }
|
||||
|
||||
const (
|
||||
errRCONUnauthorized rconError = "rcon authentication rejected"
|
||||
errRCONMalformed rconError = "invalid rcon response"
|
||||
)
|
||||
|
||||
type rconPacket struct {
|
||||
id uint32
|
||||
typ uint32
|
||||
body string
|
||||
}
|
||||
|
||||
type transportResult string
|
||||
|
||||
func (e transportResult) Error() string { return string(e) }
|
||||
|
||||
type rconExchange func([]byte) ([]byte, transportResult)
|
||||
|
||||
type authResult string
|
||||
type commandResult string
|
||||
|
||||
const (
|
||||
authOK authResult = ""
|
||||
authUnauthorized authResult = "unauthorized"
|
||||
authMalformed authResult = "invalid_response"
|
||||
)
|
||||
|
||||
func writeUint32(buffer []byte, value uint32) {
|
||||
buffer[0], buffer[1], buffer[2], buffer[3] = byte(value), byte(value>>8), byte(value>>16), byte(value>>24)
|
||||
}
|
||||
|
||||
func readUint32(buffer []byte) uint32 {
|
||||
return uint32(buffer[0]) | uint32(buffer[1])<<8 | uint32(buffer[2])<<16 | uint32(buffer[3])<<24
|
||||
}
|
||||
|
||||
func encodeRCONPacket(id, typ uint32, body string) ([]byte, error) {
|
||||
if len(body) > maxRCONPacket-10 {
|
||||
return nil, errRCONMalformed
|
||||
}
|
||||
packet := make([]byte, len(body)+14)
|
||||
writeUint32(packet[:4], uint32(len(body)+10))
|
||||
writeUint32(packet[4:8], id)
|
||||
writeUint32(packet[8:12], typ)
|
||||
copy(packet[12:], body)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func parseRCONPackets(raw []byte) ([]rconPacket, error) {
|
||||
if len(raw) == 0 || len(raw) > maxRCONResponse {
|
||||
return nil, errRCONMalformed
|
||||
}
|
||||
packets := make([]rconPacket, 0, 2)
|
||||
for len(raw) > 0 {
|
||||
if len(raw) < 14 {
|
||||
return nil, errRCONMalformed
|
||||
}
|
||||
length := int(readUint32(raw[:4]))
|
||||
if length < 10 || length > maxRCONPacket || length+4 > len(raw) {
|
||||
return nil, errRCONMalformed
|
||||
}
|
||||
packet := raw[4 : length+4]
|
||||
if packet[length-2] != 0 || packet[length-1] != 0 {
|
||||
return nil, errRCONMalformed
|
||||
}
|
||||
packets = append(packets, rconPacket{id: readUint32(packet[:4]), typ: readUint32(packet[4:8]), body: string(packet[8 : length-2])})
|
||||
raw = raw[length+4:]
|
||||
}
|
||||
return packets, nil
|
||||
}
|
||||
|
||||
func authenticate(exchange rconExchange, password string) authResult {
|
||||
auth, err := encodeRCONPacket(1, rconAuth, password)
|
||||
if err != nil {
|
||||
return authMalformed
|
||||
}
|
||||
raw, transportErr := exchange(auth)
|
||||
if transportErr != "" {
|
||||
return authResult(transportErr.Error())
|
||||
}
|
||||
packets, err := parseRCONPackets(raw)
|
||||
if err != nil {
|
||||
return authMalformed
|
||||
}
|
||||
for _, packet := range packets {
|
||||
if packet.typ == rconAuthReply && packet.id == ^uint32(0) {
|
||||
return authUnauthorized
|
||||
}
|
||||
if packet.typ == rconAuthReply && packet.id == 1 {
|
||||
return authOK
|
||||
}
|
||||
}
|
||||
return authMalformed
|
||||
}
|
||||
|
||||
// execute authenticates and executes a single framed command in one bounded
|
||||
// TCP exchange. Source RCON accepts pipelined packets; the command is ignored
|
||||
// by the server when authentication fails.
|
||||
func execute(exchange rconExchange, password, command string) commandResult {
|
||||
auth, err := encodeRCONPacket(1, rconAuth, password)
|
||||
if err != nil {
|
||||
return commandResult(err.Error())
|
||||
}
|
||||
exec, err := encodeRCONPacket(2, rconCommand, command)
|
||||
if err != nil {
|
||||
return commandResult(err.Error())
|
||||
}
|
||||
raw, transportErr := exchange(append(auth, exec...))
|
||||
if transportErr != "" {
|
||||
return commandResult(transportErr.Error())
|
||||
}
|
||||
packets, err := parseRCONPackets(raw)
|
||||
if err != nil {
|
||||
return commandResult(err.Error())
|
||||
}
|
||||
authenticated, completed := false, false
|
||||
for _, packet := range packets {
|
||||
if packet.typ == rconAuthReply && packet.id == ^uint32(0) {
|
||||
return commandResult(errRCONUnauthorized.Error())
|
||||
}
|
||||
if packet.typ == rconAuthReply && packet.id == 1 {
|
||||
authenticated = true
|
||||
}
|
||||
if packet.typ == rconCommandOut && packet.id == 2 {
|
||||
completed = true
|
||||
}
|
||||
}
|
||||
if !authenticated || !completed {
|
||||
return commandResult(errRCONMalformed.Error())
|
||||
}
|
||||
return commandResult("")
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func packetBytes(t *testing.T, id, typ uint32, body string) []byte {
|
||||
t.Helper()
|
||||
value, err := encodeRCONPacket(id, typ, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func fakeRCON(t *testing.T, handler func([]byte) []byte) rconExchange {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
buf := make([]byte, 4096)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response := handler(buf[:n]); response != nil {
|
||||
_, _ = conn.Write(response)
|
||||
}
|
||||
}()
|
||||
return func(request []byte) ([]byte, transportResult) {
|
||||
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
return nil, transportResult(err.Error())
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(time.Second))
|
||||
if _, err := conn.Write(request); err != nil {
|
||||
return nil, transportResult(err.Error())
|
||||
}
|
||||
buf := make([]byte, maxRCONResponse)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, transportResult(err.Error())
|
||||
}
|
||||
return buf[:n], transportResult("")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateSuccessAndFailure(t *testing.T) {
|
||||
if result := authenticate(fakeRCON(t, func([]byte) []byte { return packetBytes(t, 1, rconAuthReply, "") }), "secret"); result != authOK {
|
||||
t.Fatalf("success: %v", result)
|
||||
}
|
||||
if result := authenticate(fakeRCON(t, func([]byte) []byte { return packetBytes(t, ^uint32(0), rconAuthReply, "") }), "secret"); result != authUnauthorized {
|
||||
t.Fatalf("failure: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHandlesMultiPacketResponse(t *testing.T) {
|
||||
exchange := fakeRCON(t, func(request []byte) []byte {
|
||||
packets, err := parseRCONPackets(request)
|
||||
if err != nil || len(packets) != 2 || packets[1].body != "announce hello\nsecond line" {
|
||||
t.Errorf("unexpected request: %#v %v", packets, err)
|
||||
return nil
|
||||
}
|
||||
return append(packetBytes(t, 1, rconAuthReply, ""), append(packetBytes(t, 2, rconCommandOut, "accepted "), packetBytes(t, 2, rconCommandOut, "done")...)...)
|
||||
})
|
||||
if result := execute(exchange, "secret", "announce hello\nsecond line"); result != "" {
|
||||
t.Fatal(result)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Fatalf("accepted invalid packet: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRCONTimeoutAndConnectionRefused(t *testing.T) {
|
||||
timeout := func([]byte) ([]byte, transportResult) { return nil, transportResult("timeout") }
|
||||
if result := authenticate(timeout, "secret"); !strings.Contains(string(result), "timeout") {
|
||||
t.Fatalf("timeout not returned: %v", result)
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
refused := func([]byte) ([]byte, transportResult) {
|
||||
_, err := net.DialTimeout("tcp", address, 50*time.Millisecond)
|
||||
return nil, transportResult(err.Error())
|
||||
}
|
||||
if result := authenticate(refused, "secret"); result == authOK {
|
||||
t.Fatal("connection refusal not returned")
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,23 @@ container:
|
||||
image: didstopia/vrising-server
|
||||
tag: latest
|
||||
user_mode: image
|
||||
runtime_user:
|
||||
mode: environment
|
||||
uid_env: PUID
|
||||
gid_env: PGID
|
||||
stop_timeout_seconds: 120
|
||||
capabilities:
|
||||
add:
|
||||
- CHOWN
|
||||
- FOWNER
|
||||
- DAC_OVERRIDE
|
||||
- SETUID
|
||||
- SETGID
|
||||
environment:
|
||||
V_RISING_SERVER_BIND_IP: ""
|
||||
V_RISING_SERVER_BIND_IP_AUTO_DETECT: "false"
|
||||
V_RISING_SERVER_PASSWORD: ""
|
||||
V_RISING_SERVER_GAME_SETTINGS_PRESET: Custom
|
||||
|
||||
ports:
|
||||
- id: game
|
||||
@@ -55,6 +71,10 @@ container:
|
||||
publish: false
|
||||
required: true
|
||||
|
||||
capabilities:
|
||||
- announcement
|
||||
- graceful_shutdown
|
||||
|
||||
storage:
|
||||
mounts:
|
||||
- id: persistent
|
||||
@@ -388,8 +408,14 @@ configuration:
|
||||
minimum: 1
|
||||
maximum: 32
|
||||
|
||||
capabilities:
|
||||
- graceful_shutdown
|
||||
module:
|
||||
path: module/manifest.yaml
|
||||
|
||||
integration:
|
||||
module_id: vrising-rcon
|
||||
version_range: ">=1.0.0 <2.0.0"
|
||||
port_id: rcon
|
||||
required: false
|
||||
|
||||
backup:
|
||||
strategy: stop_then_archive
|
||||
@@ -428,4 +454,4 @@ updates:
|
||||
|
||||
compatibility:
|
||||
minimum_manager_version: 1.0.0
|
||||
requires_instance_migration: false
|
||||
requires_instance_migration: false
|
||||
|
||||
+1
-2
@@ -46,7 +46,6 @@ 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 +118,7 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
handler, err = web.NewHandlerCompleteWithCatalogDeploymentAndRuntime(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, func(ctx context.Context) (catalog.ScanResult, error) {
|
||||
return synchronizeCatalog(ctx, repository, templatesRoot)
|
||||
}, serversRoot, instance.NewModuleService(repository, repository, modulesRoot), logger)
|
||||
}, serversRoot, instance.NewModuleService(repository, repository), logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
networks:
|
||||
- dogama
|
||||
- control
|
||||
- games
|
||||
depends_on:
|
||||
agent:
|
||||
condition: service_started
|
||||
@@ -53,6 +54,8 @@ networks:
|
||||
name: ${DOGAMA_NETWORK:-dogama}
|
||||
control:
|
||||
internal: true
|
||||
games:
|
||||
name: ${DOGAMA_GAMES_NETWORK:-dogama-games}
|
||||
|
||||
volumes:
|
||||
agent_state:
|
||||
|
||||
+15
-3
@@ -26,10 +26,12 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Backup scheduling/retention, safe imports, export and restore with safety backups.
|
||||
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
|
||||
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-logo/artwork routes.
|
||||
- Administration stores persistent game-container UID/GID defaults (1000:1000) with decimal uint32 validation. Managed templates use them as Docker `User`; `user_mode: image` is authoritative and omits Docker `User`. Templates can map the values to declared runtime environment variables; V Rising uses `PUID`/`PGID` while retaining its root entrypoint and capabilities.
|
||||
- Deployment forms expose published template ports as distinct host-port inputs. Host bindings default to the template container port, remain persisted in the instance preview, validate decimal range and same-protocol uniqueness, and never publish `publish: false` ports.
|
||||
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
|
||||
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
|
||||
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
|
||||
- Encrypted write-only SMTP, generic HTTPS webhook, Discord and Gotify channels with event filters, queued test delivery, bounded retry and redacted terminal errors; SMTP event email is filtered by persistent personal preferences and instance access.
|
||||
- Encrypted write-only SMTP, generic HTTPS webhook, Discord and Gotify channels with explicit disabled-by-default state, persisted safe administration fields, event filters, queued test delivery, bounded retry and redacted terminal errors; SMTP event email is filtered by persistent personal preferences and instance access.
|
||||
- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures.
|
||||
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
|
||||
- Responsive server-rendered application shell with synthwave-derived design tokens, permission-aware navigation, the official DoGaMa wordmark in the sidebar, searchable real instance cards, lifecycle summaries, server-only recent Audit activity and resilient agent/database/storage/backup/audit status on the Dashboard.
|
||||
@@ -37,6 +39,8 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Dedicated administrator Audit and Settings pages; notification channels, audit retention/purge and game-container labels retain their existing backend contracts outside the Dashboard.
|
||||
- Audit uses server-side filtering and 50-event pagination; timestamps remain UTC in SQLite and are rendered in the Compose `TZ` IANA timezone with an invalid-zone fallback to UTC. Instance audit events retain a minimal game/name/slug snapshot so history stays readable after instance deletion.
|
||||
- Separate personal account settings and administrator user management, including email/password preferences, active-state session revocation, protected global roles, per-instance memberships and permission overrides.
|
||||
- Personal account settings display the authenticated email and support CSRF-protected email updates plus local PNG/JPEG avatar upload, replacement and deletion; avatars are normalized to private 256px PNG files and fall back to username initials in the identity header.
|
||||
- Administrator-configurable browser session policy: seven-day absolute lifetime and 24-hour inactivity timeout by default, bounded validation, optional inactivity expiry, dynamic enforcement for existing sessions, and throttled activity persistence. Normal authorized operations no longer require an arbitrary recent-authentication window.
|
||||
- Restrictive browser headers and bounded public HTTP headers.
|
||||
- Hardened read-only two-service Compose, capability dropping, private agent networking and distinct minimal OCI image targets.
|
||||
- Linux black-box bootstrap/authentication E2E coverage plus a documented disposable-Docker V1 release verification matrix.
|
||||
@@ -46,7 +50,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Service-owned persistent secrets: the agent atomically creates and validates its mode-`0640` shared token in the internal `agent_auth` volume; the application mounts only that secret directory read-only and independently creates and validates its mode-`0600` master key below the application data path. The application tolerates concurrent first start by waiting up to 60 seconds for the token and authenticated agent health.
|
||||
- The agent token is exactly 32 opaque random bytes. Readers preserve terminal carriage-return and newline byte values instead of treating the secret as text.
|
||||
- Two service networks: an administrator-named application/reverse-proxy network plus a private Compose control network. The agent safely ensures the fixed `DOGAMA_GAMES_NETWORK` exists and applies it to every game-container create or replacement; it is not caller-selectable through the lifecycle API.
|
||||
- Portable fresh bind-mount startup uses root identities inside the read-only, capability-free container namespaces and a private process umask. No recursive ownership change is performed; game-container UID/GID remains per-instance configuration.
|
||||
- Portable fresh bind-mount startup uses root identities inside the read-only, capability-free container namespaces and a private process umask. No recursive ownership change is performed; game-container UID/GID is controlled by Administration only for managed templates.
|
||||
- Gitea CI for pull requests and `main`, plus tag-only multi-architecture image publication and Gitea Release creation.
|
||||
|
||||
## Durable decisions
|
||||
@@ -58,8 +62,10 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Label values support only the explicit allowlist in `internal/instance/container_config.go`; unknown variables are errors, not arbitrary templates.
|
||||
- `{{game.icon_url}}` is the public icon for the game. `{{instance.slug}}` remains supported.
|
||||
- Template game artwork contains separate required local `logo` and horizontal `image` assets; template validation rejects missing files. Deployment previews expose distinct logo and artwork URLs while retaining `icon_url` as a compatible logo alias. Palworld template `1.1.0` is the first snapshot with this contract.
|
||||
- All official and local template artwork is bundled locally, served through generic template-scoped routes with detected raster Content-Type, and retained in SQLite snapshots for historical rendering. Remote, absolute, traversal and escaping-symlink asset paths are rejected; no artwork checksum is required.
|
||||
- Embedded catalog validation is collection-based: every discovered template is schema- and cross-field-validated, including referenced assets, declared template-local integration modules and ports/configuration. A module bundle lives at `<template>/module/`, is declared by `module.path`, is path-confined and is retained with the selected snapshot; no game-specific internal registry exists. Template asset contents are not SHA-256-pinned, allowing administrator-maintained local assets while preserving required-path validation. A template `version` identifies a DoGaMa snapshot, whereas `container.tag` selects the game-server image; the official Palworld template follows Pocketpair's `latest` tag for new deployments and ordinary pulls.
|
||||
- Instance slugs are derived from the display name, not canonical IDs. Accents are normalized to ASCII; whitespace, `/`, punctuation and special characters become safe hyphen separators; repeated and edge hyphens are removed.
|
||||
- Docker user mode is fixed at creation to DoGaMa UID/GID, custom numeric UID/GID, or image-defined user. Never perform automatic recursive ownership changes.
|
||||
- Docker user mode is fixed at creation to the administrator's managed game-container UID/GID or image-defined user. Image-defined templates cannot be overridden. Never perform automatic recursive ownership changes.
|
||||
- A pinned image tag is an explicit mutable tag, not an immutable digest. Tracked mode follows the template's declared default tag.
|
||||
- Replacement-requiring changes use the generic `container_config_pending` desired-versus-applied state. Replacements preserve bind-mounted data and prior running/stopped intent.
|
||||
- The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields.
|
||||
@@ -72,13 +78,19 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- `DOGAMA_NETWORK` names the application-facing network. `DOGAMA_GAMES_NETWORK` names the single agent-approved network for all created and recreated game containers; API input cannot override either bootstrap boundary.
|
||||
- DoGaMa services never recursively change ownership of application, game-server or backup roots.
|
||||
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
|
||||
- Administration notification forms update one channel at a time. SMTP and Gotify non-secret fields are rendered from the encrypted persisted configuration through an allowlist; SMTP/Discord/Gotify secrets are write-only, with empty edits retaining the existing secret.
|
||||
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
|
||||
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
|
||||
- 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 declares the bounded `announcement` and `graceful_shutdown` operations, implemented as `announce <message>` 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.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
- The Block 24 pre-release audit (2026-08-27) found and fixed a deployment-page artwork overflow at desktop widths. The fresh Palworld lifecycle run could not proceed because the declared registry `:latest` image was unavailable in the audit environment; a local versioned image retag did not make the agent installation succeed, so no Palworld runtime data was altered. The direct Didstopia V Rising image reached SteamCMD but failed before server startup with its external `beta '='`/missing configuration error; this remains an external image/SteamCMD limitation, not a DoGaMa defect.
|
||||
- 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 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.
|
||||
|
||||
@@ -70,7 +70,8 @@ Before create or replace, the agent verifies:
|
||||
- resource limits are present and within administrator limits;
|
||||
- labels use the reserved namespace and cannot be overridden;
|
||||
- custom labels are bounded, may not use either `dogama.*` or the internal `io.dogama.*` namespace, and are merged before immutable technical labels;
|
||||
- the optional Docker `User` is either an already validated numeric `UID:GID` value or omitted so the image `USER` applies;
|
||||
- the optional Docker `User` is either an already validated numeric `UID:GID` value for a managed template or omitted when `user_mode: image` applies; image mode is enforced by the agent and cannot be overridden;
|
||||
- template-declared runtime identity environment mappings are allow-listed and may carry only the administrator's validated game-container UID/GID;
|
||||
- the configured deployment-wide game network is attached; the request schema has no network field and unknown fields are rejected.
|
||||
|
||||
The canonical plan digest alone is not treated as approval. The agent embeds and
|
||||
|
||||
@@ -57,7 +57,7 @@ Suggested container paths:
|
||||
/var/lib/dogama/
|
||||
dogama.db
|
||||
catalog/
|
||||
modules/
|
||||
catalog/<game>/module/
|
||||
imports/staging/
|
||||
cache/
|
||||
/srv/game-servers/<instance-slug>/
|
||||
@@ -84,4 +84,3 @@ Paths stored in SQLite use stable instance and mount identifiers. User-supplied
|
||||
Long-running operations are durable jobs in SQLite. A per-instance lock serializes mutually exclusive actions. Jobs use explicit phases and checkpoints so a restart can resume, retry safely or mark manual intervention required. UI requests enqueue work and return an operation identifier rather than keeping a long HTTP request open.
|
||||
|
||||
The scheduler is internal for V1 and handles cron backups, retention, audit purge, optional start/stop schedules, module/catalog update checks and queued notifications. Only one scheduler leader exists because V1 runs one main-application replica.
|
||||
|
||||
|
||||
@@ -10,17 +10,26 @@ Game-specific API <-> WebAssembly adapter <-> normalized DoGaMa API
|
||||
|
||||
It may query status, list players, request an in-game save, announce, shut down gracefully, kick, ban or unban when the game supports those operations. It does not own container lifecycle, files, users, backups, scheduling or UI.
|
||||
|
||||
## Package
|
||||
## Template-local package
|
||||
|
||||
```text
|
||||
palworld-rest-1.0.0.dogama-module/
|
||||
module.wasm
|
||||
manifest.yaml
|
||||
README.md
|
||||
LICENSE
|
||||
catalog/palworld/
|
||||
template.yaml
|
||||
assets/
|
||||
module/
|
||||
manifest.yaml
|
||||
module.wasm
|
||||
src/
|
||||
README.md
|
||||
LICENSE
|
||||
```
|
||||
|
||||
The installed artifact is content-addressed. Manifest, binary checksum, source/trust status and installation time are recorded. A package cannot contain executable helpers or dynamic libraries.
|
||||
`template.yaml` declares the optional manifest as `module.path`. The path must
|
||||
remain under the template root's `module/` directory; absolute paths, traversal
|
||||
and local symbolic links are rejected. The validated module bundle contributes
|
||||
to the template snapshot digest and is retained with that snapshot, so a later
|
||||
local-template update cannot change a pinned instance's adapter. A package
|
||||
cannot contain executable helpers or dynamic libraries.
|
||||
|
||||
## Runtime contract
|
||||
|
||||
@@ -34,10 +43,21 @@ The runtime grants no ambient WASI filesystem, process, environment, raw sockets
|
||||
- structured diagnostic emission with runtime redaction;
|
||||
- cancellation/deadline checks.
|
||||
|
||||
### Go/WASI reactor modules
|
||||
|
||||
Go modules targeting `wasip1` are built as reactors with `-buildmode=c-shared`.
|
||||
The resulting WASM must export `_initialize` and the module exports used by the
|
||||
manifest; the host calls `_initialize` before invoking an operation. A module
|
||||
must not rely on the WASI command `_start` entry point. A reproducible build
|
||||
uses `CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm` and records the resulting artifact
|
||||
digest in its template-local manifest.
|
||||
|
||||
## Instance-scoped networking
|
||||
|
||||
Modules never receive an arbitrary destination URL. At activation, DoGaMa binds `instance_api` to a specific instance network identity and declared integration port. Every request is checked for protocol, port, method, timeout, redirect, request/response size and concurrency.
|
||||
|
||||
The application service is attached to the fixed `DOGAMA_GAMES_NETWORK` network so this instance-scoped binding can resolve the selected container. The module still supplies no destination and the host pins every connection to the resolved instance address and declared port.
|
||||
|
||||
- No Internet or LAN destinations.
|
||||
- No loopback, link-local, metadata or Unix-socket destinations.
|
||||
- No redirects outside the bound origin.
|
||||
@@ -62,16 +82,12 @@ Per call, enforce a deadline, instruction/fuel budget, memory ceiling, maximum h
|
||||
|
||||
## Independent versioning
|
||||
|
||||
Templates and modules have independent semantic versions.
|
||||
|
||||
- A template pins an acceptable module ID and version range.
|
||||
- A manifest states supported manager module-API versions and game IDs.
|
||||
- Installation of a new module does not activate it automatically for existing instances.
|
||||
- Activation runs schema, checksum, ABI, capability and connection tests.
|
||||
- The previous version stays available for rollback until the new version is healthy.
|
||||
- A module update never silently changes instance settings or template snapshots.
|
||||
The manifest retains its module ID and API compatibility contract, but discovery
|
||||
is template-driven: there is no game-to-module registry in the application.
|
||||
An administrator can copy/import `my-game/template.yaml`, `assets/` and
|
||||
`module/` together. A module update is accepted on the next local scan and
|
||||
creates a distinct digest; selected snapshots retain their own bundle.
|
||||
|
||||
## Prohibited behavior
|
||||
|
||||
Modules cannot create/delete containers, read SQLite, access host/game files, create backups, execute commands, manage users, expose routes or UI, contact other instances, or make unrestricted network calls. If a proposed integration needs those powers, the generic DoGaMa contract must be extended safely instead of bypassed.
|
||||
|
||||
|
||||
@@ -20,14 +20,19 @@ Create:
|
||||
|
||||
```text
|
||||
catalog/<game>/template.yaml
|
||||
modules/<module-id>/manifest.yaml
|
||||
modules/<module-id>/README.md
|
||||
modules/<module-id>/src/... implementation phase
|
||||
modules/<module-id>/tests/...
|
||||
catalog/<game>/module/manifest.yaml
|
||||
catalog/<game>/module/README.md
|
||||
catalog/<game>/module/src/... implementation phase
|
||||
catalog/<game>/module/tests/...
|
||||
```
|
||||
|
||||
Choose only required capabilities. Translate game errors into normalized errors. Use the logical `instance_api` host functions; never accept arbitrary destination URLs. Keep game API credentials as declared secret configuration.
|
||||
|
||||
Declare the optional bundle with `module.path: module/manifest.yaml` in the
|
||||
same template. The path is confined to the template's root `module/`
|
||||
directory. Local administrator templates use precisely this layout; no
|
||||
application recompilation or internal module registration is involved.
|
||||
|
||||
## Required verification for a contribution
|
||||
|
||||
- Template validates against `specs/template.schema.json`.
|
||||
@@ -53,4 +58,3 @@ Then implement the smallest valid surface:
|
||||
## Contract-edit rule
|
||||
|
||||
If a new game cannot fit the current schema, first determine whether it exposes a genuinely generic need. Extend the schema narrowly with documentation, migration/compatibility analysis, validation, negative tests and updated examples. Never add an escape hatch such as arbitrary commands, raw Compose fragments, host paths or unrestricted network permissions.
|
||||
|
||||
|
||||
@@ -21,13 +21,34 @@ web/ embedded UI source
|
||||
internal/persistence/sqlite/schema.sql embedded current SQLite schema
|
||||
specs/ schemas and normalized contracts
|
||||
catalog/ reference templates
|
||||
modules/ reference modules and fixtures
|
||||
catalog/<game>/module/ optional template-local WASM adapters and sources
|
||||
docs/
|
||||
tests/integration/
|
||||
```
|
||||
|
||||
## Initial application development
|
||||
|
||||
## Template-local integration modules
|
||||
|
||||
An administrator may create or import a local template as a self-contained
|
||||
directory:
|
||||
|
||||
```text
|
||||
my-game/
|
||||
template.yaml
|
||||
assets/
|
||||
module/ # optional
|
||||
manifest.yaml
|
||||
custom.wasm
|
||||
```
|
||||
|
||||
When present, declare the manifest in `template.yaml` with
|
||||
`module.path: module/manifest.yaml`. The module directory is confined to the
|
||||
template root: absolute paths, traversal and symbolic links are rejected. The
|
||||
manifest and WASM artifact remain subject to the generic manifest schema and
|
||||
the capability-limited WASM sandbox. No rebuild of DoGaMa or internal game
|
||||
registry entry is needed for a local module.
|
||||
|
||||
The main application requires Go 1.25. SQLite uses the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. Standard Compose supplies all internal bootstrap contracts. The agent generates its shared token and the application generates its master key independently. Direct developer execution may override `DOGAMA_LISTEN_ADDRESS`, `DOGAMA_DATABASE_PATH`, `DOGAMA_AGENT_URL`, `DOGAMA_AGENT_TOKEN_FILE` and `DOGAMA_MASTER_KEY_FILE`; lifecycle routes remain disabled when both agent overrides are absent. These are development controls, not public deployment settings. Run it with:
|
||||
|
||||
```sh
|
||||
@@ -133,8 +154,8 @@ before the restricted agent creates the first container. Repeated installation
|
||||
submission recognizes an already attached import instead of copying it twice.
|
||||
|
||||
At main-application startup, every embedded `catalog/*/template.yaml` is
|
||||
validated against `specs/template.schema.json`, checked for cross-reference and
|
||||
asset integrity, canonicalized deterministically and synchronized into SQLite.
|
||||
validated against `specs/template.schema.json`, checked for cross-references and
|
||||
referenced asset presence, canonicalized deterministically and synchronized into SQLite.
|
||||
An existing template ID/version is immutable: changing its digest fails startup
|
||||
instead of silently replacing the snapshot. Deployment previews pin that digest
|
||||
and redact secret defaults before a draft instance can enter the registry.
|
||||
|
||||
@@ -77,7 +77,7 @@ Admin-only system permissions are not delegated per instance in V1.
|
||||
## Evaluation algorithm
|
||||
|
||||
1. Deny unauthenticated or disabled users.
|
||||
2. Allow global admin, subject to re-authentication requirements for critical actions.
|
||||
2. Allow global admin for authorized operations; the server-side session policy controls session validity.
|
||||
3. Require an active membership for the target instance.
|
||||
4. Start from the membership baseline.
|
||||
5. Apply explicit deny overrides before explicit allows.
|
||||
@@ -113,4 +113,4 @@ without an active global administrator.
|
||||
|
||||
## Sensitive-action safeguards
|
||||
|
||||
Restore, destructive delete, membership changes, secret rotation and security configuration require recent authentication. Data removal requires separate checkboxes and typed instance-name confirmation. A manager never gains new abilities merely because a module exposes a capability.
|
||||
Restore, destructive delete, membership changes, secret rotation and security configuration remain protected by authorization, CSRF, confirmations and recoverable workflows as applicable. They do not use a global arbitrary recent-authentication window. A manager never gains new abilities merely because a module exposes a capability.
|
||||
|
||||
@@ -6,8 +6,9 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
|
||||
|
||||
| Entity | Purpose | Important fields |
|
||||
|---|---|---|
|
||||
| `users` | Local identities | id, username, email, password_hash, global_role, disabled_at, language, created_at |
|
||||
| `sessions` | Revocable browser sessions | id_hash, user_id, expires_at, last_seen_at |
|
||||
| `users` | Local identities | id, username, email, avatar_path, avatar_content_type, password_hash, global_role, disabled_at, language, created_at |
|
||||
| `sessions` | Revocable browser sessions | id_hash, user_id, created_at, expires_at, last_seen_at |
|
||||
| `system_settings.session_policy` | Global administrator-managed session lifetime policy | max_lifetime_seconds, inactivity_timeout_seconds, inactivity_enabled |
|
||||
| `instance_memberships` | Per-instance baseline role | instance_id, user_id, role (`user`, `manager`) |
|
||||
| `permission_overrides` | Explicit allow/deny beyond baseline | instance_id, user_id, permission, effect |
|
||||
| `templates` | Catalog identity and origin | id, origin, trust_status, active_version |
|
||||
@@ -31,7 +32,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
|
||||
| `notification_channels` | Global delivery configuration | id, type, enabled, encrypted_config, event_filter |
|
||||
| `notification_deliveries` | Bounded retry queue | id, channel_id, event_type, payload_redacted, attempt, next_attempt_at |
|
||||
| `audit_events` | Compact significant actions | id, occurred_at, actor_id, instance_id, action, outcome, summary_json |
|
||||
| `system_settings` | Admin-configured global values | key, value_json, revision |
|
||||
| `system_settings` | Admin-configured global values | key, value_json, revision; includes separate game-container UID/GID defaults |
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -45,6 +46,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
|
||||
- A port tuple `(host_ip scope, host_port, protocol)` cannot be assigned twice by DoGaMa.
|
||||
- Mount host paths are canonical absolute paths below configured roots.
|
||||
- Secret fields never coexist in plaintext settings.
|
||||
- User avatars are stored as internally named, normalized PNG files below DoGaMa's private avatar directory; SQLite stores only the internal filename and content type.
|
||||
- Backup metadata becomes `available` only after archive finalization and checksum persistence.
|
||||
- Imports expire and their staging directories are cleaned unless attached as a managed backup.
|
||||
- Audit `summary_json` is allow-listed by event type and contains no secret values or full uploaded content.
|
||||
|
||||
@@ -51,7 +51,7 @@ Maintenance mode blocks ordinary user starts and shows an administrator message
|
||||
|
||||
Docker label and image-tag changes use the same generic desired-versus-applied mechanism. `immediate` stops and replaces the container, restores its prior running/stopped intent and preserves every bind-mounted data path. `next_start` sets `container_config_pending`; the next explicit start pulls the desired image, replaces the container, clears the flag and starts it. A stopped instance remains stopped during immediate replacement.
|
||||
|
||||
The Docker user is selected at creation (`dogama`, `custom`, or image-defined) and is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
|
||||
The Docker user is selected at creation from the template policy. Managed (`dogama`) templates use the persistent Administration game-container UID/GID defaults; image-defined templates omit Docker `User` and cannot be overridden. The selection is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
|
||||
|
||||
## Crash-loop protection
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ The preview reports detected game/type, file count, expanded size, world/player
|
||||
|
||||
## Restore
|
||||
|
||||
1. Verify permission and recent authentication.
|
||||
1. Verify permission and a valid session.
|
||||
2. Verify archive checksum, manifest and compatibility.
|
||||
3. Show overwritten destinations and compatibility confidence.
|
||||
4. Create a `pre_restore` safety backup by default; disabling it is an administrator-only exceptional action.
|
||||
|
||||
@@ -19,11 +19,11 @@ The application data path contains SQLite, import staging and the application-on
|
||||
|
||||
Only host-side storage locations, image version, web port, timezone and the two Docker network names are public Compose settings. `DOGAMA_NETWORK` names the application-facing network used by a reverse proxy. `DOGAMA_GAMES_NETWORK` names the sole network that the restricted agent attaches to created and recreated game containers. API plans contain no caller-selectable network. Container paths and allowed agent roots remain fixed internal contracts. Back up the application data, game servers, backups, `agent_state` and `agent_auth` together.
|
||||
|
||||
Both services use root inside their container namespaces so Docker-created bind directories and ordinary administrator-selected paths work without knowledge of an image-specific UID/GID. They keep read-only root filesystems, `no-new-privileges`, an empty Linux capability set and a `0077` process umask for new files. The main application never receives the Docker socket. DoGaMa creates only directories it needs below the configured roots and never performs an automatic recursive `chown` of application, server or backup data. Game-container UID/GID selection remains a separate per-instance setting.
|
||||
Both services use root inside their container namespaces so Docker-created bind directories and ordinary administrator-selected paths work without knowledge of an image-specific UID/GID. They keep read-only root filesystems, `no-new-privileges`, an empty Linux capability set and a `0077` process umask for new files. The main application never receives the Docker socket. DoGaMa creates only directories it needs below the configured roots and never performs an automatic recursive `chown` of application, server or backup data. Game-container UID/GID defaults are Administration settings and apply only to templates that opt into managed identity; image-defined templates retain their native user.
|
||||
|
||||
For NAS or server-style paths, set ordinary writable locations in `.env`, for example `/srv/apps/dogama/data`, `/srv/games` and `/srv/backups`, then run `docker compose up -d`. `/var/lib/dogama/templates` is inside `DOGAMA_DATA_PATH`, so it persists as `${DOGAMA_DATA_PATH}/templates` and stays available to Catalog Scan. No `/etc` or host `/var/lib` setup, system user, systemd unit or bootstrap script is required.
|
||||
|
||||
The containers intentionally run as root only inside their own namespaces so fresh bind mounts work without host-specific UID/GID settings. Their root filesystems are read-only, all capabilities are dropped, and their private `0077` umask makes newly created data private by default. Host paths must be writable by the Docker daemon; do not recursively change ownership, because game-container UID/GID remains a per-instance choice. Set `TZ` once to a valid IANA timezone (the example uses `Europe/Paris`); it is used by both services and by Audit rendering.
|
||||
The containers intentionally run as root only inside their own namespaces so fresh bind mounts work without host-specific UID/GID settings. Their root filesystems are read-only, all capabilities are dropped, and their private `0077` umask makes newly created data private by default. Host paths must be writable by the Docker daemon; do not recursively change ownership. Set `TZ` once to a valid IANA timezone (the example uses `Europe/Paris`); it is used by both services and by Audit rendering.
|
||||
|
||||
`docker compose down` removes containers and Compose networks while preserving bind mounts and named volumes. `docker compose down -v` also destroys the `agent_state` and `agent_auth` named volumes; it can make existing managed containers impossible to manage safely and must not be used for a retained installation.
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ glance.parent=DoGaMa
|
||||
|
||||
`{{game.icon_url}}` resolves to the unauthenticated, read-only `/public/game-icons/{game-id}` route. The route serves only embedded reviewed raster content with an explicit MIME type and cache policy; it is not a public catalog or administration API.
|
||||
|
||||
At creation, the Docker user is either the DoGaMa process UID/GID (default), an explicitly validated numeric UID/GID, or omitted to use the image-defined user. An image without `USER` may therefore run as root. The selection is immutable after creation.
|
||||
Administration stores separate decimal game-container defaults for UID and GID (1000:1000 initially, bounded to Linux's uint32 range). A template with the managed `user_mode: dogama` policy receives those values as Docker `User`; DoGaMa and its agent are never affected. A template with `user_mode: image` always omits Docker `User`, even if an API caller requests an override, so the image's native `USER` and entrypoint remain authoritative. Templates may instead declare a generic `runtime_user` environment mapping; V Rising uses this to receive its administrator defaults as `PUID`/`PGID` while retaining its root entrypoint and declared capabilities.
|
||||
|
||||
The template's declared tag is the `tracked` default. An administrator may instead select a syntactically validated `pinned` tag and later return to tracked mode. Pinned means an explicitly selected mutable tag, not a digest: publishers can republish the same tag. Manual SHA-256 digest management is outside this milestone.
|
||||
|
||||
|
||||
@@ -13,13 +13,20 @@ Use one direct child directory per game:
|
||||
palworld/
|
||||
template.yaml
|
||||
assets/
|
||||
icon.png
|
||||
banner.jpg
|
||||
poster.jpg
|
||||
```
|
||||
|
||||
Add or edit files on the Docker host, then sign in as an administrator and press **Scan** in Catalog. The scan reads each directory independently, validates it, updates changed templates and removes deleted templates from the available catalog. A broken template never prevents other valid templates from appearing. The result lists the directory name and a safe validation reason; it intentionally does not disclose absolute host paths, stack traces or secrets.
|
||||
|
||||
## Template format
|
||||
|
||||
Templates use schema version `1` and are strict YAML documents. Existing deployment fields remain required: container image/tag, ports, storage mounts, configuration fields, capabilities, backup, healthcheck, imports, updates and compatibility. DoGaMa rejects unknown fields and unsafe paths, asset checksums, invalid configuration fields and invalid Docker-related declarations; a template cannot grant arbitrary Docker access.
|
||||
Templates use schema version `1` and are strict YAML documents. Existing deployment fields remain required: container image/tag, ports, storage mounts, configuration fields, capabilities, backup, healthcheck, imports, updates and compatibility. DoGaMa rejects unknown fields, unsafe paths, missing referenced assets, invalid configuration fields and invalid Docker-related declarations; a template cannot grant arbitrary Docker access. Asset contents are deliberately not checksum-pinned, so an administrator can maintain a local helper or configuration asset without invalidating an otherwise valid template.
|
||||
|
||||
For local-template upgrade compatibility, an existing asset `sha256` key is accepted and ignored. New templates should omit it.
|
||||
|
||||
`version` identifies the immutable DoGaMa template snapshot. It is not the game-server release. The server image release is selected separately by `container.tag`; a floating tag follows that image publisher's tag policy for newly created or normally pulled instances.
|
||||
|
||||
The catalog information is under `game` and `requirements`:
|
||||
|
||||
@@ -32,17 +39,19 @@ game:
|
||||
id: example-game
|
||||
name: Example Game
|
||||
description: Concise dedicated-server description.
|
||||
image: https://example.invalid/cover.jpg # vertical public cover URL
|
||||
artwork:
|
||||
logo: assets/icon.png # retained deployment artwork asset
|
||||
logo: assets/icon.png
|
||||
image: assets/banner.jpg
|
||||
poster: assets/poster.jpg
|
||||
attribution: Your attribution text
|
||||
requirements:
|
||||
minimum: { cpu_cores: 2, memory_mb: 4096, storage_gb: 20, other: ["Network connection"] }
|
||||
recommended: { cpu_cores: 4, memory_mb: 8192, storage_gb: 40 }
|
||||
```
|
||||
|
||||
`game.image` is the vertical cover displayed by Catalog and the game page. Its URL must be HTTPS, but validation never fetches it: an unavailable remote cover does not block a scan and the interface has a graphical fallback. `requirements.minimum` and `requirements.recommended` contain generic CPU, memory and storage values plus optional `other` lines. Recommended resources cannot be below minimum resources.
|
||||
`game.artwork` contains required local logo, horizontal image and poster assets. Validation never fetches remote artwork, so a scan remains local and deterministic. `requirements.minimum` and `requirements.recommended` contain generic CPU, memory and storage values plus optional `other` lines. Recommended resources cannot be below minimum resources.
|
||||
|
||||
Artwork paths must be relative to the template directory and must point to regular PNG/JPEG/GIF/WebP files supplied by the template. HTTP(S) URLs, absolute paths, traversal and escaping symlinks are rejected. DoGaMa serves the validated files locally, and stores them in each SQLite template snapshot so older catalog versions remain renderable.
|
||||
|
||||
`configuration.fields` drives the deployment form and the effective server configuration. Supported types are `string`, `integer`, `number`, `boolean`, `enum` and `secret`; defaults, required flags, numeric bounds, regular expressions and enum values are checked again by the server. Secret inputs are write-only, encrypted in the instance secret store when retained for runtime use, and never included in a persisted preview, API response, audit event or error.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| Password attack | Modern password hashing, rate limits, backoff, generic errors, repeated-failure audit/notification |
|
||||
| Supply-chain substitution | Immutable version snapshots, checksums, optional signatures/trust labels, digest-pinned images, controlled activation |
|
||||
| Label/template injection | Structured key/value parsing, reserved namespaces, explicit substitution allowlist, no arbitrary template execution |
|
||||
| Destructive mistake | Preview, recent authentication, typed-name confirmation, pre-restore/update backups and recoverable workflows |
|
||||
| Destructive mistake | Preview, typed-name confirmation, pre-restore/update backups and recoverable workflows |
|
||||
| Resource exhaustion | Upload/extraction limits, job concurrency, per-instance locks, Docker limits, disk checks, notification/module bounds |
|
||||
| Replay/race | Signed nonce/timestamp agent calls, idempotency keys, optimistic revisions and durable operation phases |
|
||||
|
||||
@@ -47,7 +47,7 @@ The deployment documentation must recommend TLS through a trusted reverse proxy
|
||||
|
||||
Display source as `official`, `verified community`, `local`, `locally modified` or `unverified`. Trust is informative but never bypasses validation/sandboxing. Catalog updates cannot overwrite local copies or silently update live instances.
|
||||
|
||||
Artwork downloads accept bounded raster formats, verify decoded content, convert locally and reject SVG in V1. Preserve source attribution metadata without loading remote assets on every page.
|
||||
Template artwork is supplied as bounded local raster assets, served only through template-scoped paths, and never loaded from a remote URL at page-render time. Preserve source attribution metadata without making runtime network requests.
|
||||
|
||||
## Security headers and API limits
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Import validation precedes container creation. Warnings distinguish guaranteed f
|
||||
|
||||
Backup list shows origin, created/imported dates, size, validation, game/template version and checksum status. Cron has common presets, custom expression, timezone and next-run preview.
|
||||
|
||||
Restore shows overwritten data, safety-backup behavior and server downtime. It requires recent authentication and typed confirmation where data is replaced. Results distinguish rolled back, safely stopped and intervention required.
|
||||
Restore shows overwritten data, safety-backup behavior and server downtime. It requires the normal valid session, CSRF and typed confirmation where data is replaced. Results distinguish rolled back, safely stopped and intervention required.
|
||||
|
||||
## Administration
|
||||
|
||||
@@ -59,7 +59,9 @@ global `admin`/`user` role, activation and deactivation, and per-instance
|
||||
`user`/`manager` memberships with explicit allow/deny overrides. Administrators
|
||||
have implicit instance access, so per-instance assignments are shown only for
|
||||
global users. Personal settings let every active user update their email,
|
||||
password and saved interface language.
|
||||
optional avatar, password and saved interface language. Avatars accept PNG or
|
||||
JPEG input up to 2 MiB, are normalized locally for the UI, and fall back to
|
||||
user initials.
|
||||
|
||||
The game-container label editor is a multiline `key=value` field with one label per line, the complete allowed-variable list, and explicit `apply immediately` versus `apply on next start` choices. Immediate application confirms that affected containers stop and are recreated, connected players disconnect, persistent data remains, and displays affected/running counts when known.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ require (
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.38.0
|
||||
|
||||
@@ -20,8 +20,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -22,12 +20,10 @@ func (s *service) prepareAssets(plan agentwire.DeploymentPlan) ([]AssetMount, er
|
||||
return nil, errors.New("asset root is not allowed")
|
||||
}
|
||||
result := make([]AssetMount, 0, len(approved))
|
||||
for _, asset := range approved {
|
||||
digest := sha256.Sum256(asset.Content)
|
||||
if hex.EncodeToString(digest[:]) != asset.SHA256 {
|
||||
return nil, errors.New("approved asset integrity check failed")
|
||||
}
|
||||
target := filepath.Join(assetRoot, asset.SHA256)
|
||||
for index, asset := range approved {
|
||||
// The template snapshot digest keeps agent-owned asset paths isolated
|
||||
// without making an asset's declared content immutable.
|
||||
target := filepath.Join(assetRoot, plan.TemplateDigest, fmt.Sprintf("asset-%d", index))
|
||||
if err := writeImmutableAsset(target, asset.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -41,18 +37,13 @@ func writeImmutableAsset(path string, content []byte) error {
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("approved asset path is not a regular file")
|
||||
}
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return errors.New("read approved asset")
|
||||
}
|
||||
existingDigest, wantedDigest := sha256.Sum256(existing), sha256.Sum256(content)
|
||||
if existingDigest != wantedDigest {
|
||||
return errors.New("approved asset content conflict")
|
||||
}
|
||||
return os.Chmod(path, 0o555)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("inspect approved asset path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return errors.New("create approved asset directory")
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".asset-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create approved asset: %w", err)
|
||||
|
||||
@@ -201,6 +201,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
|
||||
NanoCPUs int64 `json:"NanoCpus"`
|
||||
PidsLimit *int64 `json:"PidsLimit"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
NetworkMode string `json:"NetworkMode"`
|
||||
RestartPolicy map[string]string `json:"RestartPolicy"`
|
||||
@@ -224,6 +225,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
|
||||
payload.HostConfig.NanoCPUs = int64(plan.Resources.CPUCores * 1_000_000_000)
|
||||
payload.HostConfig.PidsLimit = &pidsLimit
|
||||
payload.HostConfig.CapDrop = []string{"ALL"}
|
||||
payload.HostConfig.CapAdd = append([]string(nil), plan.CapAdd...)
|
||||
payload.HostConfig.SecurityOpt = []string{"no-new-privileges:true"}
|
||||
payload.HostConfig.NetworkMode = d.network
|
||||
payload.HostConfig.RestartPolicy = map[string]string{"Name": "no"}
|
||||
|
||||
@@ -150,6 +150,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
Binds []string
|
||||
NetworkMode string `json:"NetworkMode"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
Memory int64 `json:"Memory"`
|
||||
NanoCPUs int64 `json:"NanoCpus"`
|
||||
@@ -159,7 +160,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
if err := json.Unmarshal(<-createdBodies, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.HostConfig.NetworkMode != "nas-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 {
|
||||
if payload.HostConfig.NetworkMode != "nas-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.CapAdd) != 0 || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 {
|
||||
t.Fatalf("insecure Docker host config: %#v", payload.HostConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ func NewPlanPolicy(snapshots []catalog.Snapshot, assets fs.FS) (*PlanPolicy, err
|
||||
|
||||
type ApprovedAsset struct {
|
||||
Destination string
|
||||
SHA256 string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
@@ -61,7 +60,7 @@ func (p *PlanPolicy) Assets(plan agentwire.DeploymentPlan) ([]ApprovedAsset, err
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read approved template asset: %w", err)
|
||||
}
|
||||
result = append(result, ApprovedAsset{Destination: asset.Destination, SHA256: asset.SHA256, Content: content})
|
||||
result = append(result, ApprovedAsset{Destination: asset.Destination, Content: content})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -79,9 +78,15 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
|
||||
}
|
||||
template := snapshot.Template
|
||||
imagePrefix := template.Container.Image + ":"
|
||||
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
|
||||
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.CapAdd, template.Container.Capabilities.Add) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
|
||||
return errors.New("container plan differs from template")
|
||||
}
|
||||
if template.Container.UserMode == "image" && plan.User != "" {
|
||||
return errors.New("image user template cannot receive a Docker user")
|
||||
}
|
||||
if template.Container.UserMode == "dogama" && plan.User == "" {
|
||||
return errors.New("managed user template requires a Docker user")
|
||||
}
|
||||
if len(plan.Arguments) < len(template.Container.Arguments) || !reflect.DeepEqual(plan.Arguments[:len(template.Container.Arguments)], template.Container.Arguments) || !allowedArguments(template, plan.Arguments[len(template.Container.Arguments):]) || !allowedEnvironment(template, plan.Environment) {
|
||||
return errors.New("container configuration differs from template")
|
||||
}
|
||||
@@ -119,6 +124,10 @@ func allowedEnvironment(template catalog.Template, environment map[string]string
|
||||
for key := range template.Container.Environment {
|
||||
allowed[key] = true
|
||||
}
|
||||
if template.Container.RuntimeUser.Mode == "environment" {
|
||||
allowed[template.Container.RuntimeUser.UIDEnv] = true
|
||||
allowed[template.Container.RuntimeUser.GIDEnv] = true
|
||||
}
|
||||
for _, field := range template.Configuration.Fields {
|
||||
if field.Target.Kind == "environment" {
|
||||
allowed[field.Target.Name] = true
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
@@ -9,73 +10,52 @@ import (
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
)
|
||||
|
||||
func TestEmbeddedPalworldSnapshotMatchesApplicationDeploymentPlan(t *testing.T) {
|
||||
func TestEmbeddedSnapshotsMatchApplicationDeploymentPlans(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var snapshot *catalog.Snapshot
|
||||
for i := range snapshots {
|
||||
if snapshots[i].Template.ID == "palworld-official" {
|
||||
snapshot = &snapshots[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot == nil {
|
||||
t.Fatal(`embedded template "palworld-official" not found`)
|
||||
}
|
||||
|
||||
if snapshot.Template.Version != "1.1.1" {
|
||||
t.Fatalf(
|
||||
"embedded Palworld snapshot = %s@%s",
|
||||
snapshot.Template.ID,
|
||||
snapshot.Template.Version,
|
||||
)
|
||||
}
|
||||
|
||||
preview, err := instance.BuildPreview(*snapshot, instance.PreviewRequest{
|
||||
DisplayName: "Snapshot consistency",
|
||||
Slug: "snapshot-consistency",
|
||||
HostPorts: map[string]int{
|
||||
"game": 38211,
|
||||
},
|
||||
MountPaths: map[string]string{
|
||||
"saved": filepath.Join(t.TempDir(), "saved"),
|
||||
},
|
||||
DataOrigin: "new",
|
||||
BackupRetention: 7,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan, err := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
policy, err := NewPlanPolicy(snapshots, catalogdata.Files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
known, ok := policy.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
|
||||
if !ok || known.Digest != plan.TemplateDigest {
|
||||
t.Fatalf(
|
||||
"agent snapshot=%#v plan=%s@%s digest=%s",
|
||||
known,
|
||||
plan.TemplateID,
|
||||
plan.TemplateVersion,
|
||||
plan.TemplateDigest,
|
||||
)
|
||||
}
|
||||
|
||||
if err := policy.Validate(plan); err != nil {
|
||||
t.Fatalf(
|
||||
"application plan rejected by matching embedded snapshot: %v",
|
||||
err,
|
||||
)
|
||||
for index, snapshot := range snapshots {
|
||||
t.Run(snapshot.Template.ID, func(t *testing.T) {
|
||||
hostPorts := map[string]int{}
|
||||
for portIndex, port := range snapshot.Template.Container.Ports {
|
||||
if port.Publish {
|
||||
hostPorts[port.ID] = 38000 + index*100 + portIndex
|
||||
}
|
||||
}
|
||||
mountPaths := map[string]string{}
|
||||
for _, mount := range snapshot.Template.Storage.Mounts {
|
||||
mountPaths[mount.ID] = filepath.Join(t.TempDir(), mount.ID)
|
||||
}
|
||||
preview, previewErr := instance.BuildPreview(snapshot, instance.PreviewRequest{
|
||||
DisplayName: "Snapshot consistency",
|
||||
HostPorts: hostPorts,
|
||||
MountPaths: mountPaths,
|
||||
DataOrigin: "new",
|
||||
BackupRetention: 7,
|
||||
})
|
||||
if previewErr != nil {
|
||||
t.Fatalf("build preview for %s@%s: %v", snapshot.Template.ID, snapshot.Template.Version, previewErr)
|
||||
}
|
||||
if !strings.HasSuffix(preview.Image, ":"+snapshot.Template.Container.Tag) {
|
||||
t.Fatalf("preview image %q does not use template tag %q", preview.Image, snapshot.Template.Container.Tag)
|
||||
}
|
||||
plan, planErr := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
|
||||
if planErr != nil {
|
||||
t.Fatal(planErr)
|
||||
}
|
||||
known, ok := policy.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
|
||||
if !ok || known.Digest != plan.TemplateDigest {
|
||||
t.Fatalf("agent snapshot=%#v plan=%s@%s digest=%s", known, plan.TemplateID, plan.TemplateVersion, plan.TemplateDigest)
|
||||
}
|
||||
if validateErr := policy.Validate(plan); validateErr != nil {
|
||||
t.Fatalf("application plan rejected by matching embedded snapshot: %v", validateErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type DeploymentPlan struct {
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
CapAdd []string `json:"cap_add,omitempty"`
|
||||
Ports []PlanPort `json:"ports"`
|
||||
Mounts []PlanMount `json:"mounts"`
|
||||
Resources PlanResource `json:"resources"`
|
||||
@@ -71,6 +72,7 @@ func (p DeploymentPlan) CanonicalDigest() (string, error) {
|
||||
for key, value := range p.Environment {
|
||||
copyPlan.Environment[key] = value
|
||||
}
|
||||
copyPlan.CapAdd = append([]string(nil), p.CapAdd...)
|
||||
copyPlan.Ports = append([]PlanPort(nil), p.Ports...)
|
||||
copyPlan.Mounts = append([]PlanMount(nil), p.Mounts...)
|
||||
sort.Slice(copyPlan.Ports, func(i, j int) bool { return copyPlan.Ports[i].ID < copyPlan.Ports[j].ID })
|
||||
@@ -96,7 +98,7 @@ func (p DeploymentPlan) Validate() error {
|
||||
if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 {
|
||||
return errors.New("invalid deployment plan limits")
|
||||
}
|
||||
if len(p.Labels) > 64 || len(p.Environment) > 64 || len(p.User) > 32 {
|
||||
if len(p.Labels) > 64 || len(p.Environment) > 64 || len(p.CapAdd) > 16 || len(p.User) > 32 {
|
||||
return errors.New("invalid deployment plan container configuration")
|
||||
}
|
||||
for key, value := range p.Environment {
|
||||
@@ -104,6 +106,13 @@ func (p DeploymentPlan) Validate() error {
|
||||
return errors.New("invalid deployment plan environment")
|
||||
}
|
||||
}
|
||||
seenCaps := map[string]bool{}
|
||||
for _, capability := range p.CapAdd {
|
||||
if !validLinuxCapability(capability) || seenCaps[capability] {
|
||||
return errors.New("invalid deployment plan capabilities")
|
||||
}
|
||||
seenCaps[capability] = true
|
||||
}
|
||||
for key, value := range p.Labels {
|
||||
lower := strings.ToLower(key)
|
||||
if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
|
||||
@@ -166,6 +175,15 @@ func (p DeploymentPlan) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validLinuxCapability(value string) bool {
|
||||
switch value {
|
||||
case "CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID", "NET_BIND_SERVICE", "NET_RAW", "SYS_CHROOT":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type InstanceState struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ContainerID string `json:"container_id"`
|
||||
|
||||
@@ -43,3 +43,14 @@ func TestDeploymentPlanRejectsReservedLabelsAndInvalidUser(t *testing.T) {
|
||||
t.Fatal("non-numeric Docker user accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentPlanRejectsUnknownOrAllCapabilities(t *testing.T) {
|
||||
plan := DeploymentPlan{SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", CapAdd: []string{"ALL"}, Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}}, Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30}
|
||||
if err := plan.Validate(); err == nil {
|
||||
t.Fatal("ALL capability accepted")
|
||||
}
|
||||
plan.CapAdd = []string{"CHOWN", "CHOWN"}
|
||||
if err := plan.Validate(); err == nil {
|
||||
t.Fatal("duplicate capability accepted")
|
||||
}
|
||||
}
|
||||
|
||||
+137
-18
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
@@ -31,20 +32,49 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
absoluteLifetime = 24 * time.Hour
|
||||
idleLifetime = 30 * time.Minute
|
||||
attemptWindow = 15 * time.Minute
|
||||
DefaultSessionMaxLifetime = 7 * 24 * time.Hour
|
||||
DefaultSessionInactivity = 24 * time.Hour
|
||||
MinSessionMaxLifetime = time.Hour
|
||||
MaxSessionMaxLifetime = 90 * 24 * time.Hour
|
||||
MinSessionInactivity = 5 * time.Minute
|
||||
MaxSessionInactivity = 30 * 24 * time.Hour
|
||||
activityWriteInterval = 5 * time.Minute
|
||||
attemptWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
// SessionPolicy controls the server-side lifetime of every browser session.
|
||||
// MaxLifetime is absolute from login; inactivity is independently bounded.
|
||||
type SessionPolicy struct {
|
||||
MaxLifetime time.Duration `json:"max_lifetime"`
|
||||
InactivityTimeout time.Duration `json:"inactivity_timeout"`
|
||||
InactivityEnabled bool `json:"inactivity_enabled"`
|
||||
}
|
||||
|
||||
func DefaultSessionPolicy() SessionPolicy {
|
||||
return SessionPolicy{MaxLifetime: DefaultSessionMaxLifetime, InactivityTimeout: DefaultSessionInactivity, InactivityEnabled: true}
|
||||
}
|
||||
|
||||
func (p SessionPolicy) Validate() error {
|
||||
if p.MaxLifetime < MinSessionMaxLifetime || p.MaxLifetime > MaxSessionMaxLifetime {
|
||||
return fmt.Errorf("maximum session lifetime must be between %s and %s", MinSessionMaxLifetime, MaxSessionMaxLifetime)
|
||||
}
|
||||
if p.InactivityTimeout < MinSessionInactivity || p.InactivityTimeout > MaxSessionInactivity {
|
||||
return fmt.Errorf("inactivity timeout must be between %s and %s", MinSessionInactivity, MaxSessionInactivity)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// User is the authenticated principal exposed to application handlers.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Language string `json:"language"`
|
||||
Email string `json:"email"`
|
||||
AuthenticatedAt time.Time `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Language string `json:"language"`
|
||||
Email string `json:"email"`
|
||||
AvatarPath string `json:"-"`
|
||||
AvatarContentType string `json:"-"`
|
||||
AuthenticatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// Session contains a new opaque browser credential and CSRF token.
|
||||
@@ -61,12 +91,57 @@ type Service struct {
|
||||
dummyPasswordHash string
|
||||
}
|
||||
|
||||
const sessionPolicyKey = "session_policy"
|
||||
|
||||
// New constructs an authentication service.
|
||||
func New(db *sql.DB) *Service {
|
||||
salt := make([]byte, 16)
|
||||
return &Service{db: db, now: time.Now, dummyPasswordHash: encodePassword("not a real account password", salt)}
|
||||
}
|
||||
|
||||
// SessionPolicy returns the current persisted policy, falling back to the
|
||||
// documented defaults for databases created before this setting existed.
|
||||
func (s *Service) SessionPolicy(ctx context.Context) (SessionPolicy, error) {
|
||||
policy := DefaultSessionPolicy()
|
||||
var body string
|
||||
err := s.db.QueryRowContext(ctx, "SELECT value_json FROM system_settings WHERE key=?", sessionPolicyKey).Scan(&body)
|
||||
if errors.Is(err, sql.ErrNoRows) || (err != nil && strings.Contains(err.Error(), "no such table: system_settings")) {
|
||||
return policy, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SessionPolicy{}, fmt.Errorf("load session policy: %w", err)
|
||||
}
|
||||
var stored struct {
|
||||
MaxLifetimeSeconds int64 `json:"max_lifetime_seconds"`
|
||||
InactivitySeconds int64 `json:"inactivity_timeout_seconds"`
|
||||
InactivityEnabled bool `json:"inactivity_enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &stored); err != nil {
|
||||
return SessionPolicy{}, fmt.Errorf("decode session policy: %w", err)
|
||||
}
|
||||
policy = SessionPolicy{MaxLifetime: time.Duration(stored.MaxLifetimeSeconds) * time.Second, InactivityTimeout: time.Duration(stored.InactivitySeconds) * time.Second, InactivityEnabled: stored.InactivityEnabled}
|
||||
if err := policy.Validate(); err != nil {
|
||||
return SessionPolicy{}, fmt.Errorf("stored session policy is invalid: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetSessionPolicy(ctx context.Context, policy SessionPolicy) error {
|
||||
if err := policy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(struct {
|
||||
MaxLifetimeSeconds int64 `json:"max_lifetime_seconds"`
|
||||
InactivitySeconds int64 `json:"inactivity_timeout_seconds"`
|
||||
InactivityEnabled bool `json:"inactivity_enabled"`
|
||||
}{int64(policy.MaxLifetime / time.Second), int64(policy.InactivityTimeout / time.Second), policy.InactivityEnabled})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, "INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at", sessionPolicyKey, string(body), s.now().UTC().Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
// BootstrapRequired reports whether the one-time administrator setup is pending.
|
||||
func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) {
|
||||
var completed sql.NullString
|
||||
@@ -169,31 +244,38 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
|
||||
now := s.now().UTC()
|
||||
var user User
|
||||
var expiresAt, lastSeenAt, createdAt string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, u.language, s.expires_at, s.last_seen_at, s.created_at
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.email, u.global_role, u.language, s.expires_at, s.last_seen_at, s.created_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &user.Language, &expiresAt, &lastSeenAt, &createdAt)
|
||||
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Email, &user.Role, &user.Language, &expiresAt, &lastSeenAt, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrInvalidSession
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("load session: %w", err)
|
||||
}
|
||||
var avatarPath, avatarContentType string
|
||||
if avatarErr := s.db.QueryRowContext(ctx, "SELECT avatar_path, avatar_content_type FROM users WHERE id=?", user.ID).Scan(&avatarPath, &avatarContentType); avatarErr == nil {
|
||||
user.AvatarPath, user.AvatarContentType = avatarPath, avatarContentType
|
||||
}
|
||||
expires, err1 := time.Parse(time.RFC3339Nano, expiresAt)
|
||||
lastSeen, err2 := time.Parse(time.RFC3339Nano, lastSeenAt)
|
||||
authenticatedAt, err3 := time.Parse(time.RFC3339Nano, createdAt)
|
||||
if err1 != nil || err2 != nil || err3 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
|
||||
policy, policyErr := s.SessionPolicy(ctx)
|
||||
if err1 != nil || err2 != nil || err3 != nil || policyErr != nil || !now.Before(expires) || now.Sub(authenticatedAt) >= policy.MaxLifetime || (policy.InactivityEnabled && now.Sub(lastSeen) >= policy.InactivityTimeout) {
|
||||
_ = s.Revoke(ctx, token)
|
||||
return User{}, ErrInvalidSession
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
|
||||
return User{}, fmt.Errorf("refresh session: %w", err)
|
||||
if now.Sub(lastSeen) >= activityWriteInterval {
|
||||
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
|
||||
return User{}, fmt.Errorf("refresh session: %w", err)
|
||||
}
|
||||
}
|
||||
user.AuthenticatedAt = authenticatedAt
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateUser adds a local identity after the caller has enforced administrator
|
||||
// authorization and recent authentication.
|
||||
// authorization checks are performed by the caller.
|
||||
func (s *Service) CreateUser(ctx context.Context, username, password, role string) (User, error) {
|
||||
return s.CreateUserWithEmail(ctx, username, username+"@local.invalid", password, role, false)
|
||||
}
|
||||
@@ -241,6 +323,36 @@ func (s *Service) UpdateEmail(ctx context.Context, userID, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAvatar(ctx context.Context, userID, path, contentType string) (string, error) {
|
||||
var previous string
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT avatar_path FROM users WHERE id=?", userID).Scan(&previous); err != nil {
|
||||
return "", fmt.Errorf("load user avatar: %w", err)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, "UPDATE users SET avatar_path=?, avatar_content_type=? WHERE id=?", path, contentType, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("save user avatar: %w", err)
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return "", errors.New("user not found")
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
func (s *Service) ClearAvatar(ctx context.Context, userID string) (string, error) {
|
||||
var previous string
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT avatar_path FROM users WHERE id=?", userID).Scan(&previous); err != nil {
|
||||
return "", fmt.Errorf("load user avatar: %w", err)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, "UPDATE users SET avatar_path='', avatar_content_type='' WHERE id=?", userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clear user avatar: %w", err)
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return "", errors.New("user not found")
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID, current, next string) error {
|
||||
var stored string
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE id=? AND disabled_at IS NULL", userID).Scan(&stored); err != nil || !verifyPassword(current, stored) {
|
||||
@@ -342,8 +454,12 @@ func (s *Service) Revoke(ctx context.Context, token string) error {
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, userID string, now time.Time) (Session, error) {
|
||||
token, csrf := randomToken(32), randomToken(32)
|
||||
expires := now.Add(absoluteLifetime)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id_hash, user_id, csrf_hash, created_at, expires_at, last_seen_at)
|
||||
policy, err := s.SessionPolicy(ctx)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
expires := now.Add(policy.MaxLifetime)
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id_hash, user_id, csrf_hash, created_at, expires_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, digest(token), userID, digest(csrf), now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("create session: %w", err)
|
||||
@@ -413,6 +529,9 @@ func validateCredentials(username, password string) error {
|
||||
|
||||
func normalizeEmail(value string) (string, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(value))
|
||||
if email == "" || len(email) > 254 {
|
||||
return "", errors.New("email address is invalid")
|
||||
}
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil || parsed.Address != email {
|
||||
return "", errors.New("email address is invalid")
|
||||
|
||||
@@ -146,12 +146,70 @@ func TestLoginRateLimitAndIdleExpiry(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.now = func() time.Time { return now.Add(idleLifetime + time.Second) }
|
||||
policy, err := service.SessionPolicy(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.now = func() time.Time { return now.Add(policy.InactivityTimeout + time.Second) }
|
||||
if _, err := service.Authenticate(ctx, session.Token); !errors.Is(err, ErrInvalidSession) {
|
||||
t.Fatalf("idle session error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPolicyAbsoluteInactivityAndPersistence(t *testing.T) {
|
||||
service := testService(t)
|
||||
ctx := context.Background()
|
||||
if err := service.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
service.now = func() time.Time { return now }
|
||||
policy := SessionPolicy{MaxLifetime: 2 * time.Hour, InactivityTimeout: time.Hour, InactivityEnabled: true}
|
||||
if err := service.SetSessionPolicy(ctx, policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := service.SessionPolicy(ctx); err != nil || got != policy {
|
||||
t.Fatalf("policy = %#v, error = %v", got, err)
|
||||
}
|
||||
session, err := service.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.now = func() time.Time { return now.Add(30 * time.Minute) }
|
||||
if _, err := service.Authenticate(ctx, session.Token); err != nil {
|
||||
t.Fatalf("active session rejected: %v", err)
|
||||
}
|
||||
service.now = func() time.Time { return now.Add(89 * time.Minute) }
|
||||
if _, err := service.Authenticate(ctx, session.Token); err != nil {
|
||||
t.Fatalf("activity did not extend inactivity window: %v", err)
|
||||
}
|
||||
service.now = func() time.Time { return now.Add(2*time.Hour + time.Second) }
|
||||
if _, err := service.Authenticate(ctx, session.Token); !errors.Is(err, ErrInvalidSession) {
|
||||
t.Fatalf("absolute lifetime error = %v", err)
|
||||
}
|
||||
|
||||
service.now = func() time.Time { return now }
|
||||
if err := service.SetSessionPolicy(ctx, SessionPolicy{MaxLifetime: 30 * 24 * time.Hour, InactivityTimeout: time.Hour, InactivityEnabled: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withoutIdle, err := service.Login(ctx, "admin", "correct horse battery staple", "192.0.2.2:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.now = func() time.Time { return now.Add(2 * time.Hour) }
|
||||
if _, err := service.Authenticate(ctx, withoutIdle.Token); err != nil {
|
||||
t.Fatalf("disabled inactivity rejected session: %v", err)
|
||||
}
|
||||
|
||||
if err := service.SetSessionPolicy(ctx, SessionPolicy{MaxLifetime: 7 * 24 * time.Hour, InactivityTimeout: 24 * time.Hour, InactivityEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.now = func() time.Time { return now.Add(8 * 24 * time.Hour) }
|
||||
if _, err := service.Authenticate(ctx, withoutIdle.Token); !errors.Is(err, ErrInvalidSession) {
|
||||
t.Fatalf("reduced policy error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFailureSerializesConcurrentUpdates(t *testing.T) {
|
||||
service := testService(t)
|
||||
now := time.Now().UTC()
|
||||
@@ -296,6 +354,7 @@ func testService(t *testing.T) *Service {
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, email TEXT UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')), disabled_at TEXT, created_at TEXT NOT NULL, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')));
|
||||
CREATE TABLE sessions (id_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_hash BLOB NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL);
|
||||
CREATE TABLE authentication_attempts (attempt_key TEXT PRIMARY KEY, failures INTEGER NOT NULL, blocked_until TEXT, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE system_settings (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL);
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
)
|
||||
@@ -16,11 +15,8 @@ var (
|
||||
ErrInvalidInput = errors.New("invalid authorization input")
|
||||
ErrNotFound = errors.New("authorization object not found")
|
||||
ErrConflict = errors.New("authorization conflict")
|
||||
ErrRecentAuth = errors.New("recent authentication required")
|
||||
)
|
||||
|
||||
const RecentAuthenticationWindow = 10 * time.Minute
|
||||
|
||||
const (
|
||||
PermissionInstanceView = "instance.view"
|
||||
PermissionInstanceStart = "instance.start"
|
||||
@@ -124,10 +120,9 @@ type Repository interface {
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service { return &Service{repository: repository, now: time.Now} }
|
||||
func New(repository Repository) *Service { return &Service{repository: repository} }
|
||||
|
||||
func Permissions() []string {
|
||||
result := make([]string, 0, len(allPermissions))
|
||||
@@ -143,7 +138,7 @@ func (s *Service) Require(ctx context.Context, principal auth.User, instanceID,
|
||||
return ErrDenied
|
||||
}
|
||||
if principal.Role == "admin" {
|
||||
return s.requireRecentForPermission(principal, permission)
|
||||
return nil
|
||||
}
|
||||
if principal.Role != "user" || instanceID == "" {
|
||||
return ErrDenied
|
||||
@@ -163,13 +158,13 @@ func (s *Service) Require(ctx context.Context, principal auth.User, instanceID,
|
||||
baseline = managerBaseline
|
||||
}
|
||||
if baseline[permission] || access.Overrides[permission] == "allow" {
|
||||
return s.requireRecentForPermission(principal, permission)
|
||||
return nil
|
||||
}
|
||||
return ErrDenied
|
||||
}
|
||||
|
||||
func (s *Service) SetMembership(ctx context.Context, actor auth.User, instanceID, userID, role string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
if err := s.requireAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" || (role != "user" && role != "manager") {
|
||||
@@ -178,10 +173,8 @@ func (s *Service) SetMembership(ctx context.Context, actor auth.User, instanceID
|
||||
return s.repository.SetMembership(ctx, actor.ID, instanceID, userID, role)
|
||||
}
|
||||
|
||||
func (s *Service) RequireRecentAdmin(actor auth.User) error { return s.requireRecentAdmin(actor) }
|
||||
|
||||
func (s *Service) DeleteMembership(ctx context.Context, actor auth.User, instanceID, userID string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
if err := s.requireAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" {
|
||||
@@ -191,7 +184,7 @@ func (s *Service) DeleteMembership(ctx context.Context, actor auth.User, instanc
|
||||
}
|
||||
|
||||
func (s *Service) SetOverride(ctx context.Context, actor auth.User, instanceID, userID, permission, effect string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
if err := s.requireAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" || !knownPermission(permission) || (effect != "allow" && effect != "deny") {
|
||||
@@ -201,7 +194,7 @@ func (s *Service) SetOverride(ctx context.Context, actor auth.User, instanceID,
|
||||
}
|
||||
|
||||
func (s *Service) DeleteOverride(ctx context.Context, actor auth.User, instanceID, userID, permission string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
if err := s.requireAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" || !knownPermission(permission) {
|
||||
@@ -236,7 +229,7 @@ func (s *Service) ListInstallationRequests(ctx context.Context, actor auth.User)
|
||||
}
|
||||
|
||||
func (s *Service) ReviewInstallationRequest(ctx context.Context, actor auth.User, requestID, decision, reason string) (InstallationRequest, error) {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
if err := s.requireAdmin(actor); err != nil {
|
||||
return InstallationRequest{}, err
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
@@ -246,24 +239,10 @@ func (s *Service) ReviewInstallationRequest(ctx context.Context, actor auth.User
|
||||
return s.repository.ReviewInstallationRequest(ctx, actor.ID, requestID, decision, reason)
|
||||
}
|
||||
|
||||
func (s *Service) requireRecentAdmin(actor auth.User) error {
|
||||
func (s *Service) requireAdmin(actor auth.User) error {
|
||||
if actor.ID == "" || actor.Disabled || actor.Role != "admin" {
|
||||
return ErrDenied
|
||||
}
|
||||
if actor.AuthenticatedAt.IsZero() || s.now().Sub(actor.AuthenticatedAt) > RecentAuthenticationWindow {
|
||||
return ErrRecentAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) requireRecentForPermission(actor auth.User, permission string) error {
|
||||
if permission != PermissionInstanceConfigure && permission != PermissionInstanceDelete && permission != PermissionBackupRestore && permission != PermissionBackupDelete {
|
||||
return nil
|
||||
}
|
||||
age := s.now().Sub(actor.AuthenticatedAt)
|
||||
if actor.AuthenticatedAt.IsZero() || age < 0 || age > RecentAuthenticationWindow {
|
||||
return ErrRecentAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ func TestInstanceBaselinesOverridesAndIdentifierSubstitution(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staleUser := user
|
||||
staleUser.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
|
||||
if err := service.Require(ctx, staleUser, instanceID, authorization.PermissionBackupRestore); !errors.Is(err, authorization.ErrRecentAuth) {
|
||||
t.Fatalf("stale user restore error = %v", err)
|
||||
staleUser.AuthenticatedAt = time.Now().Add(-365 * 24 * time.Hour)
|
||||
if err := service.Require(ctx, staleUser, instanceID, authorization.PermissionBackupRestore); err != nil {
|
||||
t.Fatalf("old session metadata should not trigger an age-based denial: %v", err)
|
||||
}
|
||||
if err := service.Require(ctx, user, "substituted-instance-id", authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
|
||||
t.Fatalf("substituted ID error = %v", err)
|
||||
@@ -63,12 +63,12 @@ func TestInstanceBaselinesOverridesAndIdentifierSubstitution(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := admin
|
||||
stale.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
|
||||
if err := service.Require(ctx, stale, instanceID, authorization.PermissionInstanceDelete); !errors.Is(err, authorization.ErrRecentAuth) {
|
||||
t.Fatalf("stale admin delete error = %v", err)
|
||||
stale.AuthenticatedAt = time.Now().Add(-365 * 24 * time.Hour)
|
||||
if err := service.Require(ctx, stale, instanceID, authorization.PermissionInstanceDelete); err != nil {
|
||||
t.Fatalf("old session metadata should not trigger an age-based denial: %v", err)
|
||||
}
|
||||
if err := service.SetMembership(ctx, stale, instanceID, user.ID, "user"); !errors.Is(err, authorization.ErrRecentAuth) {
|
||||
t.Fatalf("stale admin error = %v", err)
|
||||
if err := service.SetMembership(ctx, stale, instanceID, user.ID, "user"); err != nil {
|
||||
t.Fatalf("old session metadata should not trigger an age-based denial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+160
-61
@@ -12,49 +12,24 @@ import (
|
||||
|
||||
func TestInitializeOfficialAndScanDirPreservesLocalFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := catalog.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(first.Valid) < 2 {
|
||||
t.Fatalf("expected at least 2 valid official templates, got %#v", first)
|
||||
if len(first.Valid) == 0 || first.Found != len(first.Valid) || len(first.Errors) != 0 {
|
||||
t.Fatalf("initial catalog scan = %#v", first)
|
||||
}
|
||||
|
||||
var palworld *catalog.Snapshot
|
||||
for i := range first.Valid {
|
||||
if first.Valid[i].Template.ID == "palworld-official" {
|
||||
palworld = &first.Valid[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if palworld == nil {
|
||||
t.Fatal(`official template "palworld-official" is missing`)
|
||||
}
|
||||
|
||||
if palworld.Template.Game.Artwork.Image == "" {
|
||||
t.Fatal("palworld artwork image was lost")
|
||||
}
|
||||
|
||||
if len(palworld.Template.Configuration.Fields) == 0 {
|
||||
t.Fatal("palworld configuration fields were lost")
|
||||
}
|
||||
|
||||
path := filepath.Join(root, "palworld", "template.yaml")
|
||||
path := filepath.Join(root, first.Valid[0].AssetRoot, "template.yaml")
|
||||
if err := os.WriteFile(path, []byte("local customization"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil || string(body) != "local customization" {
|
||||
t.Fatalf("local template was overwritten: %q, %v", body, err)
|
||||
@@ -63,54 +38,178 @@ func TestInitializeOfficialAndScanDirPreservesLocalFiles(t *testing.T) {
|
||||
|
||||
func TestScanDirKeepsValidTemplatesWhenOneIsInvalid(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
initial, err := catalog.ScanDir(root)
|
||||
if err != nil || len(initial.Valid) == 0 {
|
||||
t.Fatalf("initial scan = %#v, %v", initial, err)
|
||||
}
|
||||
|
||||
broken := strings.Replace(
|
||||
string(body),
|
||||
"image: assets/banner.jpg",
|
||||
"image: ../invalid.jpg",
|
||||
1,
|
||||
)
|
||||
|
||||
broken := strings.Replace(initial.Valid[0].CanonicalYAML, `"image": "`+initial.Valid[0].Template.Game.Artwork.Image+`"`, `"image": "../invalid.jpg"`, 1)
|
||||
if err := os.Mkdir(filepath.Join(root, "broken"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(root, "broken", "template.yaml"),
|
||||
[]byte(broken),
|
||||
0o644,
|
||||
); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(root, "broken", "template.yaml"), []byte(broken), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := catalog.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.Found != 3 {
|
||||
t.Fatalf("found = %d, want 3; scan = %#v", result.Found, result)
|
||||
if result.Found != initial.Found+1 || len(result.Valid) != len(initial.Valid) || len(result.Errors) != 1 {
|
||||
t.Fatalf("scan with invalid template = %#v", result)
|
||||
}
|
||||
|
||||
if len(result.Valid) != 2 {
|
||||
t.Fatalf("valid = %d, want 2; scan = %#v", len(result.Valid), result)
|
||||
}
|
||||
|
||||
if len(result.Errors) != 1 {
|
||||
t.Fatalf("errors = %d, want 1; scan = %#v", len(result.Errors), result)
|
||||
}
|
||||
|
||||
if result.Errors[0].Template != "broken" ||
|
||||
!strings.Contains(result.Errors[0].Message, "/game/artwork/image") {
|
||||
if result.Errors[0].Template != "broken" || !strings.Contains(result.Errors[0].Message, "/game/artwork/image") {
|
||||
t.Fatalf("error = %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDirAcceptsLocallyModifiedReferencedAsset(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := catalog.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, snapshot := range before.Valid {
|
||||
if len(snapshot.Template.Container.Assets) == 0 {
|
||||
continue
|
||||
}
|
||||
asset := snapshot.Template.Container.Assets[0]
|
||||
assetPath := filepath.Join(root, filepath.FromSlash(snapshot.AssetRoot), filepath.FromSlash(asset.Source))
|
||||
if err := os.WriteFile(assetPath, []byte("locally maintained asset\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
templatePath := filepath.Join(root, filepath.FromSlash(snapshot.AssetRoot), "template.yaml")
|
||||
body, err := os.ReadFile(templatePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := strings.Replace(string(body), " destination: "+asset.Destination+"\n", " destination: "+asset.Destination+"\n sha256: obsolete-checksum\n", 1)
|
||||
if err := os.WriteFile(templatePath, []byte(legacy), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, scanErr := catalog.ScanDir(root)
|
||||
if scanErr != nil || len(after.Errors) != 0 || len(after.Valid) != len(before.Valid) {
|
||||
t.Fatalf("scan after modifying asset %q = %#v, %v", assetPath, after, scanErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Skip("embedded catalog has no container assets")
|
||||
}
|
||||
|
||||
func TestScanDirLoadsTemplateLocalModuleAndAcceptsChanges(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := catalog.ScanDir(root)
|
||||
if err != nil || len(first.Valid) == 0 {
|
||||
t.Fatalf("initial scan = %#v, %v", first, err)
|
||||
}
|
||||
var snapshot catalog.Snapshot
|
||||
for _, candidate := range first.Valid {
|
||||
if candidate.Template.Module != nil {
|
||||
snapshot = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if snapshot.Template.Module == nil || len(snapshot.ModuleFiles) == 0 {
|
||||
t.Fatal("template-local module was not discovered")
|
||||
}
|
||||
wasmPath := filepath.Join(root, snapshot.AssetRoot, "module", "module.wasm")
|
||||
body, err := os.ReadFile(wasmPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(wasmPath, append(body, 0), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := catalog.ScanDir(root)
|
||||
if err != nil || len(second.Errors) != 0 {
|
||||
t.Fatalf("modified local module scan = %#v, %v", second, err)
|
||||
}
|
||||
var updated catalog.Snapshot
|
||||
for _, candidate := range second.Valid {
|
||||
if candidate.Template.ID == snapshot.Template.ID {
|
||||
updated = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if updated.Digest == snapshot.Digest {
|
||||
t.Fatal("module change did not affect template snapshot digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDirAcceptsLocalTemplateWithoutModule(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := catalog.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, snapshot := range result.Valid {
|
||||
if snapshot.Template.ID != "vrising-didstopia" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(root, snapshot.AssetRoot, "template.yaml")
|
||||
body, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
withoutModule := strings.Replace(string(body), "module:\n path: module/manifest.yaml\n\n", "", 1)
|
||||
withoutModule = strings.Replace(withoutModule, "integration:\n module_id: vrising-rcon\n version_range: \">=1.0.0 <2.0.0\"\n port_id: rcon\n required: false\n\n", "", 1)
|
||||
if err := os.WriteFile(path, []byte(withoutModule), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rescanned, scanErr := catalog.ScanDir(root)
|
||||
if scanErr != nil {
|
||||
t.Fatal(scanErr)
|
||||
}
|
||||
for _, candidate := range rescanned.Valid {
|
||||
if candidate.Template.ID == "vrising-didstopia" && candidate.Template.Module == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("a template without an optional module was not accepted")
|
||||
}
|
||||
|
||||
func TestScanDirRejectsMissingAndEscapingLocalModule(t *testing.T) {
|
||||
for _, modulePath := range []string{"module/missing.yaml", "../outside.wasm", "/outside.wasm"} {
|
||||
t.Run(modulePath, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial, err := catalog.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot catalog.Snapshot
|
||||
for _, candidate := range initial.Valid {
|
||||
if candidate.Template.Module != nil {
|
||||
snapshot = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
path := filepath.Join(root, snapshot.AssetRoot, "template.yaml")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Replace(string(body), snapshot.Template.Module.Path, modulePath, 1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := catalog.ScanDir(root)
|
||||
if err != nil || len(result.Errors) != 1 || result.Errors[0].Template != snapshot.AssetRoot {
|
||||
t.Fatalf("unsafe local module scan = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+186
-15
@@ -22,6 +22,8 @@ import (
|
||||
)
|
||||
|
||||
const templateSchemaURL = "https://dogama.dev/schemas/template-v1.json"
|
||||
const moduleManifestSchemaURL = "https://dogama.dev/schemas/module-manifest-v1.json"
|
||||
const maxModuleBundleBytes = 16 << 20
|
||||
|
||||
// ValidationIssue points to one invalid field without exposing input secrets.
|
||||
type ValidationIssue struct {
|
||||
@@ -61,18 +63,25 @@ type Template struct {
|
||||
Recommended Resources `json:"recommended"`
|
||||
} `json:"requirements"`
|
||||
Container struct {
|
||||
Image string `json:"image"`
|
||||
Tag string `json:"tag"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
UserMode string `json:"user_mode,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
Ports []Port `json:"ports"`
|
||||
Image string `json:"image"`
|
||||
Tag string `json:"tag"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
UserMode string `json:"user_mode,omitempty"`
|
||||
RuntimeUser struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
UIDEnv string `json:"uid_env,omitempty"`
|
||||
GIDEnv string `json:"gid_env,omitempty"`
|
||||
} `json:"runtime_user,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
Capabilities struct {
|
||||
Add []string `json:"add,omitempty"`
|
||||
} `json:"capabilities,omitempty"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
Ports []Port `json:"ports"`
|
||||
Assets []struct {
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
SHA256 string `json:"sha256"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
} `json:"assets"`
|
||||
} `json:"container"`
|
||||
@@ -87,6 +96,9 @@ type Template struct {
|
||||
ModuleID string `json:"module_id"`
|
||||
PortID string `json:"port_id"`
|
||||
} `json:"integration,omitempty"`
|
||||
Module *struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"module,omitempty"`
|
||||
Backup struct {
|
||||
Strategy string `json:"strategy"`
|
||||
SourceMounts []string `json:"source_mounts"`
|
||||
@@ -178,6 +190,10 @@ type Snapshot struct {
|
||||
Digest string
|
||||
Origin string
|
||||
AssetRoot string
|
||||
// AssetFiles contains the template-owned artwork needed by the UI. It is
|
||||
// copied into snapshots so historical versions remain self-contained.
|
||||
AssetFiles map[string][]byte `json:"-"`
|
||||
ModuleFiles map[string][]byte `json:"-"`
|
||||
}
|
||||
|
||||
// LoadFS validates every template.yaml below root and returns stable snapshots.
|
||||
@@ -321,14 +337,32 @@ func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
|
||||
if len(issues) != 0 {
|
||||
return Snapshot{}, &ValidationErrors{Issues: issues}
|
||||
}
|
||||
moduleFiles, moduleIssues := collectModuleFiles(template, assetRoot, source)
|
||||
if len(moduleIssues) != 0 {
|
||||
return Snapshot{}, &ValidationErrors{Issues: moduleIssues}
|
||||
}
|
||||
assetFiles, assetIssues := collectArtworkFiles(template, assetRoot, source)
|
||||
if len(assetIssues) != 0 {
|
||||
return Snapshot{}, &ValidationErrors{Issues: assetIssues}
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(raw, "", " ")
|
||||
digest := sha256.Sum256(canonical)
|
||||
digester := sha256.New()
|
||||
_, _ = digester.Write(canonical)
|
||||
for _, name := range sortedModuleFiles(moduleFiles) {
|
||||
_, _ = digester.Write([]byte{0})
|
||||
_, _ = digester.Write([]byte(name))
|
||||
_, _ = digester.Write([]byte{0})
|
||||
_, _ = digester.Write(moduleFiles[name])
|
||||
}
|
||||
digest := digester.Sum(nil)
|
||||
return Snapshot{
|
||||
Template: template,
|
||||
CanonicalYAML: string(pretty) + "\n",
|
||||
Digest: hex.EncodeToString(digest[:]),
|
||||
Digest: hex.EncodeToString(digest),
|
||||
Origin: template.Source.Type,
|
||||
AssetRoot: assetRoot,
|
||||
AssetFiles: assetFiles,
|
||||
ModuleFiles: moduleFiles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -354,6 +388,24 @@ func compileSchema() (*jsonschema.Schema, error) {
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func compileModuleManifestSchema() (*jsonschema.Schema, error) {
|
||||
body, err := specs.Files.ReadFile("module-manifest.schema.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
compiler.UseRegexpEngine(compileECMAScript)
|
||||
var document any
|
||||
if err := json.Unmarshal(body, &document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compiler.AddResource(moduleManifestSchemaURL, document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compiler.Compile(moduleManifestSchemaURL)
|
||||
}
|
||||
|
||||
type ecmaRegexp regexp2.Regexp
|
||||
|
||||
func (expression *ecmaRegexp) MatchString(value string) bool {
|
||||
@@ -458,6 +510,16 @@ func crossValidate(template Template, assetRoot string, source fs.FS) []Validati
|
||||
issues = append(issues, ValidationIssue{Path: "/integration/port_id", Message: "integration must reference an integration port"})
|
||||
}
|
||||
}
|
||||
if template.Container.RuntimeUser.Mode == "environment" {
|
||||
if template.Container.UserMode != "image" || !validRuntimeEnvironmentName(template.Container.RuntimeUser.UIDEnv) || !validRuntimeEnvironmentName(template.Container.RuntimeUser.GIDEnv) || template.Container.RuntimeUser.UIDEnv == template.Container.RuntimeUser.GIDEnv {
|
||||
issues = append(issues, ValidationIssue{Path: "/container/runtime_user", Message: "environment runtime user requires distinct safe UID/GID variables and image user mode"})
|
||||
}
|
||||
} else if template.Container.RuntimeUser.Mode != "" && template.Container.RuntimeUser.Mode != "docker" {
|
||||
issues = append(issues, ValidationIssue{Path: "/container/runtime_user/mode", Message: "unknown runtime user mode"})
|
||||
}
|
||||
if template.Integration != nil && template.Module == nil {
|
||||
issues = append(issues, ValidationIssue{Path: "/module", Message: "integration requires a template-local module"})
|
||||
}
|
||||
if template.Healthcheck.PortID != "" {
|
||||
if _, exists := ports[template.Healthcheck.PortID]; !exists {
|
||||
issues = append(issues, ValidationIssue{Path: "/healthcheck/port_id", Message: "healthcheck port does not exist"})
|
||||
@@ -472,15 +534,124 @@ func crossValidate(template Template, assetRoot string, source fs.FS) []Validati
|
||||
issues = append(issues, ValidationIssue{Path: "/requirements/recommended", Message: "recommended resources must not be below minimum resources"})
|
||||
}
|
||||
for _, asset := range template.Container.Assets {
|
||||
body, err := fs.ReadFile(source, path.Join(assetRoot, asset.Source))
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || hex.EncodeToString(digest[:]) != asset.SHA256 {
|
||||
issues = append(issues, ValidationIssue{Path: "/container/assets/" + asset.Source, Message: "asset is missing or its checksum does not match"})
|
||||
if _, err := fs.Stat(source, path.Join(assetRoot, asset.Source)); err != nil {
|
||||
issues = append(issues, ValidationIssue{Path: "/container/assets/" + asset.Source, Message: "asset is missing"})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func collectArtworkFiles(template Template, assetRoot string, source fs.FS) (map[string][]byte, []ValidationIssue) {
|
||||
files := make(map[string][]byte, 3)
|
||||
for field, asset := range map[string]string{
|
||||
"logo": template.Game.Artwork.Logo,
|
||||
"image": template.Game.Artwork.Image,
|
||||
"poster": template.Game.Artwork.Poster,
|
||||
} {
|
||||
body, err := fs.ReadFile(source, path.Join(assetRoot, asset))
|
||||
if err != nil {
|
||||
return nil, []ValidationIssue{{Path: "/game/artwork/" + field, Message: "artwork asset cannot be read"}}
|
||||
}
|
||||
files[field] = body
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func validRuntimeEnvironmentName(value string) bool {
|
||||
if value == "" || strings.HasPrefix(value, "DOGAMA_") || value == "PATH" || value == "HOME" || value == "HOSTNAME" || value == "DOCKER_HOST" {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') && (index == 0 || character < '0' || character > '9') && character != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func collectModuleFiles(template Template, assetRoot string, source fs.FS) (map[string][]byte, []ValidationIssue) {
|
||||
if template.Module == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !validModulePath(template.Module.Path) {
|
||||
return nil, []ValidationIssue{{Path: "/module/path", Message: "module path must remain inside the template module directory"}}
|
||||
}
|
||||
root := path.Join(assetRoot, "module")
|
||||
files := map[string][]byte{}
|
||||
var totalBytes int
|
||||
err := fs.WalkDir(source, root, func(name string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&fs.ModeSymlink != 0 {
|
||||
return errors.New("symbolic link")
|
||||
}
|
||||
body, err := fs.ReadFile(source, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalBytes += len(body)
|
||||
if totalBytes > maxModuleBundleBytes {
|
||||
return errors.New("module bundle exceeds size limit")
|
||||
}
|
||||
rel := strings.TrimPrefix(name, strings.TrimSuffix(assetRoot, "/")+"/")
|
||||
if !strings.HasPrefix(rel, "module/") {
|
||||
return errors.New("invalid module path")
|
||||
}
|
||||
files[rel] = body
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, []ValidationIssue{{Path: "/module", Message: "module directory cannot be read"}}
|
||||
}
|
||||
if _, ok := files[template.Module.Path]; !ok {
|
||||
return nil, []ValidationIssue{{Path: "/module/path", Message: "declared module manifest is missing"}}
|
||||
}
|
||||
if err := validateModuleManifest(files[template.Module.Path], files); err != nil {
|
||||
return nil, []ValidationIssue{{Path: "/module", Message: "declared module bundle is invalid"}}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func validateModuleManifest(body []byte, files map[string][]byte) error {
|
||||
var raw any
|
||||
if err := yaml.Unmarshal(body, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
schema, err := compileModuleManifestSchema()
|
||||
if err != nil || schema.Validate(raw) != nil {
|
||||
return errors.New("invalid manifest")
|
||||
}
|
||||
var manifest struct {
|
||||
Artifacts struct {
|
||||
WASM string `yaml:"wasm"`
|
||||
} `yaml:"artifacts"`
|
||||
}
|
||||
if err := yaml.Unmarshal(body, &manifest); err != nil || manifest.Artifacts.WASM == "" {
|
||||
return errors.New("invalid artifact")
|
||||
}
|
||||
if _, ok := files["module/"+manifest.Artifacts.WASM]; !ok {
|
||||
return errors.New("missing wasm artifact")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validModulePath(value string) bool {
|
||||
return value != "" && !path.IsAbs(value) && path.Clean(value) == value && strings.HasPrefix(value, "module/") && !strings.Contains(value, "..")
|
||||
}
|
||||
|
||||
func sortedModuleFiles(files map[string][]byte) []string {
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func hasAlias(node *yaml.Node) bool {
|
||||
if node.Kind == yaml.AliasNode {
|
||||
return true
|
||||
|
||||
@@ -2,6 +2,8 @@ package catalog_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -9,7 +11,107 @@ import (
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
func TestBuiltInCatalogValidatesDeterministically(t *testing.T) {
|
||||
// TestBuiltInCatalogValidatesEveryDiscoveredTemplate deliberately enumerates
|
||||
// the embedded filesystem instead of knowing any game IDs, versions or order.
|
||||
func TestBuiltInCatalogValidatesEveryDiscoveredTemplate(t *testing.T) {
|
||||
names, err := fs.Glob(catalogdata.Files, "*/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatal("embedded catalog contains no templates")
|
||||
}
|
||||
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("load embedded catalog: %v", err)
|
||||
}
|
||||
if len(snapshots) != len(names) {
|
||||
t.Fatalf("loaded %d templates after discovering %d paths", len(snapshots), len(names))
|
||||
}
|
||||
|
||||
loaded := make(map[string]catalog.Snapshot, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
key := snapshot.Template.ID + "@" + snapshot.Template.Version
|
||||
if _, duplicate := loaded[key]; duplicate {
|
||||
t.Fatalf("duplicate loaded template identity %q", key)
|
||||
}
|
||||
loaded[key] = snapshot
|
||||
}
|
||||
for _, name := range names {
|
||||
body, readErr := catalogdata.Files.ReadFile(name)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read embedded template %q: %v", name, readErr)
|
||||
}
|
||||
snapshot, validateErr := catalog.Validate(body, path.Dir(name), catalogdata.Files)
|
||||
if validateErr != nil {
|
||||
t.Fatalf("validate embedded template path=%q: %v", name, validateErr)
|
||||
}
|
||||
key := snapshot.Template.ID + "@" + snapshot.Template.Version
|
||||
if _, ok := loaded[key]; !ok {
|
||||
t.Fatalf("validated embedded template path=%q id=%q was not loaded", name, snapshot.Template.ID)
|
||||
}
|
||||
for _, asset := range snapshot.Template.Container.Assets {
|
||||
assetPath := path.Join(path.Dir(name), asset.Source)
|
||||
if _, statErr := fs.Stat(catalogdata.Files, assetPath); statErr != nil {
|
||||
t.Fatalf("template path=%q id=%q asset=%q is missing: %v", name, snapshot.Template.ID, assetPath, statErr)
|
||||
}
|
||||
}
|
||||
if snapshot.Template.Module != nil {
|
||||
if _, ok := snapshot.ModuleFiles[snapshot.Template.Module.Path]; !ok {
|
||||
t.Fatalf("template path=%q id=%q declared module=%q is missing", name, snapshot.Template.ID, snapshot.Template.Module.Path)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"logo", "image", "poster"} {
|
||||
if len(snapshot.AssetFiles[field]) == 0 {
|
||||
t.Fatalf("template path=%q id=%q artwork=%q was not bundled", name, snapshot.Template.ID, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtworkURLsAndTraversalAreRejectedGenerically(t *testing.T) {
|
||||
name, body, snapshot := embeddedTemplate(t)
|
||||
for _, value := range []string{"https://example.invalid/art.jpg", "/tmp/art.jpg", "../art.jpg"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
invalid := []byte(strings.Replace(string(body), snapshot.Template.Game.Artwork.Image, value, 1))
|
||||
if _, err := catalog.Validate(invalid, path.Dir(name), catalogdata.Files); err == nil {
|
||||
t.Fatalf("unsafe artwork path accepted: %q", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossValidationRejectsMissingDeclaredModule(t *testing.T) {
|
||||
name, body, snapshot := embeddedTemplate(t)
|
||||
if snapshot.Template.Module == nil {
|
||||
t.Skip("embedded fixture has no module")
|
||||
}
|
||||
body = []byte(strings.Replace(string(body), snapshot.Template.Module.Path, "module/missing.yaml", 1))
|
||||
_, err := catalog.Validate(body, path.Dir(name), catalogdata.Files)
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) || len(validation.Issues) == 0 || validation.Issues[0].Path != "/module/path" {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossValidationRejectsModuleTraversalAndAbsolutePath(t *testing.T) {
|
||||
name, body, snapshot := embeddedTemplate(t)
|
||||
if snapshot.Template.Module == nil {
|
||||
t.Skip("embedded fixture has no module")
|
||||
}
|
||||
for _, modulePath := range []string{"../outside.wasm", "/outside.wasm"} {
|
||||
t.Run(modulePath, func(t *testing.T) {
|
||||
invalid := []byte(strings.Replace(string(body), snapshot.Template.Module.Path, modulePath, 1))
|
||||
_, err := catalog.Validate(invalid, path.Dir(name), catalogdata.Files)
|
||||
if err == nil {
|
||||
t.Fatalf("unsafe module path accepted: %q", modulePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInCatalogIsDeterministic(t *testing.T) {
|
||||
first, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -18,137 +120,79 @@ func TestBuiltInCatalogValidatesDeterministically(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(first) != len(second) {
|
||||
t.Fatalf("catalog snapshot count differs: first=%d second=%d", len(first), len(second))
|
||||
}
|
||||
|
||||
if len(first) < 2 {
|
||||
t.Fatalf("expected at least 2 built-in templates, got %d", len(first))
|
||||
}
|
||||
|
||||
secondByID := make(map[string]catalog.Snapshot, len(second))
|
||||
for _, snapshot := range second {
|
||||
secondByID[snapshot.Template.ID] = snapshot
|
||||
}
|
||||
|
||||
firstByID := make(map[string]catalog.Snapshot, len(first))
|
||||
for _, snapshot := range first {
|
||||
firstByID[snapshot.Template.ID] = snapshot
|
||||
|
||||
other, ok := secondByID[snapshot.Template.ID]
|
||||
if !ok {
|
||||
t.Fatalf("template %q missing from second catalog load", snapshot.Template.ID)
|
||||
for index, snapshot := range first {
|
||||
other := second[index]
|
||||
if snapshot.Template.ID != other.Template.ID || snapshot.Template.Version != other.Template.Version || snapshot.Digest != other.Digest || snapshot.CanonicalYAML != other.CanonicalYAML {
|
||||
t.Fatalf("catalog snapshot %d is not deterministic: first=%#v second=%#v", index, snapshot, other)
|
||||
}
|
||||
|
||||
if snapshot.Digest != other.Digest {
|
||||
t.Fatalf(
|
||||
"template %q digest is not deterministic: first=%q second=%q",
|
||||
snapshot.Template.ID,
|
||||
snapshot.Digest,
|
||||
other.Digest,
|
||||
)
|
||||
}
|
||||
|
||||
if snapshot.CanonicalYAML != other.CanonicalYAML {
|
||||
t.Fatalf("template %q canonical YAML is not deterministic", snapshot.Template.ID)
|
||||
}
|
||||
}
|
||||
|
||||
palworld, ok := firstByID["palworld-official"]
|
||||
if !ok {
|
||||
t.Fatal(`built-in template "palworld-official" is missing`)
|
||||
}
|
||||
|
||||
if palworld.Template.Game.Artwork.Logo != "assets/icon.png" ||
|
||||
palworld.Template.Game.Artwork.Image != "assets/banner.jpg" ||
|
||||
palworld.Template.Game.Artwork.Poster != "assets/poster.jpg" {
|
||||
t.Fatalf("palworld artwork = %#v", palworld.Template.Game.Artwork)
|
||||
}
|
||||
|
||||
vrising, ok := firstByID["vrising-didstopia"]
|
||||
if !ok {
|
||||
t.Fatal(`built-in template "vrising-didstopia" is missing`)
|
||||
}
|
||||
|
||||
if vrising.Template.Game.ID != "vrising" {
|
||||
t.Fatalf("vrising game id = %q", vrising.Template.Game.ID)
|
||||
}
|
||||
|
||||
if vrising.Template.Game.Artwork.Logo != "assets/icon.png" ||
|
||||
vrising.Template.Game.Artwork.Image != "assets/banner.jpg" ||
|
||||
vrising.Template.Game.Artwork.Poster != "assets/poster.jpg" {
|
||||
t.Fatalf("vrising artwork = %#v", vrising.Template.Game.Artwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossValidationRejectsMissingArtworkAsset(t *testing.T) {
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = []byte(strings.Replace(string(body), "image: assets/banner.jpg", "image: assets/missing.jpg", 1))
|
||||
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
|
||||
name, body, snapshot := embeddedTemplate(t)
|
||||
body = []byte(strings.Replace(string(body), "image: "+snapshot.Template.Game.Artwork.Image, "image: assets/missing", 1))
|
||||
_, err := catalog.Validate(body, path.Dir(name), catalogdata.Files)
|
||||
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
|
||||
for _, issue := range validation.Issues {
|
||||
if issue.Path == "/game/artwork/image" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("issues = %#v", validation.Issues)
|
||||
}
|
||||
|
||||
func TestSchemaErrorsContainFieldPathAndLine(t *testing.T) {
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, body, _ := embeddedTemplate(t)
|
||||
body = []byte(strings.Replace(string(body), "schema_version: 1", "schema_version: 2", 1))
|
||||
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
|
||||
_, err := catalog.Validate(body, path.Dir(name), catalogdata.Files)
|
||||
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) || len(validation.Issues) == 0 {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
|
||||
if validation.Issues[0].Path == "" || validation.Issues[0].Line == 0 {
|
||||
t.Fatalf("validation issue = %#v", validation.Issues[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossValidationRejectsUnknownBackupMount(t *testing.T) {
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
name, body, snapshot := embeddedTemplate(t)
|
||||
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
|
||||
body = []byte(strings.Replace(
|
||||
normalized,
|
||||
" - saved\n restart_after_backup",
|
||||
" - missing\n restart_after_backup",
|
||||
1,
|
||||
))
|
||||
|
||||
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
|
||||
body = []byte(strings.Replace(normalized, " - "+snapshot.Template.Backup.SourceMounts[0]+"\n", " - missing\n", 1))
|
||||
_, err := catalog.Validate(body, path.Dir(name), catalogdata.Files)
|
||||
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, issue := range validation.Issues {
|
||||
found = found || issue.Path == "/backup/source_mounts"
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Fatalf("issues = %#v", validation.Issues)
|
||||
if issue.Path == "/backup/source_mounts" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("issues = %#v", validation.Issues)
|
||||
}
|
||||
|
||||
func embeddedTemplate(t *testing.T) (string, []byte, catalog.Snapshot) {
|
||||
t.Helper()
|
||||
names, err := fs.Glob(catalogdata.Files, "*/template.yaml")
|
||||
if err != nil || len(names) == 0 {
|
||||
t.Fatalf("discover embedded template: names=%v err=%v", names, err)
|
||||
}
|
||||
body, err := catalogdata.Files.ReadFile(names[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := catalog.Validate(body, path.Dir(names[0]), catalogdata.Files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return names[0], body, snapshot
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
)
|
||||
|
||||
type ConfigurationRepository interface {
|
||||
GetGameContainerRuntimeIdentity(context.Context) (RuntimeIdentity, error)
|
||||
SetGameContainerRuntimeIdentity(context.Context, RuntimeIdentity) error
|
||||
GetGlobalLabels(context.Context) (map[string]string, error)
|
||||
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
|
||||
SaveInstanceConfiguration(context.Context, string, Preview, bool, string, string) error
|
||||
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
// configuration. It is persisted with the preview so a recreated container is
|
||||
// built identically. Secret mutations retain only their field identifier.
|
||||
type ResolvedConfiguration struct {
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
INI []INIMutation `json:"ini,omitempty"`
|
||||
SecretEnvironment map[string]string `json:"secret_environment,omitempty"`
|
||||
SecretArguments []SecretArgument `json:"secret_arguments,omitempty"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
RuntimeEnvironment map[string]string `json:"runtime_environment,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
INI []INIMutation `json:"ini,omitempty"`
|
||||
SecretEnvironment map[string]string `json:"secret_environment,omitempty"`
|
||||
SecretArguments []SecretArgument `json:"secret_arguments,omitempty"`
|
||||
}
|
||||
|
||||
type SecretArgument struct {
|
||||
@@ -39,7 +40,7 @@ type INIMutation struct {
|
||||
var protectedEnvironment = map[string]bool{"PATH": true, "HOME": true, "HOSTNAME": true, "DOCKER_HOST": true, "DOGAMA_INSTANCE_ID": true}
|
||||
|
||||
func ResolveConfiguration(template catalog.Template, values map[string]string) (ResolvedConfiguration, error) {
|
||||
result := ResolvedConfiguration{Environment: make(map[string]string), SecretEnvironment: make(map[string]string)}
|
||||
result := ResolvedConfiguration{Environment: make(map[string]string), RuntimeEnvironment: make(map[string]string), SecretEnvironment: make(map[string]string)}
|
||||
for key, value := range template.Container.Environment {
|
||||
result.Environment[key] = value
|
||||
}
|
||||
@@ -52,6 +53,9 @@ func ResolveConfiguration(template catalog.Template, values map[string]string) (
|
||||
target := field.Target
|
||||
switch target.Kind {
|
||||
case "environment":
|
||||
if value == "" && !field.Required {
|
||||
continue
|
||||
}
|
||||
if protectedEnvironment[target.Name] || strings.HasPrefix(target.Name, "DOGAMA_") {
|
||||
return ResolvedConfiguration{}, fmt.Errorf("%s targets a protected environment variable", field.ID)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,30 @@ func TestResolveConfigurationTargets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentValuesRemainExactAndOptionalValuesAreOmitted(t *testing.T) {
|
||||
template := catalog.Template{}
|
||||
template.Configuration.Fields = []catalog.ConfigField{
|
||||
{ID: "port", Type: "integer", Target: catalog.ConfigTarget{Kind: "environment", Name: "GAME_PORT"}},
|
||||
{ID: "enabled", Type: "boolean", Target: catalog.ConfigTarget{Kind: "environment", Name: "ENABLED"}},
|
||||
{ID: "optional", Type: "string", Target: catalog.ConfigTarget{Kind: "environment", Name: "OPTIONAL"}},
|
||||
}
|
||||
resolved, err := instance.ResolveConfiguration(template, map[string]string{"port": "9876", "enabled": "true", "optional": ""})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Environment["GAME_PORT"] != "9876" || resolved.Environment["ENABLED"] != "true" {
|
||||
t.Fatalf("environment values were rewritten: %#v", resolved.Environment)
|
||||
}
|
||||
if _, ok := resolved.Environment["OPTIONAL"]; ok {
|
||||
t.Fatalf("empty optional environment was emitted: %#v", resolved.Environment)
|
||||
}
|
||||
for key, value := range resolved.Environment {
|
||||
if strings.HasPrefix(value, "= ") || strings.HasPrefix(value, "=") {
|
||||
t.Fatalf("synthetic environment prefix for %s: %q", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPalworldServerNameIsWrittenBeforeStart(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,32 @@ const (
|
||||
ImageTagPinned = "pinned"
|
||||
)
|
||||
|
||||
const DefaultGameContainerUID uint32 = 1000
|
||||
const DefaultGameContainerGID uint32 = 1000
|
||||
|
||||
// RuntimeIdentity is the administrator-controlled identity for managed game
|
||||
// containers. It never applies to DoGaMa's own containers.
|
||||
type RuntimeIdentity struct {
|
||||
UID uint32 `json:"uid"`
|
||||
GID uint32 `json:"gid"`
|
||||
}
|
||||
|
||||
func (identity RuntimeIdentity) Validate() error {
|
||||
// uint32 is the Linux kernel's numeric UID/GID range.
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseRuntimeID(value string) (uint32, error) {
|
||||
if value == "" {
|
||||
return 0, errors.New("UID/GID is required")
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil {
|
||||
return 0, errors.New("UID/GID must be a decimal Linux identifier between 0 and 4294967295")
|
||||
}
|
||||
return uint32(parsed), nil
|
||||
}
|
||||
|
||||
var (
|
||||
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`)
|
||||
tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
|
||||
|
||||
@@ -2,6 +2,19 @@ package instance
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseRuntimeID(t *testing.T) {
|
||||
for _, value := range []string{"0", "1000", "65534", "4294967295"} {
|
||||
if _, err := ParseRuntimeID(value); err != nil {
|
||||
t.Fatalf("%q rejected: %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "-1", "abc", "1000:1000", "1.5", "4294967296"} {
|
||||
if _, err := ParseRuntimeID(value); err == nil {
|
||||
t.Fatalf("%q unexpectedly accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelsAndVariables(t *testing.T) {
|
||||
labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n")
|
||||
if err != nil || labels["query"] != "a=b=c" {
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,11 +21,10 @@ 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)}
|
||||
func NewModuleService(secrets SecretRepository, repository catalog.Repository) *ModuleService {
|
||||
return &ModuleService{secrets: secrets, catalog: repository}
|
||||
}
|
||||
|
||||
type ServerInfo struct {
|
||||
@@ -78,6 +75,7 @@ type moduleManifest struct {
|
||||
Network struct {
|
||||
PortIDs []string `yaml:"port_ids"`
|
||||
HTTPMethods []string `yaml:"http_methods"`
|
||||
Protocols []string `yaml:"protocols"`
|
||||
} `yaml:"network"`
|
||||
} `yaml:"permissions"`
|
||||
Limits struct {
|
||||
@@ -105,11 +103,11 @@ func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*mod
|
||||
return nil, moduleManifest{}, ErrModuleUnavailable
|
||||
}
|
||||
integration := snapshot.Template.Integration
|
||||
if integration.ModuleID == "" || strings.Contains(integration.ModuleID, "/") || strings.Contains(integration.ModuleID, "..") {
|
||||
if integration.ModuleID == "" || snapshot.Template.Module == nil || !validTemplateModulePath(snapshot.Template.Module.Path) {
|
||||
return nil, moduleManifest{}, ErrModuleUnavailable
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(s.root, integration.ModuleID, "manifest.yaml"))
|
||||
if err != nil {
|
||||
body, ok := snapshot.ModuleFiles[snapshot.Template.Module.Path]
|
||||
if !ok {
|
||||
return nil, moduleManifest{}, ErrModuleUnavailable
|
||||
}
|
||||
var manifest moduleManifest
|
||||
@@ -127,6 +125,16 @@ func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*mod
|
||||
return nil, moduleManifest{}, ErrModuleUnavailable
|
||||
}
|
||||
methods := map[string]bool{}
|
||||
tcpAllowed := false
|
||||
for _, protocol := range manifest.Permissions.Network.Protocols {
|
||||
switch protocol {
|
||||
case "http":
|
||||
case "tcp":
|
||||
tcpAllowed = true
|
||||
default:
|
||||
return nil, moduleManifest{}, ErrModuleUnavailable
|
||||
}
|
||||
}
|
||||
for _, method := range manifest.Permissions.Network.HTTPMethods {
|
||||
if method == "GET" || method == "POST" {
|
||||
methods[method] = true
|
||||
@@ -134,7 +142,7 @@ func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*mod
|
||||
return nil, moduleManifest{}, ErrModuleUnavailable
|
||||
}
|
||||
}
|
||||
if len(methods) == 0 || manifest.Artifacts.WASM == "" || filepath.Base(manifest.Artifacts.WASM) != manifest.Artifacts.WASM {
|
||||
if (len(methods) == 0 && !tcpAllowed) || manifest.Artifacts.WASM == "" || strings.Contains(manifest.Artifacts.WASM, "/") || strings.Contains(manifest.Artifacts.WASM, "..") {
|
||||
return nil, moduleManifest{}, ErrModuleUnavailable
|
||||
}
|
||||
config, secrets := map[string]string{}, map[string]string{}
|
||||
@@ -154,8 +162,8 @@ func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*mod
|
||||
config[field.ID] = v
|
||||
}
|
||||
}
|
||||
wasm, err := os.ReadFile(filepath.Join(s.root, integration.ModuleID, manifest.Artifacts.WASM))
|
||||
if err != nil {
|
||||
wasm, ok := snapshot.ModuleFiles["module/"+manifest.Artifacts.WASM]
|
||||
if !ok {
|
||||
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})
|
||||
@@ -166,6 +174,11 @@ func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*mod
|
||||
}
|
||||
|
||||
func durationMS(v int) time.Duration { return time.Duration(v) * time.Millisecond }
|
||||
|
||||
func validTemplateModulePath(value string) bool {
|
||||
return strings.HasPrefix(value, "module/") && !strings.Contains(value, "..") && !strings.HasPrefix(value, "/")
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, v := range values {
|
||||
if v == needle {
|
||||
@@ -242,7 +255,7 @@ func (s *ModuleService) Action(ctx context.Context, value StoredInstance, capabi
|
||||
var out struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
}
|
||||
if err := r.Call(ctx, operation, request, &out); err != nil || !out.Accepted {
|
||||
if err := r.CallData(ctx, operation, request, &out); err != nil || !out.Accepted {
|
||||
return ErrModuleUnavailable
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -35,6 +35,7 @@ type PreviewRequest struct {
|
||||
PublicBaseURL string `json:"-"`
|
||||
Configuration map[string]string `json:"configuration,omitempty"`
|
||||
Secrets map[string]string `json:"-"`
|
||||
RuntimeIdentity *RuntimeIdentity `json:"-"`
|
||||
}
|
||||
|
||||
type Preview struct {
|
||||
@@ -45,6 +46,7 @@ type Preview struct {
|
||||
Image string `json:"image"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
CapAdd []string `json:"cap_add,omitempty"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
|
||||
Ports []PortBinding `json:"ports"`
|
||||
@@ -158,11 +160,12 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
if request.DockerUser.Mode == "" {
|
||||
// The template policy is authoritative. In particular, an image-defined
|
||||
// USER can never be replaced by an administrator or API caller.
|
||||
if snapshot.Template.Container.UserMode == DockerUserImage {
|
||||
request.DockerUser = DockerUser{Mode: DockerUserImage}
|
||||
} else if request.DockerUser.Mode == "" {
|
||||
request.DockerUser.Mode = DockerUserDoGaMa
|
||||
if snapshot.Template.Container.UserMode == DockerUserImage {
|
||||
request.DockerUser.Mode = DockerUserImage
|
||||
}
|
||||
}
|
||||
if err := ValidateDockerUser(request.DockerUser); err != nil {
|
||||
return Preview{}, err
|
||||
@@ -171,11 +174,14 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
uid, gid, err := currentUIDGID()
|
||||
if err != nil && request.DockerUser.Mode == DockerUserDoGaMa {
|
||||
identity := RuntimeIdentity{UID: DefaultGameContainerUID, GID: DefaultGameContainerGID}
|
||||
if request.RuntimeIdentity != nil {
|
||||
identity = *request.RuntimeIdentity
|
||||
}
|
||||
if err := identity.Validate(); err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
userValue, err := DockerUserValue(request.DockerUser, uid, gid)
|
||||
userValue, err := DockerUserValue(request.DockerUser, identity.UID, identity.GID)
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
@@ -214,6 +220,13 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
runtimeEnvironment, err := RuntimeUserEnvironment(snapshot.Template, identity)
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
for key, value := range runtimeEnvironment {
|
||||
resolved.RuntimeEnvironment[key] = value
|
||||
}
|
||||
resources := request.Resources
|
||||
if resources.CPUCores == 0 {
|
||||
resources = snapshot.Template.Requirements.Recommended
|
||||
@@ -227,7 +240,10 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
|
||||
knownPorts := make(map[string]struct{}, len(snapshot.Template.Container.Ports))
|
||||
for _, port := range snapshot.Template.Container.Ports {
|
||||
knownPorts[port.ID] = struct{}{}
|
||||
hostPort := request.HostPorts[port.ID]
|
||||
hostPort, provided := request.HostPorts[port.ID]
|
||||
if port.Publish && !provided {
|
||||
hostPort = port.ContainerPort
|
||||
}
|
||||
if port.Publish && (hostPort < 1 || hostPort > 65535) {
|
||||
return Preview{}, fmt.Errorf("published port %s requires a valid host port", port.ID)
|
||||
}
|
||||
@@ -283,6 +299,7 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
|
||||
Image: snapshot.Template.Container.Image + ":" + tag.Tag,
|
||||
Entrypoint: append([]string(nil), snapshot.Template.Container.Entrypoint...),
|
||||
Arguments: append([]string(nil), snapshot.Template.Container.Arguments...),
|
||||
CapAdd: append([]string(nil), snapshot.Template.Container.Capabilities.Add...),
|
||||
StopTimeoutSeconds: snapshot.Template.Container.StopTimeoutSeconds,
|
||||
StartupTimeoutSeconds: snapshot.Template.Healthcheck.StartupTimeoutSeconds,
|
||||
Ports: ports, Mounts: mounts, Resources: resources, Settings: settings,
|
||||
@@ -336,14 +353,7 @@ func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (a
|
||||
p.DockerUser.Mode = DockerUserDoGaMa
|
||||
}
|
||||
if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa {
|
||||
uid, gid, userErr := currentUIDGID()
|
||||
if userErr != nil {
|
||||
return agentwire.DeploymentPlan{}, userErr
|
||||
}
|
||||
p.DockerUserValue, userErr = DockerUserValue(p.DockerUser, uid, gid)
|
||||
if userErr != nil {
|
||||
return agentwire.DeploymentPlan{}, userErr
|
||||
}
|
||||
return agentwire.DeploymentPlan{}, errors.New("managed Docker user is missing")
|
||||
}
|
||||
context := LabelContext{GameName: p.Game.Name, GameID: p.Game.ID, GameIconURL: p.Game.IconURL, InstanceName: p.DisplayName, InstanceID: instanceID, InstanceSlug: p.Slug, ServerName: p.DisplayName}
|
||||
global, err := ResolveLabels(p.GlobalLabels, context)
|
||||
@@ -358,6 +368,9 @@ func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (a
|
||||
for key, value := range p.ResolvedConfiguration.Environment {
|
||||
environment[key] = value
|
||||
}
|
||||
for key, value := range p.ResolvedConfiguration.RuntimeEnvironment {
|
||||
environment[key] = value
|
||||
}
|
||||
arguments := append([]string(nil), p.Arguments...)
|
||||
arguments = append(arguments, p.ResolvedConfiguration.Arguments...)
|
||||
for key, id := range p.ResolvedConfiguration.SecretEnvironment {
|
||||
@@ -386,6 +399,7 @@ func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (a
|
||||
TemplateID: p.Template.ID, TemplateVersion: p.Template.Version, TemplateDigest: p.Template.Digest,
|
||||
Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: arguments, Environment: environment,
|
||||
Labels: MergeLabels(nil, global, local), User: p.DockerUserValue,
|
||||
CapAdd: append([]string(nil), p.CapAdd...),
|
||||
Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB},
|
||||
StopTimeoutSeconds: p.StopTimeoutSeconds,
|
||||
}
|
||||
|
||||
@@ -64,12 +64,81 @@ func TestBuildPreviewUsesTemplateImageUserUnlessAdministratorSelectsOne(t *testi
|
||||
t.Fatalf("image-mode plan user=%q error=%v", plan.User, err)
|
||||
}
|
||||
request.DockerUser.Mode = instance.DockerUserDoGaMa
|
||||
request.RuntimeIdentity = &instance.RuntimeIdentity{UID: 1234, GID: 5678}
|
||||
explicit, err := instance.BuildPreview(snapshots[0], request)
|
||||
if err != nil || explicit.DockerUser.Mode != instance.DockerUserDoGaMa || explicit.DockerUserValue == "" {
|
||||
if err != nil || explicit.DockerUser.Mode != instance.DockerUserImage || explicit.DockerUserValue != "" {
|
||||
t.Fatalf("explicit user preview=%#v error=%v", explicit.DockerUser, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewUsesManagedIdentityAndVRisingEnvironmentMapping(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vrising := snapshotByID(t, snapshots, "vrising-didstopia")
|
||||
identity := instance.RuntimeIdentity{UID: 1234, GID: 5678}
|
||||
preview, err := instance.BuildPreview(vrising, instance.PreviewRequest{DisplayName: "V Rising", HostPorts: map[string]int{"game": 45230, "query": 45231}, MountPaths: map[string]string{"persistent": filepath.Join(t.TempDir(), "persistent"), "server": filepath.Join(t.TempDir(), "server")}, DataOrigin: "new", BackupRetention: 7, RuntimeIdentity: &identity})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.User != "" || plan.Environment["PUID"] != "1234" || plan.Environment["PGID"] != "5678" {
|
||||
t.Fatalf("V Rising runtime identity = user %q env %#v", plan.User, plan.Environment)
|
||||
}
|
||||
if len(plan.CapAdd) != 5 {
|
||||
t.Fatalf("V Rising capabilities changed: %#v", plan.CapAdd)
|
||||
}
|
||||
if len(plan.Ports) != 3 || plan.Ports[0].HostPort != 45230 || plan.Ports[0].ContainerPort != 9876 || plan.Ports[1].HostPort != 45231 || plan.Ports[1].ContainerPort != 9877 || plan.Ports[2].HostPort != 0 || plan.Ports[2].ContainerPort != 9878 {
|
||||
t.Fatalf("V Rising port bindings = %#v", plan.Ports)
|
||||
}
|
||||
managed := vrising
|
||||
managed.Template.Container.UserMode = instance.DockerUserDoGaMa
|
||||
managed.Template.Container.RuntimeUser.Mode = "docker"
|
||||
managedPreview, err := instance.BuildPreview(managed, instance.PreviewRequest{DisplayName: "Managed", HostPorts: map[string]int{"game": 38002, "query": 38003}, MountPaths: map[string]string{"persistent": filepath.Join(t.TempDir(), "persistent"), "server": filepath.Join(t.TempDir(), "server")}, DataOrigin: "new", BackupRetention: 7, RuntimeIdentity: &identity})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managedPlan, err := managedPreview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
|
||||
if err != nil || managedPlan.User != "1234:5678" {
|
||||
t.Fatalf("managed runtime user = %q error=%v", managedPlan.User, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewDefaultsPublishedHostPortsAndAllowsTCPUDPReuse(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
palworld := snapshotByID(t, snapshots, "palworld-official")
|
||||
preview, err := instance.BuildPreview(palworld, instance.PreviewRequest{DisplayName: "Default ports", MountPaths: map[string]string{"saved": "/srv/game-servers/default-ports/saved"}, DataOrigin: "new", BackupRetention: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
|
||||
if err != nil || len(plan.Ports) != 2 || plan.Ports[0].HostPort != 8211 || plan.Ports[1].HostPort != 0 {
|
||||
t.Fatalf("default port bindings = %#v error=%v", plan.Ports, err)
|
||||
}
|
||||
|
||||
vrising := snapshotByID(t, snapshots, "vrising-didstopia")
|
||||
vrising.Template.Container.Ports[1].Protocol = "tcp"
|
||||
request := instance.PreviewRequest{DisplayName: "Reuse protocols", HostPorts: map[string]int{"game": 45230, "query": 45230}, MountPaths: map[string]string{"persistent": "/srv/game-servers/reuse-protocols/persistent", "server": "/srv/game-servers/reuse-protocols/server"}, DataOrigin: "new", BackupRetention: 7}
|
||||
if _, err := instance.BuildPreview(vrising, request); err != nil {
|
||||
t.Fatalf("same TCP/UDP host port rejected: %v", err)
|
||||
}
|
||||
request.HostPorts["query"] = 45231
|
||||
if _, err := instance.BuildPreview(vrising, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.HostPorts["game"] = 0
|
||||
if _, err := instance.BuildPreview(vrising, request); err == nil {
|
||||
t.Fatal("missing published host port unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package instance
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func currentUIDGID() (uint32, uint32, error) {
|
||||
return uint32(unix.Getuid()), uint32(unix.Getgid()), nil
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package instance
|
||||
|
||||
// Windows is a development/test host only; Linux deployment resolves the real process identity.
|
||||
func currentUIDGID() (uint32, uint32, error) {
|
||||
return 1000, 1000, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
// RuntimeUserEnvironment maps the global managed identity through the
|
||||
// template-declared environment contract. It deliberately does not know any
|
||||
// game-specific variable names.
|
||||
func RuntimeUserEnvironment(template catalog.Template, identity RuntimeIdentity) (map[string]string, error) {
|
||||
result := map[string]string{}
|
||||
runtimeUser := template.Container.RuntimeUser
|
||||
if runtimeUser.Mode == "" || runtimeUser.Mode == "docker" {
|
||||
return result, nil
|
||||
}
|
||||
if runtimeUser.Mode != "environment" || template.Container.UserMode != DockerUserImage {
|
||||
return nil, fmt.Errorf("invalid runtime user policy")
|
||||
}
|
||||
if runtimeUser.UIDEnv == "" || runtimeUser.GIDEnv == "" || runtimeUser.UIDEnv == runtimeUser.GIDEnv {
|
||||
return nil, fmt.Errorf("invalid runtime user environment mapping")
|
||||
}
|
||||
result[runtimeUser.UIDEnv] = strconv.FormatUint(uint64(identity.UID), 10)
|
||||
result[runtimeUser.GIDEnv] = strconv.FormatUint(uint64(identity.GID), 10)
|
||||
return result, nil
|
||||
}
|
||||
+144
-15
@@ -13,6 +13,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -50,6 +51,12 @@ type Binding struct {
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
// TCPRequest is a single, bounded exchange with the template-declared TCP
|
||||
// integration endpoint. The destination is never supplied by a module.
|
||||
type TCPRequest struct {
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
@@ -69,6 +76,7 @@ type Runtime struct {
|
||||
limits Limits
|
||||
binding Binding
|
||||
client *http.Client
|
||||
dialContext func(context.Context, string, string) (net.Conn, error)
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
@@ -76,14 +84,20 @@ type Runtime struct {
|
||||
}
|
||||
|
||||
func New(ctx context.Context, wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding) (*Runtime, error) {
|
||||
transport, err := pinnedTransport(ctx, binding)
|
||||
transport, dialContext, err := pinnedTransport(ctx, binding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newWithTransport(wasm, checksum, capabilities, limits, binding, transport)
|
||||
return newWithTransportAndDial(wasm, checksum, capabilities, limits, binding, transport, dialContext)
|
||||
}
|
||||
|
||||
func newWithTransport(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper) (*Runtime, error) {
|
||||
return newWithTransportAndDial(wasm, checksum, capabilities, limits, binding, transport, func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, errors.New("TCP exchange is unavailable in this test runtime")
|
||||
})
|
||||
}
|
||||
|
||||
func newWithTransportAndDial(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper, dialContext func(context.Context, string, string) (net.Conn, error)) (*Runtime, error) {
|
||||
if len(wasm) == 0 || limits.MemoryMB == 0 || limits.MemoryMB > 256 || limits.Timeout <= 0 || limits.MaxResponseBytes < 1 || limits.MaxResponseBytes > 8<<20 || limits.MaxConcurrentCall < 1 || limits.MaxConcurrentCall > 16 {
|
||||
return nil, errors.New("invalid module runtime configuration")
|
||||
}
|
||||
@@ -91,7 +105,7 @@ func newWithTransport(wasm []byte, checksum string, capabilities []string, limit
|
||||
if hex.EncodeToString(digest[:]) != checksum {
|
||||
return nil, errors.New("module checksum mismatch")
|
||||
}
|
||||
if binding.InstanceID == "" || binding.ContainerPort < 1 || binding.ContainerPort > 65535 || transport == nil {
|
||||
if binding.InstanceID == "" || binding.ContainerPort < 1 || binding.ContainerPort > 65535 || transport == nil || dialContext == nil {
|
||||
return nil, errors.New("invalid instance API binding")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
@@ -101,35 +115,36 @@ func newWithTransport(wasm []byte, checksum string, capabilities []string, limit
|
||||
}
|
||||
seen[capability] = true
|
||||
}
|
||||
r := &Runtime{wasm: append([]byte(nil), wasm...), capabilities: append([]string(nil), capabilities...), limits: limits, binding: binding, sem: make(chan struct{}, limits.MaxConcurrentCall)}
|
||||
r := &Runtime{wasm: append([]byte(nil), wasm...), capabilities: append([]string(nil), capabilities...), limits: limits, binding: binding, sem: make(chan struct{}, limits.MaxConcurrentCall), dialContext: dialContext}
|
||||
r.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func pinnedTransport(ctx context.Context, binding Binding) (http.RoundTripper, error) {
|
||||
func pinnedTransport(ctx context.Context, binding Binding) (http.RoundTripper, func(context.Context, string, string) (net.Conn, error), error) {
|
||||
hostname := "dogama-" + strings.ToLower(binding.InstanceID)
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname)
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return nil, errors.New("resolve bound instance API")
|
||||
return nil, nil, errors.New("resolve bound instance API")
|
||||
}
|
||||
ip := addresses[0].IP
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
|
||||
return nil, errors.New("unsafe instance API address")
|
||||
return nil, nil, errors.New("unsafe instance API address")
|
||||
}
|
||||
pinned := net.JoinHostPort(ip.String(), fmt.Sprint(binding.ContainerPort))
|
||||
dialer := &net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second}
|
||||
dialContext := func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
return nil, errors.New("unsupported instance API network")
|
||||
}
|
||||
return dialer.DialContext(ctx, "tcp", pinned)
|
||||
}
|
||||
return &http.Transport{
|
||||
DisableCompression: true,
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
return nil, errors.New("unsupported instance API network")
|
||||
}
|
||||
return dialer.DialContext(ctx, "tcp", pinned)
|
||||
},
|
||||
}, nil
|
||||
DialContext: dialContext,
|
||||
}, dialContext, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Call(ctx context.Context, operation string, request any, response any) error {
|
||||
@@ -168,6 +183,33 @@ func (r *Runtime) Call(ctx context.Context, operation string, request any, respo
|
||||
return nil
|
||||
}
|
||||
|
||||
// CallData unwraps the normalized module envelope for typed service callers.
|
||||
// Call remains available for diagnostics and callers that need the envelope.
|
||||
func (r *Runtime) CallData(ctx context.Context, operation string, request any, response any) error {
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := r.Call(ctx, operation, request, &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if !envelope.OK {
|
||||
if envelope.Error != nil && envelope.Error.Message != "" {
|
||||
return errors.New(envelope.Error.Message)
|
||||
}
|
||||
return errors.New("module operation failed")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(envelope.Data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(response); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
return errors.New("invalid module response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) allowedOperation(operation string) bool {
|
||||
if operation == "initialize" || operation == "test_connection" || operation == "get_server_status" {
|
||||
return true
|
||||
@@ -184,9 +226,13 @@ type callState struct{ hostCalls int }
|
||||
type stateKey struct{}
|
||||
|
||||
func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([]byte, error) {
|
||||
return r.invokeWithConfig(ctx, operation, input, wazero.NewRuntimeConfigInterpreter())
|
||||
}
|
||||
|
||||
func (r *Runtime) invokeWithConfig(ctx context.Context, operation string, input []byte, baseConfig wazero.RuntimeConfig) ([]byte, error) {
|
||||
ctx = context.WithValue(ctx, stateKey{}, &callState{})
|
||||
pages := (r.limits.MemoryMB*1024*1024 + 65535) / 65536
|
||||
config := wazero.NewRuntimeConfigInterpreter().WithMemoryLimitPages(pages).WithCloseOnContextDone(true).WithDebugInfoEnabled(false)
|
||||
config := baseConfig.WithMemoryLimitPages(pages).WithCloseOnContextDone(true).WithDebugInfoEnabled(false)
|
||||
runtime := wazero.NewRuntimeWithConfig(ctx, config)
|
||||
defer func() { _ = runtime.Close(ctx) }()
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
|
||||
@@ -194,6 +240,7 @@ func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([
|
||||
}
|
||||
host := runtime.NewHostModuleBuilder("dogama_host")
|
||||
host.NewFunctionBuilder().WithFunc(r.httpRequest).Export("http_request")
|
||||
host.NewFunctionBuilder().WithFunc(r.tcpExchange).Export("tcp_exchange")
|
||||
host.NewFunctionBuilder().WithFunc(r.getSecret).Export("get_secret")
|
||||
host.NewFunctionBuilder().WithFunc(r.getConfig).Export("get_config")
|
||||
if _, err := host.Instantiate(ctx); err != nil {
|
||||
@@ -234,6 +281,88 @@ func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([
|
||||
return append([]byte(nil), body...), nil
|
||||
}
|
||||
|
||||
// tcpExchange provides no socket handle to the module. It writes one bounded
|
||||
// request to the instance-pinned integration endpoint then returns bytes read
|
||||
// until a short idle period, EOF, or the call deadline. This permits framed
|
||||
// protocols such as Source RCON while keeping every operation bounded.
|
||||
func (r *Runtime) tcpExchange(ctx context.Context, mod api.Module, requestPtr, requestLen, responsePtr, responseCap uint32) int32 {
|
||||
state, _ := ctx.Value(stateKey{}).(*callState)
|
||||
if state == nil || state.hostCalls >= maxHostCallsPerCall || requestLen > maxRequestBytes || responseCap > uint32(r.limits.MaxResponseBytes) {
|
||||
return -1
|
||||
}
|
||||
state.hostCalls++
|
||||
raw, ok := mod.Memory().Read(requestPtr, requestLen)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
var request TCPRequest
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&request) != nil || len(request.Body) == 0 || len(request.Body) > maxRequestBytes {
|
||||
return -2
|
||||
}
|
||||
conn, err := r.dialContext(ctx, "tcp", "")
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || isTimeout(err) {
|
||||
return -4
|
||||
}
|
||||
return -3
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
deadline := time.Now().Add(r.limits.Timeout)
|
||||
if value, exists := ctx.Deadline(); exists && value.Before(deadline) {
|
||||
deadline = value
|
||||
}
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
return -3
|
||||
}
|
||||
if _, err := conn.Write(request.Body); err != nil {
|
||||
if isTimeout(err) {
|
||||
return -4
|
||||
}
|
||||
return -3
|
||||
}
|
||||
result := make([]byte, 0, min(int(responseCap), 4096))
|
||||
buffer := make([]byte, min(4096, int(responseCap)))
|
||||
for {
|
||||
if len(result) > 0 {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
|
||||
}
|
||||
n, readErr := conn.Read(buffer)
|
||||
if n > 0 {
|
||||
if len(result)+n > int(responseCap) {
|
||||
return -5
|
||||
}
|
||||
result = append(result, buffer[:n]...)
|
||||
}
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, io.EOF) || (len(result) > 0 && isTimeout(readErr)) {
|
||||
break
|
||||
}
|
||||
if isTimeout(readErr) {
|
||||
return -4
|
||||
}
|
||||
return -3
|
||||
}
|
||||
}
|
||||
if len(result) == 0 || !mod.Memory().Write(responsePtr, result) {
|
||||
return -5
|
||||
}
|
||||
return int32(len(result))
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var netErr net.Error
|
||||
return errors.Is(err, os.ErrDeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout())
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func validateABI(compiled wazero.CompiledModule, capabilities []string) error {
|
||||
exports := compiled.ExportedFunctions()
|
||||
for _, name := range []string{"dogama_alloc", "initialize", "test_connection", "get_server_status"} {
|
||||
|
||||
@@ -3,16 +3,29 @@ package module
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
func sourceRCONPacket(id, typ uint32, body string) []byte {
|
||||
value := make([]byte, len(body)+14)
|
||||
binary.LittleEndian.PutUint32(value[:4], uint32(len(body)+10))
|
||||
binary.LittleEndian.PutUint32(value[4:8], id)
|
||||
binary.LittleEndian.PutUint32(value[8:12], typ)
|
||||
copy(value[12:], body)
|
||||
return value
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
@@ -29,7 +42,7 @@ func TestValidRelativeAPIPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPalworldAdapterReportsBoundedFailures(t *testing.T) {
|
||||
wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm")
|
||||
wasm, err := os.ReadFile("../../catalog/palworld/module/module.wasm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -75,7 +88,7 @@ func TestPalworldAdapterReportsBoundedFailures(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPalworldAdapterExecutesInSandbox(t *testing.T) {
|
||||
wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm")
|
||||
wasm, err := os.ReadFile("../../catalog/palworld/module/module.wasm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -145,3 +158,112 @@ func TestHTTPRequestPinsInstanceOriginAndDisablesRedirects(t *testing.T) {
|
||||
t.Fatal("bound transport was not used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVRisingAdapterUsesPinnedBoundedTCPRCON(t *testing.T) {
|
||||
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(wasm)
|
||||
var seen []byte
|
||||
dial := func(context.Context, string, string) (net.Conn, error) {
|
||||
client, server := net.Pipe()
|
||||
go func() {
|
||||
defer server.Close()
|
||||
buffer := make([]byte, 4096)
|
||||
n, readErr := server.Read(buffer)
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
seen = append([]byte(nil), buffer[:n]...)
|
||||
_, _ = server.Write(sourceRCONPacket(1, 2, ""))
|
||||
}()
|
||||
return client, nil
|
||||
}
|
||||
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 64, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "not-in-output"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
if err := runtime.Call(context.Background(), "test_connection", struct{}{}, &response); err != nil || !response.OK {
|
||||
t.Fatalf("connection = %#v, %v", response, err)
|
||||
}
|
||||
if len(seen) < 14 || binary.LittleEndian.Uint32(seen[8:12]) != 3 {
|
||||
t.Fatalf("unexpected RCON auth packet: %x", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVRisingAdapterRejectsAuthenticationWithoutLeakingSecret(t *testing.T) {
|
||||
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(wasm)
|
||||
dial := func(context.Context, string, string) (net.Conn, error) {
|
||||
client, server := net.Pipe()
|
||||
go func() {
|
||||
defer server.Close()
|
||||
buffer := make([]byte, 256)
|
||||
_, _ = server.Read(buffer)
|
||||
_, _ = server.Write(sourceRCONPacket(^uint32(0), 2, ""))
|
||||
}()
|
||||
return client, nil
|
||||
}
|
||||
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 1}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "very-secret-value"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := runtime.Call(context.Background(), "test_connection", struct{}{}, &response); err != nil || response.OK || response.Error.Code != "unauthorized" || strings.Contains(response.Error.Message, "very-secret-value") {
|
||||
t.Fatalf("unsafe authentication response: %#v, %v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVRisingAuthSuccessWithBothWazeroEngines(t *testing.T) {
|
||||
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(wasm)
|
||||
dial := func(context.Context, string, string) (net.Conn, error) {
|
||||
client, server := net.Pipe()
|
||||
go func() {
|
||||
defer server.Close()
|
||||
buffer := make([]byte, 256)
|
||||
if _, err := server.Read(buffer); err == nil {
|
||||
_, _ = server.Write(sourceRCONPacket(1, 2, ""))
|
||||
}
|
||||
}()
|
||||
return client, nil
|
||||
}
|
||||
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 1}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "secret"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, engine := range []struct {
|
||||
name string
|
||||
config wazero.RuntimeConfig
|
||||
}{
|
||||
{name: "compiler", config: wazero.NewRuntimeConfigCompiler()},
|
||||
{name: "interpreter", config: wazero.NewRuntimeConfigInterpreter()},
|
||||
} {
|
||||
t.Run(engine.name, func(t *testing.T) {
|
||||
result, err := runtime.invokeWithConfig(context.Background(), "test_connection", []byte("{}"), engine.config)
|
||||
if err != nil || !strings.Contains(string(result), `"connected":true`) {
|
||||
t.Fatalf("auth success result=%s err=%v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,13 @@ import (
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
ID, Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Configured bool
|
||||
ID, Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Configured bool
|
||||
SecretConfigured bool
|
||||
Config map[string]string
|
||||
FormAction string
|
||||
}
|
||||
type Input struct {
|
||||
Name, Type string
|
||||
@@ -101,7 +104,7 @@ func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateConfigShape(in.Type, in.Config); err != nil {
|
||||
if err := validateConfigShape(in.Type, in.Enabled, in.Config); err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
encrypted, err := s.seal(in.Config)
|
||||
@@ -117,7 +120,7 @@ func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, err
|
||||
if err != nil {
|
||||
return Channel{}, fmt.Errorf("save notification channel: %w", err)
|
||||
}
|
||||
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
|
||||
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true, SecretConfigured: hasConfiguredSecret(in.Type, in.Config), Config: safeConfig(in.Type, in.Config), FormAction: "/admin/notification-channels/" + id}, nil
|
||||
}
|
||||
|
||||
// Preferences returns all personal email categories, applying documented
|
||||
@@ -182,7 +185,7 @@ func (s *Service) SetLanguage(ctx context.Context, language string) error {
|
||||
return err
|
||||
}
|
||||
func (s *Service) List(ctx context.Context) ([]Channel, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,encrypted_config FROM notification_channels ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,10 +194,19 @@ func (s *Service) List(ctx context.Context) ([]Channel, error) {
|
||||
for rows.Next() {
|
||||
var c Channel
|
||||
var body string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
|
||||
var encrypted []byte
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &encrypted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &c.Events)
|
||||
config, err := s.open(encrypted)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid encrypted notification channel")
|
||||
}
|
||||
c.Configured = len(config) > 0
|
||||
c.SecretConfigured = hasConfiguredSecret(c.Type, config)
|
||||
c.Config = safeConfig(c.Type, config)
|
||||
c.FormAction = "/admin/notification-channels/" + c.ID
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -300,6 +312,9 @@ func (s *Service) queueForChannel(ctx context.Context, id string, event Event) e
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return errors.New("notification channel is disabled")
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
|
||||
@@ -536,7 +551,10 @@ func validType(v string) bool {
|
||||
func validEvent(v string) bool {
|
||||
return v == "notification.test" || v == "start.completed" || v == "stop.completed" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
|
||||
}
|
||||
func validateConfigShape(typ string, c map[string]string) error {
|
||||
func validateConfigShape(typ string, enabled bool, c map[string]string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if typ == "email" {
|
||||
if c["host"] == "" || c["from"] == "" {
|
||||
return errors.New("email host and from are required")
|
||||
@@ -544,8 +562,12 @@ func validateConfigShape(typ string, c map[string]string) error {
|
||||
if _, err := mail.ParseAddress(c["from"]); err != nil {
|
||||
return errors.New("invalid sender email")
|
||||
}
|
||||
port, err := strconv.Atoi(c["port"])
|
||||
if c["port"] != "" && (err != nil || port < 1 || port > 65535) {
|
||||
portText := c["port"]
|
||||
if portText == "" {
|
||||
portText = "587"
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return errors.New("invalid smtp port")
|
||||
}
|
||||
mode := c["tls_mode"]
|
||||
@@ -560,6 +582,40 @@ func validateConfigShape(typ string, c map[string]string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasConfiguredSecret(typ string, config map[string]string) bool {
|
||||
for _, key := range secretKeys(typ) {
|
||||
if strings.TrimSpace(config[key]) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func safeConfig(typ string, config map[string]string) map[string]string {
|
||||
keys := []string{}
|
||||
switch typ {
|
||||
case "email":
|
||||
keys = []string{"host", "port", "username", "from", "from_name", "to", "tls_mode"}
|
||||
case "gotify":
|
||||
keys = []string{"url"}
|
||||
}
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value := config[key]; value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if typ == "email" {
|
||||
if _, ok := out["port"]; !ok {
|
||||
out["port"] = "587"
|
||||
}
|
||||
if _, ok := out["tls_mode"]; !ok {
|
||||
out["tls_mode"] = "starttls"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func secretKeys(typ string) []string {
|
||||
switch typ {
|
||||
case "email":
|
||||
|
||||
@@ -190,3 +190,94 @@ func TestPreferencesPersistAndDefaults(t *testing.T) {
|
||||
t.Fatalf("unexpected saved values: %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSettingsPersistWithoutExposingSecrets(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()
|
||||
s, err := notification.New(db, bytes.Repeat([]byte{6}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
smtp, err := s.Upsert(ctx, "", notification.Input{Name: "SMTP", Type: "email", Enabled: false, Events: []string{"start.failed"}, Config: map[string]string{"host": "mail.example.test", "port": "2525", "username": "mailer", "password": "first-secret", "from": "dogama@example.test", "to": "admin@example.test", "tls_mode": "none"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if smtp.Enabled || smtp.Config["host"] != "mail.example.test" || smtp.Config["port"] != "2525" || !smtp.SecretConfigured {
|
||||
t.Fatalf("unexpected saved SMTP view: %#v", smtp)
|
||||
}
|
||||
if _, err := s.Upsert(ctx, smtp.ID, notification.Input{Name: "SMTP", Type: "email", Enabled: true, Events: []string{"start.failed"}, Config: map[string]string{"host": "mail2.example.test", "port": "2526", "from": "dogama@example.test", "tls_mode": "none"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channels, err := s.List(ctx)
|
||||
if err != nil || len(channels) != 1 {
|
||||
t.Fatalf("channels=%#v err=%v", channels, err)
|
||||
}
|
||||
if !channels[0].Enabled || channels[0].Config["host"] != "mail2.example.test" || channels[0].Config["port"] != "2526" || !channels[0].SecretConfigured {
|
||||
t.Fatalf("SMTP edit did not persist safely: %#v", channels[0])
|
||||
}
|
||||
var encrypted []byte
|
||||
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, smtp.ID).Scan(&encrypted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encrypted), "first-secret") {
|
||||
t.Fatal("SMTP secret stored in plaintext")
|
||||
}
|
||||
|
||||
discord, err := s.Upsert(ctx, "", notification.Input{Name: "Discord", Type: "discord", Enabled: false, Config: map[string]string{"url": "https://discord.example.test/webhook/secret"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotify, err := s.Upsert(ctx, "", notification.Input{Name: "Gotify", Type: "gotify", Enabled: false, Config: map[string]string{"url": "https://gotify.example.test", "token": "gotify-secret"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotify.Config["url"] != "https://gotify.example.test" || !gotify.SecretConfigured || discord.Config["url"] != "" || !discord.SecretConfigured {
|
||||
t.Fatalf("unexpected webhook views: discord=%#v gotify=%#v", discord, gotify)
|
||||
}
|
||||
if _, err := s.Upsert(ctx, smtp.ID, notification.Input{Name: "SMTP", Type: "email", Enabled: true, Config: map[string]string{"host": "mail3.example.test", "from": "dogama@example.test", "tls_mode": "none"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channels, err = s.List(ctx)
|
||||
if err != nil || len(channels) != 3 {
|
||||
t.Fatalf("cross-channel list=%#v err=%v", channels, err)
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if channel.ID == gotify.ID && (channel.Config["url"] != "https://gotify.example.test" || !channel.SecretConfigured) {
|
||||
t.Fatalf("Gotify changed while editing SMTP: %#v", channel)
|
||||
}
|
||||
if channel.ID == discord.ID && !channel.SecretConfigured {
|
||||
t.Fatalf("Discord changed while editing SMTP: %#v", channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledChannelDoesNotQueueTest(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()
|
||||
s, _ := notification.New(db, bytes.Repeat([]byte{2}, 32))
|
||||
if _, err := s.Upsert(ctx, "", notification.Input{Name: "invalid SMTP", Type: "email", Enabled: true, Config: map[string]string{"host": "mail.example.test", "port": "not-a-port"}}); err == nil {
|
||||
t.Fatal("enabled SMTP accepted invalid configuration")
|
||||
}
|
||||
channel, err := s.Upsert(ctx, "", notification.Input{Name: "SMTP", Type: "email", Enabled: false, Config: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Test(ctx, channel.ID); err == nil {
|
||||
t.Fatal("disabled channel accepted a test")
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM notification_deliveries`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("disabled test queued %d deliveries", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,14 @@ func (r *Repository) Replace(ctx context.Context, snapshots []catalog.Snapshot)
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
ids := make([]string, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
moduleBundle, marshalErr := json.Marshal(snapshot.ModuleFiles)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("encode template module bundle: %w", marshalErr)
|
||||
}
|
||||
assetBundle, marshalErr := json.Marshal(snapshot.AssetFiles)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("encode template asset bundle: %w", marshalErr)
|
||||
}
|
||||
ids = append(ids, snapshot.Template.ID)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, available, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
@@ -130,9 +138,9 @@ func (r *Repository) Replace(ctx context.Context, snapshots []catalog.Snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert catalog template: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, image, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(template_id, version) DO UPDATE SET schema_version=excluded.schema_version, canonical_yaml=excluded.canonical_yaml, digest=excluded.digest, game_id=excluded.game_id, game_name=excluded.game_name, description=excluded.description, image=excluded.image`, snapshot.Template.ID, snapshot.Template.Version, snapshot.Template.SchemaVersion, snapshot.CanonicalYAML, snapshot.Digest, snapshot.Template.Game.ID, snapshot.Template.Game.Name, snapshot.Template.Game.Description, snapshot.Template.Game.Artwork.Image, now)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, image, asset_bundle, module_bundle, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(template_id, version) DO UPDATE SET schema_version=excluded.schema_version, canonical_yaml=excluded.canonical_yaml, digest=excluded.digest, game_id=excluded.game_id, game_name=excluded.game_name, description=excluded.description, image=excluded.image, asset_bundle=excluded.asset_bundle, module_bundle=excluded.module_bundle`, snapshot.Template.ID, snapshot.Template.Version, snapshot.Template.SchemaVersion, snapshot.CanonicalYAML, snapshot.Digest, snapshot.Template.Game.ID, snapshot.Template.Game.Name, snapshot.Template.Game.Description, snapshot.Template.Game.Artwork.Image, assetBundle, moduleBundle, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert template snapshot: %w", err)
|
||||
}
|
||||
@@ -177,8 +185,9 @@ func (r *Repository) List(ctx context.Context) ([]catalog.Summary, error) {
|
||||
|
||||
func (r *Repository) Get(ctx context.Context, id, version string) (catalog.Snapshot, error) {
|
||||
var canonical, digest, origin string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT v.canonical_yaml, v.digest, t.origin FROM template_versions v JOIN templates t ON t.id=v.template_id
|
||||
WHERE v.template_id=? AND v.version=?`, id, version).Scan(&canonical, &digest, &origin)
|
||||
var assetBundle, moduleBundle []byte
|
||||
err := r.db.QueryRowContext(ctx, `SELECT v.canonical_yaml, v.digest, t.origin, v.asset_bundle, v.module_bundle FROM template_versions v JOIN templates t ON t.id=v.template_id
|
||||
WHERE v.template_id=? AND v.version=?`, id, version).Scan(&canonical, &digest, &origin, &assetBundle, &moduleBundle)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return catalog.Snapshot{}, catalog.ErrTemplateNotFound
|
||||
}
|
||||
@@ -189,7 +198,15 @@ func (r *Repository) Get(ctx context.Context, id, version string) (catalog.Snaps
|
||||
if err := json.Unmarshal([]byte(canonical), &template); err != nil {
|
||||
return catalog.Snapshot{}, fmt.Errorf("decode stored template snapshot: %w", err)
|
||||
}
|
||||
return catalog.Snapshot{Template: template, CanonicalYAML: canonical, Digest: digest, Origin: origin}, nil
|
||||
var moduleFiles map[string][]byte
|
||||
if len(moduleBundle) != 0 && json.Unmarshal(moduleBundle, &moduleFiles) != nil {
|
||||
return catalog.Snapshot{}, errors.New("decode stored template module bundle")
|
||||
}
|
||||
var assetFiles map[string][]byte
|
||||
if len(assetBundle) != 0 && json.Unmarshal(assetBundle, &assetFiles) != nil {
|
||||
return catalog.Snapshot{}, errors.New("decode stored template asset bundle")
|
||||
}
|
||||
return catalog.Snapshot{Template: template, CanonicalYAML: canonical, Digest: digest, Origin: origin, AssetFiles: assetFiles, ModuleFiles: moduleFiles}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) error {
|
||||
|
||||
@@ -80,6 +80,14 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
|
||||
if err != nil || loaded.Digest != palworld.Digest {
|
||||
t.Fatalf("loaded snapshot = %#v, error = %v", loaded, err)
|
||||
}
|
||||
if len(loaded.ModuleFiles) == 0 || loaded.Template.Module == nil || loaded.ModuleFiles[loaded.Template.Module.Path] == nil {
|
||||
t.Fatalf("template-local module bundle was not retained: %#v", loaded.Template.Module)
|
||||
}
|
||||
for _, field := range []string{"logo", "image", "poster"} {
|
||||
if len(loaded.AssetFiles[field]) == 0 {
|
||||
t.Fatalf("template artwork bundle missing %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
tampered := palworld
|
||||
tampered.Digest = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
@@ -118,6 +126,10 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
|
||||
if state != "draft" || digest != palworld.Digest {
|
||||
t.Fatalf("draft state=%q digest=%q", state, digest)
|
||||
}
|
||||
stored, err := repository.GetInstance(ctx, "opaque-instance-id")
|
||||
if err != nil || len(stored.Preview.Ports) == 0 || stored.Preview.Ports[0].HostPort != 8211 {
|
||||
t.Fatalf("stored host port bindings = %#v error=%v", stored.Preview.Ports, err)
|
||||
}
|
||||
|
||||
if _, err := repository.BeginOperation(
|
||||
ctx,
|
||||
|
||||
@@ -12,6 +12,53 @@ import (
|
||||
)
|
||||
|
||||
const globalLabelsKey = "game_container_labels"
|
||||
const gameContainerUIDKey = "game_container_uid"
|
||||
const gameContainerGIDKey = "game_container_gid"
|
||||
|
||||
func (r *Repository) GetGameContainerRuntimeIdentity(ctx context.Context) (instance.RuntimeIdentity, error) {
|
||||
identity := instance.RuntimeIdentity{UID: instance.DefaultGameContainerUID, GID: instance.DefaultGameContainerGID}
|
||||
for _, setting := range []struct {
|
||||
key string
|
||||
value *uint32
|
||||
}{{gameContainerUIDKey, &identity.UID}, {gameContainerGIDKey, &identity.GID}} {
|
||||
var body string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key=?`, setting.key).Scan(&body)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return instance.RuntimeIdentity{}, fmt.Errorf("load game-container runtime identity: %w", err)
|
||||
}
|
||||
var value uint64
|
||||
if err := json.Unmarshal([]byte(body), &value); err != nil || value > ^uint64(0)>>32 {
|
||||
return instance.RuntimeIdentity{}, errors.New("stored game-container runtime identity is invalid")
|
||||
}
|
||||
*setting.value = uint32(value)
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetGameContainerRuntimeIdentity(ctx context.Context, identity instance.RuntimeIdentity) error {
|
||||
if err := identity.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, setting := range []struct {
|
||||
key string
|
||||
value uint32
|
||||
}{{gameContainerUIDKey, identity.UID}, {gameContainerGIDKey, identity.GID}} {
|
||||
body := string(mustJSON(setting.value))
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at`, setting.key, body, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) GetGlobalLabels(ctx context.Context) (map[string]string, error) {
|
||||
var body string
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestGameContainerRuntimeIdentityDefaultsAndPersists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dogama.db")
|
||||
db, err := sqlite.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := sqlite.NewRepository(db)
|
||||
identity, err := repository.GetGameContainerRuntimeIdentity(ctx)
|
||||
if err != nil || identity != (instance.RuntimeIdentity{UID: 1000, GID: 1000}) {
|
||||
t.Fatalf("defaults = %#v error=%v", identity, err)
|
||||
}
|
||||
if err := repository.SetGameContainerRuntimeIdentity(ctx, instance.RuntimeIdentity{UID: 1234, GID: 5678}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.Close()
|
||||
db, err = sqlite.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
identity, err = sqlite.NewRepository(db).GetGameContainerRuntimeIdentity(ctx)
|
||||
if err != nil || identity != (instance.RuntimeIdentity{UID: 1234, GID: 5678}) {
|
||||
t.Fatalf("persisted = %#v error=%v", identity, err)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ CREATE TABLE users (
|
||||
global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')),
|
||||
disabled_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')));
|
||||
, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')),
|
||||
avatar_path TEXT NOT NULL DEFAULT '',
|
||||
avatar_content_type TEXT NOT NULL DEFAULT '' CHECK (avatar_content_type IN ('', 'image/png')));
|
||||
CREATE TABLE sessions (
|
||||
id_hash BLOB PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -53,6 +55,8 @@ CREATE TABLE template_versions (
|
||||
game_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
asset_bundle BLOB,
|
||||
module_bundle BLOB,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (template_id, version),
|
||||
UNIQUE (digest)
|
||||
@@ -235,7 +239,7 @@ CREATE TABLE notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord', 'gotify')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
encrypted_config BLOB NOT NULL,
|
||||
event_filter_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -284,3 +288,7 @@ CREATE INDEX audit_events_action_time_idx ON audit_events(action, occurred_at DE
|
||||
INSERT INTO system_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('game_container_uid', '1000', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('game_container_gid', '1000', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
|
||||
@@ -57,12 +57,78 @@ func initialize(ctx context.Context, db *sql.DB) error {
|
||||
return fmt.Errorf("inspect sqlite schema: %w", err)
|
||||
}
|
||||
if existing == 1 {
|
||||
return ensureDiagnosticSchema(ctx, db)
|
||||
if err := ensureDiagnosticSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureTemplateModuleSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureTemplateAssetSchema(ctx, db)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("initialize sqlite schema: %w", err)
|
||||
}
|
||||
return ensureDiagnosticSchema(ctx, db)
|
||||
if err := ensureDiagnosticSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureTemplateModuleSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureTemplateAssetSchema(ctx, db)
|
||||
}
|
||||
|
||||
func ensureTemplateAssetSchema(ctx context.Context, db *sql.DB) error {
|
||||
rows, err := db.QueryContext(ctx, "PRAGMA table_info(template_versions)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect template asset schema: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid, notNull, primaryKey int
|
||||
var name, typ string
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "asset_bundle" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "ALTER TABLE template_versions ADD COLUMN asset_bundle BLOB"); err != nil {
|
||||
return fmt.Errorf("migrate template asset schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTemplateModuleSchema preserves the complete module bundle with an
|
||||
// immutable template snapshot for stores created before template-local modules.
|
||||
func ensureTemplateModuleSchema(ctx context.Context, db *sql.DB) error {
|
||||
rows, err := db.QueryContext(ctx, "PRAGMA table_info(template_versions)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect template module schema: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid, notNull, primaryKey int
|
||||
var name, typ string
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "module_bundle" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "ALTER TABLE template_versions ADD COLUMN module_bundle BLOB"); err != nil {
|
||||
return fmt.Errorf("migrate template module schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// This additive migration is safe for existing V1 databases and keeps the
|
||||
|
||||
@@ -20,7 +20,7 @@ func (s *server) accountPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return
|
||||
}
|
||||
data := pageData{Title: "Settings", Language: s.language(r, user.Language), Languages: supportedLanguageOptions(), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "account"}
|
||||
data := pageData{Title: localized(s.language(r, user.Language), "account.title"), Language: s.language(r, user.Language), Languages: supportedLanguageOptions(), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "account"}
|
||||
if s.notifications != nil {
|
||||
data.NotificationPreferences, _ = s.notifications.Preferences(r.Context(), user.ID)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,18 @@ import (
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
)
|
||||
|
||||
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
|
||||
func requireAdmin(user auth.User) error {
|
||||
if user.ID == "" || user.Disabled || user.Role != "admin" {
|
||||
return authorization.ErrDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) requireAdminMutation(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
|
||||
var user auth.User
|
||||
var ok bool
|
||||
if api {
|
||||
@@ -33,14 +41,6 @@ func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api
|
||||
}
|
||||
return auth.User{}, false
|
||||
}
|
||||
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
|
||||
if api {
|
||||
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
|
||||
} else {
|
||||
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
|
||||
}
|
||||
return auth.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
|
||||
@@ -79,7 +79,7 @@ func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
actor, ok := s.requireAdminMutation(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
actor, ok := s.requireAdminMutation(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.notificationUpsert(w, r, r.PathValue("id"))
|
||||
}
|
||||
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
actor, ok := s.requireAdminMutation(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id s
|
||||
s.apiJSON(w, status, value)
|
||||
}
|
||||
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
actor, ok := s.requireAdminMutation(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
actor, ok := s.requireAdminMutation(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -190,12 +190,12 @@ func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "from_name": r.FormValue("from_name"), "to": r.FormValue("to"), "tls_mode": r.FormValue("tls_mode"), "token": r.FormValue("gotify_token")}
|
||||
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: r.FormValue("enabled") == "on", Events: strings.Fields(r.FormValue("events")), Config: config})
|
||||
value, err := s.notifications.Upsert(r.Context(), r.FormValue("id"), notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: r.FormValue("enabled") == "on", Events: strings.Fields(r.FormValue("events")), Config: config})
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Invalid notification channel.")
|
||||
return
|
||||
@@ -204,7 +204,7 @@ func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationLanguageForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func (s *server) notificationLanguageForm(w http.ResponseWriter, r *http.Request
|
||||
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request)
|
||||
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/administration#audit", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
)
|
||||
|
||||
const maxAvatarBytes = 2 << 20
|
||||
|
||||
func userInitials(user auth.User) string {
|
||||
value := strings.TrimSpace(user.Username)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(user.Email)
|
||||
}
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool { return unicode.IsSpace(r) || r == '-' || r == '_' })
|
||||
if len(parts) >= 2 {
|
||||
runes := []rune(strings.ToUpper(string([]rune(parts[0])[0]) + string([]rune(parts[1])[0])))
|
||||
return string(runes[:minInt(2, len(runes))])
|
||||
}
|
||||
runes := []rune(strings.ToUpper(value))
|
||||
if len(runes) > 2 {
|
||||
runes = runes[:2]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *server) accountAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.currentUser(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.AvatarPath == "" || user.AvatarContentType != "image/png" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path, ok := s.avatarPath(user.AvatarPath)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (s *server) accountAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.avatarFormUser(w, r, maxAvatarBytes+maxFormBytes)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("avatar")
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusUnprocessableEntity, localized(s.language(r, user.Language), "account.avatar_invalid"))
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(file, maxAvatarBytes+1))
|
||||
if err != nil || len(body) > maxAvatarBytes {
|
||||
s.problem(w, http.StatusRequestEntityTooLarge, localized(s.language(r, user.Language), "account.avatar_too_large"))
|
||||
return
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(body))
|
||||
if err != nil || (format != "png" && format != "jpeg") || config.Width < 1 || config.Height < 1 || config.Width > 4096 || config.Height > 4096 || int64(config.Width)*int64(config.Height) > 16*1024*1024 {
|
||||
s.problem(w, http.StatusUnprocessableEntity, localized(s.language(r, user.Language), "account.avatar_invalid"))
|
||||
return
|
||||
}
|
||||
source, _, err := image.Decode(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusUnprocessableEntity, localized(s.language(r, user.Language), "account.avatar_invalid"))
|
||||
return
|
||||
}
|
||||
imageBody := resizeAvatar(source, 256)
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, imageBody); err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(s.avatarRoot, 0o700); err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
name := "avatar-" + randomToken() + ".png"
|
||||
path := filepath.Join(s.avatarRoot, name)
|
||||
temporary, err := os.CreateTemp(s.avatarRoot, ".avatar-*")
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
_ = temporary.Close()
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
if _, err := temporary.Write(encoded.Bytes()); err != nil || temporary.Close() != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
previous, err := s.auth.SetAvatar(r.Context(), user.ID, name, "image/png")
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
s.removeAvatar(previous)
|
||||
http.Redirect(w, r, "/account#profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) accountAvatarDelete(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.accountForm(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current, err := s.auth.ClearAvatar(r.Context(), user)
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
s.removeAvatar(current)
|
||||
http.Redirect(w, r, "/account#profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) avatarFormUser(w http.ResponseWriter, r *http.Request, limit int64) (auth.User, bool) {
|
||||
user, err := s.currentUser(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return auth.User{}, false
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
if err := r.ParseMultipartForm(maxAvatarBytes); err != nil {
|
||||
s.problem(w, http.StatusRequestEntityTooLarge, localized(s.language(r, user.Language), "account.avatar_too_large"))
|
||||
return auth.User{}, false
|
||||
}
|
||||
session, err := r.Cookie(sessionCookie)
|
||||
if err != nil || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return auth.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func resizeAvatar(source image.Image, max int) image.Image {
|
||||
width, height := source.Bounds().Dx(), source.Bounds().Dy()
|
||||
if width <= max && height <= max {
|
||||
return source
|
||||
}
|
||||
scale := float64(max) / float64(width)
|
||||
if height > width {
|
||||
scale = float64(max) / float64(height)
|
||||
}
|
||||
newWidth, newHeight := maxInt(1, int(float64(width)*scale)), maxInt(1, int(float64(height)*scale))
|
||||
destination := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
|
||||
for y := 0; y < newHeight; y++ {
|
||||
for x := 0; x < newWidth; x++ {
|
||||
sx := source.Bounds().Min.X + x*width/newWidth
|
||||
sy := source.Bounds().Min.Y + y*height/newHeight
|
||||
destination.Set(x, y, source.At(sx, sy))
|
||||
}
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *server) avatarPath(name string) (string, bool) {
|
||||
if name == "" || filepath.Base(name) != name || !strings.HasSuffix(name, ".png") || s.avatarRoot == "" {
|
||||
return "", false
|
||||
}
|
||||
path := filepath.Join(s.avatarRoot, name)
|
||||
return path, filepath.Dir(path) == filepath.Clean(s.avatarRoot)
|
||||
}
|
||||
|
||||
func (s *server) removeAvatar(name string) {
|
||||
if path, ok := s.avatarPath(name); ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestAccountAvatarLifecycleAndOwnership(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
authService := auth.New(db)
|
||||
root := t.TempDir()
|
||||
handler, err := NewHandlerCompleteWithCatalogAndDeployment(authService, nil, nil, nil, nil, nil, nil, nil, root, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setup := request(t, handler, http.MethodGet, "/setup", nil)
|
||||
setupCSRF := namedCookie(t, setup, csrfCookie)
|
||||
created := formRequest(t, handler, "/setup", url.Values{"csrf_token": {setupCSRF.Value}, "username": {"admin"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, setupCSRF)
|
||||
assertStatus(t, created, http.StatusSeeOther)
|
||||
loginPage := request(t, handler, http.MethodGet, "/login", nil)
|
||||
loginCSRF := namedCookie(t, loginPage, csrfCookie)
|
||||
login := formRequest(t, handler, "/login", url.Values{"csrf_token": {loginCSRF.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, loginCSRF)
|
||||
session := namedCookie(t, login, sessionCookie)
|
||||
csrf := namedCookie(t, login, csrfCookie)
|
||||
cookies := []*http.Cookie{session, csrf}
|
||||
|
||||
account := request(t, handler, http.MethodGet, "/account", cookies)
|
||||
assertStatus(t, account, http.StatusOK)
|
||||
if !strings.Contains(account.Body.String(), "admin@example.test") || strings.Contains(account.Body.String(), "Signed in as") {
|
||||
t.Fatal("account profile does not show the current email without redundant identity text")
|
||||
}
|
||||
if response := uploadAvatar(t, handler, cookies, csrf.Value, "image/png", pngBytes(t)); response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("PNG upload status = %d", response.Code)
|
||||
}
|
||||
avatar := request(t, handler, http.MethodGet, "/account/avatar", cookies)
|
||||
assertStatus(t, avatar, http.StatusOK)
|
||||
if avatar.Header().Get("Content-Type") != "image/png" || len(avatar.Body.Bytes()) == 0 {
|
||||
t.Fatal("stored avatar was not served as PNG")
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(root, ".dogama", "avatars", "*.png"))
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("avatar files after upload = %d, want 1", len(files))
|
||||
}
|
||||
if response := uploadAvatar(t, handler, cookies, csrf.Value, "image/jpeg", jpegBytes(t)); response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("JPEG replacement status = %d", response.Code)
|
||||
}
|
||||
files, _ = filepath.Glob(filepath.Join(root, ".dogama", "avatars", "*.png"))
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("avatar files after replacement = %d, want 1", len(files))
|
||||
}
|
||||
if response := formRequest(t, handler, "/account/avatar/delete", url.Values{"csrf_token": {csrf.Value}}, cookies...); response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("avatar deletion status = %d", response.Code)
|
||||
}
|
||||
if response := request(t, handler, http.MethodGet, "/account/avatar", cookies); response.Code != http.StatusNotFound {
|
||||
t.Fatalf("deleted avatar status = %d, want 404", response.Code)
|
||||
}
|
||||
account = request(t, handler, http.MethodGet, "/account", cookies)
|
||||
if !strings.Contains(account.Body.String(), `<span class="avatar">AD</span>`) {
|
||||
t.Fatal("initial fallback is not visible after avatar deletion")
|
||||
}
|
||||
if response := uploadAvatar(t, handler, cookies, "wrong", "image/png", pngBytes(t)); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("invalid avatar CSRF status = %d", response.Code)
|
||||
}
|
||||
if response := request(t, handler, http.MethodGet, "/account/avatar", nil); response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("anonymous avatar status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadAvatar(t *testing.T, handler http.Handler, cookies []*http.Cookie, csrf, contentType string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var payload bytes.Buffer
|
||||
form := multipart.NewWriter(&payload)
|
||||
_ = form.WriteField("csrf_token", csrf)
|
||||
part, err := form.CreateFormFile("avatar", "avatar.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = part.Write(body)
|
||||
_ = form.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/account/avatar", &payload)
|
||||
req.Header.Set("Content-Type", form.FormDataContentType())
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, req)
|
||||
return response
|
||||
}
|
||||
|
||||
func pngBytes(t *testing.T) []byte { return encodeImage(t, "png") }
|
||||
func jpegBytes(t *testing.T) []byte { return encodeImage(t, "jpeg") }
|
||||
func encodeImage(t *testing.T, format string) []byte {
|
||||
t.Helper()
|
||||
imageValue := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
||||
for y := 0; y < 8; y++ {
|
||||
for x := 0; x < 8; x++ {
|
||||
imageValue.Set(x, y, color.RGBA{R: 220, G: 40, B: 150, A: 255})
|
||||
}
|
||||
}
|
||||
var body bytes.Buffer
|
||||
if format == "png" {
|
||||
_ = png.Encode(&body, imageValue)
|
||||
} else {
|
||||
_ = jpeg.Encode(&body, imageValue, &jpeg.Options{Quality: 80})
|
||||
}
|
||||
return body.Bytes()
|
||||
}
|
||||
+16
-4
@@ -15,8 +15,9 @@ var messages = map[string]map[string]string{
|
||||
"login.title": "Sign in", "login.heading": "Sign in", "login.introduction": "Manage your game servers from one secure place.", "login.submit": "Sign in", "logout.submit": "Sign out",
|
||||
"dashboard.title": "Dashboard", "dashboard.eyebrow": "Dashboard", "dashboard.heading": "Game servers", "dashboard.introduction": "Overview of all your game server instances.", "dashboard.search": "Search instances", "dashboard.search_placeholder": "Search instance", "dashboard.summary": "Instance summary", "dashboard.total": "Total instances", "dashboard.running": "Running", "dashboard.stopped": "Stopped", "dashboard.updating": "Updating", "dashboard.error": "Error", "dashboard.no_match": "No matching instance", "dashboard.empty_heading": "No instances deployed", "dashboard.empty_copy": "Your game servers will appear here when they are added from the Catalog.", "dashboard.instances_eyebrow": "Servers", "dashboard.instances": "Your instances", "dashboard.activity_eyebrow": "Operations", "dashboard.recent_activity": "Recent activity", "dashboard.no_activity": "No recent activity", "dashboard.system_eyebrow": "Health", "dashboard.system_status": "System status", "dashboard.agent": "Docker agent", "dashboard.database": "Database", "dashboard.storage": "Storage", "dashboard.backups": "Backups", "dashboard.audit_log": "Audit log", "dashboard.online": "Online", "dashboard.offline": "Offline", "dashboard.healthy": "Healthy", "dashboard.unavailable": "Unavailable", "dashboard.last_backup": "Last backup", "dashboard.no_backup": "No backup", "dashboard.retention_days": "days retention", "dashboard.unlimited": "Unlimited retention", "dashboard.by": "by",
|
||||
"catalog.title": "Catalog", "catalog.eyebrow": "Game library", "catalog.heading": "Catalog", "catalog.search": "Search games", "catalog.search_placeholder": "Search a game", "catalog.scan": "Scan", "catalog.found": "templates found", "catalog.valid": "valid", "catalog.invalid": "invalid", "catalog.no_match": "No matching game", "catalog.empty": "No game available", "catalog.empty_admin": "Add templates to /var/lib/dogama/templates, then use Scan.", "catalog.back": "Back to catalog", "catalog.minimum": "Minimum", "catalog.recommended": "Recommended", "catalog.memory": "Memory", "catalog.storage": "Storage", "catalog.other": "Other", "catalog.deploy": "Deploy", "catalog.deploy_unavailable": "Deployment will be available in the next milestone.",
|
||||
"deployment.title": "Deploy", "deployment.eyebrow": "New instance", "deployment.heading": "Configure your server", "deployment.name": "Instance name", "deployment.description": "Instance description", "deployment.parameters": "Game parameters", "deployment.backup": "Import an external save", "deployment.backup_help": "ZIP, TAR, TAR.GZ or TAR.ZST archives are validated before use.", "deployment.go": "Go",
|
||||
"settings.title": "Settings", "settings.eyebrow": "Administration", "settings.introduction": "Configure product services without crowding the server overview.", "settings.tabs": "Settings sections", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Game containers", "settings.web_access": "Web access", "settings.require_https": "Require HTTPS", "settings.canonical_url": "Canonical base URL", "settings.web_help": "Configure HTTPS enforcement and the public origin. Configure and verify your HTTPS reverse proxy before enabling this lock.", "settings.save_web": "Save web access", "settings.canonical_help": "Verify the canonical URL works before saving it.", "settings.https_help": "When enabled, unsafe HTTP requests are refused and safe navigation is redirected to HTTPS.",
|
||||
"deployment.title": "Deploy", "deployment.eyebrow": "New instance", "deployment.heading": "Configure your server", "deployment.name": "Instance name", "deployment.description": "Instance description", "deployment.network": "Network ports", "deployment.network_help": "Host ports publish the template's internal ports. Private ports are not published.", "deployment.host_port": "Host port", "deployment.container_port": "Container port", "deployment.parameters": "Game parameters", "deployment.backup": "Import an external save", "deployment.backup_help": "ZIP, TAR, TAR.GZ or TAR.ZST archives are validated before use.", "deployment.go": "Go",
|
||||
"settings.game_runtime": "Game server execution", "settings.game_runtime_help": "These defaults apply only to game-server containers.", "settings.game_runtime_uid": "Default UID", "settings.game_runtime_gid": "Default GID", "settings.game_runtime_policy_help": "Templates that allow an administered identity use these values. Templates using the image's native user ignore them.", "settings.save_game_runtime": "Save game server identity",
|
||||
"settings.title": "Settings", "settings.eyebrow": "Administration", "settings.introduction": "Configure product services without crowding the server overview.", "settings.tabs": "Settings sections", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Game containers", "settings.web_access": "Web access", "settings.sessions": "Sessions", "settings.sessions_help": "Set the absolute maximum lifetime and optional inactivity timeout for authenticated sessions.", "settings.session_max": "Maximum session lifetime", "settings.session_inactivity": "Inactivity timeout", "settings.session_inactivity_enabled": "Disconnect after a period of inactivity", "settings.days": "days", "settings.hours": "hours", "settings.save_sessions": "Save session policy", "settings.require_https": "Require HTTPS", "settings.canonical_url": "Canonical base URL", "settings.web_help": "Configure HTTPS enforcement and the public origin. Configure and verify your HTTPS reverse proxy before enabling this lock.", "settings.save_web": "Save web access", "settings.canonical_help": "Verify the canonical URL works before saving it.", "settings.https_help": "When enabled, unsafe HTTP requests are refused and safe navigation is redirected to HTTPS.",
|
||||
"settings.channels": "Notification channels", "settings.channels_help": "Secrets remain encrypted and are never displayed after saving.", "settings.no_channels": "No channel configured.", "settings.send_test": "Send test", "settings.delete": "Delete", "settings.add_channel": "Add channel", "settings.name": "Name", "settings.type": "Type", "settings.enabled": "enabled", "settings.disabled": "disabled", "settings.events": "Events (space separated)", "settings.audit_retention": "Audit retention", "settings.audit_help": "Control history size and perform explicit bounded purges.", "settings.view_audit": "View audit events", "settings.retention_days": "Retention days", "settings.maximum_entries": "Maximum entries", "settings.zero_unlimited": "Zero means unlimited and may grow the database indefinitely.", "settings.save_retention": "Save retention", "settings.delete_before": "Delete events before", "settings.confirm_purge": "Confirm bounded audit purge", "settings.purge": "Purge audit events", "settings.container_labels": "Game-container labels", "settings.container_labels_help": "These labels apply only to game-server containers.", "settings.global_labels": "Global labels", "settings.apply": "Application", "settings.next_start": "Apply on next start", "settings.immediate": "Apply immediately", "settings.disconnection": "Immediate application stops and recreates affected containers.", "settings.confirm_disconnection": "I understand the immediate-disconnection warning", "settings.save_labels": "Save game-container labels",
|
||||
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Review significant authentication and mutation events.", "audit.actor": "Actor ID", "audit.instance": "Instance ID", "audit.action": "Action", "audit.outcome": "Outcome", "audit.any": "Any", "audit.allowed": "Allowed", "audit.denied": "Denied", "audit.failed": "Failed", "audit.filter": "Filter audit", "audit.time": "Time", "audit.actor_column": "Actor", "audit.instance_column": "Instance", "audit.empty": "No audit event matches these filters.",
|
||||
"field.username": "Username", "field.password": "Password", "language": "Language", "language.english": "English", "language.french": "French", "language.save": "Save language", "account.signed_in": "Signed in as",
|
||||
@@ -29,8 +30,8 @@ var messages = map[string]map[string]string{
|
||||
"login.title": "Connexion", "login.heading": "Se connecter", "login.introduction": "Gérez vos serveurs de jeux depuis un espace sécurisé.", "login.submit": "Se connecter", "logout.submit": "Se déconnecter",
|
||||
"dashboard.title": "Tableau de bord", "dashboard.eyebrow": "Tableau de bord", "dashboard.heading": "Serveurs de jeux", "dashboard.introduction": "Vue d'ensemble de toutes vos instances de serveurs de jeux.", "dashboard.search": "Rechercher des instances", "dashboard.search_placeholder": "Rechercher une instance", "dashboard.summary": "Résumé des instances", "dashboard.total": "Instances totales", "dashboard.running": "En cours", "dashboard.stopped": "Arrêtées", "dashboard.updating": "Mise à jour", "dashboard.error": "Erreur", "dashboard.no_match": "Aucune instance correspondante", "dashboard.empty_heading": "Aucune instance déployée", "dashboard.empty_copy": "Vos serveurs de jeux apparaîtront ici lorsqu'ils seront ajoutés depuis le Catalogue.", "dashboard.instances_eyebrow": "Serveurs", "dashboard.instances": "Vos instances", "dashboard.activity_eyebrow": "Opérations", "dashboard.recent_activity": "Activité récente", "dashboard.no_activity": "Aucune activité récente", "dashboard.system_eyebrow": "Santé", "dashboard.system_status": "État du système", "dashboard.agent": "Agent Docker", "dashboard.database": "Base de données", "dashboard.storage": "Stockage", "dashboard.backups": "Sauvegardes", "dashboard.audit_log": "Journal d'audit", "dashboard.online": "En ligne", "dashboard.offline": "Hors ligne", "dashboard.healthy": "Saine", "dashboard.unavailable": "Indisponible", "dashboard.last_backup": "Dernière sauvegarde", "dashboard.no_backup": "Aucune sauvegarde", "dashboard.retention_days": "jours de rétention", "dashboard.unlimited": "Rétention illimitée", "dashboard.by": "par",
|
||||
"catalog.title": "Catalogue", "catalog.eyebrow": "Bibliothèque de jeux", "catalog.heading": "Catalogue", "catalog.search": "Rechercher des jeux", "catalog.search_placeholder": "Rechercher un jeu", "catalog.scan": "Scanner", "catalog.found": "templates trouvés", "catalog.valid": "valides", "catalog.invalid": "invalides", "catalog.no_match": "Aucun jeu correspondant", "catalog.empty": "Aucun jeu disponible", "catalog.empty_admin": "Ajoutez des templates dans /var/lib/dogama/templates, puis utilisez Scanner.", "catalog.back": "Retour au catalogue", "catalog.minimum": "Minimum", "catalog.recommended": "Recommandé", "catalog.memory": "Mémoire", "catalog.storage": "Stockage", "catalog.other": "Autre", "catalog.deploy": "Déployer", "catalog.deploy_unavailable": "Le déploiement sera disponible au prochain bloc.",
|
||||
"deployment.title": "Déployer", "deployment.eyebrow": "Nouvelle instance", "deployment.heading": "Configurer votre serveur", "deployment.name": "Nom de l’instance", "deployment.description": "Description de l’instance", "deployment.parameters": "Paramètres du jeu", "deployment.backup": "Importer une sauvegarde externe", "deployment.backup_help": "Les archives ZIP, TAR, TAR.GZ et TAR.ZST sont validées avant utilisation.", "deployment.go": "Go",
|
||||
"settings.title": "Paramètres", "settings.eyebrow": "Administration", "settings.introduction": "Configurez les services du produit sans encombrer la vue des serveurs.", "settings.tabs": "Sections des paramètres", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Conteneurs de jeux", "settings.web_access": "Accès Web", "settings.require_https": "Exiger HTTPS", "settings.canonical_url": "URL de base canonique", "settings.web_help": "Configurez l'obligation HTTPS et l'origine publique. Configurez et vérifiez votre proxy inverse HTTPS avant d'activer ce verrouillage.", "settings.save_web": "Enregistrer l'accès Web", "settings.canonical_help": "Vérifiez que l'URL canonique fonctionne avant de l'enregistrer.", "settings.https_help": "Une fois activé, les requêtes HTTP non sûres sont refusées et les navigations sûres sont redirigées vers HTTPS.",
|
||||
"deployment.title": "Déployer", "deployment.eyebrow": "Nouvelle instance", "deployment.heading": "Configurer votre serveur", "deployment.name": "Nom de l’instance", "deployment.description": "Description de l’instance", "deployment.network": "Ports réseau", "deployment.network_help": "Les ports hôte publient les ports internes du template. Les ports privés ne sont pas publiés.", "deployment.host_port": "Port hôte", "deployment.container_port": "Port du conteneur", "deployment.parameters": "Paramètres du jeu", "deployment.backup": "Importer une sauvegarde externe", "deployment.backup_help": "Les archives ZIP, TAR, TAR.GZ et TAR.ZST sont validées avant utilisation.", "deployment.go": "Go",
|
||||
"settings.title": "Paramètres", "settings.eyebrow": "Administration", "settings.introduction": "Configurez les services du produit sans encombrer la vue des serveurs.", "settings.tabs": "Sections des paramètres", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Conteneurs de jeux", "settings.web_access": "Accès Web", "settings.sessions": "Sessions", "settings.sessions_help": "Définissez la durée maximale et l'expiration optionnelle après inactivité des sessions authentifiées.", "settings.session_max": "Durée maximale d'une session", "settings.session_inactivity": "Expiration après inactivité", "settings.session_inactivity_enabled": "Déconnecter après une période d'inactivité", "settings.days": "jours", "settings.hours": "heures", "settings.save_sessions": "Enregistrer la politique des sessions", "settings.require_https": "Exiger HTTPS", "settings.canonical_url": "URL de base canonique", "settings.web_help": "Configurez l'obligation HTTPS et l'origine publique. Configurez et vérifiez votre proxy inverse HTTPS avant d'activer ce verrouillage.", "settings.save_web": "Enregistrer l'accès Web", "settings.canonical_help": "Vérifiez que l'URL canonique fonctionne avant de l'enregistrer.", "settings.https_help": "Une fois activé, les requêtes HTTP non sûres sont refusées et les navigations sûres sont redirigées vers HTTPS.",
|
||||
"settings.channels": "Canaux de notification", "settings.channels_help": "Les secrets restent chiffrés et ne sont jamais affichés après enregistrement.", "settings.no_channels": "Aucun canal configuré.", "settings.send_test": "Envoyer un test", "settings.delete": "Supprimer", "settings.add_channel": "Ajouter un canal", "settings.name": "Nom", "settings.type": "Type", "settings.enabled": "activé", "settings.disabled": "désactivé", "settings.events": "Événements (séparés par des espaces)", "settings.audit_retention": "Rétention de l'audit", "settings.audit_help": "Contrôlez la taille de l'historique et effectuez des purges limitées explicites.", "settings.view_audit": "Voir les événements d'audit", "settings.retention_days": "Jours de rétention", "settings.maximum_entries": "Nombre maximal d'entrées", "settings.zero_unlimited": "Zéro signifie illimité et peut faire croître la base indéfiniment.", "settings.save_retention": "Enregistrer la rétention", "settings.delete_before": "Supprimer les événements antérieurs au", "settings.confirm_purge": "Confirmer la purge limitée de l'audit", "settings.purge": "Purger les événements d'audit", "settings.container_labels": "Étiquettes des conteneurs de jeux", "settings.container_labels_help": "Ces étiquettes s'appliquent uniquement aux conteneurs de serveurs de jeux.", "settings.global_labels": "Étiquettes globales", "settings.apply": "Application", "settings.next_start": "Appliquer au prochain démarrage", "settings.immediate": "Appliquer immédiatement", "settings.disconnection": "L'application immédiate arrête et recrée les conteneurs concernés.", "settings.confirm_disconnection": "Je comprends l'avertissement de déconnexion immédiate", "settings.save_labels": "Enregistrer les étiquettes des conteneurs",
|
||||
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Consultez les événements importants d'authentification et de modification.", "audit.actor": "ID de l'acteur", "audit.instance": "ID de l'instance", "audit.action": "Action", "audit.outcome": "Résultat", "audit.any": "Tous", "audit.allowed": "Autorisé", "audit.denied": "Refusé", "audit.failed": "Échec", "audit.filter": "Filtrer l'audit", "audit.time": "Heure", "audit.actor_column": "Acteur", "audit.instance_column": "Instance", "audit.empty": "Aucun événement d'audit ne correspond à ces filtres.",
|
||||
"field.username": "Nom d'utilisateur", "field.password": "Mot de passe", "language": "Langue", "language.english": "Anglais", "language.french": "Français", "language.save": "Enregistrer la langue", "account.signed_in": "Connecté en tant que",
|
||||
@@ -74,6 +75,17 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
for language, values := range map[string]map[string]string{
|
||||
"en": {"account.title": "Settings", "account.introduction": "Manage your personal account.", "account.profile": "Profile", "account.security": "Security", "account.language": "Language", "account.email": "Email", "account.save_email": "Save email", "account.avatar": "Avatar", "account.avatar_alt": "User avatar", "account.avatar_help": "PNG or JPEG, up to 2 MiB.", "account.save_avatar": "Save avatar", "account.delete_avatar": "Remove avatar", "account.avatar_invalid": "The avatar must be a valid PNG or JPEG image.", "account.avatar_too_large": "The avatar must be no larger than 2 MiB.", "account.current_password": "Current password", "account.new_password": "New password", "account.confirm_password": "Confirm password", "account.change_password": "Change password", "account.save_language": "Save language"},
|
||||
"fr": {"account.title": "Paramètres", "account.introduction": "Gérez votre compte personnel.", "account.profile": "Profil", "account.security": "Sécurité", "account.language": "Langue", "account.email": "E-mail", "account.save_email": "Enregistrer l’e-mail", "account.avatar": "Avatar", "account.avatar_alt": "Avatar utilisateur", "account.avatar_help": "PNG ou JPEG, 2 Mio maximum.", "account.save_avatar": "Enregistrer l’avatar", "account.delete_avatar": "Supprimer l’avatar", "account.avatar_invalid": "L’avatar doit être une image PNG ou JPEG valide.", "account.avatar_too_large": "L’avatar ne doit pas dépasser 2 Mio.", "account.current_password": "Mot de passe actuel", "account.new_password": "Nouveau mot de passe", "account.confirm_password": "Confirmer le mot de passe", "account.change_password": "Changer le mot de passe", "account.save_language": "Enregistrer la langue"},
|
||||
} {
|
||||
for key, value := range values {
|
||||
messages[language][key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type languageOption struct {
|
||||
Code string
|
||||
Display string
|
||||
|
||||
+206
-30
@@ -22,7 +22,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
@@ -59,6 +58,7 @@ type server struct {
|
||||
notifications *notification.Service
|
||||
catalogScan func(context.Context) (catalog.ScanResult, error)
|
||||
serversRoot string
|
||||
avatarRoot string
|
||||
moduleRuntime moduleRuntime
|
||||
}
|
||||
|
||||
@@ -93,8 +93,12 @@ type pageData struct {
|
||||
Error string
|
||||
User auth.User
|
||||
GlobalLabels string
|
||||
GameContainerUID uint32
|
||||
GameContainerGID uint32
|
||||
SessionPolicy auth.SessionPolicy
|
||||
IsAdmin bool
|
||||
Channels []notification.Channel
|
||||
NotificationForms map[string]notification.Channel
|
||||
NotificationPreferences map[string]bool
|
||||
NotificationLanguage string
|
||||
AuditEvents []auditEventView
|
||||
@@ -151,10 +155,11 @@ type instanceDetailPage struct {
|
||||
}
|
||||
|
||||
type deploymentPage struct {
|
||||
Template *catalog.Template
|
||||
Values map[string]string
|
||||
Error string
|
||||
Success bool
|
||||
Template *catalog.Template
|
||||
Values map[string]string
|
||||
HostPorts map[string]string
|
||||
Error string
|
||||
Success bool
|
||||
}
|
||||
|
||||
type dashboardActivity struct {
|
||||
@@ -243,6 +248,7 @@ func NewHandlerCompleteWithCatalogAndDeployment(authService *auth.Service, repos
|
||||
// only after construction to retain compatibility with API-only constructors.
|
||||
if concrete, ok := h.(*completeHandler); ok {
|
||||
concrete.server.serversRoot = filepath.Clean(serversRoot)
|
||||
concrete.server.avatarRoot = filepath.Join(concrete.server.serversRoot, ".dogama", "avatars")
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
@@ -270,7 +276,7 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
|
||||
}
|
||||
|
||||
func newHandlerServicesWithCatalog(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), logger *slog.Logger) (http.Handler, error) {
|
||||
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
|
||||
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "initials": userInitials, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "divDuration": func(value time.Duration, divisor int64) int64 { return int64(value) / divisor }, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -278,7 +284,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
if locationErr != nil {
|
||||
logger.Warn("invalid audit display timezone; using UTC", "timezone", os.Getenv("TZ"), "event", "audit.timezone.invalid")
|
||||
}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, auditLocation: location, notifications: notificationService, catalogScan: scanner}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, auditLocation: location, notifications: notificationService, catalogScan: scanner, avatarRoot: filepath.Join(os.TempDir(), "dogama-profile-avatars")}
|
||||
if repository != nil {
|
||||
s.permissions = authorization.New(repository)
|
||||
}
|
||||
@@ -286,6 +292,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
if repository != nil {
|
||||
mux.HandleFunc("GET /public/game-icons/{gameID}", s.publicGameIcon)
|
||||
mux.HandleFunc("GET /public/game-artwork/{gameID}", s.publicGameArtwork)
|
||||
mux.HandleFunc("GET /public/template-assets/{id}/{version}/{asset}", s.publicTemplateAsset)
|
||||
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
|
||||
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
|
||||
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
|
||||
@@ -297,6 +304,8 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
mux.HandleFunc("PUT /api/v1/admin/users/{id}", s.userUpdate)
|
||||
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
|
||||
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
|
||||
mux.HandleFunc("GET /api/v1/admin/session-policy", s.sessionPolicyGet)
|
||||
mux.HandleFunc("PUT /api/v1/admin/session-policy", s.sessionPolicyPut)
|
||||
if auditService != nil {
|
||||
mux.HandleFunc("GET /api/v1/admin/audit", s.auditList)
|
||||
mux.HandleFunc("GET /api/v1/admin/audit-policy", s.auditPolicyGet)
|
||||
@@ -353,6 +362,8 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
mux.HandleFunc("GET /static/dogama-logo.png", s.logo)
|
||||
mux.HandleFunc("GET /static/dogama.png", s.brandLogo)
|
||||
mux.HandleFunc("GET /static/dogama-brand-banner.png", s.brandBanner)
|
||||
mux.HandleFunc("GET /static/icons.svg", s.icons)
|
||||
mux.HandleFunc("GET /favicon.ico", s.favicon)
|
||||
mux.HandleFunc("GET /setup", s.setupForm)
|
||||
mux.HandleFunc("POST /setup", s.setupSubmit)
|
||||
mux.HandleFunc("GET /login", s.loginForm)
|
||||
@@ -360,6 +371,8 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
mux.HandleFunc("POST /logout", s.logout)
|
||||
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
|
||||
mux.HandleFunc("POST /admin/web-access", s.webAccessForm)
|
||||
mux.HandleFunc("POST /admin/game-container-runtime", s.gameContainerRuntimeForm)
|
||||
mux.HandleFunc("POST /admin/session-policy", s.sessionPolicyForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels", s.notificationForm)
|
||||
mux.HandleFunc("POST /admin/notification-language", s.notificationLanguageForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
|
||||
@@ -375,7 +388,10 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
mux.HandleFunc("GET /catalog/{id}/deploy", s.deploymentPage)
|
||||
mux.HandleFunc("POST /catalog/{id}/deploy", s.deploymentSubmit)
|
||||
mux.HandleFunc("GET /account", s.accountPage)
|
||||
mux.HandleFunc("GET /account/avatar", s.accountAvatar)
|
||||
mux.HandleFunc("POST /account/notifications", s.accountNotificationsForm)
|
||||
mux.HandleFunc("POST /account/avatar", s.accountAvatarUpload)
|
||||
mux.HandleFunc("POST /account/avatar/delete", s.accountAvatarDelete)
|
||||
mux.HandleFunc("POST /account/email", s.accountEmailForm)
|
||||
mux.HandleFunc("POST /account/password", s.accountPasswordForm)
|
||||
mux.HandleFunc("POST /account/language", s.accountLanguageForm)
|
||||
@@ -557,26 +573,74 @@ func auditAction(method, path string) string {
|
||||
}
|
||||
|
||||
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
var publicVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$`)
|
||||
|
||||
func templateAssetURL(id, version, field string) string {
|
||||
return "/public/template-assets/" + id + "/" + version + "/" + field
|
||||
}
|
||||
|
||||
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
|
||||
s.publicGameAsset(w, r, "palworld/assets/icon.png", "image/png")
|
||||
s.publicGameAsset(w, r, "logo")
|
||||
}
|
||||
|
||||
func (s *server) publicGameArtwork(w http.ResponseWriter, r *http.Request) {
|
||||
s.publicGameAsset(w, r, "palworld/assets/banner.jpg", "image/jpeg")
|
||||
s.publicGameAsset(w, r, "image")
|
||||
}
|
||||
|
||||
func (s *server) publicGameAsset(w http.ResponseWriter, r *http.Request, assetPath, contentType string) {
|
||||
func (s *server) publicGameAsset(w http.ResponseWriter, r *http.Request, field string) {
|
||||
gameID := r.PathValue("gameID")
|
||||
if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" {
|
||||
if !publicGameIDPattern.MatchString(gameID) || s.repository == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := catalogdata.Files.ReadFile(assetPath)
|
||||
summaries, err := s.repository.List(r.Context())
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
if summary.GameID != gameID {
|
||||
continue
|
||||
}
|
||||
snapshot, getErr := s.repository.Get(r.Context(), summary.ID, summary.Version)
|
||||
if getErr != nil {
|
||||
break
|
||||
}
|
||||
body, ok := snapshot.AssetFiles[field]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
serveTemplateAsset(w, body)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *server) publicTemplateAsset(w http.ResponseWriter, r *http.Request) {
|
||||
id, version, field := r.PathValue("id"), r.PathValue("version"), r.PathValue("asset")
|
||||
if !publicGameIDPattern.MatchString(id) || !publicVersionPattern.MatchString(version) || (field != "logo" && field != "image" && field != "poster") || s.repository == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
snapshot, err := s.repository.Get(r.Context(), id, version)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, ok := snapshot.AssetFiles[field]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveTemplateAsset(w, body)
|
||||
}
|
||||
|
||||
func serveTemplateAsset(w http.ResponseWriter, body []byte) {
|
||||
contentType := http.DetectContentType(body)
|
||||
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
|
||||
http.Error(w, "unsupported artwork format", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
@@ -611,7 +675,7 @@ func (s *server) globalLabelsPut(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
if err := requireAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
@@ -664,8 +728,8 @@ func (s *server) globalLabelsForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
|
||||
if err := requireAdmin(actor); err != nil {
|
||||
s.problem(w, http.StatusForbidden, "Administrator access is required.")
|
||||
return
|
||||
}
|
||||
labels, err := instance.ParseLabels(r.FormValue("labels"))
|
||||
@@ -915,6 +979,9 @@ func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiProblem(w, http.StatusInternalServerError, "catalog_unavailable", "The catalog is unavailable.")
|
||||
return
|
||||
}
|
||||
for index := range templates {
|
||||
templates[index].Image = templateAssetURL(templates[index].ID, templates[index].Version, "image")
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, struct {
|
||||
Templates []catalog.Summary `json:"templates"`
|
||||
}{Templates: templates})
|
||||
@@ -1026,7 +1093,7 @@ func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
if err := requireAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1182,12 +1249,20 @@ func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previe
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
}
|
||||
identity := instance.RuntimeIdentity{UID: instance.DefaultGameContainerUID, GID: instance.DefaultGameContainerGID}
|
||||
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
||||
identity, err = configured.GetGameContainerRuntimeIdentity(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusInternalServerError, "settings_unavailable", "The game-container runtime settings are unavailable.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
|
||||
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
|
||||
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
|
||||
BackupRetention: request.BackupRetention, ImportID: request.ImportID,
|
||||
CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag,
|
||||
PublicBaseURL: requestBaseURL(r),
|
||||
PublicBaseURL: requestBaseURL(r), RuntimeIdentity: &identity,
|
||||
})
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
|
||||
@@ -1314,7 +1389,7 @@ func (s *server) importCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
if err := requireAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1371,7 +1446,7 @@ func (s *server) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
if err := requireAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1398,7 +1473,7 @@ func (s *server) userUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
if err := requireAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1608,8 +1683,6 @@ func (s *server) authorizationProblem(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, authorization.ErrDenied):
|
||||
status, code = http.StatusForbidden, "permission_denied"
|
||||
case errors.Is(err, authorization.ErrRecentAuth):
|
||||
status, code = http.StatusForbidden, "reauthentication_required"
|
||||
case errors.Is(err, authorization.ErrInvalidInput):
|
||||
status, code = http.StatusUnprocessableEntity, "invalid_request"
|
||||
case errors.Is(err, authorization.ErrNotFound):
|
||||
@@ -1822,6 +1895,9 @@ func (s *server) catalogDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
snapshot.Template.Game.Artwork.Logo = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "logo")
|
||||
snapshot.Template.Game.Artwork.Image = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "image")
|
||||
snapshot.Template.Game.Artwork.Poster = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "poster")
|
||||
data.CatalogDetail = &snapshot.Template
|
||||
s.render(w, http.StatusOK, "catalog-detail.html", data)
|
||||
return
|
||||
@@ -1840,10 +1916,44 @@ func (s *server) deploymentPage(w http.ResponseWriter, r *http.Request) {
|
||||
values[field.ID] = fmt.Sprint(field.Default)
|
||||
}
|
||||
}
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values}
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values, HostPorts: deploymentHostPortValues(snapshot.Template)}
|
||||
s.render(w, http.StatusOK, "deployment.html", data)
|
||||
}
|
||||
|
||||
func deploymentHostPortValues(template catalog.Template) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, port := range template.Container.Ports {
|
||||
if port.Publish {
|
||||
values[port.ID] = strconv.Itoa(port.ContainerPort)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func parseDeploymentHostPorts(r *http.Request, template catalog.Template) (map[string]int, map[string]string, error) {
|
||||
hostPorts := map[string]int{}
|
||||
displayValues := deploymentHostPortValues(template)
|
||||
used := map[string]string{}
|
||||
for _, port := range template.Container.Ports {
|
||||
if !port.Publish {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(r.FormValue("host_port_" + port.ID))
|
||||
displayValues[port.ID] = value
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 || parsed > 65535 {
|
||||
return nil, displayValues, fmt.Errorf("host port for %s must be an integer between 1 and 65535", port.ID)
|
||||
}
|
||||
key := fmt.Sprintf("%s/%d", port.Protocol, parsed)
|
||||
if previous, exists := used[key]; exists {
|
||||
return nil, displayValues, fmt.Errorf("host port %d/%s is used by both %s and %s", parsed, port.Protocol, previous, port.ID)
|
||||
}
|
||||
used[key] = port.ID
|
||||
hostPorts[port.ID] = parsed
|
||||
}
|
||||
return hostPorts, displayValues, nil
|
||||
}
|
||||
|
||||
func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
data, snapshot, ok := s.deploymentData(w, r)
|
||||
if !ok {
|
||||
@@ -1877,15 +1987,16 @@ func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
values[field.ID] = r.FormValue(name)
|
||||
}
|
||||
}
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values}
|
||||
hostPorts, displayHostPorts, hostPortErr := parseDeploymentHostPorts(r, snapshot.Template)
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values, HostPorts: displayHostPorts}
|
||||
if hostPortErr != nil {
|
||||
data.Deployment.Error = hostPortErr.Error()
|
||||
s.render(w, http.StatusUnprocessableEntity, "deployment.html", data)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("display_name"))
|
||||
slug := instance.Slugify(name)
|
||||
request := instance.PreviewRequest{DisplayName: name, Description: r.FormValue("description"), Slug: slug, HostPorts: map[string]int{}, MountPaths: map[string]string{}, Resources: snapshot.Template.Requirements.Recommended, DataOrigin: "new", BackupRetention: 7, Configuration: values, Secrets: secrets, PublicBaseURL: publicBaseURL(r)}
|
||||
for _, port := range snapshot.Template.Container.Ports {
|
||||
if port.Publish {
|
||||
request.HostPorts[port.ID] = port.ContainerPort
|
||||
}
|
||||
}
|
||||
request := instance.PreviewRequest{DisplayName: name, Description: r.FormValue("description"), Slug: slug, HostPorts: hostPorts, MountPaths: map[string]string{}, Resources: snapshot.Template.Requirements.Recommended, DataOrigin: "new", BackupRetention: 7, Configuration: values, Secrets: secrets, PublicBaseURL: publicBaseURL(r)}
|
||||
for _, mount := range snapshot.Template.Storage.Mounts {
|
||||
request.MountPaths[mount.ID] = filepath.Join(s.serversRoot, slug, mount.ID)
|
||||
}
|
||||
@@ -1911,6 +2022,15 @@ func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, 422, "deployment.html", data)
|
||||
return
|
||||
}
|
||||
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
||||
identity, identityErr := configured.GetGameContainerRuntimeIdentity(r.Context())
|
||||
if identityErr != nil {
|
||||
data.Deployment.Error = "The game-container runtime settings are unavailable."
|
||||
s.render(w, 500, "deployment.html", data)
|
||||
return
|
||||
}
|
||||
request.RuntimeIdentity = &identity
|
||||
}
|
||||
preview, buildErr := instance.BuildPreview(snapshot, request)
|
||||
if buildErr != nil {
|
||||
data.Deployment.Error = "Please correct the deployment settings."
|
||||
@@ -2043,6 +2163,9 @@ func (s *server) deploymentData(w http.ResponseWriter, r *http.Request) (pageDat
|
||||
if summary.ID == r.PathValue("id") {
|
||||
snapshot, err := s.repository.Get(r.Context(), summary.ID, summary.Version)
|
||||
if err == nil {
|
||||
snapshot.Template.Game.Artwork.Logo = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "logo")
|
||||
snapshot.Template.Game.Artwork.Image = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "image")
|
||||
snapshot.Template.Game.Artwork.Poster = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "poster")
|
||||
return data, snapshot, true
|
||||
}
|
||||
break
|
||||
@@ -2102,6 +2225,9 @@ func (s *server) catalogScanForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
data.CatalogScan = &result
|
||||
data.Catalog, _ = s.repository.List(r.Context())
|
||||
for index := range data.Catalog {
|
||||
data.Catalog[index].Image = templateAssetURL(data.Catalog[index].ID, data.Catalog[index].Version, "image")
|
||||
}
|
||||
s.render(w, http.StatusOK, "catalog.html", data)
|
||||
}
|
||||
|
||||
@@ -2127,6 +2253,9 @@ func (s *server) catalogPageData(w http.ResponseWriter, r *http.Request) (pageDa
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return pageData{}, false
|
||||
}
|
||||
for index := range data.Catalog {
|
||||
data.Catalog[index].Image = templateAssetURL(data.Catalog[index].ID, data.Catalog[index].Version, "image")
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
@@ -2198,24 +2327,46 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.repository != nil {
|
||||
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
||||
identity, identityErr := configured.GetGameContainerRuntimeIdentity(r.Context())
|
||||
if identityErr == nil {
|
||||
data.GameContainerUID, data.GameContainerGID = identity.UID, identity.GID
|
||||
}
|
||||
if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
|
||||
data.GlobalLabels = instance.FormatLabels(labels)
|
||||
}
|
||||
}
|
||||
if s.notifications != nil {
|
||||
data.Channels, _ = s.notifications.List(r.Context())
|
||||
data.NotificationForms = map[string]notification.Channel{
|
||||
"email": {Type: "email", Name: "SMTP email", FormAction: "/admin/notification-channels", Config: map[string]string{"port": "587", "tls_mode": "starttls"}},
|
||||
"discord": {Type: "discord", Name: "Discord", FormAction: "/admin/notification-channels"},
|
||||
"gotify": {Type: "gotify", Name: "Gotify", FormAction: "/admin/notification-channels"},
|
||||
}
|
||||
for _, channel := range data.Channels {
|
||||
if _, ok := data.NotificationForms[channel.Type]; ok {
|
||||
data.NotificationForms[channel.Type] = channel
|
||||
}
|
||||
}
|
||||
data.NotificationLanguage, _ = s.notifications.Language(r.Context())
|
||||
}
|
||||
}
|
||||
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 {
|
||||
policy, _ := settings.GetWebAccessPolicy(r.Context())
|
||||
data.RequireHTTPS, data.CanonicalURL = policy.RequireHTTPS, policy.CanonicalURL
|
||||
}
|
||||
data.SessionPolicy, _ = s.auth.SessionPolicy(r.Context())
|
||||
s.render(w, http.StatusOK, "settings.html", data)
|
||||
}
|
||||
|
||||
@@ -2406,6 +2557,31 @@ func (s *server) themeStylesheet(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.Copy(w, body)
|
||||
}
|
||||
|
||||
func (s *server) icons(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := assets.Open("static/icons.svg")
|
||||
if err != nil {
|
||||
http.Error(w, "Not found.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer func() { _ = body.Close() }()
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(w, body)
|
||||
}
|
||||
|
||||
func (s *server) favicon(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := assets.Open("static/favicon.ico")
|
||||
if err != nil {
|
||||
http.Error(w, "Not found.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer func() { _ = body.Close() }()
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(w, body)
|
||||
}
|
||||
|
||||
func (s *server) logo(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := assets.Open("static/dogama-logo.png")
|
||||
if err != nil {
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestInstanceUnbanCapabilityFallback(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("palworld manual fallback validates and audits", func(t *testing.T) {
|
||||
manifest, err := os.ReadFile("../../modules/palworld-rest/manifest.yaml")
|
||||
manifest, err := os.ReadFile("../../catalog/palworld/module/manifest.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -279,6 +279,20 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
|
||||
if artwork.Header().Get("Content-Type") != "image/jpeg" || artwork.Body.Len() < 100000 {
|
||||
t.Fatalf("artwork response: type=%q size=%d", artwork.Header().Get("Content-Type"), artwork.Body.Len())
|
||||
}
|
||||
vrising := request(t, handler, http.MethodGet, "/public/game-artwork/vrising", nil)
|
||||
assertStatus(t, vrising, http.StatusOK)
|
||||
if vrising.Header().Get("Content-Type") != "image/jpeg" || vrising.Body.Len() < 100000 {
|
||||
t.Fatalf("V Rising artwork response: type=%q size=%d", vrising.Header().Get("Content-Type"), vrising.Body.Len())
|
||||
}
|
||||
snapshotAsset := request(t, handler, http.MethodGet, "/public/template-assets/palworld-official/1.1.3/image", nil)
|
||||
assertStatus(t, snapshotAsset, http.StatusOK)
|
||||
if snapshotAsset.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Fatalf("snapshot artwork content type = %q", snapshotAsset.Header().Get("Content-Type"))
|
||||
}
|
||||
unsafeAsset := request(t, handler, http.MethodGet, "/public/template-assets/palworld-official/1.1.3/../image", nil)
|
||||
if unsafeAsset.Code == http.StatusOK {
|
||||
t.Fatal("template asset traversal accepted")
|
||||
}
|
||||
traversal := request(t, handler, http.MethodGet, "/public/game-icons/..%2Fprivate", nil)
|
||||
if traversal.Code == http.StatusOK {
|
||||
t.Fatal("icon traversal accepted")
|
||||
@@ -386,12 +400,34 @@ func TestNotificationAndAuditAdministration(t *testing.T) {
|
||||
settingsBody := settings.Body.String()
|
||||
for _, expected := range []string{
|
||||
"Notification channels", "Web access", "href=\"#audit\"", "href=\"/audit\"",
|
||||
"id=\"notifications\"", "id=\"notification-general\"", "id=\"email\"", "id=\"discord\"", "id=\"gotify\"",
|
||||
"id=\"notifications\"", "id=\"notification-general\"", "id=\"email\"", "id=\"discord\"", "id=\"gotify\"", "id=\"game-runtime\"", "name=\"uid\"", "name=\"gid\"",
|
||||
"Sessions", "name=\"max_lifetime_days\"", "name=\"inactivity_timeout_hours\"", "inactivity_enabled",
|
||||
} {
|
||||
if !strings.Contains(settingsBody, expected) {
|
||||
t.Fatalf("settings UI section %q missing", expected)
|
||||
}
|
||||
}
|
||||
runtimeSave := formRequest(t, handler, "/admin/game-container-runtime", url.Values{"csrf_token": {session.CSRFToken}, "uid": {"1234"}, "gid": {"5678"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
||||
assertStatus(t, runtimeSave, http.StatusSeeOther)
|
||||
identity, err := repository.GetGameContainerRuntimeIdentity(ctx)
|
||||
if err != nil || identity != (instance.RuntimeIdentity{UID: 1234, GID: 5678}) {
|
||||
t.Fatalf("runtime identity = %#v error=%v", identity, err)
|
||||
}
|
||||
invalidRuntime := formRequest(t, handler, "/admin/game-container-runtime", url.Values{"csrf_token": {session.CSRFToken}, "uid": {"1000:1000"}, "gid": {"5678"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
||||
assertStatus(t, invalidRuntime, http.StatusUnprocessableEntity)
|
||||
policySave := formRequest(t, handler, "/admin/session-policy", url.Values{"csrf_token": {session.CSRFToken}, "max_lifetime_days": {"14"}, "inactivity_timeout_hours": {"48"}, "inactivity_enabled": {"on"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
||||
assertStatus(t, policySave, http.StatusSeeOther)
|
||||
policy, err := authService.SessionPolicy(ctx)
|
||||
if err != nil || policy.MaxLifetime != 14*24*time.Hour || policy.InactivityTimeout != 48*time.Hour || !policy.InactivityEnabled {
|
||||
t.Fatalf("session policy = %#v error=%v", policy, err)
|
||||
}
|
||||
settingsReload := request(t, handler, http.MethodGet, "/administration", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
|
||||
assertStatus(t, settingsReload, http.StatusOK)
|
||||
if !strings.Contains(settingsReload.Body.String(), "value=\"14\"") || !strings.Contains(settingsReload.Body.String(), "value=\"48\"") {
|
||||
t.Fatal("saved session policy was not rendered after reload")
|
||||
}
|
||||
forgedPolicy := formRequest(t, handler, "/admin/session-policy", url.Values{"csrf_token": {"forged"}, "max_lifetime_days": {"7"}, "inactivity_timeout_hours": {"24"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
||||
assertStatus(t, forgedPolicy, http.StatusForbidden)
|
||||
notificationLanguage := formRequest(t, handler, "/admin/notification-language", url.Values{"csrf_token": {session.CSRFToken}, "language": {"fr"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
||||
assertStatus(t, notificationLanguage, http.StatusSeeOther)
|
||||
if got := notificationLanguage.Result().Header.Get("Location"); got != "/administration#notifications" {
|
||||
@@ -884,15 +920,15 @@ func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
|
||||
|
||||
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{session, sessionCSRF})
|
||||
assertStatus(t, home, http.StatusOK)
|
||||
if !strings.Contains(home.Body.String(), "Signed in as <strong>admin</strong>") {
|
||||
t.Fatalf("protected page did not identify user: %s", home.Body.String())
|
||||
if !strings.Contains(home.Body.String(), `<span class="avatar">AD</span>`) {
|
||||
t.Fatalf("protected page did not render the user identity: %s", home.Body.String())
|
||||
}
|
||||
if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" || home.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
|
||||
t.Fatal("security headers missing")
|
||||
}
|
||||
account := request(t, handler, http.MethodGet, "/account", []*http.Cookie{session, sessionCSRF})
|
||||
assertStatus(t, account, http.StatusOK)
|
||||
if !strings.Contains(account.Body.String(), `action="/account/email"`) || !strings.Contains(account.Body.String(), `action="/account/password"`) || !strings.Contains(account.Body.String(), `action="/account/language"`) {
|
||||
if !strings.Contains(account.Body.String(), `admin@example.test`) || !strings.Contains(account.Body.String(), `action="/account/email"`) || !strings.Contains(account.Body.String(), `action="/account/password"`) || !strings.Contains(account.Body.String(), `action="/account/language"`) {
|
||||
t.Fatal("account settings forms missing")
|
||||
}
|
||||
badAccountCSRF := formRequest(t, handler, "/account/email", url.Values{"csrf_token": {"wrong"}, "email": {"new@example.test"}}, session, sessionCSRF)
|
||||
@@ -1084,7 +1120,7 @@ func TestCatalogPagesAndAdminScan(t *testing.T) {
|
||||
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
|
||||
page := request(t, handler, http.MethodGet, "/catalog", adminCookies)
|
||||
assertStatus(t, page, http.StatusOK)
|
||||
for _, expected := range []string{"Palworld", palworld.Template.Game.Artwork.Image, "/catalog/palworld-official", "Scan"} {
|
||||
for _, expected := range []string{"Palworld", "/public/template-assets/palworld-official/1.1.3/image", "/catalog/palworld-official", "Scan"} {
|
||||
if !strings.Contains(page.Body.String(), expected) {
|
||||
t.Fatalf("catalog missing %q", expected)
|
||||
}
|
||||
@@ -1158,7 +1194,7 @@ func TestDeploymentFormRequiresAdminAndRendersTemplateFields(t *testing.T) {
|
||||
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
|
||||
page := request(t, handler, http.MethodGet, "/catalog/palworld-official/deploy", adminCookies)
|
||||
assertStatus(t, page, http.StatusOK)
|
||||
for _, expected := range []string{"server_name", "max_players", "admin_password", "DoGaMa Palworld Server"} {
|
||||
for _, expected := range []string{"server_name", "max_players", "admin_password", "host_port_game", "Container port", "DoGaMa Palworld Server"} {
|
||||
if !strings.Contains(page.Body.String(), expected) {
|
||||
t.Fatalf("deployment form missing %q", expected)
|
||||
}
|
||||
@@ -1171,6 +1207,36 @@ func TestDeploymentFormRequiresAdminAndRendersTemplateFields(t *testing.T) {
|
||||
|
||||
func TestDeploymentHTTPAsyncProgressAndRBAC(t *testing.T) { testDeploymentHTTPAsync(t, false) }
|
||||
|
||||
func TestParseDeploymentHostPortsValidatesPublishedBindings(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := snapshots[0].Template
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("host_port_game=45230"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
hostPorts, display, err := parseDeploymentHostPorts(req, template)
|
||||
if err != nil || hostPorts["game"] != 45230 || display["game"] != "45230" {
|
||||
t.Fatalf("host ports=%#v display=%#v error=%v", hostPorts, display, err)
|
||||
}
|
||||
var vrising catalog.Template
|
||||
for _, snapshot := range snapshots {
|
||||
if snapshot.Template.ID == "vrising-didstopia" {
|
||||
vrising = snapshot.Template
|
||||
}
|
||||
}
|
||||
duplicate := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("host_port_game=45230&host_port_query=45230"))
|
||||
duplicate.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if _, _, err := parseDeploymentHostPorts(duplicate, vrising); err == nil {
|
||||
t.Fatal("duplicate UDP host ports unexpectedly accepted")
|
||||
}
|
||||
invalid := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("host_port_game=0"))
|
||||
invalid.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if _, _, err := parseDeploymentHostPorts(invalid, template); err == nil {
|
||||
t.Fatal("invalid host port unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentWorkerPanicFailsOperation(t *testing.T) { testDeploymentHTTPAsync(t, true) }
|
||||
|
||||
func testDeploymentHTTPAsync(t *testing.T, panicWorker bool) {
|
||||
@@ -1213,7 +1279,7 @@ func testDeploymentHTTPAsync(t *testing.T, panicWorker bool) {
|
||||
}
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
for key, value := range map[string]string{"csrf_token": admin.CSRFToken, "display_name": "Async test", "description": "safe", "config_server_name": "Async", "config_server_description": "safe", "config_max_players": "16", "config_admin_password": "top-secret-value", "config_rest_api_enabled": "true", "config_rest_api_port": "8212"} {
|
||||
for key, value := range map[string]string{"csrf_token": admin.CSRFToken, "display_name": "Async test", "description": "safe", "host_port_game": "45230", "config_server_name": "Async", "config_server_description": "safe", "config_max_players": "16", "config_admin_password": "top-secret-value", "config_rest_api_enabled": "true", "config_rest_api_port": "8212"} {
|
||||
_ = form.WriteField(key, value)
|
||||
}
|
||||
_ = form.Close()
|
||||
@@ -1229,6 +1295,10 @@ func testDeploymentHTTPAsync(t *testing.T, panicWorker bool) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &accepted); err != nil || accepted["operation_id"] == "" {
|
||||
t.Fatalf("accepted=%s err=%v", response.Body.String(), err)
|
||||
}
|
||||
stored, err := repository.GetInstance(ctx, accepted["instance_id"])
|
||||
if err != nil || len(stored.Preview.Ports) == 0 || stored.Preview.Ports[0].HostPort != 45230 {
|
||||
t.Fatalf("stored deployment host ports=%#v error=%v", stored.Preview.Ports, err)
|
||||
}
|
||||
// The HTTP response has returned while the worker is deterministically blocked.
|
||||
<-agent.entered
|
||||
progressReq := httptest.NewRequest(http.MethodGet, "/api/v1/operations/"+accepted["operation_id"], nil)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
)
|
||||
|
||||
func (s *server) sessionPolicyGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
policy, err := s.auth.SessionPolicy(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusInternalServerError, "session_policy_unavailable", "The session policy is unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, sessionPolicyJSON(policy))
|
||||
}
|
||||
|
||||
func (s *server) sessionPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdminMutation(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
MaxLifetimeSeconds int64 `json:"max_lifetime_seconds"`
|
||||
InactivitySeconds int64 `json:"inactivity_timeout_seconds"`
|
||||
InactivityEnabled bool `json:"inactivity_enabled"`
|
||||
}
|
||||
if !s.decodeAPIJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
policy, err := policyFromSeconds(input.MaxLifetimeSeconds, input.InactivitySeconds, input.InactivityEnabled)
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_session_policy", err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.auth.SetSessionPolicy(r.Context(), policy); err != nil {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_session_policy", err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "session.policy.update", "allowed", nil)
|
||||
s.apiJSON(w, http.StatusOK, sessionPolicyJSON(policy))
|
||||
}
|
||||
|
||||
func (s *server) sessionPolicyForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
maxDays, maxErr := strconv.Atoi(r.FormValue("max_lifetime_days"))
|
||||
inactivityHours, inactivityErr := strconv.Atoi(r.FormValue("inactivity_timeout_hours"))
|
||||
if maxErr != nil || inactivityErr != nil || maxDays <= 0 || inactivityHours <= 0 {
|
||||
s.problem(w, http.StatusUnprocessableEntity, "Session durations must be positive whole units.")
|
||||
return
|
||||
}
|
||||
policy, err := policyFromSeconds(int64(maxDays)*24*60*60, int64(inactivityHours)*60*60, r.FormValue("inactivity_enabled") == "on")
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.auth.SetSessionPolicy(r.Context(), policy); err != nil {
|
||||
s.problem(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "session.policy.update", "allowed", nil)
|
||||
http.Redirect(w, r, "/administration#sessions", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func policyFromSeconds(maxLifetime, inactivity int64, enabled bool) (auth.SessionPolicy, error) {
|
||||
if maxLifetime <= 0 || inactivity <= 0 {
|
||||
return auth.SessionPolicy{}, auth.SessionPolicy{}.Validate()
|
||||
}
|
||||
policy := auth.SessionPolicy{MaxLifetime: time.Duration(maxLifetime) * time.Second, InactivityTimeout: time.Duration(inactivity) * time.Second, InactivityEnabled: enabled}
|
||||
return policy, policy.Validate()
|
||||
}
|
||||
|
||||
func sessionPolicyJSON(policy auth.SessionPolicy) map[string]any {
|
||||
return map[string]any{
|
||||
"max_lifetime_seconds": int64(policy.MaxLifetime / time.Second),
|
||||
"inactivity_timeout_seconds": int64(policy.InactivityTimeout / time.Second),
|
||||
"inactivity_enabled": policy.InactivityEnabled,
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,16 @@ a{
|
||||
font-weight: 900;
|
||||
text-transform: uppercase
|
||||
}
|
||||
.avatar-image{
|
||||
object-fit: cover;
|
||||
overflow: hidden
|
||||
}
|
||||
.profile-identity{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px
|
||||
}
|
||||
.app-main{
|
||||
min-height: 100vh;
|
||||
margin-left: 250px;
|
||||
@@ -1045,6 +1055,28 @@ button:disabled{
|
||||
align-items: center;
|
||||
gap: 12px
|
||||
}
|
||||
.deployment-layout{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px,.8fr) minmax(0,1.2fr);
|
||||
gap: 22px;
|
||||
max-width: 1120px;
|
||||
margin: auto
|
||||
}
|
||||
.deployment-game img{
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px
|
||||
}
|
||||
.deployment-form{
|
||||
min-width: 0
|
||||
}
|
||||
@media(max-width:700px){
|
||||
.deployment-layout{
|
||||
grid-template-columns: 1fr
|
||||
}
|
||||
}
|
||||
.scan-button,.deploy-band button{
|
||||
border: 1px solid #20d9f388;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
.sidebar nav a: :before,
|
||||
.sidebar nav span.disabled::before {
|
||||
color: #a9b4ca;
|
||||
display: grid;
|
||||
display: grid;
|
||||
flex: 0 0 22px;
|
||||
font-size: 1rem;
|
||||
height: 22px;
|
||||
@@ -91,15 +91,6 @@
|
||||
.sidebar nav a.active::before {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.nav-instances::before {
|
||||
content: "\25A4";
|
||||
}
|
||||
.nav-catalog::before {
|
||||
content: "\2667";
|
||||
}
|
||||
.nav-backups::before {
|
||||
content: "\25A3";
|
||||
}
|
||||
.nav-settings::after {
|
||||
content: "\203A";
|
||||
margin-left: auto;
|
||||
@@ -150,15 +141,6 @@
|
||||
.account .signout-button:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-dashboard::before {
|
||||
content: none;
|
||||
}
|
||||
.nav-audit::before {
|
||||
content: none;
|
||||
}
|
||||
.nav-settings::before {
|
||||
content: none;
|
||||
}
|
||||
.page-heading {
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
@@ -9,11 +9,21 @@ 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) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -26,11 +36,11 @@ 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) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
actor, ok := s.requireAdminMutation(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>
|
||||
Settings · DoGaMa
|
||||
{{.Msg "account.title"}} · DoGaMa
|
||||
</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
@@ -15,79 +15,92 @@
|
||||
<main class="app-main">
|
||||
<div class="page-heading">
|
||||
<h1>
|
||||
Settings
|
||||
{{.Msg "account.title"}}
|
||||
</h1>
|
||||
<p>
|
||||
Personal account preferences.
|
||||
{{.Msg "account.introduction"}}
|
||||
</p>
|
||||
</div>
|
||||
<nav class="tabs">
|
||||
<a href="#profile">
|
||||
Profile
|
||||
{{.Msg "account.profile"}}
|
||||
</a>
|
||||
<a href="#security">
|
||||
Security
|
||||
{{.Msg "account.security"}}
|
||||
</a>
|
||||
<a href="#language">
|
||||
Language
|
||||
{{.Msg "account.language"}}
|
||||
</a>
|
||||
<a href="#notifications">
|
||||
Notifications
|
||||
{{.Msg "settings.notifications"}}
|
||||
</a>
|
||||
</nav>
|
||||
<section class="panel settings-section" id="profile">
|
||||
<h2>
|
||||
Profile
|
||||
{{.Msg "account.profile"}}
|
||||
</h2>
|
||||
<p>
|
||||
Signed in as
|
||||
<strong>
|
||||
{{.User.Username}}
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
<div class="profile-identity">
|
||||
{{if .User.AvatarPath}}
|
||||
<img class="avatar avatar-image" src="/account/avatar" alt="{{.Msg "account.avatar_alt"}}">
|
||||
{{else}}
|
||||
<span class="avatar">{{initials .User}}</span>
|
||||
{{end}}
|
||||
<strong>{{.User.Username}}</strong>
|
||||
</div>
|
||||
<form method="post" action="/account/email">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>
|
||||
Email
|
||||
<input name="email" type="email" value="{{.User.Email}}" required autocomplete="email">
|
||||
{{.Msg "account.email"}}
|
||||
<input name="email" type="email" value="{{.User.Email}}" maxlength="254" required autocomplete="email">
|
||||
</label>
|
||||
<button type="submit">
|
||||
Save email
|
||||
{{.Msg "account.save_email"}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/account/avatar" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>{{.Msg "account.avatar"}} <input name="avatar" type="file" accept="image/png,image/jpeg"></label>
|
||||
<small class="help">{{.Msg "account.avatar_help"}}</small>
|
||||
<button type="submit">{{.Msg "account.save_avatar"}}</button>
|
||||
</form>
|
||||
{{if .User.AvatarPath}}
|
||||
<form method="post" action="/account/avatar/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit">{{.Msg "account.delete_avatar"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="panel settings-section" id="security">
|
||||
<h2>
|
||||
Security
|
||||
{{.Msg "account.security"}}
|
||||
</h2>
|
||||
<form method="post" action="/account/password">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>
|
||||
Current password
|
||||
{{.Msg "account.current_password"}}
|
||||
<input name="current_password" type="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
{{.Msg "account.new_password"}}
|
||||
<input name="new_password" type="password" required minlength="12" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
Confirm new password
|
||||
{{.Msg "account.confirm_password"}}
|
||||
<input name="confirm_password" type="password" required minlength="12" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">
|
||||
Change password
|
||||
{{.Msg "account.change_password"}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel settings-section" id="language">
|
||||
<h2>
|
||||
Language
|
||||
{{.Msg "account.language"}}
|
||||
</h2>
|
||||
<form method="post" action="/account/language">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>
|
||||
Language
|
||||
{{.Msg "account.language"}}
|
||||
<select name="language">
|
||||
{{range .Languages}}
|
||||
<option value="{{.Code}}"{{if eq $.Language .Code}} selected{{end}}>
|
||||
@@ -97,7 +110,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">
|
||||
Save language
|
||||
{{.Msg "account.save_language"}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
</a>
|
||||
{{if .IsAdmin}}
|
||||
<a class="nav-audit {{if eq .ActivePage "audit"}}active{{end}}" href="/audit">
|
||||
<svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#history"></use></svg>
|
||||
{{.Msg "nav.audit"}}
|
||||
</a>
|
||||
<a class="nav-administration {{if eq .ActivePage "administration"}}active{{end}}" href="/administration">
|
||||
@@ -35,11 +36,12 @@
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="account">
|
||||
<p class="sr-only">{{.Msg "account.signed_in"}} <strong>{{.User.Username}}</strong>.</p>
|
||||
<div class="account-card">
|
||||
<span class="avatar">
|
||||
DG
|
||||
</span>
|
||||
{{if .User.AvatarPath}}
|
||||
<img class="avatar avatar-image" src="/account/avatar" alt="{{.Msg "account.avatar_alt"}}">
|
||||
{{else}}
|
||||
<span class="avatar">{{initials .User}}</span>
|
||||
{{end}}
|
||||
<span>
|
||||
<strong>
|
||||
{{.User.Username}}
|
||||
@@ -49,12 +51,12 @@
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<nav aria-label="{{.Msg "nav.primary"}}">
|
||||
<a class="nav-account {{if eq .ActivePage "account"}}active{{end}}" href="/account">
|
||||
<svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#settings"></use></svg>
|
||||
{{.Msg "nav.settings"}}
|
||||
</a>
|
||||
</aside>
|
||||
</nav>
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="signout-button" type="submit">
|
||||
|
||||
@@ -50,6 +50,23 @@
|
||||
<textarea name="description" rows="3">
|
||||
</textarea>
|
||||
</label>
|
||||
<h2>
|
||||
{{.Msg "deployment.network"}}
|
||||
</h2>
|
||||
<p class="help">
|
||||
{{.Msg "deployment.network_help"}}
|
||||
</p>
|
||||
{{range .Deployment.Template.Container.Ports}}
|
||||
{{if .Publish}}
|
||||
<label>
|
||||
{{$.Msg "deployment.host_port"}} · {{.ID}}/{{.Protocol}}
|
||||
<small class="help">
|
||||
{{$.Msg "deployment.container_port"}}: {{.ContainerPort}}/{{.Protocol}}
|
||||
</small>
|
||||
<input required min="1" max="65535" name="host_port_{{.ID}}" type="number" value="{{index $.Deployment.HostPorts .ID}}" inputmode="numeric">
|
||||
</label>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<h2>
|
||||
{{.Msg "deployment.parameters"}}
|
||||
</h2>
|
||||
|
||||
@@ -27,17 +27,20 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}">
|
||||
<a href="/administration/users">
|
||||
<nav class="tabs" role="tablist" aria-label="{{.Msg "settings.tabs"}}">
|
||||
<a href="#users" role="tab" aria-controls="users">
|
||||
{{.Msg "template_repositories.users"}}
|
||||
</a>
|
||||
<a href="#web-access">
|
||||
{{.Msg "settings.web_access"}}
|
||||
</a>
|
||||
<a href="#sessions">
|
||||
{{.Msg "settings.sessions"}}
|
||||
</a>
|
||||
<a href="#notifications">
|
||||
{{.Msg "settings.notifications"}}
|
||||
</a>
|
||||
<a href="/administration/template-repositories">
|
||||
<a href="#repositories" role="tab" aria-controls="repositories">
|
||||
{{.Msg "template_repositories.heading"}}
|
||||
</a>
|
||||
<a href="#audit">
|
||||
@@ -46,7 +49,32 @@
|
||||
<a href="#containers">
|
||||
{{.Msg "settings.containers"}}
|
||||
</a>
|
||||
<a href="#game-runtime">
|
||||
{{.Msg "settings.game_runtime"}}
|
||||
</a>
|
||||
</nav>
|
||||
<section class="panel settings-section" id="sessions">
|
||||
<h2>{{.Msg "settings.sessions"}}</h2>
|
||||
<p>{{.Msg "settings.sessions_help"}}</p>
|
||||
<form method="post" action="/admin/session-policy">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>
|
||||
{{.Msg "settings.session_max"}}
|
||||
<input name="max_lifetime_days" type="number" min="1" max="90" step="1" value="{{divDuration .SessionPolicy.MaxLifetime 86400000000000}}">
|
||||
</label>
|
||||
<p class="help">{{.Msg "settings.days"}}</p>
|
||||
<label>
|
||||
<input type="checkbox" name="inactivity_enabled"{{if .SessionPolicy.InactivityEnabled}} checked{{end}}>
|
||||
{{.Msg "settings.session_inactivity_enabled"}}
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.session_inactivity"}}
|
||||
<input name="inactivity_timeout_hours" type="number" min="1" max="720" step="1" value="{{divDuration .SessionPolicy.InactivityTimeout 3600000000000}}">
|
||||
</label>
|
||||
<p class="help">{{.Msg "settings.hours"}}</p>
|
||||
<button type="submit">{{.Msg "settings.save_sessions"}}</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel settings-section" id="web-access">
|
||||
<h2>
|
||||
{{.Msg "settings.web_access"}}
|
||||
@@ -120,126 +148,102 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<ul class="channel-list">
|
||||
{{range .Channels}}
|
||||
<li>
|
||||
<strong>
|
||||
{{.Name}}
|
||||
</strong>
|
||||
<small>
|
||||
{{.Type}}
|
||||
·
|
||||
{{if .Enabled}}
|
||||
{{$.Msg "settings.enabled"}}
|
||||
{{else}}
|
||||
{{$.Msg "settings.disabled"}}
|
||||
{{end}}
|
||||
·
|
||||
{{if .Configured}}
|
||||
{{$.Msg "settings.configured"}}
|
||||
{{else}}
|
||||
{{$.Msg "settings.not_configured"}}
|
||||
{{end}}
|
||||
</small>
|
||||
<form method="post" action="/admin/notification-channels/{{.ID}}/test">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit">
|
||||
{{$.Msg "settings.send_test"}}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{else}}
|
||||
<li>
|
||||
{{.Msg "settings.no_channels"}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<section id="email">
|
||||
<h3>
|
||||
{{.Msg "settings.notification_email"}}
|
||||
</h3>
|
||||
<form method="post" action="/admin/notification-channels">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="SMTP email">
|
||||
<input type="hidden" name="type" value="email">
|
||||
{{with index .NotificationForms "email"}}
|
||||
<form method="post" action="{{.FormAction}}">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<input type="hidden" name="type" value="{{.Type}}">
|
||||
<input type="hidden" name="events" value="backup.completed backup.failed restore.completed restore.failed update.completed update.failed start.completed stop.completed">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
{{.Msg "settings.enabled"}}
|
||||
<input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}>
|
||||
{{$.Msg "settings.enabled"}}
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
{{.Msg "settings.sender_name"}}
|
||||
<input name="from_name">
|
||||
{{$.Msg "settings.sender_name"}}
|
||||
<input name="from_name" value="{{.Config.from_name}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.sender_email"}}
|
||||
<input required type="email" name="from">
|
||||
{{$.Msg "settings.sender_email"}}
|
||||
<input required type="email" name="from" value="{{.Config.from}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.smtp_server"}}
|
||||
<input required name="host">
|
||||
{{$.Msg "settings.smtp_server"}}
|
||||
<input required name="host" value="{{.Config.host}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.port"}}
|
||||
<input required name="port" type="number" min="1" max="65535" value="587">
|
||||
{{$.Msg "settings.port"}}
|
||||
<input required name="port" type="number" min="1" max="65535" value="{{.Config.port}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.username"}}
|
||||
<input name="smtp_username">
|
||||
{{$.Msg "settings.username"}}
|
||||
<input name="smtp_username" value="{{.Config.username}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.password"}}
|
||||
{{$.Msg "settings.password"}}
|
||||
<small>
|
||||
{{.Msg "settings.password_retain"}}
|
||||
{{$.Msg "settings.password_retain"}}
|
||||
</small>
|
||||
<input name="smtp_password" type="password">
|
||||
{{if .SecretConfigured}}<small>{{$.Msg "settings.configured"}}</small>{{end}}
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.tls_mode"}}
|
||||
{{$.Msg "settings.tls_mode"}}
|
||||
<select name="tls_mode">
|
||||
<option value="starttls">
|
||||
<option value="starttls"{{if eq .Config.tls_mode "starttls"}} selected{{end}}>
|
||||
STARTTLS
|
||||
</option>
|
||||
<option value="tls">
|
||||
<option value="tls"{{if eq .Config.tls_mode "tls"}} selected{{end}}>
|
||||
Direct TLS / SMTPS
|
||||
</option>
|
||||
<option value="none">
|
||||
<option value="none"{{if eq .Config.tls_mode "none"}} selected{{end}}>
|
||||
No TLS
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.test_recipient"}}
|
||||
<input type="email" name="to">
|
||||
{{$.Msg "settings.test_recipient"}}
|
||||
<input type="email" name="to" value="{{.Config.to}}">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">
|
||||
{{.Msg "settings.save_smtp"}}
|
||||
{{$.Msg "settings.save_smtp"}}
|
||||
</button>
|
||||
</form>
|
||||
{{if .ID}}<form method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">{{$.Msg "settings.send_test"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
<section id="discord">
|
||||
<h3>
|
||||
{{.Msg "settings.notification_discord"}}
|
||||
</h3>
|
||||
<form method="post" action="/admin/notification-channels">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="Discord">
|
||||
<input type="hidden" name="type" value="discord">
|
||||
{{with index .NotificationForms "discord"}}
|
||||
<form method="post" action="{{.FormAction}}">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<input type="hidden" name="type" value="{{.Type}}">
|
||||
<input type="hidden" name="events" value="backup.completed backup.failed restore.completed restore.failed update.completed update.failed">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
{{.Msg "settings.enabled"}}
|
||||
<input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}>
|
||||
{{$.Msg "settings.enabled"}}
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.webhook_url"}}
|
||||
<input required type="url" name="url" placeholder="https://discord.com/api/webhooks/…">
|
||||
{{$.Msg "settings.webhook_url"}}
|
||||
<input type="url" name="url" placeholder="https://discord.com/api/webhooks/…">
|
||||
{{if .SecretConfigured}}<small>{{$.Msg "settings.configured"}}</small>{{end}}
|
||||
</label>
|
||||
<button type="submit">
|
||||
{{.Msg "settings.save_discord"}}
|
||||
{{$.Msg "settings.save_discord"}}
|
||||
</button>
|
||||
</form>
|
||||
{{if .ID}}<form method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">{{$.Msg "settings.send_test"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
<section id="gotify">
|
||||
<h3>
|
||||
@@ -248,27 +252,32 @@
|
||||
<p>
|
||||
{{.Msg "settings.gotify_help"}}
|
||||
</p>
|
||||
<form method="post" action="/admin/notification-channels">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="Gotify">
|
||||
<input type="hidden" name="type" value="gotify">
|
||||
{{with index .NotificationForms "gotify"}}
|
||||
<form method="post" action="{{.FormAction}}">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<input type="hidden" name="type" value="{{.Type}}">
|
||||
<input type="hidden" name="events" value="backup.completed backup.failed restore.completed restore.failed update.completed update.failed">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
{{.Msg "settings.enabled"}}
|
||||
<input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}>
|
||||
{{$.Msg "settings.enabled"}}
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.server_url"}}
|
||||
<input required type="url" name="url" placeholder="https://gotify.example">
|
||||
{{$.Msg "settings.server_url"}}
|
||||
<input required type="url" name="url" value="{{.Config.url}}" placeholder="https://gotify.example">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.application_token"}}
|
||||
<input required name="gotify_token" type="password">
|
||||
{{$.Msg "settings.application_token"}}
|
||||
<input name="gotify_token" type="password">
|
||||
{{if .SecretConfigured}}<small>{{$.Msg "settings.configured"}}</small>{{end}}
|
||||
</label>
|
||||
<button type="submit">
|
||||
{{.Msg "settings.save_gotify"}}
|
||||
{{$.Msg "settings.save_gotify"}}
|
||||
</button>
|
||||
</form>
|
||||
{{if .ID}}<form method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">{{$.Msg "settings.send_test"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
</section>
|
||||
<section class="panel settings-section" id="audit">
|
||||
@@ -313,6 +322,17 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel settings-section" id="game-runtime">
|
||||
<h2>{{.Msg "settings.game_runtime"}}</h2>
|
||||
<p>{{.Msg "settings.game_runtime_help"}}</p>
|
||||
<form method="post" action="/admin/game-container-runtime">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>{{.Msg "settings.game_runtime_uid"}}<input required name="uid" type="number" min="0" max="4294967295" step="1" value="{{.GameContainerUID}}"></label>
|
||||
<label>{{.Msg "settings.game_runtime_gid"}}<input required name="gid" type="number" min="0" max="4294967295" step="1" value="{{.GameContainerGID}}"></label>
|
||||
<p class="help">{{.Msg "settings.game_runtime_policy_help"}}</p>
|
||||
<button type="submit">{{.Msg "settings.save_game_runtime"}}</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel settings-section" id="containers">
|
||||
<h2>
|
||||
{{.Msg "settings.container_labels"}}
|
||||
@@ -346,6 +366,8 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{{template "users-panel" .}}
|
||||
{{template "repositories-panel" .}}
|
||||
</main>
|
||||
<script src="/static/app.js">
|
||||
</script>
|
||||
|
||||
@@ -1,50 +1,5 @@
|
||||
{{define "template-repositories.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>
|
||||
{{.Title}}
|
||||
· DoGaMa
|
||||
</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="app-main">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
Administration
|
||||
</p>
|
||||
<h1>
|
||||
{{.Msg "template_repositories.heading"}}
|
||||
</h1>
|
||||
<p>
|
||||
{{.Msg "template_repositories.introduction"}}
|
||||
</p>
|
||||
</div>
|
||||
<a class="button-secondary link-button" href="/administration">
|
||||
Administration
|
||||
</a>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}">
|
||||
<a href="/administration/users">
|
||||
{{.Msg "template_repositories.users"}}
|
||||
</a>
|
||||
<a href="/administration#notifications">
|
||||
{{.Msg "settings.notifications"}}
|
||||
</a>
|
||||
<a href="/administration/template-repositories">
|
||||
{{.Msg "template_repositories.heading"}}
|
||||
</a>
|
||||
<a href="/administration#audit">
|
||||
{{.Msg "settings.audit"}}
|
||||
</a>
|
||||
</nav>
|
||||
<section class="panel settings-section">
|
||||
{{define "repositories-panel"}}
|
||||
<section class="panel settings-section" id="repositories">
|
||||
<details{{if .Error}} open{{end}}>
|
||||
<summary class="button-secondary link-button">
|
||||
{{.Msg "template_repositories.new"}}
|
||||
@@ -107,9 +62,4 @@
|
||||
</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
{{define "users.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>
|
||||
Users · DoGaMa
|
||||
</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="app-main">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
Administration
|
||||
</p>
|
||||
<h1>
|
||||
Users
|
||||
</h1>
|
||||
<p>
|
||||
Create identities, assign global roles and control access to each game server.
|
||||
</p>
|
||||
</div>
|
||||
<a class="button-secondary link-button" href="/administration">
|
||||
Administration settings
|
||||
</a>
|
||||
</div>
|
||||
<section class="panel settings-section">
|
||||
{{define "users-panel"}}
|
||||
<section class="panel settings-section" id="users">
|
||||
<h2>
|
||||
Create user
|
||||
</h2>
|
||||
@@ -204,9 +174,4 @@
|
||||
</p>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
<script src="/static/app.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
+29
-24
@@ -3,7 +3,6 @@ package web
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
@@ -14,33 +13,39 @@ func (s *server) usersPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
users, err := s.auth.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
if err := s.populateAdminUsers(r, &data); err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, "The users are unavailable.")
|
||||
return
|
||||
}
|
||||
data.Users = users
|
||||
data.Permissions = authorization.Permissions()
|
||||
s.render(w, http.StatusOK, "settings.html", data)
|
||||
}
|
||||
|
||||
func (s *server) populateAdminUsers(r *http.Request, data *pageData) error {
|
||||
users, err := s.auth.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data.Users, data.Permissions = users, authorization.Permissions()
|
||||
data.UserAccess = make(map[string]map[string]authorization.Membership, len(users))
|
||||
for _, user := range users {
|
||||
data.UserAccess[user.ID] = make(map[string]authorization.Membership)
|
||||
}
|
||||
if s.repository != nil {
|
||||
data.Instances = mustLifecycleInstances(r.Context(), s.repository)
|
||||
for _, current := range data.Instances {
|
||||
memberships, membershipErr := s.permissions.ListMemberships(r.Context(), data.User, current.ID)
|
||||
if membershipErr != nil {
|
||||
s.problem(w, http.StatusInternalServerError, "Instance access is unavailable.")
|
||||
return
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
if data.UserAccess[membership.UserID] != nil {
|
||||
data.UserAccess[membership.UserID][current.ID] = membership
|
||||
}
|
||||
if s.repository == nil {
|
||||
return nil
|
||||
}
|
||||
data.Instances = mustLifecycleInstances(r.Context(), s.repository)
|
||||
for _, current := range data.Instances {
|
||||
memberships, membershipErr := s.permissions.ListMemberships(r.Context(), data.User, current.ID)
|
||||
if membershipErr != nil {
|
||||
return errors.New("instance access unavailable")
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
if data.UserAccess[membership.UserID] != nil {
|
||||
data.UserAccess[membership.UserID][current.ID] = membership
|
||||
}
|
||||
}
|
||||
}
|
||||
s.render(w, http.StatusOK, "users.html", data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) adminUserForm(w http.ResponseWriter, r *http.Request) (auth.User, bool) {
|
||||
@@ -53,8 +58,8 @@ func (s *server) adminUserForm(w http.ResponseWriter, r *http.Request) (auth.Use
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return auth.User{}, false
|
||||
}
|
||||
if (s.permissions != nil && s.permissions.RequireRecentAdmin(actor) != nil) || (s.permissions == nil && (actor.AuthenticatedAt.IsZero() || time.Since(actor.AuthenticatedAt) > authorization.RecentAuthenticationWindow)) {
|
||||
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
|
||||
if actor.Role != "admin" || actor.Disabled {
|
||||
s.problem(w, http.StatusForbidden, "Administrator access is required.")
|
||||
return auth.User{}, false
|
||||
}
|
||||
return actor, true
|
||||
@@ -69,7 +74,7 @@ func (s *server) userCreateForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, http.StatusUnprocessableEntity, "The user could not be created.")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/administration/users", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) userUpdateForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -85,7 +90,7 @@ func (s *server) userUpdateForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, http.StatusUnprocessableEntity, "The user could not be updated.")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/administration/users", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) userMembershipForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -108,7 +113,7 @@ func (s *server) userMembershipForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/administration/users", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) userPermissionForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -131,5 +136,5 @@ func (s *server) userPermissionForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/administration/users", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/webaccess"
|
||||
)
|
||||
|
||||
func (s *server) gameContainerRuntimeForm(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.parseForm(w, r) {
|
||||
return
|
||||
}
|
||||
user, err := s.currentUser(r)
|
||||
session, cookieErr := r.Cookie(sessionCookie)
|
||||
if err != nil || cookieErr != nil || user.Role != "admin" || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
|
||||
s.problem(w, http.StatusForbidden, localized(s.language(r, ""), "error.csrf"))
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" || user.Disabled {
|
||||
s.problem(w, http.StatusForbidden, "Administrator access is required.")
|
||||
return
|
||||
}
|
||||
parse := func(name string) (uint32, error) {
|
||||
value, err := instance.ParseRuntimeID(r.FormValue(name))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
uid, err := parse("uid")
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
gid, err := parse("gid")
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
repository, ok := s.repository.(interface {
|
||||
SetGameContainerRuntimeIdentity(context.Context, instance.RuntimeIdentity) error
|
||||
})
|
||||
if !ok {
|
||||
s.problem(w, http.StatusServiceUnavailable, localized(s.language(r, user.Language), "error.internal"))
|
||||
return
|
||||
}
|
||||
if err := repository.SetGameContainerRuntimeIdentity(r.Context(), instance.RuntimeIdentity{UID: uid, GID: gid}); err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, localized(s.language(r, user.Language), "error.internal"))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/administration#game-runtime", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) webAccessForm(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.parseForm(w, r) {
|
||||
return
|
||||
|
||||
@@ -58,19 +58,41 @@
|
||||
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+$", "maxLength": 128 },
|
||||
"entrypoint": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 8 },
|
||||
"user_mode": { "type": "string", "enum": ["dogama", "image"] },
|
||||
"runtime_user": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode"],
|
||||
"properties": {
|
||||
"mode": { "enum": ["docker", "environment"] },
|
||||
"uid_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "maxLength": 128 },
|
||||
"gid_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "maxLength": 128 }
|
||||
}
|
||||
},
|
||||
"arguments": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 64 },
|
||||
"environment": { "type": "object", "maxProperties": 64, "additionalProperties": { "type": "string", "maxLength": 4096 }, "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } },
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"add": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID", "NET_BIND_SERVICE", "NET_RAW", "SYS_CHROOT"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["source", "destination", "sha256", "read_only"],
|
||||
"required": ["source", "destination", "read_only"],
|
||||
"properties": {
|
||||
"source": { "type": "string", "pattern": "^(?!/)(?!.*\\.\\./).+$", "maxLength": 200 },
|
||||
"destination": { "type": "string", "pattern": "^/[^\\u0000]*$", "maxLength": 500 },
|
||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||
"sha256": { "type": "string", "maxLength": 128 },
|
||||
"read_only": { "const": true }
|
||||
}
|
||||
}
|
||||
@@ -149,6 +171,14 @@
|
||||
"required": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": { "type": "string", "pattern": "^module/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+\\.yaml$", "maxLength": 240 }
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
+42
-8
@@ -53,7 +53,7 @@ def validate_links() -> None:
|
||||
raise ValueError("Broken internal links:\n " + "\n ".join(failures))
|
||||
|
||||
|
||||
def validate_cross_references(template: dict, manifest: dict) -> list[str]:
|
||||
def validate_cross_references(template: dict, manifest: dict | None, template_path: Path) -> list[str]:
|
||||
warnings = []
|
||||
port_ids = {item["id"] for item in template["container"]["ports"]}
|
||||
mount_ids = {item["id"] for item in template["storage"]["mounts"]}
|
||||
@@ -61,6 +61,7 @@ def validate_cross_references(template: dict, manifest: dict) -> list[str]:
|
||||
|
||||
integration = template.get("integration")
|
||||
if integration:
|
||||
assert manifest is not None, f"Declared module is missing: {integration['module_id']}"
|
||||
assert integration["port_id"] in port_ids, "Template integration references an unknown port"
|
||||
assert integration["module_id"] == manifest["id"], "Template and manifest module IDs disagree"
|
||||
assert template["game"]["id"] in manifest["game_ids"], "Manifest does not support template game ID"
|
||||
@@ -73,14 +74,21 @@ def validate_cross_references(template: dict, manifest: dict) -> list[str]:
|
||||
if mods.get("supported"):
|
||||
assert mods.get("destination_mount") in mount_ids, "Mods reference an unknown mount"
|
||||
|
||||
for field in ("logo", "image", "poster"):
|
||||
assert not template["game"]["artwork"][field].startswith(("http://", "https://")), f"Remote artwork URL is forbidden: {field}"
|
||||
path = (template_path.parent / template["game"]["artwork"][field]).resolve()
|
||||
assert path.is_relative_to(template_path.parent.resolve()), f"Artwork path escapes template: {field}"
|
||||
assert path.is_file(), f"Missing artwork asset {field}: {path}"
|
||||
for asset in template["container"].get("assets", []):
|
||||
path = (ROOT / "catalog" / "palworld" / asset["source"]).resolve()
|
||||
path = (template_path.parent / asset["source"]).resolve()
|
||||
assert path.is_relative_to(template_path.parent.resolve()), "Packaged asset path escapes template"
|
||||
assert path.is_file(), f"Missing packaged asset: {path}"
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
assert digest == asset["sha256"], f"Asset checksum mismatch: {path}"
|
||||
|
||||
if manifest is None:
|
||||
return warnings
|
||||
|
||||
wasm_digest = manifest["artifacts"]["sha256"]
|
||||
wasm_path = ROOT / "modules" / manifest["id"] / manifest["artifacts"]["wasm"]
|
||||
wasm_path = template_path.parent / "module" / manifest["artifacts"]["wasm"]
|
||||
if wasm_digest == "0" * 64 and not wasm_path.exists():
|
||||
warnings.append("Palworld module is a source specification: module.wasm and its final checksum are intentionally pending.")
|
||||
elif wasm_path.is_file():
|
||||
@@ -91,6 +99,34 @@ def validate_cross_references(template: dict, manifest: dict) -> list[str]:
|
||||
return warnings
|
||||
|
||||
|
||||
def validate_catalog() -> list[str]:
|
||||
template_schema = ROOT / "specs/template.schema.json"
|
||||
manifest_schema = ROOT / "specs/module-manifest.schema.json"
|
||||
template_paths = sorted((ROOT / "catalog").glob("*/template.yaml"))
|
||||
assert template_paths, "Catalog contains no templates"
|
||||
warnings = []
|
||||
identities = set()
|
||||
for template_path in template_paths:
|
||||
template = validate(template_schema, template_path)
|
||||
identity = (template["id"], template["version"])
|
||||
assert identity not in identities, f"Duplicate template ID and version: {identity[0]}@{identity[1]}"
|
||||
identities.add(identity)
|
||||
integration = template.get("integration")
|
||||
manifest = None
|
||||
module = template.get("module")
|
||||
if integration:
|
||||
assert module is not None, "Integration requires a template-local module"
|
||||
if module:
|
||||
module_path = Path(module["path"])
|
||||
assert not module_path.is_absolute() and ".." not in module_path.parts, "Module path escapes template"
|
||||
manifest_path = (template_path.parent / module_path).resolve()
|
||||
assert manifest_path.parent.is_relative_to((template_path.parent / "module").resolve()), "Module path is outside template module directory"
|
||||
assert manifest_path.is_file(), f"Declared module manifest is missing: {manifest_path}"
|
||||
manifest = validate(manifest_schema, manifest_path)
|
||||
warnings.extend(validate_cross_references(template, manifest, template_path))
|
||||
return warnings
|
||||
|
||||
|
||||
def validate_coverage() -> None:
|
||||
required = {
|
||||
"Docker agent": "docs/architecture/docker-agent.md",
|
||||
@@ -156,14 +192,12 @@ def validate_workflows() -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
template = validate(ROOT / "specs/template.schema.json", ROOT / "catalog/palworld/template.yaml")
|
||||
manifest = validate(ROOT / "specs/module-manifest.schema.json", ROOT / "modules/palworld-rest/manifest.yaml")
|
||||
validate_compose()
|
||||
validate_workflows()
|
||||
for fixture in ROOT.rglob("*.json"):
|
||||
load_json(fixture)
|
||||
validate_links()
|
||||
warnings = validate_cross_references(template, manifest)
|
||||
warnings = validate_catalog()
|
||||
validate_coverage()
|
||||
print("DoGaMa specification validation passed.")
|
||||
for warning in warnings:
|
||||
|
||||
Reference in New Issue
Block a user