v0.12.2 drop version from api path, deploy script update
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-27 00:50:17 -04:00
1 parent 1e7d759a19
commit 2d6246e14c
23 files changed
+611 -614

No files matched your search

+1 -1
View File
@@ -173,7 +173,7 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl
## Documentation ## Documentation
- [API Reference](./doc/api.md) - Endpoint specifications including auth - [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 - [Architecture](./doc/architecture.md) - System design with auth layer
- [Development](./doc/development.md) - Build, test, and user management - [Development](./doc/development.md) - Build, test, and user management
- [Client Guide](./doc/client.md) - Interactive debugging client - [Client Guide](./doc/client.md) - Interactive debugging client
+2 -3
View File
@@ -184,7 +184,6 @@ func main() {
go func() { go func() {
log.Printf("Chess API Server starting...") log.Printf("Chess API Server starting...")
log.Printf("API Listening on: http://%s", apiAddr) log.Printf("API Listening on: http://%s", apiAddr)
log.Printf("API Version: v1")
log.Printf("Authentication: Enabled (JWT)") log.Printf("Authentication: Enabled (JWT)")
if *dev { if *dev {
log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)") log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)")
@@ -201,8 +200,8 @@ func main() {
} else { } else {
log.Printf("Storage: Disabled (auth features unavailable)") log.Printf("Storage: Disabled (auth features unavailable)")
} }
log.Printf("API Endpoints: http://%s/api/v1/games", apiAddr) log.Printf("API Endpoints: http://%s/api/games", apiAddr)
log.Printf("Auth Endpoints: http://%s/api/v1/auth/[register|login|me]", apiAddr) log.Printf("Auth Endpoints: http://%s/api/auth/[register|login|me]", apiAddr)
log.Printf("Health: http://%s/health", apiAddr) log.Printf("Health: http://%s/health", apiAddr)
if err := app.Listen(apiAddr); err != nil { if err := app.Listen(apiAddr); err != nil {
+10 -7
View File
@@ -3,16 +3,19 @@
Run in this order; each script's header documents its inputs. The full guide is Run in this order; each script's header documents its inputs. The full guide is
[doc/deployment.md](../doc/deployment.md). [doc/deployment.md](../doc/deployment.md).
| Order | Where | Script | Purpose | | Order | Where | Step | Purpose |
|---|---|---|---| |---|---|---|---|
| 1 | Bastille host | `freebsd/host.sh <jail>` | `sysvshm=new` for PostgreSQL, jail restart | | 1 | Bastille host, once | `freebsd/host.sh <jail>` | `sysvshm=new` for PostgreSQL, jail restart |
| 2 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` | | 2 | Jail, once | `pkg install postgresql18-server postgresql18-client stockfish` | Packages |
| 3 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL 18, database, account, service, backups, optional SQLite import | | 3 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` |
| 4 | Bastille host | `freebsd/nginx-chess.conf` | Location block for the host nginx | | 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`: Supporting files, installed by `setup-jail.sh`:
- `postgresql/setup.sql` — role, database, and schema (owner or split mode) - `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/chessd` — `daemon(8)` service script
- `freebsd/rc.d/chess_server` — `daemon(8)` service script
- `freebsd/chess-backup.sh` — nightly `pg_dump` with 14-day retention - `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.
+46 -13
View File
@@ -1,19 +1,52 @@
# Host nginx: proxy /chess/ to the chess API in the jail. Include inside the # Host nginx for chess: browsers (web and WASM clients) call
# existing TLS server block, next to the security headers. Replace # <origin>/chess/api/... and <origin>/chess/health, which map to the server's
# JAIL_IP:API_PORT with the values given to setup-jail.sh. # /api/... and /health. Replace <jail-address> and <port> (chessd_port,
# default 8080).
# #
# chess-server trusts X-Real-IP only from the address in -trusted-proxies # chess-server keys rate limits on X-Real-IP, trusted only from the addresses
# (TRUSTED_PROXIES in setup-jail.sh): the address this nginx uses to reach # in chessd_trusted_proxies: the address this nginx uses to reach the jail.
# the jail. Do not forward X-Forwarded-For for rate limiting; its first entry # $remote_addr must be the real client: with PROXY protocol in front of nginx,
# is supplied by the client. # set real_ip_header proxy_protocol and set_real_ip_from for that hop.
location /chess/ {
proxy_pass http://JAIL_IP:API_PORT/; # --- http {} context ---
upstream chess_api {
server <jail-address>:<port>;
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_http_version 1.1;
proxy_set_header Connection ""; proxy_set_header Connection "";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
# Long-polling waits up to 30 seconds for a game update. proxy_read_timeout 10s;
proxy_read_timeout 45s; }
# Bound request bodies; API payloads are small JSON documents.
client_max_body_size 16k; # Everything else under /chess is not served by the API.
location = /chess {
return 404;
}
location /chess/ {
return 404;
} }
-36
View File
@@ -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"
+128
View File
@@ -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="<address nginx connects from>"
. /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"
+174 -170
View File
@@ -1,42 +1,41 @@
#!/bin/sh #!/bin/sh
# Install or update chess-server with PostgreSQL 18 inside a FreeBSD jail. # Install or upgrade chess-server (rc.d service `chessd`) with PostgreSQL
# Run as root inside the jail, from a copy of this repository: # inside a FreeBSD jail. Run as root inside the jail from a copy of this
# repository:
# #
# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \ # CHESS_BINARY=/tmp/chess-server TRUSTED_PROXIES=<nginx-address> \
# API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \
# sh deploy/freebsd/setup-jail.sh # sh deploy/freebsd/setup-jail.sh
# #
# Before the first run: # Prerequisites:
# 1. On the host: sh deploy/freebsd/host.sh <jail> (sysvshm=new, restart) # - postgresql18-server, postgresql18-client, and stockfish packages
# 2. On a build machine: make server-freebsd, then copy # - the jail has its own System V shared memory (deploy/freebsd/host.sh)
# bin/chess-server-freebsd-amd64 into the jail # - the release binary, built with `make server-freebsd` (or `make server`
# 3. For an existing SQLite deployment: note the database path; the old # on FreeBSD)
# service is stopped before the import
# #
# Required: # Inputs (environment):
# CHESS_BINARY new chess-server binary (FreeBSD/amd64, static) # CHESS_BINARY required: chess-server binary to install
# API_HOST address chess-server listens on (the jail IP) # 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: # The service layout comes from rc.conf (chessd_*) with the defaults of
# API_PORT default 8080 # deploy/freebsd/rc.d/chessd: account chess, home /home/chess, binary
# TRUSTED_PROXIES host nginx address(es) as seen from the jail; without it # ~/bin/chess-server, log /var/log/chessd.log, 0.0.0.0:8080.
# 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 script is idempotent: rerun it to upgrade the binary, change flags, or # Steps (idempotent; rerun to upgrade):
# repair configuration. It never drops data. Steps: # 1. PostgreSQL: initdb only if no cluster exists; pg_hba.conf allows only
# packages -> PostgreSQL init and hardening -> role/database/schema -> # postgres and chess over the Unix socket (peer); TCP listener disabled
# chess account, binary, JWT key, log dir -> stop old service -> schema # 2. role, database, and schema from deploy/postgresql/setup.sql (first run)
# migration -> optional SQLite import -> rc.d/rc.conf -> log rotation -> # 3. stop chessd; install the binary (previous kept as .prev) and the rc.d
# nightly backup -> start and health check # 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 set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
umask 022 umask 022
@@ -44,73 +43,96 @@ umask 022
here=$(cd "$(dirname "$0")" && pwd) here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd) repo=$(cd "$here/../.." && pwd)
: "${CHESS_BINARY:?set CHESS_BINARY to the new chess-server binary}" : "${CHESS_BINARY:?set CHESS_BINARY to the chess-server binary to install}"
: "${API_HOST:?set API_HOST to the address chess-server listens on}"
API_PORT=${API_PORT:-8080}
TRUSTED_PROXIES=${TRUSTED_PROXIES:-} TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
LOG_DIR=${LOG_DIR:-/var/log/chess}
SQLITE_DB=${SQLITE_DB:-}
OLD_SERVICE=${OLD_SERVICE:-}
SPLIT_PRIVILEGES=${SPLIT_PRIVILEGES:-no} SPLIT_PRIVILEGES=${SPLIT_PRIVILEGES:-no}
EXTRA_FLAGS=${EXTRA_FLAGS:-} ENABLE_LOG_ROTATION=${ENABLE_LOG_ROTATION:-no}
PGDATA=${PGDATA:-/var/db/postgres/data18} ENABLE_BACKUP=${ENABLE_BACKUP:-no}
# Install locations; overridable only for testing the script. # System locations; overridable only to test the script.
RC_DIR=${RC_DIR:-/usr/local/etc/rc.d} 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} NEWSYSLOG_DIR=${NEWSYSLOG_DIR:-/usr/local/etc/newsyslog.conf.d}
CRON_DIR=${CRON_DIR:-/usr/local/etc/cron.d} CRON_DIR=${CRON_DIR:-/usr/local/etc/cron.d}
SBIN_DIR=${SBIN_DIR:-/usr/local/sbin} SBIN_DIR=${SBIN_DIR:-/usr/local/sbin}
BACKUP_DIR=${BACKUP_DIR:-/var/db/postgres/backups} 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' "$*"; } log() { printf '==> %s\n' "$*"; }
die() { printf 'setup-jail: %s\n' "$*" >&2; exit 1; } 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() { pg_sql() {
su -m postgres -c "psql -X -A -t -q -v ON_ERROR_STOP=1 -d $1" 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" [ "$(id -u)" -eq 0 ] || die "run as root"
[ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD" [ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD"
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY" [ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
for f in "$repo/deploy/postgresql/setup.sql" "$repo/deploy/postgresql/migrate-sqlite.sh" \ for f in "$repo/deploy/postgresql/setup.sql" "$here/rc.d/chessd" "$here/chess-backup.sh"; do
"$here/rc.d/chess_server" "$here/chess-backup.sh"; do
[ -f "$f" ] || die "missing $f; run from a complete repository copy" [ -f "$f" ] || die "missing $f; run from a complete repository copy"
done done
case $SPLIT_PRIVILEGES in yes | no) ;; *) die "SPLIT_PRIVILEGES must be yes or no" ;; esac yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES"
if [ -n "$SQLITE_DB" ] && [ ! -r "$SQLITE_DB" ]; then yes_no ENABLE_LOG_ROTATION "$ENABLE_LOG_ROTATION"
die "SQLITE_DB not readable: $SQLITE_DB" 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 fi
[ -n "$TRUSTED_PROXIES" ] || # chessd_home: rc.conf, else the account's home. rc.d/chessd defaults to
log "WARNING: TRUSTED_PROXIES unset; behind nginx every client shares one rate limit" # /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 ---------------------------------------------------------------- case $(rc_value chessd_flags "") in
packages="postgresql18-server postgresql18-client stockfish" *-storage-path*) die "rc.conf chessd_flags contains -storage-path, which no longer exists; remove it (sysrc -x chessd_flags) and rerun" ;;
[ -n "$SQLITE_DB" ] && packages="$packages sqlite3" esac
log "installing packages: $packages"
# shellcheck disable=SC2086 # word splitting intended
pkg install -y $packages
# --- 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_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 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 ||
if [ ! -f "$PGDATA/PG_VERSION" ]; then die "initdb failed; a shared memory error means the jail needs sysvshm=new (deploy/freebsd/host.sh)"
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 <jail> on the host (sysvshm=new) and retry"
fi fi
sysrc -q postgresql_enable=YES >/dev/null
if ! service postgresql status >/dev/null 2>&1; then
log "starting PostgreSQL"
service postgresql start
fi fi
# Only the postgres superuser and the chess role, each over the Unix socket # Only the postgres superuser and the chess role, each over the Unix socket
# with peer authentication. No host lines: TCP is disabled below. # with peer authentication. A cluster initialized with FreeBSD's default flags
hba="$PGDATA/pg_hba.conf" # 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 if ! grep -q '^# Managed by chess setup-jail.sh' "$hba" 2>/dev/null; then
[ -f "$hba.orig" ] || cp -p "$hba" "$hba.orig" [ -f "$hba.orig" ] || cp -p "$hba" "$hba.orig"
log "writing $hba (original kept as pg_hba.conf.orig)" log "writing $hba (original kept as pg_hba.conf.orig)"
@@ -122,163 +144,145 @@ local chess chess peer
HBA HBA
chown postgres:postgres "$hba" chown postgres:postgres "$hba"
chmod 600 "$hba" chmod 600 "$hba"
service postgresql reload
fi fi
log "starting PostgreSQL" # Unix socket only. ALTER SYSTEM writes postgresql.auto.conf and leaves
service postgresql status >/dev/null 2>&1 || service postgresql start # postgresql.conf untouched; the setting needs a restart.
# ALTER SYSTEM writes postgresql.auto.conf, leaving postgresql.conf untouched. if [ -n "$(echo 'SHOW listen_addresses' | pg_sql postgres)" ]; then
log "disabling the PostgreSQL TCP listener"
pg_sql postgres <<'SQL' pg_sql postgres <<'SQL'
ALTER SYSTEM SET listen_addresses = ''; ALTER SYSTEM SET listen_addresses = '';
ALTER SYSTEM SET unix_socket_directories = '/tmp'; ALTER SYSTEM SET unix_socket_directories = '/tmp';
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SQL 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)" log "provisioning role, database, and schema (split privileges: $SPLIT_PRIVILEGES)"
split=false split=false
[ "$SPLIT_PRIVILEGES" = yes ] && split=true [ "$SPLIT_PRIVILEGES" = yes ] && split=true
su -m postgres -c "psql -X -q -d postgres -v split=$split -f -" <"$repo/deploy/postgresql/setup.sql" 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" ;;
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 esac
fi
[ -d "$CHESS_HOME" ] || install -d -o root -g "$account" -m 0750 "$CHESS_HOME"
install -d -o "$account" -g "$account" -m 0750 "$LOG_DIR"
binary="$CHESS_HOME/chess-server" # --- chessd --------------------------------------------------------------------
if [ -f "$binary" ] && ! cmp -s "$CHESS_BINARY" "$binary"; then if service chessd onestatus >/dev/null 2>&1; then
log "keeping the previous binary as $binary.prev" log "stopping chessd"
cp -p "$binary" "$binary.prev" service chessd onestop
fi fi
# Root-owned: the service account cannot replace its own executable.
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$binary"
key="$CHESS_HOME/jwt.key" 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
# Root-owned: the service account cannot replace its own executable file.
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$bin"
[ -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
install -o root -g wheel -m 0555 "$here/rc.d/chessd" "$RC_DIR/chessd"
if [ ! -f "$key" ]; then if [ ! -f "$key" ]; then
log "generating JWT signing key $key" log "generating JWT signing key $key"
(umask 077 && openssl rand -base64 48 >"$key") (umask 077 && openssl rand -base64 48 >"$key")
fi fi
chown "$account:$account" "$key" chown "$user:$group" "$key"
chmod 600 "$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" log "creating or migrating the chess schema"
if [ "$SPLIT_PRIVILEGES" = yes ]; then if [ "$SPLIT_PRIVILEGES" = yes ]; then
# The postgres account migrates as chess_owner. It may not be able to # The postgres account migrates as chess_owner. It may be unable to reach
# traverse the chess home, so it runs a temporary copy of the binary. # the chess home, so it runs a temporary copy of the binary.
migrator=$(mktemp /tmp/chess-migrate.XXXXXX) migrator=$(mktemp /tmp/chess-migrate.XXXXXX)
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$migrator" 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'\"" || 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"; die "schema migration failed"; }
rm -f "$migrator" rm -f "$migrator"
else else
su -m "$account" -c "$binary db init -dsn '$dsn'" su -m "$user" -c "$bin db init -dsn '$dsn'"
fi fi
# --- optional SQLite import ---------------------------------------------------- sysrc -q chessd_enable=YES >/dev/null
if [ -n "$SQLITE_DB" ]; then if [ -n "$TRUSTED_PROXIES" ]; then
if [ -n "$(echo 'SELECT 1 FROM chess.users UNION ALL SELECT 1 FROM chess.games LIMIT 1' | pg_sql chess)" ]; then sysrc -q chessd_trusted_proxies="$TRUSTED_PROXIES" >/dev/null
log "chess schema already has data; skipping SQLite import" fi
else [ -n "$(rc_value chessd_trusted_proxies "")" ] ||
log "importing $SQLITE_DB" log "WARNING: chessd_trusted_proxies is unset; behind nginx every client shares one rate limit"
work=$(mktemp -d /tmp/chess-import.XXXXXX) # Settings of the SQLite-era rc.d script that nothing reads any more.
# Copy WAL companions too: after an unclean stop they hold recent writes. for stale in chessd_storage_path chessd_dir; do
for suffix in "" -wal -shm; do if sysrc -n "$stale" >/dev/null 2>&1; then
if [ -f "$SQLITE_DB$suffix" ]; then sysrc -q -x "$stale" >/dev/null
cp "$SQLITE_DB$suffix" "$work/legacy.db$suffix" log "removed unused rc.conf setting $stale"
fi fi
done 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"
fi
fi
# --- service configuration ------------------------------------------------------- # --- optional maintenance -------------------------------------------------------------
if [ -f "$RC_DIR/chess_server" ] && ! cmp -s "$here/rc.d/chess_server" "$RC_DIR/chess_server"; then logs=$(rc_value chessd_logs /var/log/chessd.log)
cp -p "$RC_DIR/chess_server" "$RC_DIR/chess_server.orig" if [ "$ENABLE_LOG_ROTATION" = yes ]; then
fi # Daily or at 1 MB, keep 7; SIGHUP makes daemon(8) (-H) reopen the log.
install -d "$RC_DIR" [ -d "$NEWSYSLOG_DIR" ] || install -d "$NEWSYSLOG_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' \ printf '%s\n' \
"# Managed by chess setup-jail.sh" \ "# Managed by chess setup-jail.sh" \
"$LOG_DIR/chess-server.log $account:$account 640 7 1000 @T00 JC /var/run/chess_server.pid 1" \ "$logs $user:$group 640 7 1000 @T00 JC /var/run/chessd.pid 1" \
>"$NEWSYSLOG_DIR/chess_server.conf" >"$NEWSYSLOG_DIR/chessd.conf"
log "log rotation: $NEWSYSLOG_DIR/chessd.conf"
# Nightly pg_dump at 03:30, as postgres, keeping 14 days. fi
install -d "$SBIN_DIR" 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" 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" [ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
install -d "$CRON_DIR" [ -d "$CRON_DIR" ] || install -d "$CRON_DIR"
printf '%s\n' \ printf '%s\n' \
"# Managed by chess setup-jail.sh" \ "# Managed by chess setup-jail.sh" \
"SHELL=/bin/sh" \ "SHELL=/bin/sh" \
"PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" \ "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" \ "30 3 * * * postgres CHESS_BACKUP_DIR=$BACKUP_DIR $SBIN_DIR/chess-backup" \
>"$CRON_DIR/chess-backup" >"$CRON_DIR/chess-backup"
log "backups: $CRON_DIR/chess-backup -> $BACKUP_DIR"
fi
# --- start and verify ----------------------------------------------------------- # --- start and verify -----------------------------------------------------------------
log "starting chess_server" log "starting chessd"
service chess_server start 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="" health=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do 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 break
fi fi
sleep 2 sleep 2
done done
case $health in case $health in
*'"storage":"ok"'*) log "healthy: $health" ;; *'"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 esac
cat <<EOF cat <<EOF
chess-server is running at http://$API_HOST:$API_PORT with PostgreSQL storage. chessd is running with PostgreSQL storage ($url).
Remaining host step: add the location block from deploy/freebsd/nginx-chess.conf
(proxy_pass http://$API_HOST:$API_PORT/) to the host nginx, then
nginx -t && service nginx reload
Administration inside the jail: Administration inside the jail:
su -m $account -c '$binary db user list -dsn "$dsn"' su -m $user -c '$bin db user list -dsn "$dsn"'
service chess_server status|restart service chessd status|restart
tail -f $LOG_DIR/chess-server.log tail -f $logs
EOF EOF
-171
View File
@@ -1,171 +0,0 @@
#!/bin/sh
# One-time import of a chess SQLite database (schema v2, chess <= v0.11) into
# the PostgreSQL schema created by `chess-server db init`.
#
# Usage: migrate-sqlite.sh /path/to/chess.db [psql-conninfo]
#
# conninfo libpq connection string for the chess database; defaults to
# $CHESS_DSN. Run as a role that can write the chess tables and
# create temporary tables (the postgres superuser is simplest).
# CHESS_SCHEMA schema holding the chess tables (default: chess)
#
# Stop the old server first so the SQLite WAL is checkpointed. The import runs
# in one transaction and refuses a target that already holds users or games.
#
# - Every account is imported as a regular account; the old temporary/permanent
# distinction and account expiry no longer exist.
# - Expired sessions are dropped.
# - Legacy games recorded before claim columns existed get claims backfilled
# from player IDs that match an imported user, and every claim records the
# claimant's username.
# - Games without a claim are imported too; the server deletes them once they
# have been idle for -anonymous-game-ttl (24 hours by default).
set -eu
die() {
echo "migrate-sqlite: $*" >&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
+3 -1
View File
@@ -1,6 +1,8 @@
# API Reference # 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) Content-Type: `application/json` (required for POST/PUT)
+5 -3
View File
@@ -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 8. Game deletion notifies and removes all waiters
### Durable Replay Read ### 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 2. Storage queues a barrier after all previously accepted gameplay writes
3. The writer reaches the barrier only after those transactions finish 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 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 database 24 hours after their last activity (`-anonymous-game-ttl`). The
hourly cleanup evicts idle games first, then queues the delete through 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 ordered writer while excluding every game still loaded, so no live game can
lose its row. See [`deploy/postgresql`](../deploy/postgresql) for lose its row.
provisioning and the one-time SQLite import.
See [`deploy/postgresql`](../deploy/postgresql) for provisioning and
[deployment.md](./deployment.md) for the jail service.
## Security Architecture ## Security Architecture
+2 -2
View File
@@ -166,8 +166,8 @@ chess > url http://localhost:9090 # Change server URL
#### `raw` / `:` #### `raw` / `:`
Send raw API request. Send raw API request.
``` ```
chess > raw GET /api/v1/games/<id> chess > raw GET /api/games/<id>
chess > raw POST /api/v1/games '{"white":{"type":1},"black":{"type":2}}' chess > raw POST /api/games '{"white":{"type":1},"black":{"type":2}}'
``` ```
#### `clear` / `-` #### `clear` / `-`
+161 -172
View File
@@ -1,96 +1,107 @@
# Deployment: FreeBSD Jail with PostgreSQL 18 # Deployment: FreeBSD Jail with PostgreSQL 18
Production layout: host nginx terminates TLS and reverse-proxies `/chess/` to Layout: host nginx terminates TLS and proxies `/chess/api/` and `/chess/health`
`chess-server` in a Bastille jail. PostgreSQL 18 runs in the same jail and to `chess-server` in a Bastille jail. The rc.d service is `chessd`, running as
listens **only on its Unix socket**. The `chess` OS account has no shell or the unprivileged `chess` account (no shell, no password). PostgreSQL 18 runs in
password; its database role reaches only the `chess` database through peer the same jail and listens **only on its Unix socket**. The `chess` role reaches
authentication, so no database password exists. only the `chess` database through peer authentication, so no database password
exists.
``` ```
internet ──TLS──▶ host nginx ──HTTP──▶ jail: chess-server (API port) browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (chess-server, API port)
│ Unix socket, peer auth │ Unix socket, peer auth
▼ ▼
jail: PostgreSQL 18 (no TCP listener) jail: PostgreSQL 18 (no TCP listener)
``` ```
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root `host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
(`bastille console <jail>`). (`bastille console <jail>`). 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 The scripts in [`deploy/`](../deploy) are idempotent; rerun them to upgrade.
rerun for upgrades. Their header comments list all inputs. Their header comments list every input.
1. **Host:** give the jail its own System V shared-memory namespace, which 1. **Host, once:** give the jail its own System V shared-memory namespace,
PostgreSQL needs, and restart it: which PostgreSQL needs, and restart it:
```sh ```sh
host# sh deploy/freebsd/host.sh chess host# sh deploy/freebsd/host.sh <jail>
``` ```
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: binary and a checkout of this repository into the jail:
```sh ```sh
make server-freebsd # bin/chess-server-freebsd-amd64 make server-freebsd # bin/chess-server-freebsd-amd64
``` ```
3. **Jail:** install and configure everything. `OLD_SERVICE` names the rc.d 4. **Jail:** install or upgrade the service. `TRUSTED_PROXIES` is the address
service of the SQLite deployment; `SQLITE_DB` imports its data once. the host nginx connects from, as seen inside the jail.
```sh ```sh
jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \ jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \
API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \ TRUSTED_PROXIES=<nginx-address> \
OLD_SERVICE=chess SQLITE_DB=/usr/local/chess/db/chess.db \ ENABLE_LOG_ROTATION=yes ENABLE_BACKUP=yes \
sh deploy/freebsd/setup-jail.sh 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 5. **Host:** point nginx at the jail
[`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) to ([`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf))
the TLS server block, then `nginx -t && service nginx reload`. and publish the web client (below).
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.
`setup-jail.sh` inputs: `setup-jail.sh` inputs:
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `CHESS_BINARY` | required | New `chess-server` binary | | `CHESS_BINARY` | required | `chess-server` binary to install |
| `API_HOST` | required | Listen address (the jail IP) | | `TRUSTED_PROXIES` | unset (warns) | Stored as `chessd_trusted_proxies` in `rc.conf` |
| `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 |
| `SPLIT_PRIVILEGES` | `no` | `yes` for a DML-only runtime role (below) | | `SPLIT_PRIVILEGES` | `no` | `yes` for a DML-only runtime role (below) |
| `EXTRA_FLAGS` | none | More server flags, e.g. `-max-users 500` | | `ENABLE_LOG_ROTATION` | `no` | newsyslog entry for the service log |
| `PGDATA` | `/var/db/postgres/data18` | PostgreSQL data directory | | `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: What the script changes, in order:
| Step | Result | | Step | Result |
|---|---| |---|---|
| Packages | `postgresql18-server`, `postgresql18-client`, `stockfish` (and `sqlite3` for an import) | | 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 |
| PostgreSQL | `initdb` with UTF-8, builtin `C.UTF-8` locale, peer local auth, host auth rejected; data checksums are on by default in 18 | | 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` | | `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 |
| `postgresql.auto.conf` | `listen_addresses = ''` (no TCP), socket in `/tmp`, `scram-sha-256` | | Listener | `ALTER SYSTEM SET listen_addresses = ''` (no TCP) and a restart, only when TCP is still enabled |
| Provisioning | [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql) on first run only | | Provisioning | [`setup.sql`](../deploy/postgresql/setup.sql) when neither role nor database `chess` exists |
| Account | `chess` created if missing (`nologin`, no password); a warning if an existing account has a login shell | | Service stop | `service chessd onestop` through the installed rc.d script |
| Files | Binary `root:wheel 0555` (previous copy kept as `.prev`); `jwt.key` generated once, `chess:chess 0600`; log directory `chess:chess 0750` | | 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.<time>` (not inside `rc.d`, where rc(8) would run it) |
| JWT key | `~/jwt.key`, `chess:chess 0600`, generated once |
| Schema | `chess-server db init` (as `chess`, or as `chess_owner` in split mode) | | Schema | `chess-server db init` (as `chess`, or as `chess_owner` in split mode) |
| Import | [`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) when `SQLITE_DB` is set and the schema is empty | | `rc.conf` | `chessd_enable=YES`, `chessd_trusted_proxies` when given; removes the unused `chessd_storage_path` and `chessd_dir` |
| Service | [`rc.d/chess_server`](../deploy/freebsd/rc.d/chess_server) and `rc.conf` via `sysrc` | | Log rotation (opt-in) | `/usr/local/etc/newsyslog.conf.d/chessd.conf`: daily or 1 MB, 7 kept, SIGHUP to `daemon(8)` |
| Log rotation | `/usr/local/etc/newsyslog.conf.d/chess_server.conf`: daily or 1 MB, 7 kept | | Backups (opt-in) | `/usr/local/sbin/chess-backup` via `/usr/local/etc/cron.d/chess-backup`: `pg_dump` at 03:30 as `postgres`, 14 days kept in `/var/db/postgres/backups` |
| Backups | `/usr/local/sbin/chess-backup` via `/usr/local/etc/cron.d/chess-backup`: `pg_dump` at 03:30 as `postgres`, 14 days kept | | Start | `service chessd start`, then `/health` on the configured port |
The script was exercised on Linux with FreeBSD-only commands (`pkg`, `sysrc`, Files of an earlier SQLite release are not touched; delete them once the new
`service`, `pw`, `fetch`) stubbed and everything else real: PostgreSQL 18.6, release is verified.
`su`, provisioning in both privilege modes, schema migration, the SQLite
import, and the health check of the running server. The script and rc.d script were exercised on Linux with the FreeBSD-only
commands (`rc.subr`, `daemon`, `service`, `sysrc`, `pw`, `fetch`) stubbed and
everything else real (PostgreSQL 18.6, `su`, both server generations). The
tested runs were an upgrade from a running v0.11 SQLite `chessd`, an idempotent
rerun, and a fresh split-privilege install.
## Accounts and Data Retention ## Accounts and Data Retention
@@ -102,42 +113,93 @@ import, and the health check of the running server.
claimed a slot with their first move. Such games are kept indefinitely and claimed a slot with their first move. Such games are kept indefinitely and
record the player's username at claim time. record the player's username at claim time.
- Games with no registered player are **deleted 24 hours after their last - Games with no registered player are **deleted 24 hours after their last
activity** (creation, move, undo, reconfiguration, or result), from memory and activity**, from memory and from the database, by the hourly cleanup.
from the database, by the hourly cleanup. `-anonymous-game-ttl` changes the `-anonymous-game-ttl` changes the window; `0` keeps them.
window; `0` keeps them.
- Deleting a user removes their sessions; their games keep the claim and name. - Deleting a user removes their sessions; their games keep the claim and name.
## Service (`rc.d/chessd`)
`rc.conf` settings, with defaults:
| Setting | Default | Purpose |
|---|---|---|
| `chessd_enable` | `NO` | Start at boot |
| `chessd_user`, `chessd_group` | `chess` | Service account; must stay `chess` for peer authentication |
| `chessd_home` | `/home/chess` | Account home |
| `chessd_bin` | `${chessd_home}/bin/chess-server` | Server binary |
| `chessd_logs` | `/var/log/chessd.log` | Log file |
| `chessd_host`, `chessd_port` | `0.0.0.0`, `8080` | Listen address |
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500` |
The script runs `daemon(8)` as root, which writes the supervisor pidfile
(`/var/run/chessd.pid`), drops to `chess`, restarts the server 5 seconds after
an unexpected exit, and reopens the log on SIGHUP. It declares `REQUIRE:
postgresql`, so the database starts first at boot. The environment passed to
the server is `HOME` and a `PATH` that includes `/usr/local/bin` for Stockfish.
Server flags:
| Flag | Environment default | Purpose |
|---|---|---|
| `-dsn` | `CHESS_DSN` | PostgreSQL connection string; empty disables persistence |
| `-jwt-secret-file` | `CHESS_JWT_SECRET_FILE` | Stable JWT signing key |
| `-trusted-proxies` | | Proxy address(es) trusted for the client-IP header |
| `-proxy-header` | | Client-IP header set by the proxy (default `X-Real-IP`) |
| `-max-users` | | Registration cap (default 100, `0` = none) |
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell:
```sh
jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
```
## Host nginx and Web Clients
The API has no version segment: routes are `/api/...` and `/health`. With the
layout in [`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf), browsers
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`.
- `X-Real-IP` must carry the real client address (`$remote_addr`, after
`real_ip` processing when PROXY protocol is in front of nginx). The server
honors it only on connections from `chessd_trusted_proxies`.
- `proxy_set_header Connection "";` lets nginx reuse the upstream `keepalive`
connections; without it, every request opens a new connection.
- The existing security headers are unaffected: the API is same-origin under
`/chess/`.
Browser clients are static files published by the host, and must match the
server's API paths:
- **Web client:** copy `internal/server/webserver/chess-client-web/`
(`index.html`, `app.js`, `style.css`) to the site directory. Without the
embedded `/config` endpoint, it uses the `/chess` API prefix.
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
as `<origin>/chess`.
Publish the clients in the same change as the server: a client built for
`/api/v1` gets 404 from this release.
## Reference ## Reference
The sections below describe what the scripts configure, for review or manual ### PostgreSQL configuration
installation.
### Jail prerequisites What `setup-jail.sh` applies, for review or manual installation:
PostgreSQL allocates a small System V shared-memory segment even though its
main buffers use `mmap`. `host.sh` runs:
```sh ```sh
host# bastille config chess set sysvshm new
host# bastille restart chess
```
`sysvshm=new` gives the jail an isolated namespace, unlike the older shared
`allow.sysvipc`. Without it, `initdb` fails with `could not create shared
memory segment`.
### PostgreSQL
```sh
jail# pkg install postgresql18-server postgresql18-client stockfish
jail# sysrc postgresql_enable=YES jail# sysrc postgresql_enable=YES
jail# sysrc postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject" jail# sysrc postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject"
jail# service postgresql initdb jail# service postgresql initdb # only when no cluster exists
jail# service postgresql start jail# service postgresql start
jail# su -m postgres -c "psql -X -d postgres" <<'SQL' jail# su -m postgres -c "psql -X -d postgres" <<'SQL'
ALTER SYSTEM SET listen_addresses = ''; ALTER SYSTEM SET listen_addresses = '';
ALTER SYSTEM SET unix_socket_directories = '/tmp'; ALTER SYSTEM SET unix_socket_directories = '/tmp';
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SQL SQL
jail# service postgresql restart jail# service postgresql restart
``` ```
@@ -150,6 +212,9 @@ local all postgres peer
local chess chess peer local chess chess peer
``` ```
Database administration uses the `postgres` account over the socket:
`su -m postgres -c psql`.
### Provisioning and privilege models ### Provisioning and privilege models
[`setup.sql`](../deploy/postgresql/setup.sql) creates the login role `chess` [`setup.sql`](../deploy/postgresql/setup.sql) creates the login role `chess`
@@ -159,128 +224,52 @@ connections), the `chess` database (`template0`, builtin `C.UTF-8`), and the
`public`, and sets role defaults for `search_path`, `statement_timeout`, `public`, and sets role defaults for `search_path`, `statement_timeout`,
`lock_timeout`, and `idle_in_transaction_session_timeout`. `lock_timeout`, and `idle_in_transaction_session_timeout`.
```sh
jail# su -m postgres -c 'psql -X -d postgres -f -' < deploy/postgresql/setup.sql
```
| Mode | Schema owner | `chess` role can | Schema migrations | | Mode | Schema owner | `chess` role can | Schema migrations |
|---|---|---|---| |---|---|---|---|
| Owner (default) | `chess` | DML and DDL in schema `chess` only | Automatic at server start | | Owner (default) | `chess` | DML and DDL in schema `chess` only | Automatic at server start |
| Split (`-v split=true`, `SPLIT_PRIVILEGES=yes`) | `chess_owner` (NOLOGIN) | `SELECT/INSERT/UPDATE/DELETE` only | `setup-jail.sh`, or manually as `postgres` (below) | | Split (`SPLIT_PRIVILEGES=yes`) | `chess_owner` (NOLOGIN) | `SELECT/INSERT/UPDATE/DELETE` only | `setup-jail.sh`, or manually as `postgres` |
```sh
jail# su -m postgres -c "/usr/local/chess/chess-server db init \
-dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\""
```
In split mode the server refuses to start while a migration is pending. In split mode the server refuses to start while a migration is pending.
### The `chess` account
| Resource | Requirement |
|---|---|
| Shell/password | `/usr/sbin/nologin`, password disabled |
| Home | Holds `chess-server` (`root:wheel 0555`) and `jwt.key` |
| JWT key | `chess:chess 0600`, at least 32 bytes; the server refuses group- or world-readable keys |
| Log directory | `chess:chess 0750` |
| Database | Role `chess` over `/tmp/.s.PGSQL.5432`, database `chess` only |
| Network | Listens on the API port (unprivileged, above 1024) |
| Engine | `stockfish` in `PATH` (`/usr/local/bin`; set through `chess_server_env`) |
Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell:
```sh
jail# su -m chess -c '/usr/local/chess/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
```
### JWT signing key ### JWT signing key
`setup-jail.sh` generates the key once with `openssl rand -base64 48`. Without The key is 48 random bytes, base64-encoded, readable only by `chess`. Without a
a key file, production mode generates a new key at every start and every key file, production mode generates a new key at every start and every session
browser session ends on restart. To rotate, replace the file and restart the ends on restart. To rotate, replace the file and restart `chessd`; users sign
service; users then sign in again. in again.
### Service ### Rollback
`rc.conf` as written by `setup-jail.sh`: Until the old release's files are deleted, a failed upgrade can be reverted:
```sh ```sh
chess_server_enable="YES" jail# service chessd onestop
chess_server_account="chess" jail# cp /var/backups/chessd.rc.<time> /usr/local/etc/rc.d/chessd
chess_server_binary="/usr/local/chess/chess-server" jail# cp /home/chess/bin/chess-server.prev /home/chess/bin/chess-server
chess_server_logfile="/var/log/chess/chess-server.log" jail# service chessd start
chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin"
chess_server_flags="-api-host 10.17.89.10 -api-port 8080 -dsn postgres:///chess?host=/tmp -jwt-secret-file /usr/local/chess/jwt.key -log-level info -trusted-proxies 10.17.89.1"
``` ```
The rc.d script starts `daemon(8)` as root, which writes the supervisor Restore the previous web client files on the host as well.
pidfile, drops to `chess`, restarts the server if it exits, and reopens the log
on SIGHUP for newsyslog. It declares `REQUIRE: postgresql`. The SQLite-era
`-storage-path` flag no longer exists; a server started with it exits with
`flag provided but not defined`.
| Flag | Environment default | Purpose | ### Backups and restores
|---|---|---|
| `-dsn` | `CHESS_DSN` | PostgreSQL connection string; empty disables persistence |
| `-jwt-secret-file` | `CHESS_JWT_SECRET_FILE` | Stable JWT signing key |
| `-trusted-proxies` | | nginx address(es) as seen from the jail |
| `-proxy-header` | | Client-IP header set by nginx (default `X-Real-IP`) |
| `-max-users` | | Registration cap (default 100, `0` = none) |
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
### Host nginx - `chess-backup` writes `pg_dump --format=custom` files nightly and removes
those older than 14 days. Copy them off the jail.
[`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) passes the client - Restore into a freshly provisioned database:
address in `X-Real-IP` (which nginx overwrites), allows 30-second long-polls,
and bounds request bodies. The server keys rate limits on `X-Real-IP` only for
connections from `-trusted-proxies`; any other peer is keyed by its own
address. Without `-trusted-proxies`, every proxied request shares one bucket.
The existing security headers (HSTS, `X-Frame-Options`, CSP with
`connect-src 'self'`) are unaffected: the API is same-origin under `/chess/`,
and this release does not change the web client.
### SQLite import
[`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) reads a
schema-version-2 SQLite database (v0.11) and imports, in one transaction, all
users as regular accounts with their Argon2id hashes, unexpired sessions,
games with claims and player names, results, and moves. It refuses a schema
that already has data. Imported games without a registered player follow the
24-hour retention. Sessions carry over, but tokens signed with the old
per-restart key do not, so users sign in once. Manual invocation:
```sh
jail# su -m postgres -c 'sh /path/to/migrate-sqlite.sh /tmp/legacy.db dbname=chess'
```
### Upgrades, backups, and restores
- **Upgrade:** build the new binary and rerun `setup-jail.sh` with the same
inputs (omit `SQLITE_DB`). It keeps the previous binary as
`chess-server.prev`, migrates the schema, and restarts the service. To roll
back a release without a schema change, copy `.prev` back and restart.
- **Migrations** are transactional and serialized by an advisory lock. A
server refuses to start against a schema newer than it supports.
- **Backups:** `chess-backup` writes `pg_dump --format=custom` files to
`/var/db/postgres/backups` nightly and removes those older than 14 days. Copy
them off the jail.
- **Restore** into a freshly provisioned database:
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'` `su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
(`--role=chess_owner` in split mode). (`--role=chess_owner` in split mode).
- **PostgreSQL restarts:** the server retries in-flight writes across a brief - PostgreSQL restarts: the server retries in-flight writes across a brief
outage. A write that still fails marks storage `degraded` in `/health`; live outage. A write that still fails marks storage `degraded` in `/health`; live
games continue in memory, but durable history stops until the server is games continue in memory, but durable history stops until `chessd` restarts.
restarted.
### Verification checklist ### Verification checklist
```sh ```sh
jail# service chessd status
jail# fetch -qo - http://127.0.0.1:8080/health # "storage":"ok"
jail# sockstat -4 -6 -l | grep postgres # no TCP listener
jail# sockstat -4 -c | grep ':8080' # peer = nginx address
jail# su -m chess -c 'psql -X -d chess -Atc "SHOW search_path"' # chess jail# su -m chess -c 'psql -X -d chess -Atc "SHOW search_path"' # chess
jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba
jail# sockstat -4 -6 -l | grep postgres # no TCP listener host# curl -s https://<site>/chess/health # through nginx
jail# service chess_server status
jail# fetch -qo - http://10.17.89.10:8080/health # "storage":"ok"
host# curl -s https://lixen.com/chess/health # through nginx
``` ```
+3 -3
View File
@@ -15,9 +15,9 @@ into phases that each ship and test on their own.
times, and slot claims; transactional migrations (`schema_version`). times, and slot claims; transactional migrations (`schema_version`).
- [x] Each accepted move, first-move claim, and move-caused result commits in - [x] Each accepted move, first-move claim, and move-caused result commits in
one transaction; undo removes moves and clears the result atomically. one transaction; undo removes moves and clears the result atomically.
- [x] `GET /api/v1/games/{id}/history`: initial FEN plus UCI and resulting FEN - [x] `GET /api/games/{id}/history`: initial FEN plus UCI and resulting FEN
for every ply, read in one REPEATABLE READ snapshot after pending writes. for every ply, read in one REPEATABLE READ snapshot after pending writes.
- [x] `GET /api/v1/users/me/games`: claimed games, newest first, with move - [x] `GET /api/users/me/games`: claimed games, newest first, with move
count and final FEN (thumbnail) from a primary-key probe per game. count and final FEN (thumbnail) from a primary-key probe per game.
- [x] Claims survive player reconfiguration and terminal-game eviction; the - [x] Claims survive player reconfiguration and terminal-game eviction; the
listing indexes are ordered for keyset pagination. listing indexes are ordered for keyset pagination.
@@ -74,7 +74,7 @@ Test gate:
- [ ] Implement D7 (keyset cursor) once decided. - [ ] Implement D7 (keyset cursor) once decided.
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and - [ ] Additive history fields: `san` per move, `outcome`/`termination`, and
`pgnResult`; keep existing fields unchanged. `pgnResult`; keep existing fields unchanged.
- [ ] `GET /api/v1/games/{id}/pgn?ply=N`: `application/x-chess-pgn`, - [ ] `GET /api/games/{id}/pgn?ply=N`: `application/x-chess-pgn`,
`Content-Disposition: attachment; filename="chess-<date>-<id8>.pgn"`, `Content-Disposition: attachment; filename="chess-<date>-<id8>.pgn"`,
moves 1..N (default all). The FEN at any ply is already in the history. moves 1..N (default all). The FEN at any ply is already in the history.
- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games; - [ ] `ETag` and `Cache-Control: private, max-age` for terminal games;
+13 -13
View File
@@ -163,56 +163,56 @@ func (c *Client) Health() (*HealthResponse, error) {
func (c *Client) CreateGame(req *CreateGameRequest) (*GameResponse, error) { func (c *Client) CreateGame(req *CreateGameRequest) (*GameResponse, error) {
var resp GameResponse var resp GameResponse
err := c.doRequest("POST", "/api/v1/games", req, &resp) err := c.doRequest("POST", "/api/games", req, &resp)
return &resp, err return &resp, err
} }
func (c *Client) GetGame(gameID string) (*GameResponse, error) { func (c *Client) GetGame(gameID string) (*GameResponse, error) {
var resp GameResponse var resp GameResponse
err := c.doRequest("GET", "/api/v1/games/"+gameID, nil, &resp) err := c.doRequest("GET", "/api/games/"+gameID, nil, &resp)
return &resp, err return &resp, err
} }
func (c *Client) GetGameWithPoll(gameID string, moveCount int) (*GameResponse, error) { func (c *Client) GetGameWithPoll(gameID string, moveCount int) (*GameResponse, error) {
var resp GameResponse var resp GameResponse
path := fmt.Sprintf("/api/v1/games/%s?wait=true&moveCount=%d", gameID, moveCount) path := fmt.Sprintf("/api/games/%s?wait=true&moveCount=%d", gameID, moveCount)
err := c.doRequest("GET", path, nil, &resp) err := c.doRequest("GET", path, nil, &resp)
return &resp, err return &resp, err
} }
func (c *Client) DeleteGame(gameID string) error { func (c *Client) DeleteGame(gameID string) error {
return c.doRequest("DELETE", "/api/v1/games/"+gameID, nil, nil) return c.doRequest("DELETE", "/api/games/"+gameID, nil, nil)
} }
func (c *Client) MakeMove(gameID string, move string) (*GameResponse, error) { func (c *Client) MakeMove(gameID string, move string) (*GameResponse, error) {
req := &MoveRequest{Move: move} req := &MoveRequest{Move: move}
var resp GameResponse var resp GameResponse
err := c.doRequest("POST", "/api/v1/games/"+gameID+"/moves", req, &resp) err := c.doRequest("POST", "/api/games/"+gameID+"/moves", req, &resp)
return &resp, err return &resp, err
} }
func (c *Client) UndoMoves(gameID string, count int) (*GameResponse, error) { func (c *Client) UndoMoves(gameID string, count int) (*GameResponse, error) {
req := &UndoRequest{Count: count} req := &UndoRequest{Count: count}
var resp GameResponse var resp GameResponse
err := c.doRequest("POST", "/api/v1/games/"+gameID+"/undo", req, &resp) err := c.doRequest("POST", "/api/games/"+gameID+"/undo", req, &resp)
return &resp, err return &resp, err
} }
func (c *Client) GetBoard(gameID string) (*BoardResponse, error) { func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
var resp BoardResponse var resp BoardResponse
err := c.doRequest("GET", "/api/v1/games/"+gameID+"/board", nil, &resp) err := c.doRequest("GET", "/api/games/"+gameID+"/board", nil, &resp)
return &resp, err return &resp, err
} }
func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) { func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) {
var resp GameHistoryResponse var resp GameHistoryResponse
err := c.doRequest("GET", "/api/v1/games/"+gameID+"/history", nil, &resp) err := c.doRequest("GET", "/api/games/"+gameID+"/history", nil, &resp)
return &resp, err return &resp, err
} }
func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) { func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) {
var resp GameListResponse var resp GameListResponse
path := fmt.Sprintf("/api/v1/users/me/games?limit=%d&offset=%d", limit, offset) path := fmt.Sprintf("/api/users/me/games?limit=%d&offset=%d", limit, offset)
err := c.doRequest("GET", path, nil, &resp) err := c.doRequest("GET", path, nil, &resp)
return &resp, err return &resp, err
} }
@@ -224,7 +224,7 @@ func (c *Client) Register(username, password, email string) (*AuthResponse, erro
Email: email, Email: email,
} }
var resp AuthResponse var resp AuthResponse
err := c.doRequest("POST", "/api/v1/auth/register", req, &resp) err := c.doRequest("POST", "/api/auth/register", req, &resp)
return &resp, err return &resp, err
} }
@@ -234,17 +234,17 @@ func (c *Client) Login(identifier, password string) (*AuthResponse, error) {
Password: password, Password: password,
} }
var resp AuthResponse var resp AuthResponse
err := c.doRequest("POST", "/api/v1/auth/login", req, &resp) err := c.doRequest("POST", "/api/auth/login", req, &resp)
return &resp, err return &resp, err
} }
func (c *Client) Logout() error { func (c *Client) Logout() error {
return c.doRequest("POST", "/api/v1/auth/logout", nil, nil) return c.doRequest("POST", "/api/auth/logout", nil, nil)
} }
func (c *Client) GetCurrentUser() (*UserResponse, error) { func (c *Client) GetCurrentUser() (*UserResponse, error) {
var resp UserResponse var resp UserResponse
err := c.doRequest("GET", "/api/v1/auth/me", nil, &resp) err := c.doRequest("GET", "/api/auth/me", nil, &resp)
return &resp, err return &resp, err
} }
+1 -1
View File
@@ -97,7 +97,7 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
app.Get("/health", h.Health) app.Get("/health", h.Health)
// API v1 routes // API v1 routes
api := app.Group("/api/v1") api := app.Group("/api")
// Auth routes with specific rate limiting // Auth routes with specific rate limiting
auth := api.Group("/auth") auth := api.Group("/auth")
+44
View File
@@ -3,8 +3,11 @@ package http
import ( import (
"io" "io"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -59,3 +62,44 @@ func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
} }
} }
} }
func TestAPIRoutesAreUnversioned(t *testing.T) {
svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret"))
if err != nil {
t.Fatal(err)
}
// Route registration only; no handler runs, so no processor is needed.
app := NewFiberApp(nil, svc, Options{DevMode: true})
registered := make(map[string]bool)
for _, route := range app.GetRoutes(true) {
registered[route.Method+" "+route.Path] = true
if strings.HasPrefix(route.Path, "/api/v1") {
t.Errorf("versioned route still registered: %s %s", route.Method, route.Path)
}
}
for _, want := range []string{
"GET /health",
"POST /api/auth/register",
"POST /api/auth/login",
"GET /api/auth/me",
"POST /api/auth/logout",
"POST /api/games",
"GET /api/games/:gameId",
"GET /api/games/:gameId/history",
"POST /api/games/:gameId/moves",
"GET /api/users/me/games",
} {
if !registered[want] {
t.Errorf("route %s is not registered", want)
}
}
response, err := app.Test(httptest.NewRequest("GET", "/api/v1/games/5579b47e-4d3b-4eb3-abbd-846fe94cb955", nil))
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusNotFound {
t.Fatalf("GET /api/v1/... status = %d, want 404", response.StatusCode)
}
}
+1 -1
View File
@@ -196,7 +196,7 @@ func (s *Service) GenerateGameID() string {
// ApplyMoveWithState verifies that the position validated by the processor is // ApplyMoveWithState verifies that the position validated by the processor is
// still current, then commits the move, optional first-move claim, and result as // still current, then commits the move, optional first-move claim, and result as
// one in-memory transition and one SQLite transaction. // one in-memory transition and one database transaction.
func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error { func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -92,7 +92,7 @@ function restoreAuthSession() {
async function validateSession() { async function validateSession() {
try { try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/auth/me`); const response = await authFetch(`${gameState.apiUrl}/api/auth/me`);
if (response.ok) { if (response.ok) {
const user = await response.json(); const user = await response.json();
gameState.userId = user.userId; gameState.userId = user.userId;
@@ -228,7 +228,7 @@ async function handleLogin() {
let response; let response;
try { try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, { response = await fetch(`${gameState.apiUrl}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }) body: JSON.stringify({ identifier, password })
@@ -295,7 +295,7 @@ async function handleRegister() {
let response; let response;
try { try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, { response = await fetch(`${gameState.apiUrl}/api/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
@@ -337,7 +337,7 @@ async function handleRegister() {
async function handleLogout() { async function handleLogout() {
if (gameState.authToken) { if (gameState.authToken) {
try { try {
await authFetch(`${gameState.apiUrl}/api/v1/auth/logout`, { method: 'POST' }); await authFetch(`${gameState.apiUrl}/api/auth/logout`, { method: 'POST' });
} catch { } catch {
// Ignore - clear local state regardless // Ignore - clear local state regardless
} }
@@ -619,7 +619,7 @@ async function startNewGame() {
let response; let response;
try { try {
response = await authFetch(`${gameState.apiUrl}/api/v1/games`, { response = await authFetch(`${gameState.apiUrl}/api/games`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody) body: JSON.stringify(requestBody)
@@ -771,7 +771,7 @@ async function handleHumanMove(from, to) {
const toEl = document.querySelector(`[data-square="${to}"]`); const toEl = document.querySelector(`[data-square="${to}"]`);
try { try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/moves`, { const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move }) body: JSON.stringify({ move })
@@ -808,7 +808,7 @@ async function handleHumanMove(from, to) {
async function triggerComputerMove() { async function triggerComputerMove() {
lockBoard(); lockBoard();
try { try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/moves`, { const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move: 'cccc' }) body: JSON.stringify({ move: 'cccc' })
@@ -849,7 +849,7 @@ async function pollOnce() {
try { try {
const response = await authFetch( const response = await authFetch(
`${gameState.apiUrl}/api/v1/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`, `${gameState.apiUrl}/api/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`,
{ signal: gameState.pollController.signal } { signal: gameState.pollController.signal }
); );
@@ -908,7 +908,7 @@ async function undoMoves() {
} }
try { try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, { const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/undo`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 2 }) body: JSON.stringify({ count: 2 })
+3 -3
View File
@@ -61,13 +61,13 @@ LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
### Manual Testing Examples ### Manual Testing Examples
```bash ```bash
# Login as alice # Login as alice
curl -X POST http://localhost:8080/auth/login \ curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"username":"alice","password":"AlicePass123"}' -d '{"identifier":"alice","password":"AlicePass123"}'
# Create authenticated game # Create authenticated game
TOKEN="<jwt-from-login>" TOKEN="<jwt-from-login>"
curl -X POST http://localhost:8080/games \ curl -X POST http://localhost:8080/api/games \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"white":{"type":1},"black":{"type":2,"level":10}}' -d '{"white":{"type":1},"black":{"type":2,"level":10}}'
+2 -2
View File
@@ -5,7 +5,7 @@
# Requires: curl, jq # Requires: curl, jq
BASE_URL="http://localhost:8080" BASE_URL="http://localhost:8080"
API_URL="${BASE_URL}/api/v1" API_URL="${BASE_URL}/api"
# Configurable delay between API calls (in milliseconds) # Configurable delay between API calls (in milliseconds)
API_DELAY=${API_DELAY:-50} # 50ms for dev mode testing API_DELAY=${API_DELAY:-50} # 50ms for dev mode testing
@@ -130,7 +130,7 @@ fi
# Start tests # Start tests
print_header "Chess API Robustness Test Suite" print_header "Chess API Robustness Test Suite"
echo "Server: $BASE_URL" echo "Server: $BASE_URL"
echo "API Version: v1" echo "API: $API_URL"
echo -e "${MAGENTA} IMPORTANT: Server must be started with -dev flag for tests to pass!${NC}" echo -e "${MAGENTA} IMPORTANT: Server must be started with -dev flag for tests to pass!${NC}"
echo -e "${MAGENTA} Start the server first: test/run-test-server.sh${NC}" echo -e "${MAGENTA} Start the server first: test/run-test-server.sh${NC}"
echo -e "${MAGENTA} Or directly after build: bin/chess-server -dev${NC}" echo -e "${MAGENTA} Or directly after build: bin/chess-server -dev${NC}"
+1 -1
View File
@@ -7,7 +7,7 @@
# Start with: test/run-test-server.sh # Start with: test/run-test-server.sh
BASE_URL="http://localhost:8080" BASE_URL="http://localhost:8080"
API_URL="${BASE_URL}/api/v1" API_URL="${BASE_URL}/api"
CHESS_SERVER_EXEC=${1:-"bin/chess-server"} CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
# Same disposable database as the running test server (see run-test-server.sh). # Same disposable database as the running test server (see run-test-server.sh).
# Use a libpq-compatible DSN: it is passed to both chess-server and psql. # Use a libpq-compatible DSN: it is passed to both chess-server and psql.
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
BASE_URL="${BASE_URL:-http://localhost:8080}" BASE_URL="${BASE_URL:-http://localhost:8080}"
API_URL="${BASE_URL}/api/v1" API_URL="${BASE_URL}/api"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
PASS=0; FAIL=0 PASS=0; FAIL=0
+1 -1
View File
@@ -3,7 +3,7 @@
set -e set -e
# Configuration # Configuration
API_URL="${API_URL:-http://localhost:8080/api/v1}" # Updated to include /api/v1 API_URL="${API_URL:-http://localhost:8080/api}"
VERBOSE="${VERBOSE:-false}" VERBOSE="${VERBOSE:-false}"
# Colors for output # Colors for output