Skip to main content

open_chess_server/
cli.rs

1//! The server's commands, as the `open-chess` program runs them (the
2//! `open-chess` crate; built with its `server` feature):
3//!
4//!   open-chess serve      the Lichess-compatible API
5//!   open-chess analyse    the fair-play worker (needs Stockfish)
6//!   open-chess migrate    create or update the chess schema, then exit
7//!   open-chess bot create --name esl-stockfish-1800   a bot account and its token
8//!   open-chess admin grant mark            make an account an admin (or --manager)
9//!   open-chess partner create --name esports-lab --scopes read,site   a partner token
10//!   open-chess healthcheck   for container health checks
11
12use std::time::Duration;
13
14use anyhow::{Context, Result};
15use clap::{Parser, Subcommand};
16use tokio::sync::watch;
17use tracing::info;
18
19use crate::app::{self, Ctx};
20use crate::config::{AnalyseConfig, Config};
21use crate::db::Db;
22use crate::{analyse, auth, stopped};
23
24/// `open-chess` with no subcommand: serve, configured from the environment.
25#[derive(Parser)]
26struct ServeOnly {
27    #[command(flatten)]
28    cfg: Config,
29}
30
31/// The server's commands.
32#[derive(Subcommand)]
33pub enum Cmd {
34    /// Serve the API (and sweep clocks and challenges).
35    Serve(Config),
36    /// Screen finished games for fair play with Stockfish.
37    Analyse {
38        #[command(flatten)]
39        cfg: Config,
40        #[command(flatten)]
41        analyse: AnalyseConfig,
42    },
43    /// Create or update the chess schema and exit.
44    Migrate(Config),
45    /// Board photos kept for review.
46    Photos {
47        #[command(subcommand)]
48        cmd: PhotosCmd,
49    },
50    /// Bot accounts.
51    Bot {
52        #[command(subcommand)]
53        cmd: BotCmd,
54    },
55    /// Admins and managers here.
56    Admin {
57        #[command(subcommand)]
58        cmd: AdminCmd,
59    },
60    /// Partners: other services' API access.
61    Partner {
62        #[command(subcommand)]
63        cmd: PartnerCmd,
64    },
65    /// Exit 0 if the local server answers /healthz.
66    Healthcheck {
67        #[arg(long, env = "CHESS_METRICS_BIND", default_value = "0.0.0.0:9465")]
68        metrics_bind: String,
69    },
70}
71
72#[derive(Subcommand)]
73pub enum BotCmd {
74    /// Make a bot: a site player, its bot account here and an API token
75    /// (printed once).
76    Create {
77        #[command(flatten)]
78        cfg: Config,
79        /// Username, e.g. esl-stockfish-1800.
80        #[arg(long)]
81        name: String,
82        /// Token lifetime.
83        #[arg(long, default_value_t = 365)]
84        days: i64,
85    },
86    /// List bot accounts.
87    List(Config),
88}
89
90#[derive(Subcommand)]
91pub enum AdminCmd {
92    /// Make an account an admin (or, with --manager, a manager).
93    Grant {
94        #[command(flatten)]
95        cfg: Config,
96        username: String,
97        #[arg(long)]
98        manager: bool,
99    },
100    /// Take an admin's (or, with --manager, a manager's) role away.
101    Revoke {
102        #[command(flatten)]
103        cfg: Config,
104        username: String,
105        #[arg(long)]
106        manager: bool,
107    },
108    /// List admins and managers.
109    List(Config),
110}
111
112#[derive(Subcommand)]
113pub enum PartnerCmd {
114    /// Make a partner and its token (printed once).
115    Create {
116        #[command(flatten)]
117        cfg: Config,
118        #[arg(long)]
119        name: String,
120        /// What it may do: read (games, players, the feed), site (act as
121        /// the host site: its players' tokens and links).
122        #[arg(long, value_delimiter = ',', default_value = "read")]
123        scopes: Vec<String>,
124    },
125    /// List partners.
126    List(Config),
127    /// Stop a partner's token working.
128    Revoke {
129        #[command(flatten)]
130        cfg: Config,
131        id: i64,
132    },
133}
134
135#[derive(Subcommand)]
136pub enum PhotosCmd {
137    /// Write the labelled photos as a training set for chess-vision-train:
138    /// `photos/<id>.<ext>` and `labels.jsonl` (datasets `open-chess-photos`,
139    /// or with `--public-only` `open-chess-public`, in its datasets.toml).
140    /// Only photos kept under the photo licence; each line says its licence.
141    Export {
142        #[command(flatten)]
143        cfg: Config,
144        /// Directory to write (created; existing photos are kept).
145        #[arg(long)]
146        out: std::path::PathBuf,
147        /// Only photos two people agreed on, or an admin labelled, not ones
148        /// only the player who sent them confirmed.
149        #[arg(long)]
150        reviewed_only: bool,
151        /// Only photos their senders released as CC0 (the public dataset).
152        #[arg(long)]
153        public_only: bool,
154    },
155}
156
157fn tracing_init() {
158    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
159        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
160    tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
161}
162
163/// Ctrl-C or SIGTERM flips the returned watch to true.
164fn shutdown_signal() -> watch::Receiver<bool> {
165    let (tx, rx) = watch::channel(false);
166    tokio::spawn(async move {
167        let ctrl_c = tokio::signal::ctrl_c();
168        #[cfg(unix)]
169        {
170            let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
171                .expect("SIGTERM handler");
172            tokio::select! {
173                _ = ctrl_c => {}
174                _ = term.recv() => {}
175            }
176        }
177        #[cfg(not(unix))]
178        let _ = ctrl_c.await;
179        info!("shutting down");
180        let _ = tx.send(true);
181    });
182    rx
183}
184
185/// Whether the environment configures a server (`DATABASE_URL` is set):
186/// `open-chess` with no command serves then.
187pub fn configured() -> bool {
188    std::env::var_os("DATABASE_URL").is_some_and(|v| !v.is_empty())
189}
190
191/// Run a server command; `None` serves, configured from the environment.
192pub fn run(cmd: Option<Cmd>) -> Result<()> {
193    tracing_init();
194    tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(dispatch(cmd))
195}
196
197async fn dispatch(cmd: Option<Cmd>) -> Result<()> {
198    let cmd = match cmd {
199        Some(c) => c,
200        None => {
201            Cmd::Serve(ServeOnly::try_parse_from(["open-chess"]).context("DATABASE_URL must be set")?.cfg)
202        }
203    };
204    match cmd {
205        Cmd::Serve(cfg) => serve(cfg).await,
206        Cmd::Analyse { cfg, analyse: a } => {
207            cfg.validate()?;
208            let db = Db::connect(&cfg)?;
209            db.migrate().await?;
210            let ctx = Ctx::new(db.clone(), cfg.clone());
211            let stop = shutdown_signal();
212            let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind)
213                .await
214                .context("binding CHESS_METRICS_BIND")?;
215            let stop_ops = stop.clone();
216            // The worker counts into the registry /metrics serves.
217            let metrics = ctx.metrics.clone();
218            tokio::spawn(async move {
219                let _ = axum::serve(ops, app::ops_router(ctx)).with_graceful_shutdown(wait(stop_ops)).await;
220            });
221            analyse::run(db, metrics, a, stop).await
222        }
223        Cmd::Migrate(cfg) => {
224            cfg.validate()?;
225            Db::connect(&cfg)?.migrate().await?;
226            info!("chess schema up to date");
227            Ok(())
228        }
229        Cmd::Bot { cmd: BotCmd::Create { cfg, name, days } } => bot_create(cfg, &name, days).await,
230        Cmd::Bot { cmd: BotCmd::List(cfg) } => bot_list(cfg).await,
231        Cmd::Photos { cmd: PhotosCmd::Export { cfg, out, reviewed_only, public_only } } => {
232            photos_export(cfg, &out, reviewed_only, public_only).await
233        }
234        Cmd::Admin { cmd } => admin(cmd).await,
235        Cmd::Partner { cmd } => partner(cmd).await,
236        Cmd::Healthcheck { metrics_bind } => healthcheck(&metrics_bind),
237    }
238}
239
240async fn wait(mut stop: watch::Receiver<bool>) {
241    stopped(&mut stop).await;
242}
243
244async fn serve(cfg: Config) -> Result<()> {
245    cfg.validate()?;
246    let db = Db::connect(&cfg)?;
247    db.migrate().await?;
248    let ctx = Ctx::new(db.clone(), cfg.clone()).with_reader()?;
249    let stop = shutdown_signal();
250
251    crate::spawn_background(&ctx, &stop);
252
253    let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.context("binding CHESS_METRICS_BIND")?;
254    let ops_ctx = ctx.clone();
255    let stop_ops = stop.clone();
256    tokio::spawn(async move {
257        let _ = axum::serve(ops, app::ops_router(ops_ctx)).with_graceful_shutdown(wait(stop_ops)).await;
258    });
259
260    let listener = tokio::net::TcpListener::bind(&cfg.bind).await.context("binding CHESS_BIND")?;
261    info!(bind = cfg.bind, public = cfg.public_url(), "chess server listening");
262    if cfg.esports_login && cfg.esports().is_none() {
263        tracing::warn!(
264            "ESPORTS_LOGIN is on but ESPORTS_SECRET isn't set (32+ characters): signing in with the site is off"
265        );
266    }
267    axum::serve(listener, app::router(&ctx)?).with_graceful_shutdown(wait(stop)).await?;
268    Ok(())
269}
270
271async fn bot_create(cfg: Config, name: &str, days: i64) -> Result<()> {
272    cfg.validate()?;
273    let db = Db::connect(&cfg)?;
274    db.migrate().await?;
275    let mut c = db.conn().await?;
276    let tx = c.transaction().await?;
277    let player: i64 = tx
278        .query_one(
279            &db.sql("INSERT INTO chess.players (name, key_hash) VALUES ($1, $2) RETURNING id"),
280            &[&name, &auth::hash_key(&auth::random_token(28))],
281        )
282        .await?
283        .get(0);
284    tx.execute(
285        &db.sql("INSERT INTO chess.accounts (player_id, username, kind) VALUES ($1, $2, 'bot')"),
286        &[&player, &name],
287    )
288    .await
289    .context("that username is taken or invalid (letters, digits, _ and -, 2–30 characters)")?;
290    let token = format!("{}{}", auth::TOKEN_PREFIX, auth::random_token(36));
291    let prefix: String = token.chars().take(auth::TOKEN_PREFIX.len() + 6).collect();
292    let scopes: Vec<String> =
293        ["bot:play", "challenge:read", "challenge:write"].iter().map(|s| (*s).to_owned()).collect();
294    let expires = chrono::Utc::now() + chrono::Duration::days(days.clamp(1, 365));
295    tx.execute(
296        &db.sql(
297            "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
298             VALUES ($1, 'chess bot', $2, $3, $4, $5)",
299        ),
300        &[&player, &auth::hash_key(&token), &prefix, &scopes, &expires],
301    )
302    .await?;
303    tx.commit().await?;
304    println!("bot {name} created (player {player}); its token, shown once:\n{token}");
305    Ok(())
306}
307
308async fn bot_list(cfg: Config) -> Result<()> {
309    cfg.validate()?;
310    let db = Db::connect(&cfg)?;
311    let c = db.conn().await?;
312    for r in c
313        .query(
314            &db.sql("SELECT username, player_id, seen_at FROM chess.accounts WHERE kind = 'bot' ORDER BY lower(username)"),
315            &[],
316        )
317        .await?
318    {
319        let seen: Option<chrono::DateTime<chrono::Utc>> = r.get(2);
320        println!(
321            "{:<30} player {:<6} last seen {}",
322            r.get::<_, String>(0),
323            r.get::<_, i64>(1),
324            seen.map_or_else(|| "never".into(), |t| t.to_rfc3339())
325        );
326    }
327    Ok(())
328}
329
330async fn photos_export(
331    cfg: Config,
332    out: &std::path::Path,
333    reviewed_only: bool,
334    public_only: bool,
335) -> Result<()> {
336    cfg.validate()?;
337    let db = Db::connect(&cfg)?;
338    let c = db.conn().await?;
339    std::fs::create_dir_all(out.join("photos")).with_context(|| format!("creating {}", out.display()))?;
340    let rows = c
341        .query(
342            &db.sql(
343                "SELECT id, player_id, created_at, image, mime, label, label_source, public
344                   FROM chess.board_photos
345                  WHERE label IS NOT NULL AND NOT unreadable AND terms IS NOT NULL
346                    AND ($1 = false OR label_source IN ('consensus', 'admin', 'reviewer'))
347                    AND ($2 = false OR public)
348                  ORDER BY id",
349            ),
350            &[&reviewed_only, &public_only],
351        )
352        .await?;
353    let mut lines = String::new();
354    for r in &rows {
355        let id: i64 = r.get(0);
356        let player: Option<i64> = r.get(1);
357        let at: chrono::DateTime<chrono::Utc> = r.get(2);
358        let ext = match r.get::<_, String>(4).as_str() {
359            "image/png" => "png",
360            "image/webp" => "webp",
361            _ => "jpg",
362        };
363        let name = format!("photos/{id}.{ext}");
364        std::fs::write(out.join(&name), r.get::<_, Vec<u8>>(3))?;
365        // One player's photos on one day are probably of the same board, so
366        // they share a group and a split never divides them.
367        let group = format!("player-{}-{}", player.unwrap_or(0), at.format("%Y-%m-%d"));
368        lines.push_str(&serde_json::to_string(&serde_json::json!({
369            "image": name, "placement": r.get::<_, String>(5), "group": group, "source": r.get::<_, Option<String>>(6),
370            "licence": if r.get::<_, bool>(7) { "CC0-1.0" } else { PHOTO_LICENCE },
371        }))?);
372        lines.push('\n');
373    }
374    std::fs::write(out.join("labels.jsonl"), lines)?;
375    info!(photos = rows.len(), out = %out.display(), "exported");
376    Ok(())
377}
378
379/// Photos kept under the photo licence (docs/photo-licence.md), as the
380/// trainer's datasets.toml names it.
381const PHOTO_LICENCE: &str = "LicenseRef-open-chess-photo-licence-1";
382
383async fn admin(cmd: AdminCmd) -> Result<()> {
384    let (cfg, grant) = match &cmd {
385        AdminCmd::Grant { cfg, .. } => (cfg, Some(true)),
386        AdminCmd::Revoke { cfg, .. } => (cfg, Some(false)),
387        AdminCmd::List(cfg) => (cfg, None),
388    };
389    cfg.validate()?;
390    let db = Db::connect(cfg)?;
391    db.migrate().await?;
392    let c = db.conn().await?;
393    if let (
394        Some(on),
395        AdminCmd::Grant { username, manager, .. } | AdminCmd::Revoke { username, manager, .. },
396    ) = (grant, &cmd)
397    {
398        let sql = match (manager, on) {
399            (false, true) => "UPDATE chess.players SET role = 'admin'",
400            (false, false) => "UPDATE chess.players SET role = 'player'",
401            (true, true) => {
402                "UPDATE chess.players SET roles = array_append(array_remove(roles, 'manager'), 'manager')"
403            }
404            (true, false) => "UPDATE chess.players SET roles = array_remove(roles, 'manager')",
405        };
406        let keys: Option<i64> = c
407            .query_opt(
408                &db.sql(
409                    "SELECT (SELECT count(*) FROM chess.passkeys k WHERE k.player_id = a.player_id)
410                       FROM chess.accounts a WHERE lower(a.username) = lower($1)",
411                ),
412                &[username],
413            )
414            .await?
415            .map(|r| r.get(0));
416        let keys = keys.with_context(|| format!("no account here is called {username}"))?;
417        anyhow::ensure!(
418            !on || keys > 0,
419            "{username} needs a passkey first (their rights will need it): they add one on their account page, then grant again"
420        );
421        c.execute(
422            &db.sql(&format!(
423                "{sql} WHERE id = (SELECT player_id FROM chess.accounts WHERE lower(username) = lower($1))"
424            )),
425            &[username],
426        )
427        .await?;
428        let what = if *manager { "a manager" } else { "an admin" };
429        println!(
430            "{username} {} {what}.{}",
431            if on { "is now" } else { "is no longer" },
432            if on { " Their rights need a passkey check each session." } else { "" }
433        );
434        return Ok(());
435    }
436    for r in c
437        .query(
438            &db.sql(
439                "SELECT a.username, p.role, p.roles, p.email, (SELECT count(*) FROM chess.passkeys k WHERE k.player_id = p.id)
440                   FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
441                  WHERE p.role = 'admin' OR 'manager' = ANY(p.roles) ORDER BY lower(a.username)",
442            ),
443            &[],
444        )
445        .await?
446    {
447        let (admin, manager) = auth::staff_roles(&r.get::<_, String>(1), &r.get::<_, Vec<String>>(2));
448        let roles = [(admin, "admin"), (manager, "manager")].iter().filter(|x| x.0).map(|x| x.1).collect::<Vec<_>>();
449        println!(
450            "{:<24} {:<16} {:<32} {} passkey(s)",
451            r.get::<_, String>(0),
452            roles.join(","),
453            r.get::<_, Option<String>>(3).unwrap_or_default(),
454            r.get::<_, i64>(4)
455        );
456    }
457    Ok(())
458}
459
460async fn partner(cmd: PartnerCmd) -> Result<()> {
461    let cfg = match &cmd {
462        PartnerCmd::Create { cfg, .. } | PartnerCmd::Revoke { cfg, .. } | PartnerCmd::List(cfg) => cfg,
463    };
464    cfg.validate()?;
465    let db = Db::connect(cfg)?;
466    db.migrate().await?;
467    let c = db.conn().await?;
468    match &cmd {
469        PartnerCmd::Create { name, scopes, .. } => {
470            let (id, token) = crate::http::admin::make_partner(&db, &*c, name, scopes, None).await?;
471            println!("partner {name} made (id {id}); its token, shown once:\n{token}");
472        }
473        PartnerCmd::Revoke { id, .. } => {
474            let n = c
475                .execute(
476                    &db.sql(
477                        "UPDATE chess.partners SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL",
478                    ),
479                    &[id],
480                )
481                .await?;
482            anyhow::ensure!(n == 1, "no partner {id} (or already revoked)");
483            println!("partner {id} revoked");
484        }
485        PartnerCmd::List(_) => {
486            for r in c
487                .query(
488                    &db.sql(
489                        "SELECT id, name, prefix, scopes, last_used_at FROM chess.partners
490                          WHERE revoked_at IS NULL ORDER BY id",
491                    ),
492                    &[],
493                )
494                .await?
495            {
496                let used: Option<chrono::DateTime<chrono::Utc>> = r.get(4);
497                println!(
498                    "{:<4} {:<24} {}… {:<12} last used {}",
499                    r.get::<_, i64>(0),
500                    r.get::<_, String>(1),
501                    r.get::<_, String>(2),
502                    r.get::<_, Vec<String>>(3).join(","),
503                    used.map_or_else(|| "never".into(), |t| t.to_rfc3339())
504                );
505            }
506        }
507    }
508    Ok(())
509}
510
511fn healthcheck(bind: &str) -> Result<()> {
512    use std::io::{Read, Write};
513    let port = bind.rsplit(':').next().unwrap_or("9465");
514    let mut s = std::net::TcpStream::connect(format!("127.0.0.1:{port}")).context("connecting")?;
515    s.set_read_timeout(Some(Duration::from_secs(5)))?;
516    s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
517    let mut buf = String::new();
518    s.read_to_string(&mut buf)?;
519    if buf.starts_with("HTTP/1.0 200") || buf.starts_with("HTTP/1.1 200") {
520        Ok(())
521    } else {
522        anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or(""))
523    }
524}