feat(deb): package the server, and ask the questions that fail silently
CI / fmt, clippy, test (pull_request) Successful in 1m48s
CI / advisories and licences (pull_request) Successful in 42s
CI / static musl binary (pull_request) Successful in 1m46s
CI / debian package (pull_request) Failing after 3h10m28s

DR-015, DR-016. §8 already shipped a static binary and an optional
container; this adds the third form, and it packages the SAME binary the
musl job proved static rather than building its own. Two builds of the
same commit could diverge, and the whole point of that assertion is that
the artifact an operator installs is the one that was checked.

Built with dpkg-deb from an explicit staging tree rather than cargo-deb.
debconf's `config` script and `templates` live in the control archive
next to the maintainer scripts, and controlling that archive directly
beats discovering what a wrapper will copy into it. dpkg-dev is on every
Debian builder, so this adds no build dependency.

Why debconf at all: two settings fail SILENTLY when unset. Without
JRAY_TMDB_API_KEY every upload stays `pending` and is never listed;
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 leave a server that works and is quietly doing the
wrong thing — the worst thing to leave to a README nobody reads.

Three properties, each 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 one would make dpkg prompt on every
upgrade about changes the package itself had made.

Hand edits survive. postinst rewrites only the keys debconf manages;
comments, ordering and any other setting are left alone.

A blank key on reconfigure keeps the existing one. Otherwise pressing
Enter through a dpkg-reconfigure would unpublish every future upload.

The seeding guard is worth its comment, because the obvious version is
wrong twice over. `config` seeds unanswered questions from the env file
so a reconfigure shows what is actually in force. Seeding
unconditionally overwrites a preseed — debconf-set-selections marks what
it sets as seen — so every unattended install would quietly reconfigure
itself back to whatever was on disk. Guarding on an empty value does not
work either: server-id and bind carry template Defaults, so db_get
returns "localhost" for a question nobody answered. The test is the
`seen` flag, which is the actual question being asked.

Purge keeps the database, knowingly departing from the expectation that
purge removes everything. Manifests are the output of real CV compute on
media the operator may no longer have, and §8 says federation is
explicitly not a backup. Destroying that during an `apt purge` is not a
trade worth making for tidiness; postrm names the path instead.

The nginx example is documentation, not installed configuration. 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 — and §8
leaves the edge to the operator deliberately.

Verified by running it, not by reading it: a full lifecycle in a
bookworm container — build, preseeded install, mode-600 env file, key
absent from debconf's database afterwards, `systemd-analyze verify` on
the unit, the installed binary answering /health and /ready, reconfigure
preserving both the key and an unmanaged setting, and purge leaving the
database. It failed on the seeding bug above the first time, which is
why that guard exists. CI runs the same checks against every build.

TRACES: DR-015, DR-016 | PR-004
This commit is contained in:
2026-09-06 09:58:47 +02:00
parent b4cf0c0fbf
commit 6c0c80f20b
12 changed files with 817 additions and 0 deletions
+146
View File
@@ -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"
+31
View File
@@ -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`
+47
View File
@@ -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
+4
View File
@@ -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:
+66
View File
@@ -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 <debconf-key> <env-key>
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
+120
View File
@@ -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
+51
View File
@@ -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
+11
View File
@@ -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
+64
View File
@@ -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.
+78
View File
@@ -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;
+57
View File
@@ -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
+142
View File
@@ -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" <<EOF
Package: jray-server
Version: $VERSION
Section: net
Priority: optional
Architecture: amd64
Depends: debconf (>= 0.5) | debconf-2.0, init-system-helpers (>= 1.54)
Installed-Size: $SIZE
Maintainer: Duncan Tourolle <duncan@tourolle.paris>
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/^/ /'