diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a567981..dae4e84 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -149,3 +149,149 @@ jobs: name: jray-server-x86_64-musl path: target/x86_64-unknown-linux-musl/release/jray-server if-no-files-found: error + + # ── Debian package ──────────────────────────────────────────────────────── + # + # DR-015. The .deb is not a second build of the software: it packages the very + # binary the job above already proved static, so the artifact an operator + # installs is byte-identical to the one CI verified. Building it twice would + # let the two diverge silently. + deb: + name: debian package + runs-on: ubuntu-latest + needs: musl + steps: + - uses: actions/checkout@v4 + + - name: Fetch the verified musl binary + uses: actions/download-artifact@v3 + with: + name: jray-server-x86_64-musl + path: prebuilt + + - name: Build the package + run: | + chmod +x prebuilt/jray-server + scripts/build-deb.sh --binary prebuilt/jray-server + + # The install is where packaging actually fails, so it is exercised rather + # than assumed: an unattended preseed proves the debconf path works without + # a terminal, which is the case a release must not break. `dpkg -i` runs as + # root on the runner, and the maintainer scripts skip the systemd wiring + # when /run/systemd/system is absent. + - name: Install it unattended and check what it configured + run: | + set -e + sudo apt-get update -qq + sudo apt-get install -y -qq debconf-utils + cat <<'SEED' | sudo debconf-set-selections + jray-server jray-server/server-id string ci.example.org + jray-server jray-server/bind string 127.0.0.1:8080 + jray-server jray-server/trusted-proxies string 127.0.0.1 + jray-server jray-server/tmdb-api-key password ci-key + SEED + sudo DEBIAN_FRONTEND=noninteractive dpkg -i dist/*.deb + + sudo test -f /etc/jray-server/env + [ "$(sudo stat -c '%a' /etc/jray-server/env)" = "600" ] \ + || { echo "::error::env file is not mode 600"; exit 1; } + sudo grep -q '^JRAY_SERVER_ID=ci.example.org$' /etc/jray-server/env \ + || { echo "::error::debconf answer did not reach the env file"; exit 1; } + # The key must land in the file and not linger in debconf's database. + sudo grep -q '^JRAY_TMDB_API_KEY=ci-key$' /etc/jray-server/env \ + || { echo "::error::API key missing from the env file"; exit 1; } + if sudo debconf-show jray-server | grep -q 'ci-key'; then + echo "::error::API key still present in the debconf database"; exit 1 + fi + sudo /usr/bin/jray-server --version >/dev/null 2>&1 || true + echo "installed cleanly" + + # DR-016. Reconfigure is the operation that silently destroys a working + # install: press Enter through the password prompt and a naive postinst + # blanks the key, after which every upload stays pending forever and the + # server looks fine. Asserted, not trusted. + # + # The second half guards the inverse mistake — the debconf `config` script + # seeds unanswered questions from the env file, and an unguarded seed would + # overwrite the preseed above, so an unattended install would reconfigure + # itself back to whatever was on disk. + - name: Reconfigure keeps the key, and updates what it was told to + run: | + set -e + printf 'jray-server jray-server/tmdb-api-key password\n' | sudo debconf-set-selections + printf 'jray-server jray-server/contact string changed@example.org\n' | sudo debconf-set-selections + echo 'JRAY_JOB_BATCH=32' | sudo tee -a /etc/jray-server/env >/dev/null + sudo dpkg-reconfigure -f noninteractive jray-server + + sudo grep -q '^JRAY_TMDB_API_KEY=ci-key$' /etc/jray-server/env \\ + || { echo "::error::a blank answer wiped the configured API key"; exit 1; } + sudo grep -q '^JRAY_CONTACT=changed@example.org$' /etc/jray-server/env \\ + || { echo "::error::preseeded value was overwritten by the env-file seed"; exit 1; } + # A setting the package does not manage must survive untouched. + sudo grep -q '^JRAY_JOB_BATCH=32$' /etc/jray-server/env \\ + || { echo "::error::reconfigure discarded a hand-added setting"; exit 1; } + + - name: Purge, and check the database is not collateral + run: | + set -e + sudo mkdir -p /var/lib/jray-server && sudo touch /var/lib/jray-server/jray.db + sudo DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq jray-server + sudo test ! -f /etc/jray-server/env \ + || { echo "::error::configuration survived purge"; exit 1; } + # Deliberate deviation from "purge removes everything": manifests are + # real CV compute and federation is not a backup. See postrm. + sudo test -f /var/lib/jray-server/jray.db \ + || { echo "::error::purge destroyed the database"; exit 1; } + + - name: Upload the package + uses: actions/upload-artifact@v3 + with: + name: jray-server-deb + path: dist/*.deb + if-no-files-found: error + + # ── Publishing, on tags only ────────────────────────────────────────── + # + # Two channels, deliberately. The apt registry is the one that gives + # operators upgrades; the release asset is for people who would rather not + # add a third-party apt source to their machine. + # + # NOTE: this uses the automatic Actions token. If your Gitea build does not + # grant it package:write, replace it with a PAT held in a repository secret + # — the symptom is a 401 from the upload below, not a silent no-op. + - name: Publish to the Gitea Debian registry + if: startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + DEB=$(ls dist/*.deb) + code=$(curl -sS -o /tmp/up.log -w '%{http_code}' \ + --user "${{ github.repository_owner }}:$TOKEN" \ + --upload-file "$DEB" \ + "${{ github.server_url }}/api/packages/${{ github.repository_owner }}/debian/pool/stable/main/upload") + echo "upload HTTP $code"; cat /tmp/up.log + case "$code" in 201|409) ;; *) echo "::error::registry upload failed"; exit 1 ;; esac + + - name: Attach the package to the release + if: startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + TAG="${GITHUB_REF#refs/tags/}" + API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + id=$(curl -sS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" \ + | sed -n 's/.*"id":[ ]*\([0-9]*\).*/\1/p' | head -1) + if [ -z "$id" ]; then + id=$(curl -sS -X POST -H "Authorization: token $TOKEN" \ + -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" "$API/releases" \ + | sed -n 's/.*"id":[ ]*\([0-9]*\).*/\1/p' | head -1) + fi + [ -n "$id" ] || { echo "::error::could not resolve a release for $TAG"; exit 1; } + DEB=$(ls dist/*.deb) + curl -sS -X POST -H "Authorization: token $TOKEN" \ + -F "attachment=@$DEB" \ + "$API/releases/$id/assets?name=$(basename "$DEB")" >/dev/null + echo "attached $(basename "$DEB") to release $TAG" diff --git a/README.md b/README.md index be66ee5..084c60e 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,37 @@ curl -sX POST -H 'content-type: application/json' -d '{}' \ ## Deployment +### Install from the apt repository + +```sh +sudo install -d -m0755 /etc/apt/keyrings +curl -fsSL https://gitea.tourolle.paris/api/packages/dtourolle/debian/repository.key \ + | sudo gpg --dearmor -o /etc/apt/keyrings/gitea-dtourolle.gpg +echo "deb [signed-by=/etc/apt/keyrings/gitea-dtourolle.gpg] \ +https://gitea.tourolle.paris/api/packages/dtourolle/debian stable main" \ + | sudo tee /etc/apt/sources.list.d/jray-server.list +sudo apt update && sudo apt install jray-server +``` + +The install **asks** for the settings that matter — public hostname, listen +address, trusted proxies, TMDB key, contact — because two of them fail silently +when unset rather than loudly (see §8, DR-016). Change them later with: + +```sh +sudo dpkg-reconfigure jray-server +``` + +The package ships one static binary, the systemd unit, and an example nginx +site at `/usr/share/doc/jray-server/examples/nginx-jray-server.conf`. The +example is **not** installed into nginx: the proxy usually runs on a different +host, so copy it to whichever machine that is. + +Prefer not to add a third-party apt source? Every tagged release also carries +the `.deb` as an asset — `sudo dpkg -i jray-server_*.deb` does the same thing, +minus upgrades. + +### Notes + §8's deployment notes are requirements, not suggestions: - Enforce the body cap at **both** the proxy and the app. `client_max_body_size` diff --git a/SPEC.md b/SPEC.md index 5af4a8b..3f1678d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1510,6 +1510,13 @@ addresses. - Ship a **single static binary** (musl target) plus the SQLite file. Optional container image, but neither Docker nor Compose should be required. +- Ship a **Debian package** as well (DR-015). It is a third distribution form + alongside the raw binary and the container, not a replacement for either: it + packages the *same* musl binary CI has already proved static, so what an + operator installs is byte-identical to the artifact that was verified. + Published to the Gitea Debian registry, so `apt install jray-server` and + ordinary upgrades work, and attached to the release for operators who would + rather not add a third-party apt source. - Put all database access behind a **thin repository trait** rather than scattering queries through handlers. This is what keeps the Turso/Postgres options above cheap, and it localises the single-writer serialization @@ -1532,6 +1539,46 @@ addresses. plain file copy of a live WAL database). Manifests represent real CV compute; federation (§9a) gives partial resilience but is not a backup. +### First-run configuration — DR-016 + +The settings an operator must get right are not discoverable from the binary, +and two of them fail *silently* when unset: without `JRAY_TMDB_API_KEY` every +upload stays `pending` and is never listed, and without `JRAY_TRUSTED_PROXIES` +the `X-Forwarded-For` header is ignored, so every client shares one rate-limit +bucket and every abuse report points at the proxy. Both produce a working +server that is quietly doing the wrong thing — the worst kind of default to +leave to a README. + +So the package **asks**, at install time, via debconf: public hostname, listen +address, trusted proxies, TMDB key, contact, and whether to publish the peer +directory. It is re-runnable with `dpkg-reconfigure jray-server`, and +preseedable for unattended installs. + +Three properties this has to hold, each of which is a way packaging usually +goes wrong: + +- **The generated config is not a dpkg conffile.** It is written from the + debconf answers, so shipping it as a conffile would make dpkg prompt on every + upgrade about changes the package itself had made. +- **Hand edits survive.** Only the keys debconf manages are rewritten; + comments, ordering and any other setting are left alone, so editing the file + directly and running `dpkg-reconfigure` later do not fight. +- **A blank API key on reconfigure keeps the existing one.** Otherwise pressing + Enter through a reconfigure would silently unpublish every future upload. + +The database is **not** removed on purge, which knowingly departs from the +usual expectation. Manifests are the output of real CV compute on media the +operator may no longer have, and federation is explicitly not a backup; +destroying that during an `apt purge` is not a trade worth making for +tidiness. `postrm` says where the file is and leaves the decision to the +operator. + +An example nginx site ships in `/usr/share/doc/jray-server/examples/` rather +than being installed into any nginx configuration directory. The proxy commonly +runs on a *different* host from the server, so a file dropped into this +machine's nginx would be in the wrong place — and §8 leaves the edge to the +operator deliberately. + --- ## 9. JRay plugin integration diff --git a/docs/requirements.md b/docs/requirements.md index 964d3a1..0b442e6 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -100,6 +100,8 @@ attempts are rejected. | DR-012 | Dependency audit: advisories, licence policy, source policy | PR-004 | Medium | Done | | DR-013 | API errors use the status codes the spec names, not the framework's defaults | SR-003 | Medium | Done | | DR-014 | Portable SQL — no SQLite-specific form where a standard one exists | PR-004 | Medium | Done | +| DR-015 | Debian package installing the binary, the systemd unit and a default configuration | PR-004 | Medium | Done | +| DR-016 | First-run configuration is prompted at install time and re-runnable, never hand-written from scratch | PR-004 | Medium | Done | --- @@ -169,6 +171,8 @@ topology is the point, so this is a deliberate choice rather than an oversight. | DR-007 | **static** | The musl release target builds and links one binary needing only a database file | Build property, not a runtime one — a unit test asserting it would assert nothing | | DR-012 | **static** | `cargo deny check` passes advisories, licences and sources | A copyleft-incompatible transitive dependency must fail the build, not be discovered later | | DR-014 | **static** | `schema.sql` uses no SQLite-specific form where a standard one exists | `INSERT OR REPLACE` must not reappear in place of `INSERT ... ON CONFLICT` | +| DR-015 | **static** | The package builds, installs unattended and purges without taking the database with it | Exercised in CI by a real `dpkg -i` and `apt purge`, not by inspecting the file list — packaging fails at install time or not at all | +| DR-016 | **static** | A preseeded install reaches the env file, and a blank key on reconfigure keeps the existing one | The reconfigure case is the one that silently destroys a working install, so it is asserted rather than trusted | Three are worth singling out, because each verifies a claim that would otherwise be an assertion: diff --git a/packaging/debian/config b/packaging/debian/config new file mode 100644 index 0000000..1659fec --- /dev/null +++ b/packaging/debian/config @@ -0,0 +1,66 @@ +#!/bin/sh +# debconf question script. Runs before unpacking, and again on +# `dpkg-reconfigure jray-server`. +# +# Existing values are read back out of /etc/jray-server/env first, so a +# reconfigure shows what is actually in force rather than the package defaults. +# Without this, an operator who edited the env file by hand would be shown stale +# answers and silently have their edits reverted by postinst. +set -e +. /usr/share/debconf/confmodule + +ENV_FILE=/etc/jray-server/env + +# Read one KEY=value out of the env file, ignoring comments. Values are written +# unquoted by postinst, so no unquoting is needed. +env_value() { + [ -f "$ENV_FILE" ] || return 0 + sed -n "s/^$1=//p" "$ENV_FILE" | tail -1 +} + +# Seed only a question debconf has never had an answer to. +# +# The test is the `seen` flag, not whether the value is empty: two of these +# templates carry a Default, so db_get returns "localhost" or "127.0.0.1:8080" +# for a question nobody has answered, and an emptiness check would never seed +# them. `seen` distinguishes "this is the template default" from "somebody chose +# this", which is the actual question. +# +# And it has to be a guard rather than an unconditional db_set. debconf-set- +# selections marks what it sets as seen, so an unconditional seed would overwrite +# a value the operator had just preseeded — every unattended install would +# quietly reconfigure itself back to whatever was already on disk. Preseeding is +# the entire point of the unattended path, so debconf wins wherever it has an +# answer and the env file only fills in what it does not. +seed() { # seed + db_fget "$1" seen || RET="" + [ "$RET" = "true" ] && return 0 + v=$(env_value "$2") + [ -n "$v" ] && db_set "$1" "$v" + return 0 +} + +seed jray-server/server-id JRAY_SERVER_ID +seed jray-server/bind JRAY_BIND +seed jray-server/trusted-proxies JRAY_TRUSTED_PROXIES +seed jray-server/contact JRAY_CONTACT + +# The API key is deliberately NOT seeded back into the prompt: it is a password +# template, so debconf would render it as a filled-in field the operator cannot +# read, and accepting it would just rewrite what is already there. Blank means +# "keep the existing key" and postinst implements exactly that. + +db_fget jray-server/publish-peer-directory seen || RET="" +if [ "$RET" != "true" ] && [ "$(env_value JRAY_PUBLISH_PEER_DIRECTORY)" = "1" ]; then + db_set jray-server/publish-peer-directory true +fi + +db_input high jray-server/server-id || true +db_input high jray-server/bind || true +db_input high jray-server/trusted-proxies || true +db_input high jray-server/tmdb-api-key || true +db_input medium jray-server/contact || true +db_input medium jray-server/publish-peer-directory || true +db_go || true + +exit 0 diff --git a/packaging/debian/postinst b/packaging/debian/postinst new file mode 100644 index 0000000..a1727ac --- /dev/null +++ b/packaging/debian/postinst @@ -0,0 +1,120 @@ +#!/bin/sh +# Configure jray-server from the debconf answers. +# +# /etc/jray-server/env is deliberately NOT a dpkg conffile. A conffile is for a +# file the package ships and the operator may edit; this one is *generated* from +# debconf, so shipping it would make dpkg prompt on every upgrade about changes +# the package itself had made. Instead it is written here and updated key by +# key, which leaves comments, ordering and any setting debconf does not manage +# untouched. +set -e +. /usr/share/debconf/confmodule + +CONF_DIR=/etc/jray-server +ENV_FILE="$CONF_DIR/env" + +# Update one KEY=value in place, appending if absent. Everything else in the +# file - comments, blank lines, settings this package does not ask about - is +# preserved, which is what makes hand-editing and dpkg-reconfigure coexist. +set_kv() { + key="$1"; val="$2" + if grep -q "^$key=" "$ENV_FILE" 2>/dev/null; then + # `|` as the delimiter: values are hostnames, IP lists and URLs, none of + # which contain it, whereas `/` appears in contacts and base URLs. + sed -i "s|^$key=.*|$key=$val|" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + fi +} + +case "$1" in +configure) + mkdir -p "$CONF_DIR" + chmod 0755 "$CONF_DIR" + + if [ ! -f "$ENV_FILE" ]; then + cat > "$ENV_FILE" <<'EOF' +# jray-server configuration. +# +# Written by the package from your debconf answers; re-run +# dpkg-reconfigure jray-server +# to change them. Hand edits to this file are preserved: the package updates +# only the keys it manages and leaves everything else alone. +# +# The full set of variables is in SPEC.md section 8 and src/config.rs. + +EOF + fi + # 0600 before anything is written into it: the TMDB key lands here. + chmod 0600 "$ENV_FILE" + + db_get jray-server/server-id && set_kv JRAY_SERVER_ID "$RET" + db_get jray-server/bind && set_kv JRAY_BIND "$RET" + db_get jray-server/trusted-proxies && set_kv JRAY_TRUSTED_PROXIES "$RET" + db_get jray-server/contact && set_kv JRAY_CONTACT "$RET" + + db_get jray-server/publish-peer-directory + if [ "$RET" = "true" ]; then + set_kv JRAY_PUBLISH_PEER_DIRECTORY 1 + else + set_kv JRAY_PUBLISH_PEER_DIRECTORY 0 + fi + + # Blank means "keep whatever is already configured" - see the note in the + # debconf template. Only overwrite when the operator actually supplied one. + db_get jray-server/tmdb-api-key + if [ -n "$RET" ]; then + set_kv JRAY_TMDB_API_KEY "$RET" + elif ! grep -q '^JRAY_TMDB_API_KEY=' "$ENV_FILE" 2>/dev/null; then + set_kv JRAY_TMDB_API_KEY "" + fi + # Drop the secret from debconf's database now that it is in the env file. + # config.dat is root-only, so this is defence in depth rather than a fix for + # a leak - but there is no reason for a second copy to outlive its use. + db_set jray-server/tmdb-api-key "" || true + + set_kv JRAY_DB "/var/lib/jray-server/jray.db" + + # Warn about the two settings whose absence fails silently rather than + # loudly. Both are recoverable with dpkg-reconfigure, and neither stops the + # service starting, so the operator would otherwise find out from a log line + # they had no reason to read. + if ! grep -q '^JRAY_TMDB_API_KEY=.' "$ENV_FILE" 2>/dev/null; then + echo "jray-server: no TMDB API key set - uploads will stay pending and never be listed." >&2 + echo " Set one with: dpkg-reconfigure jray-server" >&2 + fi + if ! grep -q '^JRAY_TRUSTED_PROXIES=.' "$ENV_FILE" 2>/dev/null; then + echo "jray-server: no trusted proxies set - X-Forwarded-For will be ignored, so every" >&2 + echo " client shares one rate-limit bucket. Set your proxy's address with:" >&2 + echo " dpkg-reconfigure jray-server" >&2 + fi + ;; + +abort-upgrade|abort-remove|abort-deconfigure) ;; +*) echo "postinst called with unknown argument \`$1'" >&2; exit 1 ;; +esac + +# systemd wiring, in the form dh_installsystemd generates. StateDirectory= in the +# unit creates and owns /var/lib/jray-server, so there is no directory or user to +# set up here. +if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ]; then + if [ -d /run/systemd/system ]; then + systemctl --system daemon-reload >/dev/null 2>&1 || true + fi + if deb-systemd-helper debian-installed jray-server.service 2>/dev/null; then + deb-systemd-helper unmask jray-server.service >/dev/null || true + if deb-systemd-helper --quiet was-enabled jray-server.service; then + deb-systemd-helper enable jray-server.service >/dev/null || true + else + deb-systemd-helper update-state jray-server.service >/dev/null || true + fi + fi + if [ -d /run/systemd/system ]; then + # Starting an unconfigured install is safe by construction: JRAY_BIND + # defaults to loopback, so it is not reachable until the operator says + # otherwise. + deb-systemd-invoke restart jray-server.service >/dev/null || true + fi +fi + +exit 0 diff --git a/packaging/debian/postrm b/packaging/debian/postrm new file mode 100644 index 0000000..62d7f28 --- /dev/null +++ b/packaging/debian/postrm @@ -0,0 +1,51 @@ +#!/bin/sh +set -e + +DB_DIR=/var/lib/jray-server + +if [ -d /run/systemd/system ]; then + systemctl --system daemon-reload >/dev/null 2>&1 || true +fi + +case "$1" in +purge) + # Configuration goes, including the TMDB key. + rm -f /etc/jray-server/env + rmdir --ignore-fail-on-non-empty /etc/jray-server 2>/dev/null || true + + if [ -f /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_purge || true + fi + + if [ -x /usr/bin/deb-systemd-helper ]; then + deb-systemd-helper purge jray-server.service >/dev/null || true + deb-systemd-helper unmask jray-server.service >/dev/null || true + fi + + # The database is deliberately NOT deleted on purge, and that is a knowing + # deviation from the usual expectation that purge removes everything. + # + # Manifests are the output of real CV compute on media the operator may no + # longer have, and federation (SPEC section 9a) gives partial resilience but + # is explicitly not a backup. Silently destroying that during an `apt purge` + # - a command people run to clean up - is not a trade worth making for + # tidiness. Say where it is instead, and let the operator decide. + if [ -d "$DB_DIR" ]; then + echo "jray-server: purged, but the database was kept at $DB_DIR" >&2 + echo " It holds contributed manifests, which are not recoverable" >&2 + echo " from this package. Remove it yourself if you mean to:" >&2 + echo " rm -rf $DB_DIR" >&2 + fi + ;; + +remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + if [ "$1" = remove ] && [ -x /usr/bin/deb-systemd-helper ]; then + deb-systemd-helper mask jray-server.service >/dev/null || true + fi + ;; + +*) echo "postrm called with unknown argument \`$1'" >&2; exit 1 ;; +esac + +exit 0 diff --git a/packaging/debian/prerm b/packaging/debian/prerm new file mode 100644 index 0000000..83f9ed5 --- /dev/null +++ b/packaging/debian/prerm @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +if [ -d /run/systemd/system ] && [ "$1" = remove ]; then + # SIGTERM, which main.rs handles: stop accepting, drain in-flight requests, + # let the cast-check worker finish its tick. TimeoutStopSec in the unit gives + # it 30 s before systemd escalates. + deb-systemd-invoke stop jray-server.service >/dev/null || true +fi + +exit 0 diff --git a/packaging/debian/templates b/packaging/debian/templates new file mode 100644 index 0000000..447416e --- /dev/null +++ b/packaging/debian/templates @@ -0,0 +1,64 @@ +Template: jray-server/server-id +Type: string +Default: localhost +Description: Public hostname of this JRay server: + Identifies this instance in federation (SPEC section 9a) and is recorded on + every manifest it originates, so peers can tell whose judgement they are + replicating. + . + Use the name operators will reach you on, for example jray.example.org. + Leaving it as "localhost" is fine for a private trial and wrong for anything + federated. + +Template: jray-server/bind +Type: string +Default: 127.0.0.1:8080 +Description: Address and port to listen on: + The server speaks plain HTTP and expects TLS to be terminated by your reverse + proxy (SPEC section 8). + . + Keep the default if the proxy runs on this same host. If the proxy is + elsewhere - a separate container or VM, which is the common case - this must + be an address that host can reach, for example 0.0.0.0:8080. Firewall the + port to the proxy if you do that: the default is loopback precisely so an + unconfigured install is not reachable. + +Template: jray-server/trusted-proxies +Type: string +Description: Trusted reverse proxy addresses (comma-separated): + Rate limiting and abuse-report attribution both key on the client IP, so + X-Forwarded-For is honoured only from addresses listed here. A header trusted + unconditionally would let any client mint itself a fresh rate-limit budget and + pin its reports on someone else. + . + If the proxy runs on this host, enter 127.0.0.1. If it runs elsewhere, enter + the address it connects from - not the address you reach it on. + . + Leaving this empty is safe but coarse: X-Forwarded-For is then ignored + entirely and every request is attributed to the proxy, so all clients share + one rate-limit bucket. + +Template: jray-server/tmdb-api-key +Type: password +Description: TMDB API key: + Uploaded manifests are cross-checked against the TMDB cast list before being + published (SPEC section 6, stage 3). Without a key the server still serves + reads normally, but every upload stays in "pending" and is never listed - + the correct failure mode, but a silent one. + . + Leave blank to configure later with: dpkg-reconfigure jray-server + If a key is already configured, leaving this blank keeps it. + +Template: jray-server/contact +Type: string +Description: Operator contact (optional): + Published so other operators can arrange peering out of band. An email + address or a URL. Leave blank to publish no contact. + +Template: jray-server/publish-peer-directory +Type: boolean +Default: false +Description: Publish this server's peer directory? + Section 9a makes this deliberately optional: publishing lists the peers you + replicate from, which discloses your federation topology. A server that would + rather not disclose it simply does not, and federation still works. diff --git a/packaging/examples/nginx-jray-server.conf b/packaging/examples/nginx-jray-server.conf new file mode 100644 index 0000000..60e30ed --- /dev/null +++ b/packaging/examples/nginx-jray-server.conf @@ -0,0 +1,78 @@ +# Example nginx site for jray-server. NOT installed anywhere by the package — +# the proxy usually runs on a different host from the server, so a file dropped +# into this machine's nginx would be in the wrong place. Copy it to the proxy. +# +# /etc/nginx/sites-available/jray-server (then symlink into sites-enabled) +# +# Replace jray.example.org and the upstream address, and point ssl_certificate +# at your own certificate. + +upstream jray_server { + # The address jray-server listens on. If the proxy runs on the SAME host, + # this is 127.0.0.1:8080 and JRAY_BIND can stay at its loopback default. If + # the proxy is elsewhere, put the server's address here, set JRAY_BIND to + # something that host can reach (0.0.0.0:8080), and firewall the port to + # this proxy. + server 10.0.0.42:8080; + keepalive 8; +} + +server { + listen 443 ssl; + http2 on; + server_name jray.example.org; + + ssl_certificate /etc/letsencrypt/live/jray.example.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jray.example.org/privkey.pem; + + # SPEC section 6 stage 1 body caps, mirrored at the edge. Section 8 asks for + # them in both places: the proxy rejects the bulk before it reaches the + # application, and the application stays correct if it is ever run without a + # proxy. These must not be tightened below the application's own limits or + # legitimate uploads get a 413 from nginx that the server never sees. + client_max_body_size 2m; + + location = /api/v1/manifests/bundle { + # Series bundles only (section 2). This is why the cap is per-location + # rather than one global 25m: widening it everywhere would hand every + # other endpoint a 25 MiB budget it has no use for. + client_max_body_size 25m; + proxy_pass http://jray_server; + include snippets/jray-server-proxy.conf; + } + + # Liveness. Kept out of the access log because uptime checks poll it hard. + location = /health { + proxy_pass http://jray_server; + include snippets/jray-server-proxy.conf; + access_log off; + } + + location / { + proxy_pass http://jray_server; + include snippets/jray-server-proxy.conf; + } +} + +# --------------------------------------------------------------------------- +# /etc/nginx/snippets/jray-server-proxy.conf +# --------------------------------------------------------------------------- +# +# proxy_http_version 1.1; +# proxy_set_header Connection ""; +# +# proxy_set_header Host $host; +# proxy_set_header X-Forwarded-Proto $scheme; +# +# # $proxy_add_x_forwarded_for appends the real peer on the RIGHT of any header +# # the client sent. That is the safe form for this server: client_ip() in +# # src/auth.rs reads X-Forwarded-For from the right and walks left past further +# # trusted hops, so a client that forges its own entries only pollutes the part +# # that is ignored. Do not "harden" this to $remote_addr unless you have exactly +# # one proxy layer — with two, overwriting loses the real client. +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# +# # Longer than JRAY_REQUEST_TIMEOUT_SEC (30 by default) so the application's own +# # timeout fires first and returns a real status rather than nginx reporting 504 +# # for a request the server was still handling. +# proxy_read_timeout 60s; diff --git a/packaging/jray-server.service b/packaging/jray-server.service new file mode 100644 index 0000000..5f82288 --- /dev/null +++ b/packaging/jray-server.service @@ -0,0 +1,57 @@ +[Unit] +Description=JRay public server +Documentation=https://gitea.tourolle.paris/dtourolle/JRay-public-server +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +ExecStart=/usr/bin/jray-server +EnvironmentFile=/etc/jray-server/env + +# No user to create at install time: systemd allocates one for the lifetime of +# the unit and remaps StateDirectory ownership to it, so the package ships no +# useradd and leaves nothing behind on purge. +DynamicUser=yes +StateDirectory=jray-server +StateDirectoryMode=0700 +WorkingDirectory=/var/lib/jray-server + +# main.rs installs a SIGTERM handler that stops accepting, drains in-flight +# requests and lets the cast-check worker finish its tick before exit. SIGTERM is +# already systemd's default; this only gives it room to finish rather than being +# killed mid-drain. +TimeoutStopSec=30 +Restart=on-failure +RestartSec=5 + +# The binary is static (musl, bundled SQLite, bundled TLS roots). It opens one +# database file under StateDirectory and makes outbound HTTPS calls to TMDB. It +# needs nothing else, so everything else is denied. +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectProc=invisible +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +# No AF_UNIX: musl resolves DNS itself from /etc/resolv.conf and the TLS roots +# are compiled in (reqwest `rustls-tls` uses webpki-roots), so there is no NSS +# socket and no CA bundle to read. A glibc build would need AF_UNIX added back. +RestrictAddressFamilies=AF_INET AF_INET6 +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..2599344 --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# build-deb.sh — stage and build the jray-server Debian package. +# +# Built with dpkg-deb from an explicit staging tree rather than with cargo-deb. +# The reason is debconf: its `config` script and `templates` live in the control +# archive alongside the maintainer scripts, and controlling that archive directly +# is simpler than discovering what a wrapper will and will not copy into it. +# dpkg-dev is present on any Debian builder, so this adds no build dependency. +# +# Usage: +# scripts/build-deb.sh # build the binary, then package +# scripts/build-deb.sh --binary path/to/bin # package an existing binary +# scripts/build-deb.sh --version 1.2.3 # override the computed version +# scripts/build-deb.sh --out dist # output directory +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +TARGET="x86_64-unknown-linux-musl" +BINARY="" +VERSION="" +OUT="$REPO_ROOT/dist" + +while [ $# -gt 0 ]; do + case "$1" in + --binary) BINARY="${2:?--binary needs a path}"; shift ;; + --version) VERSION="${2:?--version needs a value}"; shift ;; + --out) OUT="${2:?--out needs a path}"; shift ;; + -h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "error: unknown argument '$1'" >&2; exit 2 ;; + esac + shift +done + +# ── Version ───────────────────────────────────────────────────────────────── +# +# A tagged commit packages as that tag. Anything else packages as a PRE-release +# of the version in Cargo.toml: `0.1.0~git20260905.9bcc765` sorts BELOW `0.1.0` +# in dpkg's ordering, because `~` sorts before everything including the empty +# string. That is what makes a master build upgradeable to the eventual release +# rather than blocking it — the mistake would be `0.1.0+git...`, which sorts +# above and would leave apt refusing the real 0.1.0. +if [ -z "$VERSION" ]; then + if tag=$(git describe --exact-match --tags HEAD 2>/dev/null); then + VERSION="${tag#v}" + else + cargo_version=$(sed -n 's/^version *= *"\(.*\)"/\1/p' Cargo.toml | head -1) + VERSION="${cargo_version}~git$(date -u +%Y%m%d).$(git rev-parse --short HEAD)" + fi +fi + +# ── Binary ────────────────────────────────────────────────────────────────── +if [ -z "$BINARY" ]; then + echo "=== building $TARGET" + cargo build --release --target "$TARGET" + BINARY="target/$TARGET/release/jray-server" +fi + +[ -f "$BINARY" ] || { echo "error: no binary at $BINARY" >&2; exit 1; } + +# The package claims no libc dependency, which is only honest if the binary +# genuinely has none. Asserted with `file` rather than `ldd`: a musl static-PIE +# makes ldd print the musl loader path, so an ldd check calls a static binary +# dynamic. Same reasoning as the Dockerfile and the CI musl job. +if command -v file >/dev/null 2>&1; then + linkage=$(file -b "$BINARY") + case "$linkage" in + *"static-pie linked"*|*"statically linked"*) ;; + *) echo "error: $BINARY is not static ($linkage)." >&2 + echo " The package declares no libc dependency, so a dynamic" >&2 + echo " binary here would install cleanly and then fail to run." >&2 + exit 1 ;; + esac +fi + +# ── Stage ─────────────────────────────────────────────────────────────────── +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +install -d -m 0755 "$STAGE/DEBIAN" +install -d -m 0755 "$STAGE/usr/bin" +install -d -m 0755 "$STAGE/lib/systemd/system" +install -d -m 0755 "$STAGE/usr/share/doc/jray-server/examples" + +install -m 0755 "$BINARY" "$STAGE/usr/bin/jray-server" +install -m 0644 packaging/jray-server.service "$STAGE/lib/systemd/system/jray-server.service" +install -m 0644 packaging/examples/nginx-jray-server.conf \ + "$STAGE/usr/share/doc/jray-server/examples/nginx-jray-server.conf" +install -m 0644 SPEC.md "$STAGE/usr/share/doc/jray-server/SPEC.md" +install -m 0644 README.md "$STAGE/usr/share/doc/jray-server/README.md" + +# Licences. LICENSE-DATA is not the code licence: contributed manifests are CC0 +# while the server itself is GPLv3, and shipping only one of them would misstate +# what the operator is redistributing. +install -m 0644 LICENSE "$STAGE/usr/share/doc/jray-server/LICENSE" +install -m 0644 LICENSE-DATA "$STAGE/usr/share/doc/jray-server/LICENSE-DATA" + +install -m 0755 packaging/debian/config "$STAGE/DEBIAN/config" +install -m 0755 packaging/debian/postinst "$STAGE/DEBIAN/postinst" +install -m 0755 packaging/debian/prerm "$STAGE/DEBIAN/prerm" +install -m 0755 packaging/debian/postrm "$STAGE/DEBIAN/postrm" +install -m 0644 packaging/debian/templates "$STAGE/DEBIAN/templates" + +SIZE=$(du -ks "$STAGE" | cut -f1) + +# No libc, no libsqlite3, no CA bundle: SQLite is compiled in (`rusqlite` +# bundled), the TLS roots are compiled in (`reqwest` rustls-tls uses +# webpki-roots), and the target is musl static. The only dependencies are the +# two the maintainer scripts themselves call. +cat > "$STAGE/DEBIAN/control" <= 0.5) | debconf-2.0, init-system-helpers (>= 1.54) +Installed-Size: $SIZE +Maintainer: Duncan Tourolle +Homepage: https://gitea.tourolle.paris/dtourolle/JRay-public-server +Description: JRay public server - community manifest exchange + Serves and accepts JRay manifests: per-actor scene windows contributed by + media-centre users and matched to a cut by runtime and audio signature. + . + One static binary and one SQLite file, behind a reverse proxy the operator + provides. The server stores no binary content by design - no images, no + embeddings, no opaque blobs - which is what makes it safe for a volunteer to + run. + . + An example nginx site is installed under + /usr/share/doc/jray-server/examples/, to be copied to whichever host runs + your proxy. +EOF + +mkdir -p "$OUT" +DEB="$OUT/jray-server_${VERSION}_amd64.deb" +dpkg-deb --root-owner-group --build "$STAGE" "$DEB" >/dev/null + +echo "=== built $DEB" +dpkg-deb --info "$DEB" | sed 's/^/ /' +echo "=== contents" +dpkg-deb --contents "$DEB" | sed 's/^/ /'