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
+617 -620

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
- [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
+2 -3
View File
@@ -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 {
+10 -7
View File
@@ -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 <jail>` | `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 <jail>` | `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.
+46 -13
View File
@@ -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
# <origin>/chess/api/... and <origin>/chess/health, which map to the server's
# /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
# (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 <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_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;
}
-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"
+180 -176
View File
@@ -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 \
# CHESS_BINARY=/tmp/chess-server TRUSTED_PROXIES=<nginx-address> \
# sh deploy/freebsd/setup-jail.sh
#
# Before the first run:
# 1. On the host: sh deploy/freebsd/host.sh <jail> (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 <jail> 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"
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
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
done
# --- 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' \
# --- 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" \
"$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' \
"$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
# --- 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 <<EOF
chess-server is running at http://$API_HOST:$API_PORT with PostgreSQL storage.
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
chessd is running with PostgreSQL storage ($url).
Administration inside the jail:
su -m $account -c '$binary db user list -dsn "$dsn"'
service chess_server status|restart
tail -f $LOG_DIR/chess-server.log
su -m $user -c '$bin db user list -dsn "$dsn"'
service chessd status|restart
tail -f $logs
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
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)
+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
### 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
+2 -2
View File
@@ -166,8 +166,8 @@ chess > url http://localhost:9090 # Change server URL
#### `raw` / `:`
Send raw API request.
```
chess > raw GET /api/v1/games/<id>
chess > raw POST /api/v1/games '{"white":{"type":1},"black":{"type":2}}'
chess > raw GET /api/games/<id>
chess > raw POST /api/games '{"white":{"type":1},"black":{"type":2}}'
```
#### `clear` / `-`
+161 -172
View File
@@ -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)
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 <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
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 <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:
```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=<nginx-address> \
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.<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) |
| Import | [`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) when `SQLITE_DB` is set and the schema is empty |
| Service | [`rc.d/chess_server`](../deploy/freebsd/rc.d/chess_server) and `rc.conf` via `sysrc` |
| Log rotation | `/usr/local/etc/newsyslog.conf.d/chess_server.conf`: daily or 1 MB, 7 kept |
| Backups | `/usr/local/sbin/chess-backup` via `/usr/local/etc/cron.d/chess-backup`: `pg_dump` at 03:30 as `postgres`, 14 days kept |
| `rc.conf` | `chessd_enable=YES`, `chessd_trusted_proxies` when given; removes the unused `chessd_storage_path` and `chessd_dir` |
| Log rotation (opt-in) | `/usr/local/etc/newsyslog.conf.d/chessd.conf`: daily or 1 MB, 7 kept, SIGHUP to `daemon(8)` |
| 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` |
| Start | `service chessd start`, then `/health` on the configured port |
The script was exercised on Linux with FreeBSD-only commands (`pkg`, `sysrc`,
`service`, `pw`, `fetch`) stubbed and everything else real: PostgreSQL 18.6,
`su`, provisioning in both privilege modes, schema migration, the SQLite
import, and the health check of the running server.
Files of an earlier SQLite release are not touched; delete them once the new
release is verified.
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
@@ -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
record the player's username at claim time.
- Games with no registered player are **deleted 24 hours after their last
activity** (creation, move, undo, reconfiguration, or result), from memory and
from the database, by the hourly cleanup. `-anonymous-game-ttl` changes the
window; `0` keeps them.
activity**, from memory and from the database, by the hourly cleanup.
`-anonymous-game-ttl` changes the window; `0` keeps them.
- 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
The sections below describe what the scripts configure, for review or manual
installation.
### PostgreSQL configuration
### Jail prerequisites
PostgreSQL allocates a small System V shared-memory segment even though its
main buffers use `mmap`. `host.sh` runs:
What `setup-jail.sh` applies, for review or manual installation:
```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_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# su -m postgres -c "psql -X -d postgres" <<'SQL'
ALTER SYSTEM SET listen_addresses = '';
ALTER SYSTEM SET unix_socket_directories = '/tmp';
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SQL
jail# service postgresql restart
```
@@ -150,6 +212,9 @@ local all postgres peer
local chess chess peer
```
Database administration uses the `postgres` account over the socket:
`su -m postgres -c psql`.
### Provisioning and privilege models
[`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`,
`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 |
|---|---|---|---|
| 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) |
```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'\""
```
| Split (`SPLIT_PRIVILEGES=yes`) | `chess_owner` (NOLOGIN) | `SELECT/INSERT/UPDATE/DELETE` only | `setup-jail.sh`, or manually as `postgres` |
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
`setup-jail.sh` generates the key once with `openssl rand -base64 48`. Without
a key file, production mode generates a new key at every start and every
browser session ends on restart. To rotate, replace the file and restart the
service; users then sign in again.
The key is 48 random bytes, base64-encoded, readable only by `chess`. Without a
key file, production mode generates a new key at every start and every session
ends on restart. To rotate, replace the file and restart `chessd`; users sign
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
chess_server_enable="YES"
chess_server_account="chess"
chess_server_binary="/usr/local/chess/chess-server"
chess_server_logfile="/var/log/chess/chess-server.log"
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"
jail# service chessd onestop
jail# cp /var/backups/chessd.rc.<time> /usr/local/etc/rc.d/chessd
jail# cp /home/chess/bin/chess-server.prev /home/chess/bin/chess-server
jail# service chessd start
```
The rc.d script starts `daemon(8)` as root, which writes the supervisor
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`.
Restore the previous web client files on the host as well.
| 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` | | 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`) |
### Backups and restores
### Host nginx
[`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) passes the client
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:
- `chess-backup` writes `pg_dump --format=custom` files 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>'`
(`--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
games continue in memory, but durable history stops until the server is
restarted.
games continue in memory, but durable history stops until `chessd` restarts.
### Verification checklist
```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 postgres -c "select 1"' # rejected by pg_hba
jail# sockstat -4 -6 -l | grep postgres # no TCP listener
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
host# curl -s https://<site>/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`).
- [x] Each accepted move, first-move claim, and move-caused result commits in
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.
- [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.
- [x] Claims survive player reconfiguration and terminal-game eviction; the
listing indexes are ordered for keyset pagination.
@@ -74,7 +74,7 @@ Test gate:
- [ ] Implement D7 (keyset cursor) once decided.
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and
`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"`,
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;
+13 -13
View File
@@ -163,56 +163,56 @@ func (c *Client) Health() (*HealthResponse, error) {
func (c *Client) CreateGame(req *CreateGameRequest) (*GameResponse, error) {
var resp GameResponse
err := c.doRequest("POST", "/api/v1/games", req, &resp)
err := c.doRequest("POST", "/api/games", req, &resp)
return &resp, err
}
func (c *Client) GetGame(gameID string) (*GameResponse, error) {
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
}
func (c *Client) GetGameWithPoll(gameID string, moveCount int) (*GameResponse, error) {
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)
return &resp, err
}
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) {
req := &MoveRequest{Move: move}
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
}
func (c *Client) UndoMoves(gameID string, count int) (*GameResponse, error) {
req := &UndoRequest{Count: count}
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
}
func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
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
}
func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) {
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
}
func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) {
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)
return &resp, err
}
@@ -224,7 +224,7 @@ func (c *Client) Register(username, password, email string) (*AuthResponse, erro
Email: email,
}
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
}
@@ -234,17 +234,17 @@ func (c *Client) Login(identifier, password string) (*AuthResponse, error) {
Password: password,
}
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
}
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) {
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
}
+1 -1
View File
@@ -97,7 +97,7 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
app.Get("/health", h.Health)
// API v1 routes
api := app.Group("/api/v1")
api := app.Group("/api")
// Auth routes with specific rate limiting
auth := api.Group("/auth")
+44
View File
@@ -3,8 +3,11 @@ package http
import (
"io"
"net/http/httptest"
"strings"
"testing"
"chess/internal/server/service"
"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
// 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 {
s.mu.Lock()
defer s.mu.Unlock()
@@ -92,7 +92,7 @@ function restoreAuthSession() {
async function validateSession() {
try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/auth/me`);
const response = await authFetch(`${gameState.apiUrl}/api/auth/me`);
if (response.ok) {
const user = await response.json();
gameState.userId = user.userId;
@@ -228,7 +228,7 @@ async function handleLogin() {
let response;
try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, {
response = await fetch(`${gameState.apiUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password })
@@ -295,7 +295,7 @@ async function handleRegister() {
let response;
try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, {
response = await fetch(`${gameState.apiUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
@@ -337,7 +337,7 @@ async function handleRegister() {
async function handleLogout() {
if (gameState.authToken) {
try {
await authFetch(`${gameState.apiUrl}/api/v1/auth/logout`, { method: 'POST' });
await authFetch(`${gameState.apiUrl}/api/auth/logout`, { method: 'POST' });
} catch {
// Ignore - clear local state regardless
}
@@ -619,7 +619,7 @@ async function startNewGame() {
let response;
try {
response = await authFetch(`${gameState.apiUrl}/api/v1/games`, {
response = await authFetch(`${gameState.apiUrl}/api/games`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
@@ -771,7 +771,7 @@ async function handleHumanMove(from, to) {
const toEl = document.querySelector(`[data-square="${to}"]`);
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move })
@@ -808,7 +808,7 @@ async function handleHumanMove(from, to) {
async function triggerComputerMove() {
lockBoard();
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move: 'cccc' })
@@ -849,7 +849,7 @@ async function pollOnce() {
try {
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 }
);
@@ -908,7 +908,7 @@ async function undoMoves() {
}
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',
headers: { 'Content-Type': 'application/json' },
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
```bash
# 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" \
-d '{"username":"alice","password":"AlicePass123"}'
-d '{"identifier":"alice","password":"AlicePass123"}'
# Create authenticated game
TOKEN="<jwt-from-login>"
curl -X POST http://localhost:8080/games \
curl -X POST http://localhost:8080/api/games \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"white":{"type":1},"black":{"type":2,"level":10}}'
+2 -2
View File
@@ -5,7 +5,7 @@
# Requires: curl, jq
BASE_URL="http://localhost:8080"
API_URL="${BASE_URL}/api/v1"
API_URL="${BASE_URL}/api"
# Configurable delay between API calls (in milliseconds)
API_DELAY=${API_DELAY:-50} # 50ms for dev mode testing
@@ -130,7 +130,7 @@ fi
# Start tests
print_header "Chess API Robustness Test Suite"
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} Start the server first: test/run-test-server.sh${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
BASE_URL="http://localhost:8080"
API_URL="${BASE_URL}/api/v1"
API_URL="${BASE_URL}/api"
CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
# 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.
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
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'
PASS=0; FAIL=0
+1 -1
View File
@@ -3,7 +3,7 @@
set -e
# 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}"
# Colors for output