This commit is contained in:
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
exec /usr/bin/python3 /opt/ludarium/dolphin-controller.py
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
exec /usr/bin/python3 /opt/ludarium/eden-controller.py
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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" "$#"
|
||||
Reference in New Issue
Block a user