This commit is contained in:
1 parent
1e7d759a19
commit
2d6246e14c
23 files changed
+629
-632
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
Executable
+128
@@ -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"
|
||||
+187
-183
@@ -1,42 +1,41 @@
|
||||
#!/bin/sh
|
||||
# Install or update chess-server with PostgreSQL 18 inside a FreeBSD jail.
|
||||
# Run as root inside the jail, from a copy of this repository:
|
||||
# Install or upgrade chess-server (rc.d service `chessd`) with PostgreSQL
|
||||
# inside a FreeBSD jail. Run as root inside the jail from a copy of this
|
||||
# repository:
|
||||
#
|
||||
# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \
|
||||
# API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \
|
||||
# sh deploy/freebsd/setup-jail.sh
|
||||
# CHESS_BINARY=/tmp/chess-server TRUSTED_PROXIES=<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"
|
||||
fi
|
||||
done
|
||||
chown -R postgres "$work"
|
||||
su -m postgres -c "cd '$work' && CHESS_SCHEMA=chess sh -s -- '$work/legacy.db' dbname=chess" \
|
||||
<"$repo/deploy/postgresql/migrate-sqlite.sh"
|
||||
rm -rf "$work"
|
||||
sysrc -q chessd_enable=YES >/dev/null
|
||||
if [ -n "$TRUSTED_PROXIES" ]; then
|
||||
sysrc -q chessd_trusted_proxies="$TRUSTED_PROXIES" >/dev/null
|
||||
fi
|
||||
[ -n "$(rc_value chessd_trusted_proxies "")" ] ||
|
||||
log "WARNING: chessd_trusted_proxies is unset; behind nginx every client shares one rate limit"
|
||||
# Settings of the SQLite-era rc.d script that nothing reads any more.
|
||||
for stale in chessd_storage_path chessd_dir; do
|
||||
if sysrc -n "$stale" >/dev/null 2>&1; then
|
||||
sysrc -q -x "$stale" >/dev/null
|
||||
log "removed unused rc.conf setting $stale"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- optional maintenance -------------------------------------------------------------
|
||||
logs=$(rc_value chessd_logs /var/log/chessd.log)
|
||||
if [ "$ENABLE_LOG_ROTATION" = yes ]; then
|
||||
# Daily or at 1 MB, keep 7; SIGHUP makes daemon(8) (-H) reopen the log.
|
||||
[ -d "$NEWSYSLOG_DIR" ] || install -d "$NEWSYSLOG_DIR"
|
||||
printf '%s\n' \
|
||||
"# Managed by chess setup-jail.sh" \
|
||||
"$logs $user:$group 640 7 1000 @T00 JC /var/run/chessd.pid 1" \
|
||||
>"$NEWSYSLOG_DIR/chessd.conf"
|
||||
log "log rotation: $NEWSYSLOG_DIR/chessd.conf"
|
||||
fi
|
||||
if [ "$ENABLE_BACKUP" = yes ]; then
|
||||
# Nightly pg_dump at 03:30 as postgres, keeping 14 days.
|
||||
[ -d "$SBIN_DIR" ] || install -d "$SBIN_DIR"
|
||||
install -o root -g wheel -m 0555 "$here/chess-backup.sh" "$SBIN_DIR/chess-backup"
|
||||
[ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
|
||||
[ -d "$CRON_DIR" ] || install -d "$CRON_DIR"
|
||||
printf '%s\n' \
|
||||
"# Managed by chess setup-jail.sh" \
|
||||
"SHELL=/bin/sh" \
|
||||
"PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" \
|
||||
"30 3 * * * postgres CHESS_BACKUP_DIR=$BACKUP_DIR $SBIN_DIR/chess-backup" \
|
||||
>"$CRON_DIR/chess-backup"
|
||||
log "backups: $CRON_DIR/chess-backup -> $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# --- service configuration -------------------------------------------------------
|
||||
if [ -f "$RC_DIR/chess_server" ] && ! cmp -s "$here/rc.d/chess_server" "$RC_DIR/chess_server"; then
|
||||
cp -p "$RC_DIR/chess_server" "$RC_DIR/chess_server.orig"
|
||||
fi
|
||||
install -d "$RC_DIR"
|
||||
install -o root -g wheel -m 0555 "$here/rc.d/chess_server" "$RC_DIR/chess_server"
|
||||
|
||||
flags="-api-host $API_HOST -api-port $API_PORT -dsn $dsn -jwt-secret-file $key -log-level info"
|
||||
[ -n "$TRUSTED_PROXIES" ] && flags="$flags -trusted-proxies $TRUSTED_PROXIES"
|
||||
[ -n "$EXTRA_FLAGS" ] && flags="$flags $EXTRA_FLAGS"
|
||||
sysrc -q chess_server_enable=YES >/dev/null
|
||||
sysrc -q chess_server_account="$account" >/dev/null
|
||||
sysrc -q chess_server_binary="$binary" >/dev/null
|
||||
sysrc -q chess_server_logfile="$LOG_DIR/chess-server.log" >/dev/null
|
||||
sysrc -q chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin" >/dev/null
|
||||
sysrc -q chess_server_flags="$flags" >/dev/null
|
||||
|
||||
# Rotate daily or at 1 MB, keep a week; SIGHUP makes daemon(8) reopen the log.
|
||||
install -d "$NEWSYSLOG_DIR"
|
||||
printf '%s\n' \
|
||||
"# Managed by chess setup-jail.sh" \
|
||||
"$LOG_DIR/chess-server.log $account:$account 640 7 1000 @T00 JC /var/run/chess_server.pid 1" \
|
||||
>"$NEWSYSLOG_DIR/chess_server.conf"
|
||||
|
||||
# Nightly pg_dump at 03:30, as postgres, keeping 14 days.
|
||||
install -d "$SBIN_DIR"
|
||||
install -o root -g wheel -m 0555 "$here/chess-backup.sh" "$SBIN_DIR/chess-backup"
|
||||
install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
|
||||
install -d "$CRON_DIR"
|
||||
printf '%s\n' \
|
||||
"# Managed by chess setup-jail.sh" \
|
||||
"SHELL=/bin/sh" \
|
||||
"PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" \
|
||||
"30 3 * * * postgres CHESS_BACKUP_DIR=$BACKUP_DIR $SBIN_DIR/chess-backup" \
|
||||
>"$CRON_DIR/chess-backup"
|
||||
|
||||
# --- start and verify -----------------------------------------------------------
|
||||
log "starting chess_server"
|
||||
service chess_server start
|
||||
# --- start and verify -----------------------------------------------------------------
|
||||
log "starting chessd"
|
||||
service chessd start
|
||||
health_host=$host
|
||||
[ "$health_host" = 0.0.0.0 ] && health_host=127.0.0.1
|
||||
url="http://$health_host:$port/health"
|
||||
health=""
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if health=$(fetch -q -T 2 -o - "http://$API_HOST:$API_PORT/health" 2>/dev/null); then
|
||||
if health=$(fetch -q -T 2 -o - "$url" 2>/dev/null); then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
case $health in
|
||||
*'"storage":"ok"'*) log "healthy: $health" ;;
|
||||
*) die "chess-server did not report healthy storage (got: ${health:-no response}); see $LOG_DIR/chess-server.log" ;;
|
||||
*) die "no healthy response from $url (got: ${health:-nothing}); see $logs, and check that the jail firewall allows loopback" ;;
|
||||
esac
|
||||
|
||||
cat <<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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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` / `-`
|
||||
|
||||
+166
-177
@@ -1,96 +1,107 @@
|
||||
# Deployment: FreeBSD Jail with PostgreSQL 18
|
||||
|
||||
Production layout: host nginx terminates TLS and reverse-proxies `/chess/` to
|
||||
`chess-server` in a Bastille jail. PostgreSQL 18 runs in the same jail and
|
||||
listens **only on its Unix socket**. The `chess` OS account has no shell or
|
||||
password; its database role reaches only the `chess` database through peer
|
||||
authentication, so no database password exists.
|
||||
Layout: host nginx terminates TLS and proxies `/chess/api/` and `/chess/health`
|
||||
to `chess-server` in a Bastille jail. The rc.d service is `chessd`, running as
|
||||
the unprivileged `chess` account (no shell, no password). PostgreSQL 18 runs in
|
||||
the same jail and listens **only on its Unix socket**. The `chess` role reaches
|
||||
only the `chess` database through peer authentication, so no database password
|
||||
exists.
|
||||
|
||||
```
|
||||
internet ──TLS──▶ host nginx ──HTTP──▶ jail: chess-server (API port)
|
||||
│ Unix socket, peer auth
|
||||
▼
|
||||
jail: PostgreSQL 18 (no TCP listener)
|
||||
browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (chess-server, API port)
|
||||
│ Unix socket, peer auth
|
||||
▼
|
||||
jail: PostgreSQL 18 (no TCP listener)
|
||||
```
|
||||
|
||||
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
|
||||
(`bastille console <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# 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
|
||||
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
|
||||
host# curl -s https://<site>/chess/health # through nginx
|
||||
```
|
||||
+3
-3
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user