Publish Ludarium source
Public source validation / source (push) Successful in 2m16s

This commit is contained in:
Ludarium release export
2026-09-03 02:08:58 +02:00
commit df869819ce
339 changed files with 46455 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
POSTGRES_PASSWORD=replace-with-a-long-random-password
LUDARIUM_ADMIN_TOKEN=replace-with-a-separate-long-random-token
TZ=Europe/Brussels
LUDARIUM_HTTP_PORT=1230
IGDB_CLIENT_ID=
IGDB_CLIENT_SECRET="" # optional; leave empty to disable
MOBYGAMES_API_KEY="" # optional; leave empty to disable
SCREENSCRAPER_DEVID=
SCREENSCRAPER_DEVPASSWORD="" # optional; leave empty to disable
SCREENSCRAPER_SOFTNAME=Ludarium
SCREENSCRAPER_USER=
SCREENSCRAPER_PASSWORD="" # optional; leave empty to disable
RETROACHIEVEMENTS_API_KEY="" # optional; leave empty to disable
RETROACHIEVEMENTS_USERNAME=
BROWSERPLAY_MAX_SESSIONS=2
LUDARIUM_DATA_ROOT=./data
PUID=1000
PGID=1000
GAMES_LIBRARY=/path/to/games
PS4_LIBRARY=/path/to/ps4-games
PS5_LIBRARY=/path/to/ps5-games
# Optional embedded Switch player (use with compose.switch.yml).
SWITCH_CONTAINER_NAME=Ludarium-Switch
SWITCH_DATA_ROOT=./data/switch
SWITCH_LIBRARY=/path/to/switch-games
SWITCH_PLAYER_URL=https://switch.example.test/switch-player/
SWITCH_PUBLIC_URL=http://[IP]:[PORT:1262]/
SWITCH_REMOTE_PORT=1262
SWITCH_EMBED_PORT=1231
SWITCH_EMBED_ORIGIN=
SWITCH_CONTROL_TOKEN=replace-with-a-separate-random-secret
SWITCH_CATALOG_PREFIX=roms/switch
# Optional isolated GameCube/Wii player (compose.dolphin.yml).
DOLPHIN_DATA_ROOT=./data/dolphin
DOLPHIN_GAMECUBE_LIBRARY=/path/to/gamecube-games
DOLPHIN_WII_LIBRARY=/path/to/wii-games
DOLPHIN_PLAYER_URL=https://dolphin.example.test/dolphin-player/
DOLPHIN_PUBLIC_URL=http://[IP]:[PORT:1263]/
DOLPHIN_REMOTE_PORT=1263
DOLPHIN_EMBED_PORT=1232
DOLPHIN_REMOTE_USER=
DOLPHIN_REMOTE_PASSWORD=
DOLPHIN_CONTROL_TOKEN=
DOLPHIN_PROXY_CERT_SHA256=
# Exact SHA-256 of the read-only GameCube/Wii artifact used by the live launch + savestate qualification gate.
DOLPHIN_FIXTURE_SHA256=
DOLPHIN_GAMECUBE_CATALOG_PREFIX=roms/gamecube
DOLPHIN_WII_CATALOG_PREFIX=roms/wii
SWITCH_DRI_NODE=/dev/dri/renderD128
SWITCH_REMOTE_USER=replace-with-a-local-player-user
SWITCH_REMOTE_PASSWORD=replace-with-a-long-random-player-password
# SHA-256 of Eden's self-signed TLS certificate, without colons. See deploy/README.md.
SWITCH_PROXY_CERT_SHA256=replace-with-64-lowercase-hex-characters
+197
View File
@@ -0,0 +1,197 @@
# Deployment and operations
## Unraid DockerMan
The Unraid Compose deployment runs the web application and its private PostgreSQL 16 process inside one supervised `Ludarium` container. DockerMan exposes one WebUI on port 1230 and one persisted PNG icon; PostgreSQL has no host port. The entrypoint forwards shutdown signals to both processes and the database remains isolated in its durable bind mount.
Install `src/Ludarium.Web/public/favicon.svg` as `/boot/config/plugins/dockerMan/images/Ludarium-icon.svg` on the Unraid host before recreating the application container.
## Clean start
1. Copy `.env.example` to `.env` and replace both secrets with independent random values.
2. Set the three host library paths. Do not remove the `:ro` suffixes in `compose.yml`.
3. From `deploy/`, run `docker compose build --pull` and `docker compose up -d`.
4. Wait for `docker compose ps` to report the single service healthy, then open `http://HOST:1230` (or the configured `LUDARIUM_HTTP_PORT`) and supply the admin bearer token through a trusted reverse proxy or API client.
5. Add `/library/games`, `/library/ps4`, and `/library/ps5` in Settings and verify each root. A writable or unverifiable mapping remains visibly unsafe.
Set `PUID` and `PGID` to the numeric owner of library directories that are not world-readable. Ludarium remains non-root; only its writable appdata directories should be owned by this identity. Never relax source-library permissions solely for a scan.
The application container is read-only, drops all Linux capabilities, enables `no-new-privileges`, runs as the configured non-root UID/GID, and has no Docker socket, privileged mode, host networking, or source-write mount.
## Optional native metadata providers
Ludarium's catalog, collections, tags, personal state, relationships, media and exports work offline and do not require RomM or another service. Optional metadata enrichment is performed directly by Ludarium when the corresponding operator-owned credentials are present:
- IGDB: `IGDB_CLIENT_ID` and `IGDB_CLIENT_SECRET`;
- MobyGames: `MOBYGAMES_API_KEY`;
- ScreenScraper: `SCREENSCRAPER_DEVID`, `SCREENSCRAPER_DEVPASSWORD` and `SCREENSCRAPER_SOFTNAME`; account user/password are optional;
- RetroAchievements: `RETROACHIEVEMENTS_API_KEY`; `RETROACHIEVEMENTS_USERNAME` additionally enables personal unlock progress.
Leave variables empty to keep a provider disabled. Provider status and missing configuration are shown in Settings. Credentials stay in the local deployment environment; they are never written to support bundles. Metadata claims, media sources and ambiguous conflicts retain provider provenance, and uncertain conflicts enter review instead of being silently merged.
## Native browser play
EmulatorJS 4.2.3 and the official Play!.js PS2 runtime are checksum-pinned and bundled into the Ludarium image. No RomM installation, account, API token, shared database or runtime internet access is required. Play!.js uses its built-in HLE BIOS; Ludarium does not expose an uploaded PS2 BIOS to the browser. `BROWSERPLAY_MAX_SESSIONS` limits concurrent two-hour sessions from 1 through 8 and defaults to 2.
### Optional isolated Switch player
`compose.switch.yml` adds LinuxServer's browser-accessible Eden image pinned to its exact amd64
digest. It is a native remote Wayland/WebRTC application. Ludarium embeds that remote desktop on
the separate `SWITCH_EMBED_PORT` origin through a certificate-pinned streaming/WebSocket proxy;
Eden credentials remain server-side and are never written into browser state. Set
`SWITCH_DATA_ROOT`, `SWITCH_LIBRARY`, `SWITCH_PLAYER_URL`, `SWITCH_REMOTE_USER`,
`SWITCH_REMOTE_PASSWORD`, `SWITCH_PROXY_CERT_SHA256` and optionally `SWITCH_DRI_NODE`, then start
with both Compose files. Obtain the lowercase certificate pin from the already-initialized Eden
configuration with `openssl x509 -in config/ssl/cert.pem -noout -fingerprint -sha256`, removing
colons from the fingerprint. Recheck the pin after replacing Eden's persistent configuration.
For access through a public reverse proxy, set `SWITCH_EMBED_ORIGIN` to a dedicated HTTPS child
origin such as `https://player.ludarium.example/` and forward that hostname to
`SWITCH_EMBED_PORT`. The child origin is required: Ludarium rejects the player proxy on the main
application hostname so the remote desktop cannot access the administrator origin. Keep WebSocket
upgrade enabled on this proxy host.
Before the first start, after restoring appdata, or after changing `PUID`/`PGID`, run the bounded
appdata preparation step. It refuses root identities, broad data roots and any overlap between the
app-owned data root and source library. It normalizes only Eden's writable config ownership; it
never changes the read-only game library.
```sh
./prepare-switch-appdata.sh
```
`/games` is mounted read-only, Docker's socket is never exposed, Docker-in-Docker is disabled,
HTTPS Basic authentication is mandatory, and app-owned Eden configuration is separate from every
source library. Eden runs at `/switch-player/`; sharing, collaboration, file transfer, command
execution and its file/app sidebars are locked off. Uploading personal `prod.keys` or `title.keys`
through Ludarium atomically provisions the selected verified key into Eden's app-owned key
directory. Ludarium links Switch games to the embedded player but does not claim exact per-title
directory. For a game with one exact present base XCI/NSP, Ludarium starts that title directly in
Eden. Update/DLC-only, missing, ambiguous or writable-source records remain unavailable instead of
opening a generic game picker.
```sh
docker compose -f compose.yml -f compose.switch.yml up -d
```
The independently versioned sidecar has its own fail-closed security gate. Run it against an
isolated healthy candidate, never production appdata:
```sh
./run-eden-security-gate.sh \
ludarium/eden-controller:0.4.9-rc.1 \
Ludarium-Switch-Candidate \
/mnt/user/appdata/ludarium-candidate/eden-0.4.9/evidence
```
The gate retains both raw and OpenVEX-resolved Grype JSON. It accepts a Critical decision only when
the exact running image is healthy with zero restarts, `/games` is read-only, the executable
runtime-boundary audit passes, and the raw Critical occurrence/ID sets exactly equal the VEX sets.
High and lower findings remain visible in the raw report.
The current LinuxServer Wayland path requires HTTPS for its best WebCodecs path and, for
proprietary NVIDIA rendering, driver 580 or newer. Intel/AMD DRM render nodes can be selected
directly. Keep both Ludarium and Eden on a trusted LAN. The direct HTTPS URL is retained only as a
fallback; the embedded surface is capability-gated and the hardened Eden desktop does not expose
its normal command/file controls.
### Optional isolated GameCube and Wii player
`compose.dolphin.yml` adds the digest-pinned LinuxServer Dolphin/Selkies runtime and a minimal
exact-title controller. Configure `DOLPHIN_DATA_ROOT`, both source-library paths, separate remote
credentials and control token, the initialized TLS certificate pin, and the public ports. Both
source libraries retain `:ro`; saves and savestates are written only below the app-owned Dolphin
data root. No Docker socket is mounted and the remote desktop's command, file, app, clipboard and
sharing controls are locked off.
Do not populate `DOLPHIN_FIXTURE_SHA256` just to enable the UI. First run an exact owned or freely
licensed read-only GameCube/Wii image through the isolated candidate and verify launch, streamed
video/input, save-state, load-state and persistence after restart. Confirm the source manifest is
unchanged, then record that exact artifact's lowercase SHA-256. Ludarium remains fail-closed until
this evidence field is a valid digest; it does not imply compatibility for every game.
```sh
docker compose -f compose.yml -f compose.dolphin.yml up -d --build
```
The public player origin, when used, must be a dedicated HTTPS child hostname with WebSocket
upgrade enabled. Ludarium rejects a configured origin that is not a child of the main host and
keeps the remote credentials server-side.
The Play control appears only for one exact present ROM linked by the scanner, on a verified read-only library and with an allowlisted platform, extension and size. An explicit click creates an audited, expiring capability; the browser receives no source path. The scoped content route resolves the stored artifact server-side, revalidates the mount and streams with Range support. PS2 has a dedicated 8 GiB ceiling and 32 MiB per-read bound; other platforms retain the 512 MiB ceiling. Executables, scripts, archives and unknown content fail closed.
## Isolated candidate gate
Never point a candidate at production appdata. On Unraid, export the normal secret and library variables plus an isolated root and run the fail-closed gate:
```sh
export LUDARIUM_CANDIDATE_DATA_ROOT=/mnt/user/appdata/ludarium-candidate/0.4.19-rc.8
export LUDARIUM_CANDIDATE_PORT=1232
export LUDARIUM_IMAGE=ludarium/ludarium:0.4.19-rc.8
export PUID=1000 PGID=1000 # numeric owner/read-capable identity for the current archive
./run-candidate-gate.sh
```
The script accepts candidate data only below `/mnt/user/appdata/ludarium-candidate` (or `/tmp/ludarium-candidate`), uses a distinct Compose project, network and container name, and refuses to continue unless all three library mounts report `RW=false`. It tests an exact `git archive` of `HEAD`, so ignored build/cache files and deployment secrets never enter the ephemeral test workspace. It first runs locked restore, formatting, Release build and all .NET tests with PostgreSQL Testcontainers enabled. It records source manifests before/after, builds the pinned image, checks the runtime schema against the version declared in source and checks readiness, runs frontend unit/build/audit plus the full responsive Playwright/axe matrix in an ephemeral tmpfs, restarts the app, restores a custom-format dump into a temporary database, and runs Gitleaks/Syft/Grype. Scanner binaries are checksum-pinned, cached and downloaded with bounded retries/timeouts; the remaining Playwright image pull is digest-pinned and bounded. Its final line contains the image digest, port, schema and backup path required for release evidence.
On Unraid, the .NET container defaults to 4 CPUs, 8 GiB memory/no extra swap and 1,024 PIDs;
the browser container defaults to 4 CPUs, 4 GiB memory/no extra swap and 1,024 PIDs. MSBuild uses
two nodes, Vitest four workers and Compose one concurrent build. Lower-capacity hosts can override
`LUDARIUM_GATE_CPUS`, `LUDARIUM_GATE_MEMORY`, `LUDARIUM_BROWSER_GATE_CPUS`,
`LUDARIUM_BROWSER_GATE_MEMORY`, `LUDARIUM_GATE_BUILD_NODES` and
`LUDARIUM_COMPOSE_PARALLEL_LIMIT`. A reboot or interrupted shell invalidates the attempt; restart
with a new versioned candidate root instead of reusing partial evidence.
After that gate passes, run `./run-live-browserplay-gate.sh` with the same candidate environment. It materializes the original cartridge fixtures and reproducibly builds the PS2 ISO below candidate appdata with a digest-pinned PS2SDK image, verifies every SHA-256, mounts the set read-only and performs an Integrity scan. The matrix proves scoped Range delivery and real local canvases; state-capable EmulatorJS cores also prove cancel/relaunch/restore, while Play!.js proves frames and cancellation without claiming a state API. It fails on browser console errors, request failures or a missing local core and automatically restores the canonical candidate mounts afterward.
When the Docker registry is unavailable, set `LUDARIUM_BROWSER_GATE_MODE=external`. The script then reports `browser-external-required` instead of a full pass; run the checked-in `e2e/workflows.mjs` and `e2e/accessibility.mjs` from a Playwright 1.62.1 workstation against the candidate before promotion. This mode never converts a missing browser result into a green gate.
The durable archive-share paths on the current host are `/mnt/user/Media/Games`, `/mnt/user/PS4-Games` and `/mnt/user/PS5-Games`. Map them to `/library/games`, `/library/ps4` and `/library/ps5` respectively and keep every mount read-only. The PS4 and PS5 shares are currently empty; populate or remount them before starting a production scan so retained inventory is not marked missing.
### Candidate retention and cleanup
After production validation, stop the candidate application and database and disable their restart policies. Retain candidate appdata, database dumps and the prior production backup for 30 days unless an operator-approved retention policy requires longer. During this window, verify production health and backup readability before removing anything.
Stopping is non-destructive:
```sh
docker update --restart=no ludarium-candidate-Ludarium
docker stop ludarium-candidate-Ludarium
```
After 30 days, first resolve and record the exact candidate project/appdata paths. Remove only explicitly approved candidate containers and versioned candidate appdata; never target `/mnt/user/appdata`, the production `/mnt/user/appdata/ludarium` tree or any library root. Candidate cleanup is intentionally not automated because deletion requires a fresh operator decision.
## Backup and restore
Stop application writes with `docker compose stop ludarium`, create a PostgreSQL custom-format dump with `docker compose exec -T ludarium pg_dump -U ludarium -Fc ludarium > ludarium.dump`, and archive `data/app` plus the optional exports. Restart Ludarium afterward.
To restore, start a clean Ludarium container, pipe the dump through `docker compose exec -T ludarium pg_restore -U ludarium -d ludarium --clean --if-exists`, restore `data/app`, then start Ludarium. Verify libraries, claims, reviews, snapshots and `/health/ready`. Game libraries are not part of this backup and remain untouched.
## Upgrade and rollback
Back up first, pull/build the new pinned image, then run `docker compose up -d`. Migrations are forward-only. Rollback means restoring the matching prior image and its pre-upgrade database dump; never run an older binary against a newer schema without a documented compatibility statement.
## HTTPS, reverse proxy and token rotation
Expose Ludarium outside a trusted LAN only through an HTTPS reverse proxy. Preserve `X-Forwarded-For`, restrict request-body size and do not log the `Authorization` header. Ludarium itself has no cloud authentication dependency.
Validate both a normal GET and a completed authenticated inventory export through the public host.
Some Nginx Proxy Manager/OpenResty combinations can accept ordinary HTTP/2 navigation while
stalling longer browser POST streams. If and only if the public browser export stalls while the
same request completes through the direct HTTP/1.1 origin, disable HTTP/2 on the main Ludarium
proxy host and keep bounded `proxy_read_timeout`/`proxy_send_timeout` plus
`proxy_buffering off`/`proxy_request_buffering off`. The dedicated Switch player host may retain
HTTP/2 and must retain WebSocket upgrades. Run `nginx -t` and keep a proxy-manager database backup
before reload.
To rotate the administrator token, generate a new high-entropy value, update only `LUDARIUM_ADMIN_TOKEN` in the protected deployment environment and recreate the application container. Existing browser sessions receive `401`, erase their session-scoped token and return to the login screen. Never place the token in Compose YAML, shell history, support bundles or source control.
Before promotion, create a custom-format database backup and retain the previous image digest. If post-migration validation fails, stop application writes and restore both the pre-upgrade dump and matching prior image; do not run an older application against schema 7 or later.
## Live Unraid validation
Follow `docs/25_RELEASE_AND_HANDOFF.md`. Capture independent path/name/hash manifests before and after quick/deep scans, restart during discovery, test one unavailable root, create exports, and restore a backup into a clean stack.
For production browser smoke tests set `PLAYWRIGHT_MUTATE_CATALOG=0` so the general workflow remains read-only. Run the Game Data Vault creation story only against an isolated candidate database; it deliberately creates a temporary game identity and is not a production smoke test.
For the non-mutating structural manifest gate, run `verify-source-manifest.sh` with every host library path before and after the scan. The file count and SHA-256 digest must be identical. Record the result together with the Docker `RW=false` mount inspection in `RELEASE_GATE_EVIDENCE.md`.
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
set -eu
fail() {
printf 'Eden runtime audit failed: %s\n' "$1" >&2
exit 1
}
for command in eden Xwayland nginx python3 xdotool findmnt ss pgrep; do
command -v "$command" >/dev/null 2>&1 || fail "required command is absent: $command"
done
for command in docker dockerd containerd gcc g++ make cmake git ssh curl cron crond; do
command -v "$command" >/dev/null 2>&1 && fail "unused privileged/development command is present: $command"
done
for process in eden Xwayland nginx python3 selkies; do
pgrep -f "$process" >/dev/null 2>&1 || fail "required process is not running: $process"
done
for process in dockerd containerd cron crond sshd; do
pgrep -x "$process" >/dev/null 2>&1 && fail "unused service is running: $process"
done
mount_options="$(findmnt -n -o OPTIONS --target /games)"
case ",$mount_options," in
*,ro,*) ;;
*) fail "/games is not mounted read-only" ;;
esac
[ -z "${DEV_MODE+x}" ] || fail "PixelFlux development mode must remain disabled"
if env | grep -Eiq '^(http|https|all|no)_proxy=|^netrc='; then
fail "proxy or netrc environment can expose unused libcurl credential paths"
fi
if find /config /root -xdev -name .netrc -print -quit 2>/dev/null | grep -q .; then
fail "a runtime .netrc file is present"
fi
if grep -RIEq '^[[:space:]]*map[[:space:]]' /etc/nginx /defaults 2>/dev/null; then
fail "nginx map directives are forbidden in the isolated player"
fi
xwayland="$(pgrep -a -x Xwayland | head -1)"
printf '%s' "$xwayland" | grep -Fq -- '-rootless' || fail "Xwayland is not rootless"
printf '%s' "$xwayland" | grep -Eq -- '-listen[[:space:]]+tcp' && fail "Xwayland TCP listening is enabled"
ss -lnt | awk 'NR > 1 { print $4 }' | grep -Eq '(^|:):?60[0-9][0-9]$' && fail "an X11 TCP port is listening"
if find /config -xdev -type f \( -iname '*.svg' -o -iname '*.dtd' \) -print -quit 2>/dev/null | grep -q .; then
fail "untrusted SVG or DTD input is present in app-owned runtime configuration"
fi
if find /config -xdev -type f -size -1M -exec grep -Il 'rist://' {} + 2>/dev/null | grep -q .; then
fail "a RIST media URL is present in runtime configuration"
fi
env | grep -Eiq 'rist://' && fail "a RIST media URL is present in the environment"
printf 'Eden runtime audit passed: minimal tools, read-only games, bounded local display and no vulnerable optional protocol configuration.\n'
+83
View File
@@ -0,0 +1,83 @@
# Optional browser-accessible GameCube/Wii runtime. Combine with compose.yml after setting DOLPHIN_*.
# Both source libraries are mounted read-only; only /config is writable for saves and savestates.
services:
ludarium:
environment:
LUDARIUM_DOLPHIN_PLAYER_URL: ${DOLPHIN_PLAYER_URL:?set the trusted-LAN Dolphin URL}
LUDARIUM_DOLPHIN_PROXY_URL: https://dolphin:3001/
LUDARIUM_DOLPHIN_PROXY_USERNAME: ${DOLPHIN_REMOTE_USER:?set the Dolphin trusted-LAN username}
LUDARIUM_DOLPHIN_PROXY_PASSWORD: ${DOLPHIN_REMOTE_PASSWORD:?set the Dolphin trusted-LAN password}
LUDARIUM_DOLPHIN_PROXY_CERT_SHA256: ${DOLPHIN_PROXY_CERT_SHA256:?pin the Dolphin TLS certificate SHA-256}
LUDARIUM_DOLPHIN_EMBED_PORT: ${DOLPHIN_EMBED_PORT:-1232}
LUDARIUM_DOLPHIN_EMBED_ORIGIN: ${DOLPHIN_EMBED_ORIGIN:-}
LUDARIUM_DOLPHIN_CONTROL_URL: http://dolphin:8765/
LUDARIUM_DOLPHIN_CONTROL_TOKEN: ${DOLPHIN_CONTROL_TOKEN:?set the internal Dolphin control token}
LUDARIUM_DOLPHIN_FIXTURE_SHA256: ${DOLPHIN_FIXTURE_SHA256:?set after deterministic live launch/save-state validation}
LUDARIUM_DOLPHIN_GAMECUBE_CATALOG_PREFIX: ${DOLPHIN_GAMECUBE_CATALOG_PREFIX:-roms/gamecube}
LUDARIUM_DOLPHIN_WII_CATALOG_PREFIX: ${DOLPHIN_WII_CATALOG_PREFIX:-roms/wii}
ports:
- ${DOLPHIN_EMBED_PORT:-1232}:8734
depends_on:
dolphin:
condition: service_healthy
dolphin:
container_name: ${DOLPHIN_CONTAINER_NAME:-Ludarium-Dolphin}
image: ${DOLPHIN_IMAGE:-ludarium/dolphin-controller:0.4.19-rc.8}
build:
context: ..
dockerfile: Dockerfile.dolphin-controller
labels:
net.unraid.docker.webui: "${DOLPHIN_PUBLIC_URL:-http://[IP]:[PORT:1263]/}"
net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg"
net.unraid.docker.managed: "dockerman"
net.unraid.docker.shell: ""
environment:
PUID: ${PUID:-1654}
PGID: ${PGID:-1654}
TZ: ${TZ:-Europe/Brussels}
PIXELFLUX_WAYLAND: "true"
DRINODE: ${DOLPHIN_DRI_NODE:-/dev/dri/renderD128}
DRI_NODE: ${DOLPHIN_DRI_NODE:-/dev/dri/renderD128}
START_DOCKER: "false"
DISABLE_IPV6: "true"
CUSTOM_USER: ${DOLPHIN_REMOTE_USER:?set the Dolphin trusted-LAN username}
PASSWORD: ${DOLPHIN_REMOTE_PASSWORD:?set the Dolphin trusted-LAN password}
LUDARIUM_DOLPHIN_CONTROL_TOKEN: ${DOLPHIN_CONTROL_TOKEN:?set the internal Dolphin control token}
# App-owned save directory the Game Data Vault captures and restores. Override only when a
# different Dolphin build stores its saves and states elsewhere.
LUDARIUM_DOLPHIN_SAVE_ROOT: ${DOLPHIN_SAVE_ROOT:-/config/.local/share/dolphin-emu}
TITLE: Ludarium Dolphin
FILE_MANAGER_PATH: /games
SUBFOLDER: /dolphin-player/
HARDEN_DESKTOP: "true"
HARDEN_OPENBOX: "true"
SELKIES_ENABLE_SHARING: "false|locked"
SELKIES_ENABLE_COLLAB: "false|locked"
SELKIES_ENABLE_SHARED: "false|locked"
SELKIES_FILE_TRANSFERS: "none|locked"
SELKIES_COMMAND_ENABLED: "false|locked"
SELKIES_CLIPBOARD_ENABLED: "false|locked"
SELKIES_MICROPHONE_ENABLED: "false|locked"
SELKIES_UI_SIDEBAR_SHOW_CLIPBOARD: "false|locked"
SELKIES_UI_SIDEBAR_SHOW_FILES: "false|locked"
SELKIES_UI_SIDEBAR_SHOW_APPS: "false|locked"
MAX_RES: 3840x2160
ports:
- ${DOLPHIN_REMOTE_PORT:-1263}:3001
devices:
- /dev/dri:/dev/dri
shm_size: 1gb
volumes:
- ${DOLPHIN_DATA_ROOT:?set isolated app-owned Dolphin data}/config:/config:rw
- ${DOLPHIN_GAMECUBE_LIBRARY:?set the GameCube source library}:/games/gamecube:ro
- ${DOLPHIN_WII_LIBRARY:?set the Wii source library}:/games/wii:ro
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=3).read()"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
+85
View File
@@ -0,0 +1,85 @@
# Optional browser-accessible Switch runtime. Combine with compose.yml only after setting all
# SWITCH_* variables. The game library is always mounted read-only and no Docker socket is exposed.
services:
ludarium:
environment:
LUDARIUM_SWITCH_PLAYER_URL: ${SWITCH_PLAYER_URL:?set the trusted-LAN Eden URL}
LUDARIUM_SWITCH_KEYS_ROOT: /app/switch-keys
LUDARIUM_SWITCH_PROXY_URL: https://eden:3001/
LUDARIUM_SWITCH_PROXY_USERNAME: ${SWITCH_REMOTE_USER:?set the Eden trusted-LAN username}
LUDARIUM_SWITCH_PROXY_PASSWORD: ${SWITCH_REMOTE_PASSWORD:?set the Eden trusted-LAN password}
LUDARIUM_SWITCH_PROXY_CERT_SHA256: ${SWITCH_PROXY_CERT_SHA256:?pin the Eden TLS certificate SHA-256}
LUDARIUM_SWITCH_EMBED_PORT: ${SWITCH_EMBED_PORT:-1231}
LUDARIUM_SWITCH_EMBED_ORIGIN: ${SWITCH_EMBED_ORIGIN:-}
LUDARIUM_SWITCH_CONTROL_URL: http://eden:8765/
LUDARIUM_SWITCH_CONTROL_TOKEN: ${SWITCH_CONTROL_TOKEN:?set the internal Eden control token}
LUDARIUM_SWITCH_CATALOG_PREFIX: ${SWITCH_CATALOG_PREFIX:-roms/switch}
ports:
- ${SWITCH_EMBED_PORT:-1231}:8734
volumes:
- ${SWITCH_DATA_ROOT:?set isolated app-owned Switch data}/config/.local/share/eden/keys:/app/switch-keys:rw
depends_on:
eden:
condition: service_healthy
eden:
container_name: ${SWITCH_CONTAINER_NAME:-Ludarium-Switch}
image: ${SWITCH_IMAGE:-ludarium/eden-controller:0.4.9-rc.1}
labels:
net.unraid.docker.webui: "${SWITCH_PUBLIC_URL:-http://[IP]:[PORT:1262]/}"
net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg"
net.unraid.docker.managed: "dockerman"
net.unraid.docker.shell: ""
environment:
PUID: ${PUID:-1654}
PGID: ${PGID:-1654}
TZ: ${TZ:-Europe/Brussels}
PIXELFLUX_WAYLAND: "true"
DRINODE: ${SWITCH_DRI_NODE:-/dev/dri/renderD128}
DRI_NODE: ${SWITCH_DRI_NODE:-/dev/dri/renderD128}
START_DOCKER: "false"
DISABLE_IPV6: "true"
CUSTOM_USER: ${SWITCH_REMOTE_USER:?set the Eden trusted-LAN username}
PASSWORD: ${SWITCH_REMOTE_PASSWORD:?set the Eden trusted-LAN password}
LUDARIUM_EDEN_CONTROL_TOKEN: ${SWITCH_CONTROL_TOKEN:?set the internal Eden control token}
# App-owned save directory the Game Data Vault captures and restores. Override only when a
# different Eden build stores its saves elsewhere.
LUDARIUM_EDEN_SAVE_ROOT: ${SWITCH_SAVE_ROOT:-/config/.local/share/eden}
TITLE: Ludarium Switch
FILE_MANAGER_PATH: /games
SUBFOLDER: /switch-player/
HARDEN_DESKTOP: "true"
HARDEN_OPENBOX: "true"
SELKIES_ENABLE_SHARING: "false|locked"
SELKIES_ENABLE_COLLAB: "false|locked"
SELKIES_ENABLE_SHARED: "false|locked"
SELKIES_FILE_TRANSFERS: "false|locked"
SELKIES_COMMAND_ENABLED: "false|locked"
SELKIES_CLIPBOARD_ENABLED: "false|locked"
SELKIES_CLIPBOARD_IN_ENABLED: "false|locked"
SELKIES_CLIPBOARD_OUT_ENABLED: "false|locked"
SELKIES_MICROPHONE_ENABLED: "false|locked"
SELKIES_UI_SIDEBAR_SHOW_CLIPBOARD: "false|locked"
SELKIES_UI_SIDEBAR_SHOW_FILES: "false|locked"
SELKIES_UI_SIDEBAR_SHOW_APPS: "false|locked"
ports:
- ${SWITCH_REMOTE_PORT:-1262}:3001
devices:
- /dev/dri:/dev/dri
shm_size: 1gb
volumes:
- ${SWITCH_DATA_ROOT}/config:/config:rw
- ${SWITCH_LIBRARY:?set the Switch source library}:/games:ro
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD
- python3
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=3).read()"
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
+52
View File
@@ -0,0 +1,52 @@
name: ludarium
services:
ludarium:
container_name: ${LUDARIUM_CONTAINER_NAME:-Ludarium}
build:
context: ..
dockerfile: Dockerfile.unraid
args:
LUDARIUM_SOURCE_REVISION: ${GITEA_COMMIT_SHA:-local}
LUDARIUM_RELEASE_VERSION: ${LUDARIUM_RELEASE_VERSION:-0.4.19-rc.8}
image: ${LUDARIUM_IMAGE:-ludarium/ludarium:0.4.19-rc.8}
environment:
ConnectionStrings__Ludarium: Host=127.0.0.1;Database=ludarium;Username=ludarium;Password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD};GSS Encryption Mode=Disable
LUDARIUM_ADMIN_TOKEN: ${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN}
RAWG_API_KEY: ${RAWG_API_KEY:-}
IGDB_CLIENT_ID: ${IGDB_CLIENT_ID:-}
IGDB_CLIENT_SECRET: ${IGDB_CLIENT_SECRET:-}
MOBYGAMES_API_KEY: ${MOBYGAMES_API_KEY:-}
SCREENSCRAPER_DEVID: ${SCREENSCRAPER_DEVID:-}
SCREENSCRAPER_DEVPASSWORD: ${SCREENSCRAPER_DEVPASSWORD:-}
SCREENSCRAPER_SOFTNAME: ${SCREENSCRAPER_SOFTNAME:-Ludarium}
SCREENSCRAPER_USER: ${SCREENSCRAPER_USER:-}
SCREENSCRAPER_PASSWORD: ${SCREENSCRAPER_PASSWORD:-}
RETROACHIEVEMENTS_API_KEY: ${RETROACHIEVEMENTS_API_KEY:-}
RETROACHIEVEMENTS_USERNAME: ${RETROACHIEVEMENTS_USERNAME:-}
BROWSERPLAY_MAX_SESSIONS: ${BROWSERPLAY_MAX_SESSIONS:-2}
POSTGRES_DB: ludarium
POSTGRES_USER: ludarium
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
PUID: ${PUID:-1654}
PGID: ${PGID:-1654}
TZ: ${TZ:-Europe/Brussels}
ports: ["${LUDARIUM_HTTP_PORT:-1230}:8734"]
labels:
net.unraid.docker.webui: "${LUDARIUM_PUBLIC_URL:-http://[IP]:[PORT:1230]/}"
net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg"
net.unraid.docker.managed: "dockerman"
net.unraid.docker.shell: ""
volumes:
- ${LUDARIUM_DATA_ROOT:-./data}/app:/app/data:rw
- ${LUDARIUM_DATA_ROOT:-./data}/cache:/app/cache:rw
- ${LUDARIUM_DATA_ROOT:-./data}/exports:/app/exports:rw
- ${LUDARIUM_DATA_ROOT:-./data}/postgres:/var/lib/postgresql/data:rw
- ${GAMES_LIBRARY:-./fixtures/games}:/library/games:ro
- ${PS4_LIBRARY:-./fixtures/ps4}:/library/ps4:ro
- ${PS5_LIBRARY:-./fixtures/ps5}:/library/ps5:ro
read_only: true
tmpfs: [/tmp, /var/run/postgresql]
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, KILL, SETGID, SETUID]
security_opt: [no-new-privileges:true]
restart: unless-stopped
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/with-contenv bash
exec /usr/bin/python3 /opt/ludarium/dolphin-controller.py
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Ludarium Dolphin (GameCube and Wii) sidecar controller.
Only the Dolphin-specific runtime identity lives here; the bounded HTTP control surface,
path validation, display resolution and process supervision are shared with every other
Ludarium emulator sidecar. See deploy/ludarium_sidecar.py.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ludarium_sidecar as sidecar
TOKEN = os.environ.get("LUDARIUM_DOLPHIN_CONTROL_TOKEN", "")
GAMES = os.environ.get("LUDARIUM_GAMES_ROOT", "/games")
# App-owned save directory. Ludarium exports and restores it through the vault; the exact
# location differs per emulator build, so the operator can override it.
SAVE_ROOT = os.environ.get("LUDARIUM_DOLPHIN_SAVE_ROOT", "/config/.local/share/dolphin-emu")
# Dolphin's profile also holds shader caches and dumps, which dwarf one bounded revision.
# Only the GameCube memory cards, the Wii NAND and the savestates are captured.
SAVE_PATHS = tuple(part.strip()
for part in os.environ.get("LUDARIUM_DOLPHIN_SAVE_PATHS", "").split(",")
if part.strip()) or ("GC", "Wii", "StateSaves")
RUNTIME = sidecar.EmulatorRuntime(
name="dolphin",
process_name="dolphin-emu",
launch_argv=lambda target: ["/usr/bin/dolphin-emu", "--batch", "--exec", str(target)],
games=GAMES,
extensions={".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"},
hotkeys={"pause-resume": "F10", "fullscreen": "alt+Return",
"save-state": "shift+F1", "load-state": "F1"},
save_root=SAVE_ROOT,
save_paths=SAVE_PATHS,
save_mode="native-and-savestate-persistent",
# Dolphin's own main window also carries "Dolphin" in its title, so the launched
# process id is the authoritative match and this pattern is only a bounded fallback.
window_name="Dolphin",
# Both source libraries are mounted under one /games root, so the runtime path must
# name its platform directory. The API only ever emits "gamecube/..." or "wii/...".
platforms={"gamecube", "wii"},
)
def main():
sidecar.serve(RUNTIME, TOKEN)
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/with-contenv bash
exec /usr/bin/python3 /opt/ludarium/eden-controller.py
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Ludarium Eden (Nintendo Switch) sidecar controller.
Only the Switch-specific runtime identity lives here; the bounded HTTP control surface,
path validation, display resolution and process supervision are shared with every other
Ludarium emulator sidecar. See deploy/ludarium_sidecar.py.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ludarium_sidecar as sidecar
TOKEN = os.environ.get("LUDARIUM_EDEN_CONTROL_TOKEN", "")
GAMES = os.environ.get("LUDARIUM_GAMES_ROOT", "/games")
# App-owned save directory. Ludarium exports and restores it through the vault; the exact
# location differs per emulator build, so the operator can override it.
SAVE_ROOT = os.environ.get("LUDARIUM_EDEN_SAVE_ROOT", "/config/.local/share/eden")
# Eden keeps Switch save data in its emulated NAND user profile.
SAVE_PATHS = tuple(part.strip()
for part in os.environ.get("LUDARIUM_EDEN_SAVE_PATHS", "").split(",")
if part.strip()) or ("nand/user/save",)
RUNTIME = sidecar.EmulatorRuntime(
name="eden",
process_name="eden",
launch_argv=lambda target: ["/usr/bin/eden", "-f", "-g", str(target)],
games=GAMES,
extensions={".xci", ".nsp"},
hotkeys={"pause-resume": "F4", "fullscreen": "F11"},
save_root=SAVE_ROOT,
save_paths=SAVE_PATHS,
save_mode="native-persistent",
# Eden titles the running game window "eden <title> |"; the emulator's own front-end
# window does not match, so a hotkey cannot reach it if _NET_WM_PID is unavailable.
window_name=r"eden .* \|",
)
def main():
sidecar.serve(RUNTIME, TOKEN)
if __name__ == "__main__":
main()
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -Eeuo pipefail
postgres_pid=""
app_pid=""
shutdown_requested=0
terminate_children() {
[[ -z "$app_pid" ]] || kill -TERM "$app_pid" 2>/dev/null || true
[[ -z "$postgres_pid" ]] || kill -TERM "$postgres_pid" 2>/dev/null || true
for _ in $(seq 1 10); do
if { [[ -z "$app_pid" ]] || ! kill -0 "$app_pid" 2>/dev/null; } &&
{ [[ -z "$postgres_pid" ]] || ! kill -0 "$postgres_pid" 2>/dev/null; }; then
break
fi
sleep 1
done
[[ -z "$app_pid" ]] || ! kill -0 "$app_pid" 2>/dev/null || kill -KILL "$app_pid" 2>/dev/null || true
[[ -z "$postgres_pid" ]] || ! kill -0 "$postgres_pid" 2>/dev/null || kill -KILL "$postgres_pid" 2>/dev/null || true
[[ -z "$app_pid" ]] || wait "$app_pid" 2>/dev/null || true
[[ -z "$postgres_pid" ]] || wait "$postgres_pid" 2>/dev/null || true
}
shutdown() {
shutdown_requested=1
trap - TERM INT
terminate_children
}
trap shutdown TERM INT
runtime_owner="${PUID:-1654}:${PGID:-1654}"
for app_directory in /app/data /app/cache /app/exports; do
current_owner="$(stat -c '%u:%g' "$app_directory")"
if [[ "$current_owner" != "$runtime_owner" ]]; then
chown -R "$runtime_owner" "$app_directory"
fi
done
/usr/local/bin/docker-entrypoint.sh postgres &
postgres_pid=$!
for attempt in $(seq 1 60); do
if pg_isready --host=127.0.0.1 --username="${POSTGRES_USER:-ludarium}" --dbname="${POSTGRES_DB:-ludarium}" >/dev/null 2>&1; then break; fi
if ! kill -0 "$postgres_pid" 2>/dev/null; then wait "$postgres_pid"; exit $?; fi
if [[ "$attempt" == "60" ]]; then echo "PostgreSQL did not become ready within 60 seconds" >&2; exit 1; fi
sleep 1
done
/usr/local/bin/repair-postgres-collation.sh
gosu "${PUID:-1654}:${PGID:-1654}" /usr/share/dotnet/dotnet /app/Ludarium.Api.dll &
app_pid=$!
set +e
wait -n "$postgres_pid" "$app_pid"
first_status=$?
set -e
if [[ "$shutdown_requested" == "1" ]]; then
exit 0
fi
if ! kill -0 "$postgres_pid" 2>/dev/null; then
echo "PostgreSQL exited unexpectedly; stopping the Ludarium API." >&2
terminate_children
[[ "$first_status" == "0" ]] && exit 1
exit "$first_status"
fi
echo "The Ludarium API exited; stopping embedded PostgreSQL." >&2
terminate_children
exit "$first_status"
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0"?>
<Container version="2">
<Name>Ludarium</Name><Repository>ludarium/ludarium:0.4.19-rc.8</Repository><Registry>https://hub.docker.com/r/ludarium/ludarium</Registry>
<Network>bridge</Network><Privileged>false</Privileged><Support>https://github.com/ludarium/ludarium</Support><Project>https://github.com/ludarium/ludarium</Project>
<ExtraParams>--read-only --cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=KILL --cap-add=SETGID --cap-add=SETUID --security-opt=no-new-privileges --tmpfs=/tmp --tmpfs=/var/run/postgresql</ExtraParams>
<Overview>Local-first game archive intelligence. Source libraries must be mapped read-only.</Overview><WebUI>http://[IP]:[PORT:1230]/</WebUI><Icon>/boot/config/plugins/dockerMan/images/Ludarium-icon.svg</Icon>
<Config Name="Web UI" Target="8734" Default="1230" Mode="tcp" Description="Ludarium web port" Type="Port" Display="always" Required="true" Mask="false"/>
<Config Name="Appdata" Target="/app/data" Default="/mnt/user/appdata/ludarium" Mode="rw" Type="Path" Display="always" Required="true" Mask="false"/>
<Config Name="Exports" Target="/app/exports" Default="/mnt/user/appdata/ludarium/exports" Mode="rw" Type="Path" Display="advanced" Required="true" Mask="false"/>
<Config Name="Games library" Target="/library/games" Default="/mnt/user/Media/Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/>
<Config Name="PS4 library" Target="/library/ps4" Default="/mnt/user/PS4-Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/>
<Config Name="PS5 library" Target="/library/ps5" Default="/mnt/user/PS5-Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/>
<Config Name="Database connection" Target="ConnectionStrings__Ludarium" Default="" Mode="" Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="Admin token" Target="LUDARIUM_ADMIN_TOKEN" Default="" Mode="" Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="RAWG API key" Target="RAWG_API_KEY" Default="" Mode="" Description="Optional: enables online wishlist discovery; the offline core remains fully usable without it" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="IGDB client ID" Target="IGDB_CLIENT_ID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="IGDB client secret" Target="IGDB_CLIENT_SECRET" Default="" Mode="" Description="Optional native metadata provider secret" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="MobyGames API key" Target="MOBYGAMES_API_KEY" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ScreenScraper developer ID" Target="SCREENSCRAPER_DEVID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ScreenScraper developer password" Target="SCREENSCRAPER_DEVPASSWORD" Default="" Mode="" Description="Optional native metadata provider secret" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ScreenScraper software name" Target="SCREENSCRAPER_SOFTNAME" Default="Ludarium" Mode="" Description="Optional native metadata provider client name" Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="ScreenScraper user" Target="SCREENSCRAPER_USER" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="ScreenScraper password" Target="SCREENSCRAPER_PASSWORD" Default="" Mode="" Description="Optional personal ScreenScraper account secret" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="RetroAchievements API key" Target="RETROACHIEVEMENTS_API_KEY" Default="" Mode="" Description="Optional native achievements provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="RetroAchievements user" Target="RETROACHIEVEMENTS_USERNAME" Default="" Mode="" Description="Optional personal achievement progress" Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="Timezone" Target="TZ" Default="Europe/Brussels" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/>
<Config Name="Runtime UID" Target="PUID" Default="1000" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/>
<Config Name="Runtime GID" Target="PGID" Default="1000" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/>
</Container>
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env python3
"""Shared control-surface primitives for Ludarium emulator sidecars.
Every isolated player sidecar exposes the same bounded surface: a token-authenticated
HTTP server on :8765 with /health, /v1/status, /v1/launch and /v1/action. Only the
emulator binary, its game-path allowlist, its window identity and its hotkey map
differ, so a concrete controller supplies only those.
Keeping the surface in one module is a safety property, not only tidiness. The
Xwayland display resolution below was found and fixed against the live Eden runtime;
a per-sidecar copy silently missed that fix. Anything a future Azahar, xemu, Cemu or
Vita3K controller needs belongs here so it inherits the same behaviour.
The module has no third-party dependencies: the sidecar images are minimized and must
not regain a package manager surface.
"""
import hmac
import io
import json
import os
import signal
import subprocess
import tarfile
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path, PurePosixPath
CONTROL_PORT = 8765
MIN_REQUEST_BYTES = 2
MAX_REQUEST_BYTES = 8192
# The Ludarium game-data vault stores one revision of at most 64 MiB, so an archive larger than that
# could never be kept. Refusing it here keeps the failure at the sidecar instead of half-way upstream.
MAX_SAVE_DATA_BYTES = 64 * 1024 * 1024
MAX_SAVE_DATA_ENTRIES = 20000
MAX_SAVE_DATA_ENTRY_BYTES = 256 * 1024 * 1024
MAX_SAVE_DATA_EXPANDED_BYTES = 512 * 1024 * 1024
SETUIDGID = "/usr/bin/s6-setuidgid"
RUNTIME_USER = "abc"
XDOTOOL = "/usr/bin/xdotool"
def respond(handler, status, body):
payload = json.dumps(body).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(payload)))
handler.end_headers()
handler.wfile.write(payload)
def authorized(handler, token):
supplied = handler.headers.get("X-Ludarium-Control-Token", "")
return bool(token) and hmac.compare_digest(token, supplied)
def resolve_game_path(value, games, extensions, platforms=None):
"""Resolve a runtime-relative game path to an exact file inside the read-only mount.
This is the sidecar's last defence. The API already validates and prefixes the path,
but the controller must never execute anything it cannot prove lives inside the
games mount, carries an allowlisted extension and is a regular file. Symlinks are
resolved before the containment check, so a link out of the mount fails closed.
"""
relative = PurePosixPath(value)
if relative.is_absolute() or not relative.parts:
raise ValueError("Unsafe game path")
if any(part in ("", ".", "..") for part in relative.parts):
raise ValueError("Unsafe game path")
if platforms is not None and (len(relative.parts) < 2 or relative.parts[0] not in platforms):
raise ValueError("Unsafe game path")
root = Path(games).resolve()
target = (root / Path(*relative.parts)).resolve()
if root not in target.parents:
raise ValueError("Game is outside the read-only games mount")
if target.suffix.lower() not in extensions:
raise ValueError("Game is not an allowlisted read-only image")
if not target.is_file():
raise ValueError("Game is not a present read-only file")
return target
def desktop_display(process_name):
"""Resolve the Xwayland display owned by the active emulator desktop.
PixelFlux allocates a new display number after container restarts while retaining
older socket files. Reading the running desktop process is authoritative; the newest
socket is only a bounded fallback for the short interval before the emulator has
published its environment.
"""
try:
process = subprocess.run(
["/usr/bin/pgrep", "-o", "-x", process_name], capture_output=True, text=True,
timeout=2, check=False)
pid = process.stdout.strip()
if process.returncode == 0 and pid.isdigit():
environment = subprocess.run(
[SETUIDGID, RUNTIME_USER, "/bin/cat", f"/proc/{pid}/environ"],
capture_output=True, timeout=2, check=False)
for item in environment.stdout.split(b"\0"):
if item.startswith(b"DISPLAY=:"):
display = item.removeprefix(b"DISPLAY=").decode("ascii", errors="ignore")
if display[1:].isdigit():
return display
except (OSError, subprocess.SubprocessError):
pass
sockets = []
try:
for socket in Path("/tmp/.X11-unix").glob("X*"):
if socket.name[1:].isdigit():
sockets.append((socket.stat().st_mtime_ns, int(socket.name[1:])))
except OSError:
pass
if sockets:
return f":{max(sockets)[1]}"
return os.environ.get("DISPLAY", ":0")
def resolve_save_paths(save_root, save_paths):
"""Resolve the subdirectories of a profile that actually hold save data.
An emulator profile is not a save directory. Dolphin's holds shader caches and dumps that dwarf
the bounded revision size, so capturing the whole profile would fail on any real installation.
Naming the save-bearing subdirectories keeps a capture to what an operator wants back, and
resolving them here means a wrong path is visible in /v1/status instead of silently capturing
the wrong thing.
"""
if save_root is None:
return []
root = Path(save_root)
if not root.is_dir():
return []
root = root.resolve()
if not save_paths:
return [root]
resolved = []
for relative in save_paths:
candidate = PurePosixPath(relative)
if candidate.is_absolute() or any(part in ("", ".", "..") for part in candidate.parts):
continue
target = (root / Path(*candidate.parts)).resolve()
if root in target.parents and target.is_dir():
resolved.append(target)
return resolved
def export_save_data(save_root, save_paths=()):
"""Pack an emulator's app-owned save directories into one bounded gzip archive.
Only regular files are packed and every ownership, device and symlink attribute is dropped, so
the archive describes save content and nothing about the container it came from. Paths stay
relative to the profile root, so a restore lands exactly where the capture came from.
"""
root = Path(save_root).resolve() if save_root is not None else None
directories = resolve_save_paths(save_root, save_paths)
if not directories:
raise ValueError("No save-data directory is present for this runtime")
buffer = io.BytesIO()
packed = 0
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
for directory in directories:
for path in sorted(directory.rglob("*")):
if path.is_symlink() or not path.is_file():
continue
packed += 1
if packed > MAX_SAVE_DATA_ENTRIES:
raise ValueError("The save directories hold more files than one revision may carry")
info = archive.gettarinfo(str(path), arcname=str(path.relative_to(root).as_posix()))
info.uid = info.gid = 0
info.uname = info.gname = ""
info.mode = 0o644
info.mtime = int(info.mtime)
with path.open("rb") as content:
archive.addfile(info, content)
if buffer.tell() > MAX_SAVE_DATA_BYTES:
raise ValueError("The save-data archive exceeds the bounded revision size")
if packed == 0:
raise ValueError("The configured save directories hold no save data yet")
return buffer.getvalue()
def import_save_data(save_root, payload, save_paths=()):
"""Restore a previously exported archive over an emulator's save directories.
The archive is operator data that has travelled through the vault, so every member is checked
before extraction: only plain relative files are written, nothing may escape the root, and a
member must land inside one of the configured save directories. A restore therefore cannot
reach an emulator's configuration even if the stored archive was crafted to try.
"""
root = Path(save_root).resolve() if save_root is not None else None
directories = resolve_save_paths(save_root, save_paths)
if root is None or not root.is_dir() or not directories:
raise ValueError("No save-data directory is present for this runtime")
if len(payload) > MAX_SAVE_DATA_BYTES:
raise ValueError("The save-data archive exceeds the bounded revision size")
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
members = archive.getmembers()
if len(members) > MAX_SAVE_DATA_ENTRIES:
raise ValueError("The save-data archive holds more files than one revision may carry")
expanded = 0
validated = []
for member in members:
if not member.isfile():
raise ValueError(f"The save-data archive contains an unsupported entry '{member.name}'")
if member.size < 0 or member.size > MAX_SAVE_DATA_ENTRY_BYTES:
raise ValueError(f"The save-data archive entry '{member.name}' is too large")
expanded += member.size
if expanded > MAX_SAVE_DATA_EXPANDED_BYTES:
raise ValueError("The expanded save-data archive exceeds the restore limit")
relative = PurePosixPath(member.name)
if relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts):
raise ValueError(f"The save-data archive contains an unsafe path '{member.name}'")
destination = (root / Path(*relative.parts)).resolve()
if root not in destination.parents:
raise ValueError(f"The save-data archive escapes the save directory at '{member.name}'")
if not any(directory == destination or directory in destination.parents
for directory in directories):
raise ValueError(f"The save-data archive targets '{member.name}' outside the save directories")
validated.append((member, destination))
restored = 0
for member, destination in validated:
destination.parent.mkdir(parents=True, exist_ok=True)
source = archive.extractfile(member)
if source is None:
raise ValueError(f"The save-data archive entry '{member.name}' is unreadable")
temporary = None
try:
with source, tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as output:
temporary = Path(output.name)
copied = 0
while chunk := source.read(1024 * 1024):
copied += len(chunk)
if copied > member.size or copied > MAX_SAVE_DATA_ENTRY_BYTES:
raise ValueError(f"The save-data archive entry '{member.name}' exceeds its declared size")
output.write(chunk)
if copied != member.size:
raise ValueError(f"The save-data archive entry '{member.name}' is truncated")
os.chmod(temporary, 0o644)
os.replace(temporary, destination)
temporary = None
restored += 1
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return restored
def runtime_environment(process_name):
return {
**os.environ,
"HOME": "/config",
"DISPLAY": desktop_display(process_name),
"XDG_RUNTIME_DIR": "/config/.XDG",
"WAYLAND_DISPLAY": "wayland-0",
}
def visible_windows(environment, pid=None, name=None):
"""List visible window ids for the launched title.
The launched process id is authoritative: it is the exact emulator Ludarium started,
so a hotkey can never reach an unrelated window such as an emulator's own menu or
file browser. Matching on the window name stays as a bounded fallback for runtimes
that do not publish _NET_WM_PID.
"""
for query in ([["--pid", str(pid)]] if pid else []) + ([["--name", name]] if name else []):
found = subprocess.run(
[SETUIDGID, RUNTIME_USER, XDOTOOL, "search", "--onlyvisible", *query],
env=environment, capture_output=True, text=True, timeout=5, check=False)
windows = [line for line in found.stdout.splitlines() if line.isdigit()]
if windows:
return windows
return []
class EmulatorRuntime:
"""Owns the single title process a sidecar may run at a time."""
def __init__(self, name, process_name, launch_argv, games, extensions, hotkeys,
save_mode, platforms=None, window_name=None, ready_seconds=1.0, save_root=None,
save_paths=()):
self.name = name
self.process_name = process_name
self.launch_argv = launch_argv
self.games = games
self.extensions = extensions
self.hotkeys = hotkeys
self.save_mode = save_mode
self.platforms = platforms
self.window_name = window_name
self.ready_seconds = ready_seconds
self.save_root = save_root
self.save_paths = tuple(save_paths)
self.lock = threading.Lock()
self.active = None
def log(self, message):
print(f"{self.name}-controller: {message}", flush=True)
def resolve(self, value):
return resolve_game_path(value, self.games, self.extensions, self.platforms)
def environment(self):
return runtime_environment(self.process_name)
def resolved_save_paths(self):
return resolve_save_paths(self.save_root, self.save_paths)
@property
def save_data_available(self):
return len(self.resolved_save_paths()) > 0
def status(self):
directories = self.resolved_save_paths()
with self.lock:
running = self.active is not None and self.active.poll() is None
return {"running": running, "pid": self.active.pid if running else None,
"saveMode": self.save_mode, "saveData": len(directories) > 0,
# Naming what will be captured makes a misconfigured path visible here rather
# than at the moment an operator tries to keep their progress.
"saveRoot": self.save_root,
"savePaths": [str(directory) for directory in directories]}
def terminate_locked(self):
if self.active is None or self.active.poll() is not None:
self.active = None
return False
self.active.send_signal(signal.SIGTERM)
try:
self.active.wait(timeout=8)
except subprocess.TimeoutExpired:
self.active.kill()
self.active.wait(timeout=3)
self.active = None
return True
def stop(self):
with self.lock:
return self.terminate_locked()
def launch(self, target):
environment = self.environment()
with self.lock:
self.terminate_locked()
self.active = subprocess.Popen(
[SETUIDGID, RUNTIME_USER, *self.launch_argv(target)], env=environment,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
process = self.active
pid = process.pid
time.sleep(self.ready_seconds)
if process.poll() is not None:
self.log(f"{self.name} exited early with code {process.returncode}; "
f"display={environment['DISPLAY']}; game={target}")
return None
return pid
def hotkey(self, key):
environment = self.environment()
with self.lock:
pid = self.active.pid if self.active is not None and self.active.poll() is None else None
windows = visible_windows(environment, pid, self.window_name)
if not windows:
self.log(f"no visible {self.name} title window for hotkey {key}; "
f"display={environment['DISPLAY']}; pid={pid}")
return False
subprocess.run(
[SETUIDGID, RUNTIME_USER, XDOTOOL, "windowactivate", "--sync", windows[-1], "key", key],
env=environment, timeout=5, check=True)
return True
def build_handler(runtime, token):
class Handler(BaseHTTPRequestHandler):
server_version = f"Ludarium{runtime.name.capitalize()}Controller/1"
def log_message(self, fmt, *args):
runtime.log(fmt % args)
def do_GET(self):
if self.path == "/health":
return respond(self, 200, {"status": "healthy"})
if not authorized(self, token):
return respond(self, 404, {"message": "Not found"})
if self.path == "/v1/status":
return respond(self, 200, runtime.status())
if self.path == "/v1/save-data":
return self.export_save_data()
return respond(self, 404, {"message": "Not found"})
def export_save_data(self):
if not runtime.save_data_available:
return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"})
try:
payload = export_save_data(runtime.save_root, runtime.save_paths)
except (ValueError, OSError, tarfile.TarError) as error:
runtime.log(f"save-data export failed: {error}")
return respond(self, 409, {"message": str(error)})
self.send_response(200)
self.send_header("Content-Type", "application/gzip")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_POST(self):
if not authorized(self, token):
return respond(self, 404, {"message": "Not found"})
if self.path == "/v1/save-data":
return self.import_save_data()
body = {}
try:
length = int(self.headers.get("Content-Length", "0"))
if length < MIN_REQUEST_BYTES or length > MAX_REQUEST_BYTES:
raise ValueError("Invalid request size")
body = json.loads(self.rfile.read(length))
if not isinstance(body, dict):
raise ValueError("Invalid request body")
if self.path == "/v1/launch":
return self.launch(body)
if self.path == "/v1/action":
return self.action(body)
return respond(self, 404, {"message": "Not found"})
except (ValueError, json.JSONDecodeError) as error:
runtime.log(f"rejected {self.path}: {error}; "
f"path={body.get('path') if isinstance(body, dict) else None!r}")
return respond(self, 400, {"message": str(error)})
except (OSError, subprocess.SubprocessError) as error:
return respond(self, 503, {"message": f"{runtime.name} control operation failed",
"detail": str(error)})
def import_save_data(self):
if not runtime.save_data_available:
return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"})
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > MAX_SAVE_DATA_BYTES:
return respond(self, 400, {"message": "The save-data archive is empty or exceeds its bound"})
payload = self.rfile.read(length)
try:
written = import_save_data(runtime.save_root, payload, runtime.save_paths)
except (ValueError, OSError, tarfile.TarError, EOFError) as error:
runtime.log(f"save-data restore rejected: {error}")
return respond(self, 400, {"message": str(error)})
runtime.log(f"restored {written} save-data files")
return respond(self, 202, {"state": "restored", "files": written})
def launch(self, body):
target = runtime.resolve(body.get("path", ""))
pid = runtime.launch(target)
if pid is None:
return respond(self, 503, {"message": f"{runtime.name} exited before the title started"})
return respond(self, 202, {"state": "starting", "pid": pid, "saveMode": runtime.save_mode})
def action(self, body):
action = body.get("action", "")
if action == "stop":
if not runtime.stop():
return respond(self, 409, {"message": f"No Ludarium-started {runtime.name} title is running"})
return respond(self, 202, {"state": "accepted", "action": action})
if action not in runtime.hotkeys:
raise ValueError("Unsupported action")
if not runtime.hotkey(runtime.hotkeys[action]):
return respond(self, 409, {"message": f"No running {runtime.name} title window was found"})
return respond(self, 202, {"state": "accepted", "action": action})
return Handler
def serve(runtime, token, port=CONTROL_PORT):
if not token:
raise SystemExit(f"A {runtime.name} control token is required")
ThreadingHTTPServer(("0.0.0.0", port), build_handler(runtime, token)).serve_forever()
+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
set -eu
: "${SWITCH_DATA_ROOT:?set the isolated app-owned Switch data root}"
: "${SWITCH_LIBRARY:?set the read-only Switch source library}"
: "${PUID:?set the non-root Eden UID}"
: "${PGID:?set the non-root Eden GID}"
case "$PUID:$PGID" in
*[!0-9:]*|:*|*:) echo "PUID and PGID must be numeric." >&2; exit 1 ;;
esac
if [ "$PUID" -eq 0 ] || [ "$PGID" -eq 0 ]; then
echo "Eden must use a non-root PUID and PGID." >&2
exit 1
fi
case "$SWITCH_DATA_ROOT" in
/*) ;;
*) echo "SWITCH_DATA_ROOT must be absolute." >&2; exit 1 ;;
esac
case "$SWITCH_LIBRARY" in
/*) ;;
*) echo "SWITCH_LIBRARY must be absolute." >&2; exit 1 ;;
esac
data_parent=$(dirname -- "$SWITCH_DATA_ROOT")
data_name=$(basename -- "$SWITCH_DATA_ROOT")
if [ ! -d "$data_parent" ]; then
echo "The SWITCH_DATA_ROOT parent must already exist: $data_parent" >&2
exit 1
fi
data_parent=$(readlink -f -- "$data_parent")
data_root=$(readlink -m -- "$data_parent/$data_name")
library_root=$(readlink -f -- "$SWITCH_LIBRARY")
case "$data_root" in
/|/mnt|/mnt/user|/mnt/user/appdata)
echo "Refusing unsafe SWITCH_DATA_ROOT: $data_root" >&2
exit 1
;;
esac
case "$data_root/" in
"$library_root"/*)
echo "SWITCH_DATA_ROOT must not be inside the source library." >&2
exit 1
;;
esac
case "$library_root/" in
"$data_root"/*)
echo "SWITCH_LIBRARY must not be inside app-owned data." >&2
exit 1
;;
esac
mkdir -p -- "$data_root/config"
data_root=$(readlink -f -- "$data_root")
# Eden must be able to read and update its own persistent config after a PUID/PGID change.
# This path is app-owned; the source library is intentionally never touched.
chown -R "$PUID:$PGID" -- "$data_root/config"
chmod 750 -- "$data_root/config"
find "$data_root/config" -type f -name qt-config.ini -exec chmod 600 {} +
printf 'Prepared %s for Eden identity %s:%s; source library untouched: %s\n' \
"$data_root/config" "$PUID" "$PGID" "$library_root"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -Eeuo pipefail
database="${POSTGRES_DB:-ludarium}"
database_user="${POSTGRES_USER:-ludarium}"
if [[ ! "$database" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "PostgreSQL collation repair requires a simple validated database identifier." >&2
exit 1
fi
psql_base=(psql -v ON_ERROR_STOP=1 -U "$database_user" -d "$database")
versions="$("${psql_base[@]}" -At -F '|' -c \
"select coalesce(datcollversion,''),coalesce(pg_database_collation_actual_version(oid),'') from pg_database where datname=current_database()")"
IFS='|' read -r stored_version actual_version <<< "$versions"
if [[ -z "$actual_version" || "$stored_version" == "$actual_version" ]]; then
exit 0
fi
printf 'PostgreSQL collation drift detected for %s: stored=%s actual=%s\n' \
"$database" "${stored_version:-<unversioned>}" "$actual_version"
has_platform_definitions="$("${psql_base[@]}" -At -c \
"select to_regclass('public.platform_definitions') is not null")"
if [[ "$has_platform_definitions" == "t" ]]; then
unsafe_duplicates="$("${psql_base[@]}" -At -c "
with duplicate_ids as (
select id from platform_definitions group by id having count(*) > 1
), variants as (
select p.id,
count(distinct jsonb_build_object(
'custom',p.custom,'enabled',p.enabled,'version',p.version,
'data',p.data - 'updatedAt')) as semantic_variants,
bool_or(p.custom) as contains_custom
from platform_definitions p join duplicate_ids d using(id)
group by p.id
)
select count(*) from variants where contains_custom or semantic_variants <> 1")"
if [[ "$unsafe_duplicates" != "0" ]]; then
echo "Collation repair found ambiguous or custom platform duplicates; refusing automatic data changes." >&2
exit 1
fi
removed_duplicates="$("${psql_base[@]}" -At -c "
with ranked as (
select ctid,row_number() over(partition by id order by updated_at desc,ctid desc) as ordinal
from platform_definitions
), removed as (
delete from platform_definitions p using ranked r
where p.ctid=r.ctid and r.ordinal > 1 returning 1
)
select count(*) from removed")"
if [[ "$removed_duplicates" != "0" ]]; then
printf 'Removed %s semantically identical built-in platform duplicate(s) before reindex.\n' "$removed_duplicates"
fi
fi
"${psql_base[@]}" -c "REINDEX DATABASE \"$database\"" >/dev/null
if [[ -z "$stored_version" ]]; then
"${psql_base[@]}" -c "
update pg_database
set datcollversion=pg_database_collation_actual_version(oid)
where datname=current_database() and datcollversion is null" >/dev/null
else
"${psql_base[@]}" -c "ALTER DATABASE \"$database\" REFRESH COLLATION VERSION" >/dev/null
fi
refreshed_version="$("${psql_base[@]}" -At -c \
"select coalesce(datcollversion,'') from pg_database where datname=current_database()")"
if [[ "$refreshed_version" != "$actual_version" ]]; then
echo "PostgreSQL collation version did not refresh to the runtime version." >&2
exit 1
fi
printf 'PostgreSQL collation indexes rebuilt and version refreshed to %s.\n' "$refreshed_version"
+255
View File
@@ -0,0 +1,255 @@
#!/bin/sh
set -eu
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
compose="$repo/deploy/compose.yml"
project="${LUDARIUM_CANDIDATE_PROJECT:-ludarium-candidate}"
candidate_port="${LUDARIUM_CANDIDATE_PORT:-1232}"
data_root="${LUDARIUM_CANDIDATE_DATA_ROOT:?set an isolated candidate data root}"
release_identity="$(tr -d '\r\n' < "$repo/VERSION")"
image="${LUDARIUM_IMAGE:-ludarium/ludarium:$release_identity}"
release_version="${LUDARIUM_RELEASE_VERSION:-${image##*:}}"
playwright_image="mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e"
browser_gate_mode="${LUDARIUM_BROWSER_GATE_MODE:-docker}"
dotnet_image="mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0"
gate_cpus="${LUDARIUM_GATE_CPUS:-4}"
gate_memory="${LUDARIUM_GATE_MEMORY:-8g}"
browser_gate_cpus="${LUDARIUM_BROWSER_GATE_CPUS:-4}"
browser_gate_memory="${LUDARIUM_BROWSER_GATE_MEMORY:-4g}"
build_nodes="${LUDARIUM_GATE_BUILD_NODES:-2}"
export COMPOSE_PARALLEL_LIMIT="${LUDARIUM_COMPOSE_PARALLEL_LIMIT:-1}"
case "$browser_gate_mode" in docker|external) ;; *) echo "browser gate mode must be docker or external" >&2; exit 2;; esac
case "$gate_cpus:$browser_gate_cpus:$build_nodes:$COMPOSE_PARALLEL_LIMIT" in
*[!0-9.:]*) echo "candidate resource limits must be positive numeric values" >&2; exit 2 ;;
esac
for value in "$gate_cpus" "$browser_gate_cpus" "$build_nodes" "$COMPOSE_PARALLEL_LIMIT"; do
[ "$value" != "0" ] || { echo "candidate resource limits must be greater than zero" >&2; exit 2; }
done
case "$gate_memory:$browser_gate_memory" in
*[!0-9kKmMgGtT:]*|:) echo "candidate memory limits must use Docker byte values such as 8g" >&2; exit 2 ;;
esac
case "$data_root" in
/mnt/user/appdata/ludarium-candidate|/mnt/user/appdata/ludarium-candidate/*|/tmp/ludarium-candidate|/tmp/ludarium-candidate/*) ;;
*) echo "candidate data root must remain below /mnt/user/appdata/ludarium-candidate or /tmp/ludarium-candidate" >&2; exit 2 ;;
esac
: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
: "${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN}"
: "${GAMES_LIBRARY:?set GAMES_LIBRARY}"
: "${PS4_LIBRARY:?set PS4_LIBRARY}"
: "${PS5_LIBRARY:?set PS5_LIBRARY}"
: "${PUID:?set the numeric read-capable application UID}"
: "${PGID:?set the numeric read-capable application GID}"
for root in "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY"; do
[ -d "$root" ] || { echo "library root unavailable: $root" >&2; exit 3; }
done
command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; }
ensure_image() {
target_image="$1"
docker image inspect "$target_image" >/dev/null 2>&1 && return
attempt=1
while [ "$attempt" -le 3 ]; do
if command -v timeout >/dev/null; then timeout 420 docker pull "$target_image" && return
else docker pull "$target_image" && return
fi
attempt=$((attempt + 1))
sleep $((attempt * 5))
done
echo "bounded image download failed after 3 attempts: $target_image" >&2
exit 3
}
[ "$browser_gate_mode" = "external" ] || ensure_image "$playwright_image"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT INT TERM
before="$work/source-before.txt"
after="$work/source-after.txt"
source_archive="$work/source.tar"
git -C "$repo" archive --format=tar --output="$source_archive" HEAD
sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$before"
cat "$before"
if command -v dotnet >/dev/null; then
dotnet restore "$repo/Ludarium.slnx" --locked-mode
dotnet format "$repo/Ludarium.slnx" --no-restore --verify-no-changes
dotnet build "$repo/Ludarium.slnx" -c Release --no-restore -m:"$build_nodes" -p:UseSharedCompilation=false
LUDARIUM_RUN_CONTAINER_TESTS=1 dotnet test "$repo/Ludarium.slnx" -c Release --no-build --no-restore -m:"$build_nodes"
else
docker run --rm --network host --cpus "$gate_cpus" --memory "$gate_memory" \
--memory-swap "$gate_memory" --pids-limit 1024 --tmpfs /work:rw,nosuid,nodev,size=2g \
-v /var/run/docker.sock:/var/run/docker.sock -v "$source_archive:/source.tar:ro" -w /work \
-e LUDARIUM_RUN_CONTAINER_TESTS=1 -e LUDARIUM_GATE_BUILD_NODES="$build_nodes" \
"$dotnet_image" sh -lc \
'tar -xf /source.tar -C /work && dotnet restore Ludarium.slnx --locked-mode && dotnet format Ludarium.slnx --no-restore --verify-no-changes && dotnet build Ludarium.slnx -c Release --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES" -p:UseSharedCompilation=false && dotnet test Ludarium.slnx -c Release --no-build --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES"'
fi
# The emulator sidecar controllers are the last defence before a process launch inside an
# isolated player. They are stdlib-only, so this gate needs no dependency installation.
if command -v python3 >/dev/null; then
( cd "$repo" && python3 -m unittest discover -s tests/controllers -t tests/controllers )
else
docker run --rm --network none --cpus "$gate_cpus" --memory 512m \
-v "$repo:/repo:ro" -w /repo python:3.13-alpine@sha256:540c7d91f98ff6880174c40e99067bf5941eb54d818a7a5e094d188b196a934d \
python -m unittest discover -s tests/controllers -t tests/controllers
fi
mkdir -p "$data_root/app" "$data_root/cache" "$data_root/exports" "$data_root/postgres" "$data_root/backups"
chown -R "$PUID:$PGID" "$data_root/app" "$data_root/cache" "$data_root/exports"
export LUDARIUM_DATA_ROOT="$data_root" LUDARIUM_HTTP_PORT="$candidate_port" LUDARIUM_IMAGE="$image"
export LUDARIUM_CONTAINER_NAME="${LUDARIUM_CANDIDATE_CONTAINER_NAME:-${project}-Ludarium}"
docker compose -p "$project" -f "$compose" build --pull
image_digest="$(docker image inspect "$image" --format '{{.Id}}')"
docker compose -p "$project" -f "$compose" up -d --wait
app_id="$(docker compose -p "$project" -f "$compose" ps -q ludarium)"
[ -n "$app_id" ] || { echo "candidate app container was not created" >&2; exit 4; }
initial_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
[ "$initial_restart_count" = "0" ] || { echo "candidate started with an unexpected restart count: $initial_restart_count" >&2; exit 4; }
docker inspect "$app_id" --format '{{range .Config.Env}}{{println .}}{{end}}' |
grep -Fq 'GSS Encryption Mode=Disable' || {
echo "candidate database connection must disable unused local GSS negotiation" >&2
exit 4
}
for destination in /library/games /library/ps4 /library/ps5; do
docker inspect "$app_id" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' | grep -F "$destination false" >/dev/null || {
echo "read-only mount proof failed for $destination" >&2; exit 5;
}
done
wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null
schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select max(version) from schema_versions')"
expected_schema="$(sed -n 's/.*CurrentSchemaVersion = \([0-9][0-9]*\).*/\1/p' "$repo/src/Ludarium.Infrastructure/PostgresStore.cs")"
case "$expected_schema" in ''|*[!0-9]*) echo "could not read the declared schema version" >&2; exit 6;; esac
[ "$schema" = "$expected_schema" ] || { echo "expected schema $expected_schema, found $schema" >&2; exit 6; }
server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')"
[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding, found $server_encoding" >&2; exit 6; }
docker compose -p "$project" -f "$compose" exec -T ludarium locale -a | grep -Fx 'en_US.utf8' >/dev/null || {
echo "candidate runtime must provide the legacy production locale en_US.utf8" >&2
exit 6
}
for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do
docker compose -p "$project" -f "$compose" exec -T ludarium \
psql -v ON_ERROR_STOP=1 -U ludarium -d ludarium \
-c "ALTER SYSTEM SET $locale_setting TO 'en_US.utf8'" >/dev/null
done
reload_result="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select pg_reload_conf()')"
[ "$reload_result" = "t" ] || { echo "failed to load the production-shaped locale profile" >&2; exit 6; }
collation_fixture="ludarium_collation_fixture"
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium --if-exists "$collation_fixture"
docker compose -p "$project" -f "$compose" exec -T ludarium \
createdb -U ludarium --template=template0 --locale=en_US.utf8 "$collation_fixture"
docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d "$collation_fixture" -c "
create table platform_definitions(id text,custom boolean not null,enabled boolean not null,version bigint not null,data jsonb not null,updated_at timestamptz not null);
insert into platform_definitions values
('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-10T00:00:00Z\"}',timestamptz '2026-08-10T00:00:00Z'),
('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-21T00:00:00Z\"}',timestamptz '2026-08-21T00:00:00Z');
create table indexed_fixture(id text primary key);
insert into indexed_fixture values('alpha'),('beta');" >/dev/null
fixture_index_before="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")"
docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d postgres -c \
"update pg_database set datcollversion=null where datname='$collation_fixture'" >/dev/null
docker compose -p "$project" -f "$compose" exec -T -e POSTGRES_DB="$collation_fixture" ludarium \
/usr/local/bin/repair-postgres-collation.sh
fixture_rows="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select count(*) from platform_definitions where id='game-gear'")"
fixture_index_after="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")"
fixture_version_current="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select datcollversion=pg_database_collation_actual_version(oid) from pg_database where datname=current_database()")"
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium "$collation_fixture"
[ "$fixture_rows" = "1" ] || { echo "legacy collation fixture did not safely deduplicate built-in rows" >&2; exit 6; }
[ "$fixture_index_before" != "$fixture_index_after" ] || { echo "legacy collation fixture index was not rebuilt" >&2; exit 6; }
[ "$fixture_version_current" = "t" ] || { echo "legacy collation fixture version was not refreshed" >&2; exit 6; }
api="http://127.0.0.1:$candidate_port/api/v1"
auth="Authorization: Bearer $LUDARIUM_ADMIN_TOKEN"
libraries="$(curl -fsS -H "$auth" "$api/libraries")"
if [ "$(printf '%s' "$libraries" | jq 'length')" = "0" ]; then
create_library() {
curl -fsS -H "$auth" -H 'Content-Type: application/json' -d "$2" "$api/libraries" | jq -r '.id'
}
games_id="$(create_library games '{"name":"Games","path":"/library/games","kind":"Mixed","recursive":true,"hashPolicy":"OnDemand","inspectArchives":true,"maxConcurrency":1}')"
ps4_id="$(create_library ps4 '{"name":"PS4 Games","path":"/library/ps4","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')"
ps5_id="$(create_library ps5 '{"name":"PS5 Games","path":"/library/ps5","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')"
libraries="$(curl -fsS -H "$auth" "$api/libraries")"
fi
scan_ids=""
for library_id in $(printf '%s' "$libraries" | jq -r '.[].id'); do
curl -fsS -X POST -H "$auth" "$api/libraries/$library_id/verify" >/dev/null
scan_id="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d '{"mode":"Quick"}' "$api/libraries/$library_id/scans" | jq -r '.id')"
scan_ids="$scan_ids $scan_id"
done
deadline=$(( $(date +%s) + 1800 ))
while :; do
all_done=true
for scan_id in $scan_ids; do
state="$(curl -fsS -H "$auth" "$api/scans/$scan_id" | jq -r '.state')"
[ "$state" != "Failed" ] || { echo "candidate bootstrap scan failed: $scan_id" >&2; exit 6; }
[ "$state" = "Completed" ] || [ "$state" = "Cancelled" ] || all_done=false
done
[ "$all_done" = true ] && break
[ "$(date +%s)" -lt "$deadline" ] || { echo "candidate bootstrap scans timed out" >&2; exit 6; }
sleep 5
done
if [ "$browser_gate_mode" = "docker" ]; then
docker run --rm --network host --cpus "$browser_gate_cpus" --memory "$browser_gate_memory" \
--memory-swap "$browser_gate_memory" --pids-limit 1024 \
--tmpfs /work:rw,exec,nosuid,nodev,size=768m \
-e PLAYWRIGHT_BASE_URL="http://127.0.0.1:$candidate_port" \
-e LUDARIUM_ADMIN_TOKEN \
-v "$source_archive:/source.tar:ro" -w /work "$playwright_image" \
sh -lc 'tar -xf /source.tar -C /work --strip-components=2 src/Ludarium.Web && (npm ci --ignore-scripts || (sleep 5 && npm ci --ignore-scripts) || (sleep 15 && npm ci --ignore-scripts)) && node node_modules/vitest/vitest.mjs run --maxWorkers=4 && node node_modules/typescript/bin/tsc --noEmit && node node_modules/vite/bin/vite.js build && npm audit --audit-level=high && node e2e/workflows.mjs && node e2e/game-data-vault.mjs && node e2e/accessibility.mjs'
else
printf 'browser gate delegated: run the checked-in Playwright workflows against http://HOST:%s before promotion\n' "$candidate_port"
fi
post_browser_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
[ "$post_browser_restart_count" = "$initial_restart_count" ] || {
echo "candidate restarted unexpectedly during browser gates: $initial_restart_count -> $post_browser_restart_count" >&2
exit 6
}
docker compose -p "$project" -f "$compose" restart ludarium
docker compose -p "$project" -f "$compose" up -d --wait
wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null
server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')"
[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding after restart, found $server_encoding" >&2; exit 6; }
for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do
configured_locale="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc "show $locale_setting")"
[ "$configured_locale" = "en_US.utf8" ] || {
echo "production-shaped locale $locale_setting did not survive restart: $configured_locale" >&2
exit 6
}
done
post_controlled_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
[ "$post_controlled_restart_count" = "$initial_restart_count" ] || {
echo "candidate restart count changed after controlled restart: $initial_restart_count -> $post_controlled_restart_count" >&2
exit 6
}
backup="$data_root/backups/candidate-schema${expected_schema}.dump"
docker compose -p "$project" -f "$compose" exec -T ludarium pg_dump -U ludarium -Fc ludarium > "$backup"
docker compose -p "$project" -f "$compose" exec -T ludarium createdb -U ludarium ludarium_restore
docker compose -p "$project" -f "$compose" exec -T ludarium pg_restore -U ludarium -d ludarium_restore --clean --if-exists < "$backup"
restored_schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium_restore -Atc 'select max(version) from schema_versions')"
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium ludarium_restore
[ "$restored_schema" = "$expected_schema" ] || {
echo "restore schema verification failed: expected $expected_schema, found $restored_schema" >&2
exit 7
}
sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$after"
cat "$after"
cmp -s "$before" "$after" || { echo "source manifest changed" >&2; exit 8; }
LUDARIUM_IMAGE="$image" LUDARIUM_RELEASE_VERSION="$release_version" LUDARIUM_RELEASE_TOOL_CACHE="$data_root/release-tools" sh "$repo/deploy/run-security-gates.sh"
if [ "$browser_gate_mode" = "docker" ]; then gate_state=passed; else gate_state=infrastructure-passed-browser-external-required; fi
printf 'candidate gate %s image=%s digest=%s port=%s schema=%s encoding=%s backup=%s\n' "$gate_state" "$image" "$image_digest" "$candidate_port" "$schema" "$server_encoding" "$backup"
+93
View File
@@ -0,0 +1,93 @@
#!/bin/sh
set -eu
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
image="${1:-${SWITCH_IMAGE:-ludarium/eden-controller:0.4.9-rc.1}}"
container="${2:-${SWITCH_CONTAINER_NAME:-Ludarium-Switch}}"
evidence="${3:?set an evidence directory below /mnt/user/appdata/ludarium-candidate or /tmp}"
vex="$repo/deploy/security/eden-controller-0.4.9.openvex.json"
cache="${LUDARIUM_RELEASE_TOOL_CACHE:-/mnt/user/appdata/ludarium-candidate/release-tools}"
case "$evidence" in
/mnt/user/appdata/ludarium-candidate/*|/tmp/*) ;;
*) echo "Eden evidence must remain below /mnt/user/appdata/ludarium-candidate or /tmp" >&2; exit 2 ;;
esac
command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; }
command -v jq >/dev/null || { echo "jq is required" >&2; exit 3; }
[ -f "$vex" ] || { echo "version-bound Eden OpenVEX document is missing" >&2; exit 3; }
mkdir -p "$evidence" "$cache/bin" "$cache/downloads" "$cache/grype-db"
grype="$cache/bin/grype-0.116.1"
if [ ! -x "$grype" ]; then
archive="$cache/downloads/grype_0.116.1_linux_amd64.tar.gz"
checksum="0122df7b655981abe547ad3d2190d65551dac6a2bfc80b4dc2a989b5d0587458"
if [ ! -f "$archive" ] || ! printf '%s %s\n' "$checksum" "$archive" | sha256sum -c - >/dev/null 2>&1; then
command -v curl >/dev/null || { echo "curl is required to acquire the pinned scanner" >&2; exit 3; }
rm -f "$archive.part"
curl --fail --location --silent --show-error --retry 5 --retry-all-errors \
--connect-timeout 15 --max-time 300 \
https://github.com/anchore/grype/releases/download/v0.116.1/grype_0.116.1_linux_amd64.tar.gz \
-o "$archive.part"
printf '%s %s\n' "$checksum" "$archive.part" | sha256sum -c - >/dev/null
mv "$archive.part" "$archive"
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT INT TERM
tar -xzf "$archive" -C "$work" grype
install -m 0755 "$work/grype" "$grype"
fi
container_image="$(docker inspect "$container" --format '{{.Image}}')"
expected_image="$(docker image inspect "$image" --format '{{.Id}}')"
[ "$container_image" = "$expected_image" ] || {
echo "running Eden container does not use the exact requested image" >&2
exit 4
}
[ "$(docker inspect "$container" --format '{{.State.Health.Status}}')" = "healthy" ] || {
echo "Eden container is not healthy" >&2
exit 4
}
[ "$(docker inspect "$container" --format '{{.RestartCount}}')" = "0" ] || {
echo "Eden candidate has restarted" >&2
exit 4
}
docker inspect "$container" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' |
grep -F '/games false' >/dev/null || { echo "Eden /games mount is not read-only" >&2; exit 4; }
docker exec "$container" /opt/ludarium/audit-eden-runtime.sh | tee "$evidence/runtime-audit.txt"
cp "$vex" "$evidence/openvex.json"
run_grype() {
if command -v timeout >/dev/null; then
timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$@"
else
env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$@"
fi
}
run_grype "$grype" db update
run_grype "$grype" "docker:$image" -o json --file "$evidence/grype-raw.json"
run_grype "$grype" "docker:$image" --vex "$vex" --fail-on critical \
-o json --file "$evidence/grype-vex.json"
raw_critical="$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-raw.json")"
active_critical="$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-vex.json")"
ignored_critical="$(jq '[.ignoredMatches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-vex.json")"
[ "$raw_critical" -gt 0 ] || { echo "expected raw Critical evidence is absent; review the VEX baseline" >&2; exit 5; }
[ "$active_critical" = "0" ] || { echo "unresolved Critical Eden findings remain" >&2; exit 5; }
[ "$ignored_critical" = "$raw_critical" ] || { echo "VEX does not account for every raw Critical match" >&2; exit 5; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT INT TERM
jq -r '.matches[] | select(.vulnerability.severity == "Critical") | .vulnerability.id' \
"$evidence/grype-raw.json" | sort -u > "$work/raw-ids"
jq -r '.ignoredMatches[] | select(.vulnerability.severity == "Critical") | .vulnerability.id' \
"$evidence/grype-vex.json" | sort -u > "$work/vex-ids"
cmp -s "$work/raw-ids" "$work/vex-ids" || { echo "VEX Critical ID set differs from raw evidence" >&2; exit 5; }
raw_high="$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "$evidence/grype-raw.json")"
unique_critical="$(wc -l < "$work/raw-ids" | tr -d ' ')"
sha256sum "$evidence/runtime-audit.txt" "$evidence/openvex.json" \
"$evidence/grype-raw.json" "$evidence/grype-vex.json" > "$evidence/SHA256SUMS"
printf 'Eden security gate passed image=%s digest=%s raw_critical=%s unique_critical=%s active_critical=0 raw_high=%s\n' \
"$image" "$expected_image" "$raw_critical" "$unique_critical" "$raw_high"
+150
View File
@@ -0,0 +1,150 @@
#!/bin/sh
set -eu
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
profile="${1:-full}"
case "$profile" in
source|test|lint|typecheck|build|security|full) ;;
*) echo "validation profile is not allowlisted: $profile" >&2; exit 2 ;;
esac
cd "$repo"
git_worktree=false
if [ -n "${WSL_INTEROP:-}" ] && command -v git.exe >/dev/null 2>&1; then
dotnet_command=dotnet.exe
python_command=python.exe
docker_command=docker.exe
default_release_cache="/var/tmp/ludarium-release-tools"
npm_run() { cmd.exe /d /c npm "$@"; }
repo_windows="$(wslpath -w "$repo")"
if git.exe -C "$repo_windows" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git.exe -C "$repo_windows" diff --check
if git.exe -C "$repo_windows" grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
echo "unresolved merge markers detected" >&2
exit 1
fi
git_worktree=true
fi
else
dotnet_command=dotnet
if command -v python3 >/dev/null 2>&1; then python_command=python3; else python_command=python; fi
docker_command=docker
default_release_cache="${RUNNER_TEMP:-/tmp}/ludarium-release-tools"
npm_run() { npm "$@"; }
if git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$repo" diff --check
if git -C "$repo" grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
echo "unresolved merge markers detected" >&2
exit 1
fi
git_worktree=true
fi
fi
if [ "$git_worktree" = false ]; then
if grep -RInI -E '^(<<<<<<< |=======$|>>>>>>> )' \
--exclude='*.lock' --exclude='*.patch' .; then
echo "unresolved merge markers detected" >&2
exit 1
fi
fi
sh deploy/verify-release-identity.sh
case "$profile" in
source|test|lint|build|full)
"$dotnet_command" restore Ludarium.slnx --locked-mode
;;
esac
case "$profile" in
source|lint|full)
"$dotnet_command" format Ludarium.slnx --verify-no-changes --no-restore
;;
esac
case "$profile" in
source|build|full)
"$dotnet_command" build Ludarium.slnx --configuration Release --no-restore
;;
esac
case "$profile" in
test)
"$dotnet_command" test Ludarium.slnx --configuration Release --no-restore
;;
source|full)
"$dotnet_command" test Ludarium.slnx --configuration Release --no-build --no-restore
;;
esac
case "$profile" in
source|test|typecheck|build|security|full)
npm_run ci --ignore-scripts --prefix src/Ludarium.Web
;;
esac
case "$profile" in
source|test|full)
npm_run run test --prefix src/Ludarium.Web
;;
esac
case "$profile" in
typecheck)
npm_run run typecheck --prefix src/Ludarium.Web
;;
source|build|full)
npm_run run build --prefix src/Ludarium.Web
;;
esac
case "$profile" in
source|security|full)
npm_run audit --audit-level=high --prefix src/Ludarium.Web
;;
esac
case "$profile" in
source|test|full)
"$python_command" -m unittest discover -s tests/controllers -t tests/controllers
;;
esac
case "$profile" in
security|full)
release="$(tr -d '\r\n' < VERSION)"
image="${LUDARIUM_IMAGE:-ludarium/ludarium:validation-${GITHUB_SHA:-$release}}"
build_network="${LUDARIUM_DOCKER_BUILD_NETWORK:-default}"
emulatorjs_asset=""
cleanup_build_inputs() {
[ -z "$emulatorjs_asset" ] || rm -f "$emulatorjs_asset" "${emulatorjs_asset}.part"
}
trap cleanup_build_inputs EXIT HUP INT TERM
case "$build_network" in
default) "$docker_command" build --pull --file Dockerfile.unraid --tag "$image" . ;;
host)
emulatorjs_version=4.2.3
emulatorjs_sha256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b
emulatorjs_asset="$repo/.build-inputs/emulatorjs/$emulatorjs_version.7z"
mkdir -p "$(dirname "$emulatorjs_asset")"
if [ ! -f "$emulatorjs_asset" ] || ! printf '%s %s\n' "$emulatorjs_sha256" "$emulatorjs_asset" | sha256sum -c - >/dev/null 2>&1; then
rm -f "$emulatorjs_asset" "${emulatorjs_asset}.part"
download_attempt=1
while ! "$docker_command" run --rm --dns 1.1.1.1 --dns 8.8.8.8 alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 \
wget -q -T 60 "https://github.com/EmulatorJS/EmulatorJS/releases/download/v$emulatorjs_version/$emulatorjs_version.7z" -O - \
> "${emulatorjs_asset}.part"; do
rm -f "${emulatorjs_asset}.part"
[ "$download_attempt" -lt 3 ] || exit 1
sleep "$download_attempt"
download_attempt=$((download_attempt + 1))
done
printf '%s %s\n' "$emulatorjs_sha256" "${emulatorjs_asset}.part" | sha256sum -c - >/dev/null
mv "${emulatorjs_asset}.part" "$emulatorjs_asset"
fi
"$docker_command" build --network host --pull --file Dockerfile.unraid --tag "$image" .
;;
*) echo "docker build network is not allowlisted: $build_network" >&2; exit 2 ;;
esac
cleanup_build_inputs
trap - EXIT HUP INT TERM
LUDARIUM_IMAGE="$image" \
LUDARIUM_RELEASE_VERSION="${GITHUB_SHA:-$release}" \
LUDARIUM_RELEASE_TOOL_CACHE="${LUDARIUM_RELEASE_TOOL_CACHE:-$default_release_cache}" \
sh deploy/run-security-gates.sh
;;
esac
printf 'managed validation passed: %s\n' "$profile"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
set -eu
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
output="$repo/deploy/security"
mkdir -p "$output"
application_image="${1:-${LUDARIUM_IMAGE:-ludarium/ludarium:0.4.1}}"
release_version="${LUDARIUM_RELEASE_VERSION:-${application_image##*:}}"
cache="${LUDARIUM_RELEASE_TOOL_CACHE:-/mnt/user/appdata/ludarium/release-tools}"
mkdir -p "$cache/bin" "$cache/downloads" "$cache/grype-db" "$cache/tmp"
TMPDIR="$cache/tmp"
export TMPDIR
scan_source="docker:$application_image"
image_archive=""
if [ -n "${WSL_INTEROP:-}" ] \
&& command -v docker.exe >/dev/null 2>&1 \
&& docker.exe image inspect "$application_image" >/dev/null 2>&1 \
&& ! docker image inspect "$application_image" >/dev/null 2>&1; then
image_archive="$(mktemp "$cache/tmp/ludarium-image.XXXXXX.tar")"
trap 'rm -f "$image_archive"' EXIT HUP INT TERM
docker.exe save --output "$(wslpath -w "$image_archive")" "$application_image"
scan_source="docker-archive:$image_archive"
fi
download_tool() {
name="$1" version="$2" archive="$3" checksum="$4" url="$5"
binary="$cache/bin/$name-$version"
[ -x "$binary" ] && { printf '%s\n' "$binary"; return; }
package="$cache/downloads/$archive"
if [ ! -f "$package" ] || ! printf '%s %s\n' "$checksum" "$package" | sha256sum -c - >/dev/null 2>&1; then
rm -f "$package.part"
curl --fail --location --silent --show-error --retry 5 --retry-all-errors \
--connect-timeout 15 --max-time 300 "$url" -o "$package.part"
printf '%s %s\n' "$checksum" "$package.part" | sha256sum -c - >/dev/null
mv "$package.part" "$package"
fi
work="$(mktemp -d)"
tar -xzf "$package" -C "$work" "$name"
install -m 0755 "$work/$name" "$binary"
rm -rf "$work"
printf '%s\n' "$binary"
}
gitleaks="$(download_tool gitleaks 8.30.1 gitleaks_8.30.1_linux_x64.tar.gz \
551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz)"
syft="$(download_tool syft 1.50.0 syft_1.50.0_linux_amd64.tar.gz \
bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788 \
https://github.com/anchore/syft/releases/download/v1.50.0/syft_1.50.0_linux_amd64.tar.gz)"
grype="$(download_tool grype 0.116.1 grype_0.116.1_linux_amd64.tar.gz \
0122df7b655981abe547ad3d2190d65551dac6a2bfc80b4dc2a989b5d0587458 \
https://github.com/anchore/grype/releases/download/v0.116.1/grype_0.116.1_linux_amd64.tar.gz)"
# Scan the materialized candidate tree. A linked Git worktree can reference metadata
# outside /repo, which would otherwise make a containerized history scan inspect zero commits.
"$gitleaks" dir "$repo" --config "$repo/.gitleaks.toml" --redact --report-format json --report-path "$output/gitleaks.json"
"$syft" "$scan_source" -o "cyclonedx-json=$output/ludarium-$release_version.cdx.json"
if command -v timeout >/dev/null; then
timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" db update
timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" "$scan_source" -o json --file "$output/grype.json" --fail-on high
else
GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" db update
GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" "$scan_source" -o json --file "$output/grype.json" --fail-on high
fi
printf 'security gates passed for %s\n' "$application_image"
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0"?>
<Container version="2">
<Name>Ludarium</Name>
<Repository>ludarium/ludarium:0.4.19-rc.8</Repository>
<Network>ludarium_default</Network>
<Shell/>
<Privileged>false</Privileged>
<Overview>Premium read-only game-library inventory, classification, metadata and integrity management for Unraid.</Overview>
<Category>MediaApp:Other</Category>
<WebUI>http://[IP]:[PORT:1230]/</WebUI>
<Icon>/boot/config/plugins/dockerMan/images/Ludarium-icon.svg</Icon>
<ExtraParams>--read-only --cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=KILL --cap-add=SETGID --cap-add=SETUID --security-opt=no-new-privileges --tmpfs=/tmp --tmpfs=/var/run/postgresql</ExtraParams>
<PostArgs/>
<CPUset/>
<DonateText/>
<DonateLink/>
<Requires/>
<Config Name="WebUI" Target="8734" Default="1230" Mode="tcp" Description="Ludarium web interface" Type="Port" Display="always" Required="true" Mask="false">1230</Config>
<Config Name="RAWG API key" Target="RAWG_API_KEY" Default="" Mode="" Description="Optional online wishlist discovery" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="IGDB client ID" Target="IGDB_CLIENT_ID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="IGDB client secret" Target="IGDB_CLIENT_SECRET" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="MobyGames API key" Target="MOBYGAMES_API_KEY" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ScreenScraper developer ID" Target="SCREENSCRAPER_DEVID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ScreenScraper developer password" Target="SCREENSCRAPER_DEVPASSWORD" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ScreenScraper software name" Target="SCREENSCRAPER_SOFTNAME" Default="Ludarium" Mode="" Description="Optional native metadata provider client name" Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="ScreenScraper user" Target="SCREENSCRAPER_USER" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="ScreenScraper password" Target="SCREENSCRAPER_PASSWORD" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="RetroAchievements API key" Target="RETROACHIEVEMENTS_API_KEY" Default="" Mode="" Description="Optional native achievements provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="RetroAchievements user" Target="RETROACHIEVEMENTS_USERNAME" Default="" Mode="" Description="Optional personal achievement progress" Type="Variable" Display="advanced" Required="false" Mask="false"/>
</Container>
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
set -eu
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
version="$(tr -d '\r\n' < "$repo/VERSION")"
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || {
echo "VERSION is not a semantic version" >&2
exit 1
}
require_literal() {
file="$1"
grep -F "$version" "$repo/$file" >/dev/null || {
echo "$file does not reference release $version" >&2
exit 1
}
}
for file in \
CHANGELOG.md \
deploy/README.md \
deploy/compose.dolphin.yml \
deploy/compose.yml \
deploy/ludarium-unraid.xml \
deploy/unraid/my-Ludarium.xml
do
require_literal "$file"
done
grep -F 'ReadAllText('"'"'$(MSBuildThisFileDirectory)VERSION'"'"')' "$repo/Directory.Build.props" >/dev/null || {
echo "Directory.Build.props does not derive the assembly version from VERSION" >&2
exit 1
}
for dockerfile in Dockerfile Dockerfile.unraid
do
grep -E '^COPY .*VERSION.*Directory.Build.props|^COPY .*Directory.Build.props.*VERSION' "$repo/$dockerfile" >/dev/null || {
echo "$dockerfile does not copy VERSION into the build context" >&2
exit 1
}
done
grep -F 'ReleaseIdentity.Version' "$repo/src/Ludarium.Api/Program.cs" >/dev/null
grep -F 'release_identity="$(tr' "$repo/deploy/run-candidate-gate.sh" >/dev/null
if grep -R -n -E --include='*.cs' '0\.4\.[0-9]+-rc\.[0-9]+' \
"$repo/src/Ludarium.Api" "$repo/src/Ludarium.Application" "$repo/src/Ludarium.Infrastructure" |
grep -v -F "$version"; then
echo "A runtime release identifier bypasses ReleaseIdentity" >&2
exit 1
fi
printf 'release identity verified: %s\n' "$version"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "usage: $0 ROOT [ROOT ...]" >&2
exit 2
fi
manifest_file="$(mktemp)"
trap 'rm -f "$manifest_file"' EXIT INT TERM
root_index=0
file_count=0
for root in "$@"; do
if [ ! -d "$root" ]; then
echo "library root is unavailable: $root" >&2
exit 3
fi
root_index=$((root_index + 1))
count="$(find "$root" -type f -printf . | wc -c)"
file_count=$((file_count + count))
(cd "$root" && find . -type f -printf "${root_index}\t%P\t%s\t%T@\0") >> "$manifest_file"
done
digest="$(LC_ALL=C sort -z "$manifest_file" | sha256sum | cut -d ' ' -f 1)"
printf 'sha256=%s files=%s roots=%s\n' "$digest" "$file_count" "$#"