From 52ac0fe31a94a1453e7eb71620e7bd8df2cfcb7b42f603f35517eb2b06839d5f Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sun, 27 Sep 2026 00:49:47 -0400 Subject: [PATCH] v0.12.2 drop version from api path, deploy script update --- README.md | 2 +- cmd/chess-server/main.go | 5 +- deploy/README.md | 17 +- deploy/freebsd/nginx-chess.conf | 59 ++- deploy/freebsd/rc.d/chess_server | 36 -- deploy/freebsd/rc.d/chessd | 128 ++++++ deploy/freebsd/setup-jail.sh | 370 +++++++++--------- deploy/postgresql/migrate-sqlite.sh | 171 -------- doc/api.md | 4 +- doc/architecture.md | 8 +- doc/client.md | 4 +- doc/deployment.md | 343 ++++++++-------- doc/todo.md | 6 +- internal/client/api/client.go | 26 +- internal/server/http/handler.go | 4 +- internal/server/http/handler_test.go | 44 +++ internal/server/service/game.go | 2 +- .../server/webserver/chess-client-web/app.js | 18 +- .../webserver/chess-client-web/style.css | 45 ++- test/README.md | 6 +- test/test-api.sh | 4 +- test/test-db.sh | 2 +- test/test-endstate.sh | 2 +- test/test-longpoll.sh | 2 +- 24 files changed, 658 insertions(+), 650 deletions(-) delete mode 100755 deploy/freebsd/rc.d/chess_server create mode 100755 deploy/freebsd/rc.d/chessd delete mode 100755 deploy/postgresql/migrate-sqlite.sh diff --git a/README.md b/README.md index 040a5f4..d62a1d0 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl ## Documentation - [API Reference](./doc/api.md) - Endpoint specifications including auth -- [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, service account, SQLite data import +- [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, `chessd` service, nginx, web clients - [Architecture](./doc/architecture.md) - System design with auth layer - [Development](./doc/development.md) - Build, test, and user management - [Client Guide](./doc/client.md) - Interactive debugging client diff --git a/cmd/chess-server/main.go b/cmd/chess-server/main.go index 355bd39..fd4cc65 100644 --- a/cmd/chess-server/main.go +++ b/cmd/chess-server/main.go @@ -184,7 +184,6 @@ func main() { go func() { log.Printf("Chess API Server starting...") log.Printf("API Listening on: http://%s", apiAddr) - log.Printf("API Version: v1") log.Printf("Authentication: Enabled (JWT)") if *dev { log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)") @@ -201,8 +200,8 @@ func main() { } else { log.Printf("Storage: Disabled (auth features unavailable)") } - log.Printf("API Endpoints: http://%s/api/v1/games", apiAddr) - log.Printf("Auth Endpoints: http://%s/api/v1/auth/[register|login|me]", apiAddr) + log.Printf("API Endpoints: http://%s/api/games", apiAddr) + log.Printf("Auth Endpoints: http://%s/api/auth/[register|login|me]", apiAddr) log.Printf("Health: http://%s/health", apiAddr) if err := app.Listen(apiAddr); err != nil { diff --git a/deploy/README.md b/deploy/README.md index 0249006..7af1c8a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,16 +3,19 @@ Run in this order; each script's header documents its inputs. The full guide is [doc/deployment.md](../doc/deployment.md). -| Order | Where | Script | Purpose | +| Order | Where | Step | Purpose | |---|---|---|---| -| 1 | Bastille host | `freebsd/host.sh ` | `sysvshm=new` for PostgreSQL, jail restart | -| 2 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` | -| 3 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL 18, database, account, service, backups, optional SQLite import | -| 4 | Bastille host | `freebsd/nginx-chess.conf` | Location block for the host nginx | +| 1 | Bastille host, once | `freebsd/host.sh ` | `sysvshm=new` for PostgreSQL, jail restart | +| 2 | Jail, once | `pkg install postgresql18-server postgresql18-client stockfish` | Packages | +| 3 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` | +| 4 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL setup, database, `chessd` service, optional log rotation and backups; rerun to upgrade | +| 5 | Bastille host | `freebsd/nginx-chess.conf` | nginx locations for `/chess/api/` and `/chess/health` | Supporting files, installed by `setup-jail.sh`: - `postgresql/setup.sql` — role, database, and schema (owner or split mode) -- `postgresql/migrate-sqlite.sh` — one-time import of a v0.11 SQLite database -- `freebsd/rc.d/chess_server` — `daemon(8)` service script +- `freebsd/rc.d/chessd` — `daemon(8)` service script - `freebsd/chess-backup.sh` — nightly `pg_dump` with 14-day retention + +Keep deployment-specific values (addresses, host names) in `rc.conf` and the +host nginx configuration, not in this repository. diff --git a/deploy/freebsd/nginx-chess.conf b/deploy/freebsd/nginx-chess.conf index 0cb79e6..bf3524b 100644 --- a/deploy/freebsd/nginx-chess.conf +++ b/deploy/freebsd/nginx-chess.conf @@ -1,19 +1,52 @@ -# Host nginx: proxy /chess/ to the chess API in the jail. Include inside the -# existing TLS server block, next to the security headers. Replace -# JAIL_IP:API_PORT with the values given to setup-jail.sh. +# Host nginx for chess: browsers (web and WASM clients) call +# /chess/api/... and /chess/health, which map to the server's +# /api/... and /health. Replace and (chessd_port, +# default 8080). # -# chess-server trusts X-Real-IP only from the address in -trusted-proxies -# (TRUSTED_PROXIES in setup-jail.sh): the address this nginx uses to reach -# the jail. Do not forward X-Forwarded-For for rate limiting; its first entry -# is supplied by the client. -location /chess/ { - proxy_pass http://JAIL_IP:API_PORT/; +# chess-server keys rate limits on X-Real-IP, trusted only from the addresses +# in chessd_trusted_proxies: the address this nginx uses to reach the jail. +# $remote_addr must be the real client: with PROXY protocol in front of nginx, +# set real_ip_header proxy_protocol and set_real_ip_from for that hop. + +# --- http {} context --- +upstream chess_api { + server :; + keepalive 16; +} + +# --- TLS server {} context --- +location /chess/api/ { + proxy_pass http://chess_api/api/; + proxy_http_version 1.1; + # Empty Connection header lets nginx reuse upstream keepalive connections. + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + + # Long-polling holds a request up to 30 seconds. + proxy_buffering off; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + + # API payloads are small JSON documents. + client_max_body_size 16k; +} + +location = /chess/health { + proxy_pass http://chess_api/health; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - # Long-polling waits up to 30 seconds for a game update. - proxy_read_timeout 45s; - # Bound request bodies; API payloads are small JSON documents. - client_max_body_size 16k; + proxy_read_timeout 10s; +} + +# Everything else under /chess is not served by the API. +location = /chess { + return 404; +} +location /chess/ { + return 404; } diff --git a/deploy/freebsd/rc.d/chess_server b/deploy/freebsd/rc.d/chess_server deleted file mode 100755 index a98ffe6..0000000 --- a/deploy/freebsd/rc.d/chess_server +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/sh -# -# PROVIDE: chess_server -# REQUIRE: LOGIN postgresql -# KEYWORD: shutdown -# -# rc.conf variables (setup-jail.sh sets them with sysrc): -# chess_server_enable YES to start at boot -# chess_server_flags chess-server arguments (-dsn, -jwt-secret-file, ...) -# chess_server_env environment, including PATH=... for stockfish -# chess_server_account unprivileged account (default: chess) -# chess_server_binary default: /usr/local/chess/chess-server -# chess_server_logfile default: /var/log/chess/chess-server.log -# -# daemon(8) starts as root, writes the supervisor pidfile, drops to the -# account, restarts the server if it exits (-r, 5 s delay), and reopens the -# log file on SIGHUP (-H) for newsyslog. Do not set chess_server_user: rc.subr -# would switch users before daemon runs and daemon could not drop privileges. - -. /etc/rc.subr - -name=chess_server -rcvar=chess_server_enable -load_rc_config $name - -: ${chess_server_enable:="NO"} -: ${chess_server_account:="chess"} -: ${chess_server_binary:="/usr/local/chess/chess-server"} -: ${chess_server_logfile:="/var/log/chess/chess-server.log"} - -pidfile="/var/run/${name}.pid" -procname="daemon" -command="/usr/sbin/daemon" -command_args="-f -r -R 5 -H -P ${pidfile} -o ${chess_server_logfile} -u ${chess_server_account} ${chess_server_binary} ${chess_server_flags}" - -run_rc_command "$1" diff --git a/deploy/freebsd/rc.d/chessd b/deploy/freebsd/rc.d/chessd new file mode 100755 index 0000000..f7567bc --- /dev/null +++ b/deploy/freebsd/rc.d/chessd @@ -0,0 +1,128 @@ +#!/bin/sh +# +# PROVIDE: chessd +# REQUIRE: DAEMON NETWORKING postgresql +# KEYWORD: shutdown +# +# chessd_enable: Set to YES to enable chessd on startup. +# chessd_user: User to run as. Default: chess +# chessd_group: Group to run as. Default: chess +# chessd_home: Account home. Default: /home/chess +# chessd_bin: Server binary. Default: ${chessd_home}/bin/chess-server +# chessd_logs: Log file path. Default: /var/log/chessd.log +# chessd_host: Listen address. Default: 0.0.0.0 +# chessd_port: Listen port. Default: 8080 +# chessd_dsn: PostgreSQL connection. Default: postgres:///chess?host=/tmp +# (database chess over the Unix socket, as the OS user) +# chessd_jwt_key: JWT signing key, created on first start (mode 0600). +# Default: ${chessd_home}/jwt.key +# chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is +# trusted as the client address. Default: none +# chessd_flags: Additional flags passed to the binary. Default: "" +# +# Add the following to /etc/rc.conf to enable: +# chessd_enable="YES" +# chessd_trusted_proxies="
" + +. /etc/rc.subr + +name="chessd" +rcvar="chessd_enable" +desc="Chess Server Daemon" + +load_rc_config "$name" + +: ${chessd_enable:="NO"} +: ${chessd_user:="chess"} +: ${chessd_group:="chess"} +: ${chessd_home:="/home/chess"} +: ${chessd_bin:="${chessd_home}/bin/chess-server"} +: ${chessd_logs:="/var/log/chessd.log"} +: ${chessd_host:="0.0.0.0"} +: ${chessd_port:="8080"} +: ${chessd_dsn:="postgres:///chess?host=/tmp"} +: ${chessd_jwt_key:="${chessd_home}/jwt.key"} +: ${chessd_trusted_proxies:=""} +: ${chessd_flags:=""} + +command="${chessd_bin}" +pidfile="/var/run/${name}.pid" + +start_precmd="${name}_prestart" +start_cmd="${name}_start" +stop_cmd="${name}_stop" +status_cmd="${name}_status" + +chessd_prestart() +{ + if [ ! -x "${command}" ]; then + err 1 "${command} is missing or not executable" + fi + + touch "${chessd_logs}" + chown "${chessd_user}:${chessd_group}" "${chessd_logs}" + chmod 640 "${chessd_logs}" + + # A stable signing key keeps sessions valid across restarts. The server + # refuses a key readable by group or others. + if [ ! -f "${chessd_jwt_key}" ]; then + (umask 077 && /usr/bin/openssl rand -base64 48 > "${chessd_jwt_key}") || + err 1 "cannot create ${chessd_jwt_key}" + chown "${chessd_user}:${chessd_group}" "${chessd_jwt_key}" + echo "Created JWT signing key ${chessd_jwt_key}." + fi +} + +chessd_start() +{ + if chessd_status >/dev/null 2>&1; then + echo "${desc} is already running as PID $(cat ${pidfile})." + return 0 + fi + + set -- -api-host "${chessd_host}" -api-port "${chessd_port}" \ + -dsn "${chessd_dsn}" -jwt-secret-file "${chessd_jwt_key}" + if [ -n "${chessd_trusted_proxies}" ]; then + set -- "$@" -trusted-proxies "${chessd_trusted_proxies}" + fi + + echo "Starting ${desc}." + # -r restarts the server 5 seconds after an unexpected exit (for example + # when PostgreSQL is not yet accepting connections); -H reopens the log + # file on SIGHUP so newsyslog can rotate it. + /usr/sbin/daemon \ + -P "${pidfile}" \ + -r -R 5 \ + -H \ + -u "${chessd_user}" \ + -o "${chessd_logs}" \ + -f \ + /usr/bin/env \ + HOME="${chessd_home}" \ + PATH="${chessd_home}/bin:/usr/local/bin:/usr/bin:/bin" \ + "${command}" "$@" ${chessd_flags} +} + +chessd_stop() +{ + if [ -f "${pidfile}" ]; then + echo "Stopping ${desc}." + kill -TERM "$(cat ${pidfile})" 2>/dev/null + wait_for_pids "$(cat ${pidfile})" + rm -f "${pidfile}" + else + echo "${desc} is not running (no pidfile)." + fi +} + +chessd_status() +{ + if [ -f "${pidfile}" ] && kill -0 "$(cat ${pidfile})" 2>/dev/null; then + echo "${desc} is running as PID $(cat ${pidfile})." + else + echo "${desc} is not running." + return 1 + fi +} + +run_rc_command "$1" diff --git a/deploy/freebsd/setup-jail.sh b/deploy/freebsd/setup-jail.sh index b9619ad..42be1b5 100644 --- a/deploy/freebsd/setup-jail.sh +++ b/deploy/freebsd/setup-jail.sh @@ -1,42 +1,41 @@ #!/bin/sh -# Install or update chess-server with PostgreSQL 18 inside a FreeBSD jail. -# Run as root inside the jail, from a copy of this repository: +# Install or upgrade chess-server (rc.d service `chessd`) with PostgreSQL +# inside a FreeBSD jail. Run as root inside the jail from a copy of this +# repository: # -# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \ -# API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \ -# sh deploy/freebsd/setup-jail.sh +# CHESS_BINARY=/tmp/chess-server TRUSTED_PROXIES= \ +# sh deploy/freebsd/setup-jail.sh # -# Before the first run: -# 1. On the host: sh deploy/freebsd/host.sh (sysvshm=new, restart) -# 2. On a build machine: make server-freebsd, then copy -# bin/chess-server-freebsd-amd64 into the jail -# 3. For an existing SQLite deployment: note the database path; the old -# service is stopped before the import +# Prerequisites: +# - postgresql18-server, postgresql18-client, and stockfish packages +# - the jail has its own System V shared memory (deploy/freebsd/host.sh) +# - the release binary, built with `make server-freebsd` (or `make server` +# on FreeBSD) # -# Required: -# CHESS_BINARY new chess-server binary (FreeBSD/amd64, static) -# API_HOST address chess-server listens on (the jail IP) +# Inputs (environment): +# CHESS_BINARY required: chess-server binary to install +# TRUSTED_PROXIES address(es) the host nginx connects from, stored as +# chessd_trusted_proxies in rc.conf. Without it, every +# proxied client shares one rate-limit bucket. +# SPLIT_PRIVILEGES yes|no (default no); see deploy/postgresql/setup.sql +# ENABLE_LOG_ROTATION yes|no (default no): newsyslog entry for the log +# ENABLE_BACKUP yes|no (default no): nightly pg_dump via cron # -# Optional: -# API_PORT default 8080 -# TRUSTED_PROXIES host nginx address(es) as seen from the jail; without it -# all proxied clients share one rate-limit bucket -# CHESS_HOME default: the chess account's home, else /usr/local/chess -# LOG_DIR default /var/log/chess -# SQLITE_DB v0.11 SQLite database to import once into an empty schema -# OLD_SERVICE rc.d name of the previous chess service to stop and -# disable (for example chess); skipped when unset -# SPLIT_PRIVILEGES yes: runtime role gets DML only, schema owned by -# chess_owner (default no; see deploy/postgresql/setup.sql) -# EXTRA_FLAGS additional chess-server flags (e.g. "-max-users 500") -# PGDATA default /var/db/postgres/data18 +# The service layout comes from rc.conf (chessd_*) with the defaults of +# deploy/freebsd/rc.d/chessd: account chess, home /home/chess, binary +# ~/bin/chess-server, log /var/log/chessd.log, 0.0.0.0:8080. # -# The script is idempotent: rerun it to upgrade the binary, change flags, or -# repair configuration. It never drops data. Steps: -# packages -> PostgreSQL init and hardening -> role/database/schema -> -# chess account, binary, JWT key, log dir -> stop old service -> schema -# migration -> optional SQLite import -> rc.d/rc.conf -> log rotation -> -# nightly backup -> start and health check +# Steps (idempotent; rerun to upgrade): +# 1. PostgreSQL: initdb only if no cluster exists; pg_hba.conf allows only +# postgres and chess over the Unix socket (peer); TCP listener disabled +# 2. role, database, and schema from deploy/postgresql/setup.sql (first run) +# 3. stop chessd; install the binary (previous kept as .prev) and the rc.d +# script (previous copy saved under /var/backups) +# 4. JWT key; schema migration; rc.conf settings +# 5. optional log rotation and backups +# 6. start chessd and check /health +# Files of a previous SQLite release are left untouched; remove them once the +# new release is verified. set -eu PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin umask 022 @@ -44,73 +43,96 @@ umask 022 here=$(cd "$(dirname "$0")" && pwd) repo=$(cd "$here/../.." && pwd) -: "${CHESS_BINARY:?set CHESS_BINARY to the new chess-server binary}" -: "${API_HOST:?set API_HOST to the address chess-server listens on}" -API_PORT=${API_PORT:-8080} +: "${CHESS_BINARY:?set CHESS_BINARY to the chess-server binary to install}" TRUSTED_PROXIES=${TRUSTED_PROXIES:-} -LOG_DIR=${LOG_DIR:-/var/log/chess} -SQLITE_DB=${SQLITE_DB:-} -OLD_SERVICE=${OLD_SERVICE:-} SPLIT_PRIVILEGES=${SPLIT_PRIVILEGES:-no} -EXTRA_FLAGS=${EXTRA_FLAGS:-} -PGDATA=${PGDATA:-/var/db/postgres/data18} -# Install locations; overridable only for testing the script. +ENABLE_LOG_ROTATION=${ENABLE_LOG_ROTATION:-no} +ENABLE_BACKUP=${ENABLE_BACKUP:-no} +# System locations; overridable only to test the script. RC_DIR=${RC_DIR:-/usr/local/etc/rc.d} +RC_BACKUP_DIR=${RC_BACKUP_DIR:-/var/backups} NEWSYSLOG_DIR=${NEWSYSLOG_DIR:-/usr/local/etc/newsyslog.conf.d} CRON_DIR=${CRON_DIR:-/usr/local/etc/cron.d} SBIN_DIR=${SBIN_DIR:-/usr/local/sbin} BACKUP_DIR=${BACKUP_DIR:-/var/db/postgres/backups} -# The database role is named after the OS account: peer authentication maps -# one to the other, so no password exists anywhere. -account=chess -dsn='postgres:///chess?host=/tmp' - log() { printf '==> %s\n' "$*"; } die() { printf 'setup-jail: %s\n' "$*" >&2; exit 1; } -# Run SQL from stdin as the postgres superuser against database $1. +# SQL from stdin, run as the postgres superuser against database $1. pg_sql() { su -m postgres -c "psql -X -A -t -q -v ON_ERROR_STOP=1 -d $1" } -# --- preflight --------------------------------------------------------------- +# rc.conf value of $1, or the default $2. +rc_value() { + _value=$(sysrc -n "$1" 2>/dev/null) || _value="" + if [ -n "$_value" ]; then echo "$_value"; else echo "$2"; fi +} + +yes_no() { + case $2 in yes | no) ;; *) die "$1 must be yes or no" ;; esac +} + +# --- preflight ----------------------------------------------------------------- [ "$(id -u)" -eq 0 ] || die "run as root" [ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD" [ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY" -for f in "$repo/deploy/postgresql/setup.sql" "$repo/deploy/postgresql/migrate-sqlite.sh" \ - "$here/rc.d/chess_server" "$here/chess-backup.sh"; do +for f in "$repo/deploy/postgresql/setup.sql" "$here/rc.d/chessd" "$here/chess-backup.sh"; do [ -f "$f" ] || die "missing $f; run from a complete repository copy" done -case $SPLIT_PRIVILEGES in yes | no) ;; *) die "SPLIT_PRIVILEGES must be yes or no" ;; esac -if [ -n "$SQLITE_DB" ] && [ ! -r "$SQLITE_DB" ]; then - die "SQLITE_DB not readable: $SQLITE_DB" +yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES" +yes_no ENABLE_LOG_ROTATION "$ENABLE_LOG_ROTATION" +yes_no ENABLE_BACKUP "$ENABLE_BACKUP" + +command -v postgres >/dev/null 2>&1 || die "PostgreSQL is not installed (pkg install postgresql18-server postgresql18-client)" +pg_major=$(postgres --version | sed -n 's/^postgres (PostgreSQL) \([0-9][0-9]*\).*/\1/p') +[ -n "$pg_major" ] && [ "$pg_major" -ge 17 ] || die "PostgreSQL 17 or newer is required (found: $(postgres --version))" +command -v stockfish >/dev/null 2>&1 || log "WARNING: stockfish not found in PATH; computer moves will fail" + +user=$(rc_value chessd_user chess) +group=$(rc_value chessd_group chess) +[ "$user" = chess ] || die "chessd_user is $user; peer authentication requires the OS account and database role to both be chess" +if ! id "$user" >/dev/null 2>&1; then + log "creating account $user (home /home/$user, no shell, no password)" + pw useradd -n "$user" -c "Chess server" -d "/home/$user" -m -s /usr/sbin/nologin -w no fi -[ -n "$TRUSTED_PROXIES" ] || - log "WARNING: TRUSTED_PROXIES unset; behind nginx every client shares one rate limit" +# chessd_home: rc.conf, else the account's home. rc.d/chessd defaults to +# /home/chess, so any other home is recorded in rc.conf. +account_home=$(pw usershow "$user" | cut -d: -f9) +home=$(rc_value chessd_home "$account_home") +if [ "$home" != /home/chess ] && [ -z "$(rc_value chessd_home "")" ]; then + sysrc -q chessd_home="$home" >/dev/null +fi +bin=$(rc_value chessd_bin "$home/bin/chess-server") +host=$(rc_value chessd_host 0.0.0.0) +port=$(rc_value chessd_port 8080) +dsn=$(rc_value chessd_dsn 'postgres:///chess?host=/tmp') +key=$(rc_value chessd_jwt_key "$home/jwt.key") -# --- packages ---------------------------------------------------------------- -packages="postgresql18-server postgresql18-client stockfish" -[ -n "$SQLITE_DB" ] && packages="$packages sqlite3" -log "installing packages: $packages" -# shellcheck disable=SC2086 # word splitting intended -pkg install -y $packages +case $(rc_value chessd_flags "") in +*-storage-path*) die "rc.conf chessd_flags contains -storage-path, which no longer exists; remove it (sysrc -x chessd_flags) and rerun" ;; +esac -# --- PostgreSQL ---------------------------------------------------------------- +# --- PostgreSQL ------------------------------------------------------------------- +pgdata=$(rc_value postgresql_data "/var/db/postgres/data$pg_major") +if [ ! -f "$pgdata/PG_VERSION" ]; then + log "initializing PostgreSQL cluster in $pgdata" + sysrc -q postgresql_enable=YES >/dev/null + sysrc -q postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject" >/dev/null + service postgresql initdb || + die "initdb failed; a shared memory error means the jail needs sysvshm=new (deploy/freebsd/host.sh)" +fi sysrc -q postgresql_enable=YES >/dev/null -sysrc -q postgresql_data="$PGDATA" >/dev/null -sysrc -q postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject" >/dev/null - -if [ ! -f "$PGDATA/PG_VERSION" ]; then - log "initializing PostgreSQL cluster in $PGDATA" - if ! service postgresql initdb; then - die "initdb failed. If it reports a shared memory error, run deploy/freebsd/host.sh on the host (sysvshm=new) and retry" - fi +if ! service postgresql status >/dev/null 2>&1; then + log "starting PostgreSQL" + service postgresql start fi # Only the postgres superuser and the chess role, each over the Unix socket -# with peer authentication. No host lines: TCP is disabled below. -hba="$PGDATA/pg_hba.conf" +# with peer authentication. A cluster initialized with FreeBSD's default flags +# trusts every local connection; this replaces that. +hba="$pgdata/pg_hba.conf" if ! grep -q '^# Managed by chess setup-jail.sh' "$hba" 2>/dev/null; then [ -f "$hba.orig" ] || cp -p "$hba" "$hba.orig" log "writing $hba (original kept as pg_hba.conf.orig)" @@ -122,163 +144,145 @@ local chess chess peer HBA chown postgres:postgres "$hba" chmod 600 "$hba" + service postgresql reload fi -log "starting PostgreSQL" -service postgresql status >/dev/null 2>&1 || service postgresql start -# ALTER SYSTEM writes postgresql.auto.conf, leaving postgresql.conf untouched. -pg_sql postgres <<'SQL' +# Unix socket only. ALTER SYSTEM writes postgresql.auto.conf and leaves +# postgresql.conf untouched; the setting needs a restart. +if [ -n "$(echo 'SHOW listen_addresses' | pg_sql postgres)" ]; then + log "disabling the PostgreSQL TCP listener" + pg_sql postgres <<'SQL' ALTER SYSTEM SET listen_addresses = ''; ALTER SYSTEM SET unix_socket_directories = '/tmp'; -ALTER SYSTEM SET password_encryption = 'scram-sha-256'; SQL -# listen_addresses and unix_socket_directories apply only after a restart. -service postgresql restart + service postgresql restart + if [ -n "$(echo 'SHOW listen_addresses' | pg_sql postgres)" ]; then + log "WARNING: listen_addresses is still set, from a source that overrides postgresql.auto.conf (postgresql_flags?); PostgreSQL still listens on TCP" + fi +fi -if [ -z "$(echo "SELECT 1 FROM pg_roles WHERE rolname = 'chess'" | pg_sql postgres)" ]; then +have_role=$(echo "SELECT 1 FROM pg_roles WHERE rolname = 'chess'" | pg_sql postgres) +have_db=$(echo "SELECT 1 FROM pg_database WHERE datname = 'chess'" | pg_sql postgres) +case "$have_role$have_db" in +11) log "role and database chess exist; provisioning unchanged" ;; +"") log "provisioning role, database, and schema (split privileges: $SPLIT_PRIVILEGES)" split=false [ "$SPLIT_PRIVILEGES" = yes ] && split=true su -m postgres -c "psql -X -q -d postgres -v split=$split -f -" <"$repo/deploy/postgresql/setup.sql" -else - log "role chess exists; leaving database provisioning unchanged" + ;; +*) die "only one of role chess and database chess exists; inspect with: su -m postgres -c psql" ;; +esac + +# --- chessd -------------------------------------------------------------------- +if service chessd onestatus >/dev/null 2>&1; then + log "stopping chessd" + service chessd onestop fi -# --- chess account and files --------------------------------------------------- -if ! id "$account" >/dev/null 2>&1; then - CHESS_HOME=${CHESS_HOME:-/usr/local/chess} - log "creating account $account (home $CHESS_HOME, no shell, no password)" - pw useradd -n "$account" -c "Chess server" -d "$CHESS_HOME" -s /usr/sbin/nologin -w no -else - CHESS_HOME=${CHESS_HOME:-$(pw usershow "$account" | cut -d: -f9)} - shell=$(pw usershow "$account" | cut -d: -f10) - case $shell in - */nologin | */false) ;; - *) log "WARNING: $account has login shell $shell; consider: pw usermod $account -s /usr/sbin/nologin" ;; - esac +bindir=$(dirname "$bin") +[ -d "$bindir" ] || install -d -o root -g wheel -m 0755 "$bindir" +if [ -f "$bin" ] && ! cmp -s "$CHESS_BINARY" "$bin"; then + log "keeping the previous binary as $bin.prev" + cp -p "$bin" "$bin.prev" fi -[ -d "$CHESS_HOME" ] || install -d -o root -g "$account" -m 0750 "$CHESS_HOME" -install -d -o "$account" -g "$account" -m 0750 "$LOG_DIR" +# Root-owned: the service account cannot replace its own executable file. +install -o root -g wheel -m 0555 "$CHESS_BINARY" "$bin" -binary="$CHESS_HOME/chess-server" -if [ -f "$binary" ] && ! cmp -s "$CHESS_BINARY" "$binary"; then - log "keeping the previous binary as $binary.prev" - cp -p "$binary" "$binary.prev" +[ -d "$RC_DIR" ] || install -d "$RC_DIR" +if [ -f "$RC_DIR/chessd" ] && ! cmp -s "$here/rc.d/chessd" "$RC_DIR/chessd"; then + # Saved outside rc.d: rc(8) would run a second copy found there. + [ -d "$RC_BACKUP_DIR" ] || install -d -m 0750 "$RC_BACKUP_DIR" + saved="$RC_BACKUP_DIR/chessd.rc.$(date -u +%Y%m%dT%H%M%SZ)" + cp -p "$RC_DIR/chessd" "$saved" + log "previous rc.d script saved as $saved" fi -# Root-owned: the service account cannot replace its own executable. -install -o root -g wheel -m 0555 "$CHESS_BINARY" "$binary" +install -o root -g wheel -m 0555 "$here/rc.d/chessd" "$RC_DIR/chessd" -key="$CHESS_HOME/jwt.key" if [ ! -f "$key" ]; then log "generating JWT signing key $key" (umask 077 && openssl rand -base64 48 >"$key") fi -chown "$account:$account" "$key" +chown "$user:$group" "$key" chmod 600 "$key" -# --- stop the previous service ------------------------------------------------ -if [ -n "$OLD_SERVICE" ] && [ "$OLD_SERVICE" != chess_server ]; then - log "stopping and disabling previous service $OLD_SERVICE" - service "$OLD_SERVICE" onestop || true - sysrc -q "${OLD_SERVICE}_enable=NO" >/dev/null || true -fi -service chess_server onestatus >/dev/null 2>&1 && service chess_server onestop - -# --- schema ------------------------------------------------------------------ log "creating or migrating the chess schema" if [ "$SPLIT_PRIVILEGES" = yes ]; then - # The postgres account migrates as chess_owner. It may not be able to - # traverse the chess home, so it runs a temporary copy of the binary. + # The postgres account migrates as chess_owner. It may be unable to reach + # the chess home, so it runs a temporary copy of the binary. migrator=$(mktemp /tmp/chess-migrate.XXXXXX) install -o root -g wheel -m 0555 "$CHESS_BINARY" "$migrator" su -m postgres -c "$migrator db init -dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\"" || { rm -f "$migrator"; die "schema migration failed"; } rm -f "$migrator" else - su -m "$account" -c "$binary db init -dsn '$dsn'" + su -m "$user" -c "$bin db init -dsn '$dsn'" fi -# --- optional SQLite import ---------------------------------------------------- -if [ -n "$SQLITE_DB" ]; then - if [ -n "$(echo 'SELECT 1 FROM chess.users UNION ALL SELECT 1 FROM chess.games LIMIT 1' | pg_sql chess)" ]; then - log "chess schema already has data; skipping SQLite import" - else - log "importing $SQLITE_DB" - work=$(mktemp -d /tmp/chess-import.XXXXXX) - # Copy WAL companions too: after an unclean stop they hold recent writes. - for suffix in "" -wal -shm; do - if [ -f "$SQLITE_DB$suffix" ]; then - cp "$SQLITE_DB$suffix" "$work/legacy.db$suffix" - fi - done - chown -R postgres "$work" - su -m postgres -c "cd '$work' && CHESS_SCHEMA=chess sh -s -- '$work/legacy.db' dbname=chess" \ - <"$repo/deploy/postgresql/migrate-sqlite.sh" - rm -rf "$work" +sysrc -q chessd_enable=YES >/dev/null +if [ -n "$TRUSTED_PROXIES" ]; then + sysrc -q chessd_trusted_proxies="$TRUSTED_PROXIES" >/dev/null +fi +[ -n "$(rc_value chessd_trusted_proxies "")" ] || + log "WARNING: chessd_trusted_proxies is unset; behind nginx every client shares one rate limit" +# Settings of the SQLite-era rc.d script that nothing reads any more. +for stale in chessd_storage_path chessd_dir; do + if sysrc -n "$stale" >/dev/null 2>&1; then + sysrc -q -x "$stale" >/dev/null + log "removed unused rc.conf setting $stale" fi +done + +# --- optional maintenance ------------------------------------------------------------- +logs=$(rc_value chessd_logs /var/log/chessd.log) +if [ "$ENABLE_LOG_ROTATION" = yes ]; then + # Daily or at 1 MB, keep 7; SIGHUP makes daemon(8) (-H) reopen the log. + [ -d "$NEWSYSLOG_DIR" ] || install -d "$NEWSYSLOG_DIR" + printf '%s\n' \ + "# Managed by chess setup-jail.sh" \ + "$logs $user:$group 640 7 1000 @T00 JC /var/run/chessd.pid 1" \ + >"$NEWSYSLOG_DIR/chessd.conf" + log "log rotation: $NEWSYSLOG_DIR/chessd.conf" +fi +if [ "$ENABLE_BACKUP" = yes ]; then + # Nightly pg_dump at 03:30 as postgres, keeping 14 days. + [ -d "$SBIN_DIR" ] || install -d "$SBIN_DIR" + install -o root -g wheel -m 0555 "$here/chess-backup.sh" "$SBIN_DIR/chess-backup" + [ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR" + [ -d "$CRON_DIR" ] || install -d "$CRON_DIR" + printf '%s\n' \ + "# Managed by chess setup-jail.sh" \ + "SHELL=/bin/sh" \ + "PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" \ + "30 3 * * * postgres CHESS_BACKUP_DIR=$BACKUP_DIR $SBIN_DIR/chess-backup" \ + >"$CRON_DIR/chess-backup" + log "backups: $CRON_DIR/chess-backup -> $BACKUP_DIR" fi -# --- service configuration ------------------------------------------------------- -if [ -f "$RC_DIR/chess_server" ] && ! cmp -s "$here/rc.d/chess_server" "$RC_DIR/chess_server"; then - cp -p "$RC_DIR/chess_server" "$RC_DIR/chess_server.orig" -fi -install -d "$RC_DIR" -install -o root -g wheel -m 0555 "$here/rc.d/chess_server" "$RC_DIR/chess_server" - -flags="-api-host $API_HOST -api-port $API_PORT -dsn $dsn -jwt-secret-file $key -log-level info" -[ -n "$TRUSTED_PROXIES" ] && flags="$flags -trusted-proxies $TRUSTED_PROXIES" -[ -n "$EXTRA_FLAGS" ] && flags="$flags $EXTRA_FLAGS" -sysrc -q chess_server_enable=YES >/dev/null -sysrc -q chess_server_account="$account" >/dev/null -sysrc -q chess_server_binary="$binary" >/dev/null -sysrc -q chess_server_logfile="$LOG_DIR/chess-server.log" >/dev/null -sysrc -q chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin" >/dev/null -sysrc -q chess_server_flags="$flags" >/dev/null - -# Rotate daily or at 1 MB, keep a week; SIGHUP makes daemon(8) reopen the log. -install -d "$NEWSYSLOG_DIR" -printf '%s\n' \ - "# Managed by chess setup-jail.sh" \ - "$LOG_DIR/chess-server.log $account:$account 640 7 1000 @T00 JC /var/run/chess_server.pid 1" \ - >"$NEWSYSLOG_DIR/chess_server.conf" - -# Nightly pg_dump at 03:30, as postgres, keeping 14 days. -install -d "$SBIN_DIR" -install -o root -g wheel -m 0555 "$here/chess-backup.sh" "$SBIN_DIR/chess-backup" -install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR" -install -d "$CRON_DIR" -printf '%s\n' \ - "# Managed by chess setup-jail.sh" \ - "SHELL=/bin/sh" \ - "PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" \ - "30 3 * * * postgres CHESS_BACKUP_DIR=$BACKUP_DIR $SBIN_DIR/chess-backup" \ - >"$CRON_DIR/chess-backup" - -# --- start and verify ----------------------------------------------------------- -log "starting chess_server" -service chess_server start +# --- start and verify ----------------------------------------------------------------- +log "starting chessd" +service chessd start +health_host=$host +[ "$health_host" = 0.0.0.0 ] && health_host=127.0.0.1 +url="http://$health_host:$port/health" health="" for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do - if health=$(fetch -q -T 2 -o - "http://$API_HOST:$API_PORT/health" 2>/dev/null); then + if health=$(fetch -q -T 2 -o - "$url" 2>/dev/null); then break fi sleep 2 done case $health in *'"storage":"ok"'*) log "healthy: $health" ;; -*) die "chess-server did not report healthy storage (got: ${health:-no response}); see $LOG_DIR/chess-server.log" ;; +*) die "no healthy response from $url (got: ${health:-nothing}); see $logs, and check that the jail firewall allows loopback" ;; esac cat <&2 - exit 1 -} - -[ $# -ge 1 ] && [ $# -le 2 ] || die "usage: $0 /path/to/chess.db [psql-conninfo]" -sqlite_db=$1 -conninfo=${2:-${CHESS_DSN:-}} -schema=${CHESS_SCHEMA:-chess} - -for tool in sqlite3 psql; do - command -v "$tool" >/dev/null 2>&1 || die "$tool is required" -done -[ -r "$sqlite_db" ] || die "cannot read $sqlite_db" -[ -n "$conninfo" ] || die "no connection string: pass one or set CHESS_DSN" - -version=$(sqlite3 -readonly "$sqlite_db" 'PRAGMA user_version;') -[ "$version" = 2 ] || die "SQLite schema version is $version; start chess v0.11 on it once to upgrade to version 2" - -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT INT TERM - -export_table() { - sqlite3 -readonly -csv -nullvalue '\N' "$sqlite_db" "$2" >"$work/$1.csv" -} -export_table users 'SELECT user_id, username, email, password_hash, account_type, - created_at, expires_at, last_login_at FROM users' -export_table sessions 'SELECT session_id, user_id, created_at, expires_at FROM sessions' -export_table games 'SELECT game_id, initial_fen, - white_player_id, white_type, white_level, white_search_time, white_claimed_by, - black_player_id, black_type, black_level, black_search_time, black_claimed_by, - start_time_utc, result, end_time_utc FROM games' -export_table moves 'SELECT game_id, move_number, move_uci, fen_after_move, - player_color, move_time_utc FROM moves' - -# psql's \copy paths are resolved relative to the working directory. -cd "$work" -psql -X -q -v ON_ERROR_STOP=1 -v schema="$schema" "$conninfo" <<'SQL' -BEGIN; -SET LOCAL search_path TO :"schema"; --- Legacy CURRENT_TIMESTAMP defaults carry no offset and were written in UTC. -SET LOCAL TIME ZONE 'UTC'; - -DO $$ -BEGIN - -- Separate statements: PL/pgSQL plans each one only when it is reached. - IF to_regclass('schema_version') IS NULL THEN - RAISE EXCEPTION 'no chess schema in search_path; run chess-server db init first'; - END IF; - IF (SELECT version FROM schema_version) <> 1 THEN - RAISE EXCEPTION 'target schema must be at version 1'; - END IF; - IF EXISTS (SELECT 1 FROM users) OR EXISTS (SELECT 1 FROM games) THEN - RAISE EXCEPTION 'target already contains chess data; import only into an empty schema'; - END IF; -END $$; - -CREATE TEMP TABLE import_users ( - user_id text, username text, email text, password_hash text, account_type text, - created_at text, expires_at text, last_login_at text -) ON COMMIT DROP; -CREATE TEMP TABLE import_sessions ( - session_id text, user_id text, created_at text, expires_at text -) ON COMMIT DROP; -CREATE TEMP TABLE import_games ( - game_id text, initial_fen text, - white_player_id text, white_type smallint, white_level smallint, white_search_time integer, - white_claimed_by text, - black_player_id text, black_type smallint, black_level smallint, black_search_time integer, - black_claimed_by text, - start_time_utc text, result text, end_time_utc text -) ON COMMIT DROP; -CREATE TEMP TABLE import_moves ( - game_id text, move_number integer, move_uci text, fen_after_move text, - player_color text, move_time_utc text -) ON COMMIT DROP; - -\copy import_users FROM 'users.csv' WITH (FORMAT csv, NULL '\N') -\copy import_sessions FROM 'sessions.csv' WITH (FORMAT csv, NULL '\N') -\copy import_games FROM 'games.csv' WITH (FORMAT csv, NULL '\N') -\copy import_moves FROM 'moves.csv' WITH (FORMAT csv, NULL '\N') - -INSERT INTO users (user_id, username, email, password_hash, created_at, last_login_at) -SELECT user_id::uuid, lower(username), NULLIF(lower(email), ''), password_hash, - created_at::timestamptz, NULLIF(last_login_at, '')::timestamptz -FROM import_users; - -INSERT INTO sessions (session_id, user_id, created_at, expires_at) -SELECT s.session_id::uuid, s.user_id::uuid, s.created_at::timestamptz, s.expires_at::timestamptz -FROM import_sessions s -WHERE s.expires_at::timestamptz > now() - AND s.user_id IN (SELECT user_id FROM import_users); - --- A claim may be empty in legacy rows; before claim columns existed, an --- authenticated creator was recorded only as the slot's player ID. Names are --- filled from the imported users below. -INSERT INTO games (game_id, initial_fen, - white_player_id, white_type, white_level, white_search_time, white_claimed_by, - black_player_id, black_type, black_level, black_search_time, black_claimed_by, - start_time_utc, result, end_time_utc) -SELECT g.game_id::uuid, g.initial_fen, - g.white_player_id::uuid, g.white_type, g.white_level, g.white_search_time, - COALESCE(NULLIF(g.white_claimed_by, '')::uuid, - (SELECT u.user_id::uuid FROM import_users u WHERE u.user_id = g.white_player_id)), - g.black_player_id::uuid, g.black_type, g.black_level, g.black_search_time, - COALESCE(NULLIF(g.black_claimed_by, '')::uuid, - (SELECT u.user_id::uuid FROM import_users u WHERE u.user_id = g.black_player_id)), - g.start_time_utc::timestamptz, - NULLIF(g.result, ''), - CASE WHEN NULLIF(g.result, '') IS NOT NULL - THEN COALESCE(NULLIF(g.end_time_utc, '')::timestamptz, g.start_time_utc::timestamptz) - END -FROM import_games g; - -UPDATE games g SET - white_name = (SELECT u.username FROM users u WHERE u.user_id = g.white_claimed_by), - black_name = (SELECT u.username FROM users u WHERE u.user_id = g.black_claimed_by); - -INSERT INTO moves (game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc) -SELECT m.game_id::uuid, m.move_number, m.move_uci, m.fen_after_move, m.player_color, - m.move_time_utc::timestamptz -FROM import_moves m -WHERE m.game_id IN (SELECT game_id FROM import_games); - --- Replay listings read the move count from the last move number, so every --- game's line must be numbered 1..n without gaps. -DO $$ -DECLARE broken integer; -BEGIN - SELECT count(*) INTO broken FROM ( - SELECT game_id FROM moves GROUP BY game_id HAVING max(move_number) <> count(*) - ) gaps; - IF broken > 0 THEN - RAISE EXCEPTION '% game(s) have gaps in move numbering; import aborted', broken; - END IF; -END $$; - -SELECT format('imported: %s users (%s dropped), %s sessions (%s expired or orphaned), %s games (%s anonymous), %s moves (%s orphaned)', - (SELECT count(*) FROM users), (SELECT count(*) FROM import_users) - (SELECT count(*) FROM users), - (SELECT count(*) FROM sessions), (SELECT count(*) FROM import_sessions) - (SELECT count(*) FROM sessions), - (SELECT count(*) FROM games), - (SELECT count(*) FROM games WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL), - (SELECT count(*) FROM moves), (SELECT count(*) FROM import_moves) - (SELECT count(*) FROM moves)) AS summary -\gset -\echo :summary -COMMIT; -SQL diff --git a/doc/api.md b/doc/api.md index 7d22f80..7d6fd8b 100644 --- a/doc/api.md +++ b/doc/api.md @@ -1,6 +1,8 @@ # API Reference -Base URL: `http://localhost:8080/api/v1` +Base URL: `http://localhost:8080/api` + +The API is unversioned; `/health` is served at the root, outside `/api`. Content-Type: `application/json` (required for POST/PUT) diff --git a/doc/architecture.md b/doc/architecture.md index 1ecd429..58967f4 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -111,7 +111,7 @@ schema is current, which lets a DML-only runtime role start the server. 8. Game deletion notifies and removes all waiters ### Durable Replay Read -1. Client requests `GET /api/v1/games/{id}/history` +1. Client requests `GET /api/games/{id}/history` 2. Storage queues a barrier after all previously accepted gameplay writes 3. The writer reaches the barrier only after those transactions finish 4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction @@ -263,8 +263,10 @@ outlives deleted accounts, and the name snapshot keeps it readable. database 24 hours after their last activity (`-anonymous-game-ttl`). The hourly cleanup evicts idle games first, then queues the delete through the ordered writer while excluding every game still loaded, so no live game can - lose its row. See [`deploy/postgresql`](../deploy/postgresql) for -provisioning and the one-time SQLite import. + lose its row. + +See [`deploy/postgresql`](../deploy/postgresql) for provisioning and +[deployment.md](./deployment.md) for the jail service. ## Security Architecture diff --git a/doc/client.md b/doc/client.md index 27b3953..b301586 100644 --- a/doc/client.md +++ b/doc/client.md @@ -166,8 +166,8 @@ chess > url http://localhost:9090 # Change server URL #### `raw` / `:` Send raw API request. ``` -chess > raw GET /api/v1/games/ -chess > raw POST /api/v1/games '{"white":{"type":1},"black":{"type":2}}' +chess > raw GET /api/games/ +chess > raw POST /api/games '{"white":{"type":1},"black":{"type":2}}' ``` #### `clear` / `-` diff --git a/doc/deployment.md b/doc/deployment.md index e42b42d..53a1cf8 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -1,96 +1,107 @@ # Deployment: FreeBSD Jail with PostgreSQL 18 -Production layout: host nginx terminates TLS and reverse-proxies `/chess/` to -`chess-server` in a Bastille jail. PostgreSQL 18 runs in the same jail and -listens **only on its Unix socket**. The `chess` OS account has no shell or -password; its database role reaches only the `chess` database through peer -authentication, so no database password exists. +Layout: host nginx terminates TLS and proxies `/chess/api/` and `/chess/health` +to `chess-server` in a Bastille jail. The rc.d service is `chessd`, running as +the unprivileged `chess` account (no shell, no password). PostgreSQL 18 runs in +the same jail and listens **only on its Unix socket**. The `chess` role reaches +only the `chess` database through peer authentication, so no database password +exists. ``` -internet ──TLS──▶ host nginx ──HTTP──▶ jail: chess-server (API port) - │ Unix socket, peer auth - ▼ - jail: PostgreSQL 18 (no TCP listener) +browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (chess-server, API port) + │ Unix socket, peer auth + ▼ + jail: PostgreSQL 18 (no TCP listener) ``` `host#` runs on the Bastille host as root; `jail#` runs inside the jail as root -(`bastille console `). +(`bastille console `). Addresses in this document are placeholders. -## Scripted Deployment +## Scripted Installation and Upgrade -The scripts in [`deploy/`](../deploy) perform every step below and are safe to -rerun for upgrades. Their header comments list all inputs. +The scripts in [`deploy/`](../deploy) are idempotent; rerun them to upgrade. +Their header comments list every input. -1. **Host:** give the jail its own System V shared-memory namespace, which - PostgreSQL needs, and restart it: +1. **Host, once:** give the jail its own System V shared-memory namespace, + which PostgreSQL needs, and restart it: ```sh - host# sh deploy/freebsd/host.sh chess + host# sh deploy/freebsd/host.sh ``` -2. **Build** the static server on any machine with Go 1.26+, then copy the +2. **Jail, once:** install the packages: + + ```sh + jail# pkg install postgresql18-server postgresql18-client stockfish + ``` + +3. **Build** the release (FreeBSD `make` or GNU make) and copy the server binary and a checkout of this repository into the jail: ```sh make server-freebsd # bin/chess-server-freebsd-amd64 ``` -3. **Jail:** install and configure everything. `OLD_SERVICE` names the rc.d - service of the SQLite deployment; `SQLITE_DB` imports its data once. +4. **Jail:** install or upgrade the service. `TRUSTED_PROXIES` is the address + the host nginx connects from, as seen inside the jail. ```sh jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \ - API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \ - OLD_SERVICE=chess SQLITE_DB=/usr/local/chess/db/chess.db \ + TRUSTED_PROXIES= \ + ENABLE_LOG_ROTATION=yes ENABLE_BACKUP=yes \ sh deploy/freebsd/setup-jail.sh ``` - It ends with a health check that requires `"storage":"ok"`. + The script stops `chessd`, installs everything, starts it again, and fails + unless `/health` reports `"storage":"ok"`. -4. **Host:** add the location block from - [`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) to - the TLS server block, then `nginx -t && service nginx reload`. - -5. **Verify** with the checklist at the end of this document. Keep the old - SQLite file until you are satisfied; the import does not modify it. +5. **Host:** point nginx at the jail + ([`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf)) + and publish the web client (below). `setup-jail.sh` inputs: | Variable | Default | Meaning | |---|---|---| -| `CHESS_BINARY` | required | New `chess-server` binary | -| `API_HOST` | required | Listen address (the jail IP) | -| `API_PORT` | `8080` | Listen port | -| `TRUSTED_PROXIES` | empty (warns) | nginx address(es) as seen from the jail | -| `CHESS_HOME` | account home, else `/usr/local/chess` | Binary and JWT key location | -| `LOG_DIR` | `/var/log/chess` | Server log directory | -| `SQLITE_DB` | none | v0.11 SQLite database to import into an empty schema | -| `OLD_SERVICE` | none | Previous rc.d service to stop and disable | +| `CHESS_BINARY` | required | `chess-server` binary to install | +| `TRUSTED_PROXIES` | unset (warns) | Stored as `chessd_trusted_proxies` in `rc.conf` | | `SPLIT_PRIVILEGES` | `no` | `yes` for a DML-only runtime role (below) | -| `EXTRA_FLAGS` | none | More server flags, e.g. `-max-users 500` | -| `PGDATA` | `/var/db/postgres/data18` | PostgreSQL data directory | +| `ENABLE_LOG_ROTATION` | `no` | newsyslog entry for the service log | +| `ENABLE_BACKUP` | `no` | Nightly `pg_dump` via cron | + +The service layout (account, home, binary, log, listen address, DSN, key) +comes from `rc.conf` `chessd_*` settings, with the defaults of +[`rc.d/chessd`](../deploy/freebsd/rc.d/chessd): account `chess`, home +`/home/chess`, binary `~/bin/chess-server`, log `/var/log/chessd.log`, +`0.0.0.0:8080`, DSN `postgres:///chess?host=/tmp`, key `~/jwt.key`. What the script changes, in order: | Step | Result | |---|---| -| Packages | `postgresql18-server`, `postgresql18-client`, `stockfish` (and `sqlite3` for an import) | -| PostgreSQL | `initdb` with UTF-8, builtin `C.UTF-8` locale, peer local auth, host auth rejected; data checksums are on by default in 18 | -| `pg_hba.conf` | Only `postgres` (all databases) and `chess` (database `chess`), both `local` + `peer`; original kept as `pg_hba.conf.orig` | -| `postgresql.auto.conf` | `listen_addresses = ''` (no TCP), socket in `/tmp`, `scram-sha-256` | -| Provisioning | [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql) on first run only | -| Account | `chess` created if missing (`nologin`, no password); a warning if an existing account has a login shell | -| Files | Binary `root:wheel 0555` (previous copy kept as `.prev`); `jwt.key` generated once, `chess:chess 0600`; log directory `chess:chess 0750` | +| Preflight | Requires root, PostgreSQL 17+ installed, `chessd_user=chess`; refuses an `rc.conf` `chessd_flags` that still contains `-storage-path`; warns if `stockfish` is missing | +| Cluster | `initdb` only when none exists: UTF-8, builtin `C.UTF-8` locale, peer local auth, host auth rejected | +| `pg_hba.conf` | Only `postgres` (all databases) and `chess` (database `chess`), both `local` + `peer`; original kept as `pg_hba.conf.orig`. This replaces the trust-everything default of FreeBSD's standard `initdb` flags | +| Listener | `ALTER SYSTEM SET listen_addresses = ''` (no TCP) and a restart, only when TCP is still enabled | +| Provisioning | [`setup.sql`](../deploy/postgresql/setup.sql) when neither role nor database `chess` exists | +| Service stop | `service chessd onestop` through the installed rc.d script | +| Binary | `root:wheel 0555`; the previous file kept as `chess-server.prev` | +| rc.d | [`rc.d/chessd`](../deploy/freebsd/rc.d/chessd) installed; a differing previous copy saved as `/var/backups/chessd.rc.