From f80d00802e54bfafccfbdb314ecf80d817fbd99c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:42:51 +0200 Subject: [PATCH] Server reference generated from horizon-server's source docs/api/server-reference.md lists horizon-server's commands (its help text), every server.toml key (config::TEMPLATE: default, the server.properties key it writes, what it does) and the control API's endpoints. Tests fail when the page is out of date (HORIZON_WRITE_DOCS=1 rewrites it), when the template and Config disagree on the keys, when a key has no comment, or when api::ROUTES and the endpoints route() answers differ. api::ROUTES replaces the endpoint list in api.rs's module comment and the hand-kept path list of the 405 arm. The template's uncommented keys get a comment (what init writes for new servers; no setting changes). Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + crates/horizon-server/src/api.rs | 112 ++++++++++++--- crates/horizon-server/src/config.rs | 16 +-- crates/horizon-server/src/main.rs | 2 + crates/horizon-server/src/reference.rs | 190 +++++++++++++++++++++++++ docs/api/server-reference.md | 137 ++++++++++++++++++ 6 files changed, 433 insertions(+), 25 deletions(-) create mode 100644 crates/horizon-server/src/reference.rs create mode 100644 docs/api/server-reference.md diff --git a/.gitattributes b/.gitattributes index 13584e4d9..358aeae7a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ docs/launcher-commands.md text eol=lf docs/horizon-ctl-commands.md text eol=lf website/src/tokens.css text eol=lf +docs/api/*-reference.md text eol=lf diff --git a/crates/horizon-server/src/api.rs b/crates/horizon-server/src/api.rs index c9e553fab..3b0741e8a 100644 --- a/crates/horizon-server/src/api.rs +++ b/crates/horizon-server/src/api.rs @@ -2,17 +2,7 @@ //! `.horizon/api.json` that only the server's own user can read. The CLI manages a running server through //! it; the web panel and the MCP server will too. //! -//! - `GET /api/v1/status`: the supervisor's state, the game's status (TPS, MSPT, players), RAM and CPU. -//! - `GET /api/v1/logs?since=N`: the log lines after the Nth. -//! - `POST /api/v1/command {"line": "..."}`: a console command; the lines it logged. -//! - `POST /api/v1/stop`, `POST /api/v1/restart`. -//! - `GET /api/v1/metrics?since=T`: the samples taken after T (seconds since 1970), up to 10 minutes. -//! - `GET /api/v1/backups`; `POST /api/v1/backups {"name": "..."}` backs up now; `POST /api/v1/restore -//! {"name": "..."}` restarts the game with that backup's world. -//! - `POST /api/v1/update {"tag": "...", "timeout": seconds}`: restarts the game on that installed release, -//! and goes back to the one in use if it isn't up within the timeout; without a tag, downloads the newest -//! release first (update.rs). `GET /api/v1/status` says how it went (`update`). -//! - `POST /api/v1/panel/login`: a one-time login link for the web panel (with the token only). +//! Its routes are [`ROUTES`], listed in docs/api/server-reference.md. use crate::{ backup, @@ -41,6 +31,99 @@ const LOG_LINES: usize = 2000; /// The samples kept for the panel's graphs: 10 minutes, one a second. const METRICS: usize = 600; +/// One endpoint of the control API: what the reference (docs/api/server-reference.md) says about it. +#[cfg_attr(not(test), expect(dead_code, reason = "only the reference reads all of it"))] +pub struct Route { + pub method: &'static str, + pub path: &'static str, + /// The JSON body or query it takes, "" for none. + pub takes: &'static str, + /// The JSON it answers with. + pub answers: &'static str, + pub what: &'static str, +} + +/// Every endpoint `start` and [`route`] answer; a test checks the two agree. +pub const ROUTES: [Route; 11] = [ + Route { + method: "GET", + path: "/api/v1/status", + takes: "", + answers: "`phase`, `version`, `pid`, `uptime_seconds`, `engine_pid`, `engine_uptime_seconds`, `crashes`, `last_crash`, `sample` (TPS, MSPT, players, RAM, CPU), `update`", + what: "How the server does", + }, + Route { + method: "GET", + path: "/api/v1/metrics", + takes: "`?since=T` (seconds since 1970)", + answers: "`{\"samples\": [...]}`: one a second, the last 10 minutes", + what: "The samples taken after T, for graphs", + }, + Route { + method: "GET", + path: "/api/v1/logs", + takes: "`?since=N`", + answers: "`{\"next\": N, \"lines\": [...]}`", + what: "The log lines after the Nth (the last 2000 are kept)", + }, + Route { + method: "POST", + path: "/api/v1/command", + takes: "`{\"line\": \"\"}`", + answers: "the lines the command logged", + what: "Run a console command on the running game", + }, + Route { + method: "POST", + path: "/api/v1/stop", + takes: "", + answers: "`{\"ok\": true}`", + what: "Stop the game and the supervisor", + }, + Route { + method: "POST", + path: "/api/v1/restart", + takes: "", + answers: "`{\"ok\": true}`", + what: "Restart the game", + }, + Route { + method: "GET", + path: "/api/v1/backups", + takes: "", + answers: "`{\"backups\": [{\"name\", \"bytes\"}]}`", + what: "List the backups", + }, + Route { + method: "POST", + path: "/api/v1/backups", + takes: "`{\"name\": \"...\"}` (optional)", + answers: "`{\"name\": \"\"}`", + what: "Back up now (a running game saves first and stays up)", + }, + Route { + method: "POST", + path: "/api/v1/restore", + takes: "`{\"name\": \"\"}`", + answers: "`{\"name\": \"\"}`", + what: "Restart the game with that backup's world (the current one is kept in backups/)", + }, + Route { + method: "POST", + path: "/api/v1/update", + takes: "`{\"tag\": \"...\", \"timeout\": seconds}` (both optional)", + answers: "`{\"ok\": true}`; `status` says how it went (`update`)", + what: "Restart on that installed release, or download the newest first; goes back if the game isn't up within the timeout", + }, + Route { + method: "POST", + path: "/api/v1/panel/login", + takes: "", + answers: "`{\"url\": \"...\"}`", + what: "A one-time login link for the web panel (with the token only, not from the panel)", + }, +]; + /// What the supervisor is doing. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Phase { @@ -488,12 +571,7 @@ pub fn route(shared: &Arc, request: &Request) -> Response { Err(e) => Response::error(409, &e), } } - ( - _, - "/api/v1/status" | "/api/v1/logs" | "/api/v1/command" | "/api/v1/stop" - | "/api/v1/restart" | "/api/v1/backups" | "/api/v1/restore" | "/api/v1/metrics" - | "/api/v1/update", - ) => Response::error(405, "wrong method"), + (_, path) if ROUTES.iter().any(|r| r.path == path) => Response::error(405, "wrong method"), _ => Response::error(404, "no such endpoint"), } } diff --git a/crates/horizon-server/src/config.rs b/crates/horizon-server/src/config.rs index 0a66b6fdf..32e2be79b 100644 --- a/crates/horizon-server/src/config.rs +++ b/crates/horizon-server/src/config.rs @@ -19,22 +19,22 @@ pub const TEMPLATE: &str = r##"# Horizon server settings (docs/server.md). The s # every start: edit this file, not that one. [server] -motd = "A Minecraft Server" -port = 25565 +motd = "A Minecraft Server" # the line under the server's name in the server list +port = 25565 # the port players connect to bind = "" # listen on every address, or only on this one (server-ip) -max_players = 20 +max_players = 20 # how many players can be online at once online_mode = true # check players' Minecraft accounts (false: anyone joins under any name) whitelist = true # only whitelisted players and operators can join enforce_whitelist = false # kick online players taken off the whitelist -view_distance = 10 -simulation_distance = 10 +view_distance = 10 # chunks sent around each player +simulation_distance = 10 # chunks around each player where mobs, crops and redstone tick [world] name = "world" # the world folder seed = "" # empty: a random seed on the first start game_mode = "survival" # survival, creative, adventure or spectator difficulty = "easy" # peaceful, easy, normal or hard -hardcore = false +hardcore = false # one life: a player who dies becomes a spectator # Any other server.properties key, spelled as vanilla spells it, for example: # spawn-protection = 16 @@ -43,7 +43,7 @@ hardcore = false [engine] path = "" # the game program; empty: the one installed beside horizon-server resources = "" # the prepared game files; empty: resources/ here, else the usual places -restart_on_crash = true +restart_on_crash = true # start the game again when it crashes [backups] every = "6h" # automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off @@ -58,7 +58,7 @@ allow_public = false # true lets bind be any address; never put the pane "##; /// Each typed setting: its section, its name, and the vanilla key it writes. -const TYPED: [(&str, &str, &str); 14] = [ +pub(crate) const TYPED: [(&str, &str, &str); 14] = [ ("server", "motd", "motd"), ("server", "port", "server-port"), ("server", "bind", "server-ip"), diff --git a/crates/horizon-server/src/main.rs b/crates/horizon-server/src/main.rs index b4512530c..0526bcd55 100644 --- a/crates/horizon-server/src/main.rs +++ b/crates/horizon-server/src/main.rs @@ -10,6 +10,8 @@ mod http; mod init; mod lists; mod panel; +#[cfg(test)] +mod reference; mod service; mod settings; mod signals; diff --git a/crates/horizon-server/src/reference.rs b/crates/horizon-server/src/reference.rs new file mode 100644 index 000000000..e8297565a --- /dev/null +++ b/crates/horizon-server/src/reference.rs @@ -0,0 +1,190 @@ +//! docs/api/server-reference.md: `horizon-server`'s commands, every `server.toml` key and the control API's +//! endpoints, generated from the source, and the tests that fail when it is out of date. Update it with: +//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference`. + +use crate::{api::ROUTES, config}; +use std::fmt::Write; + +const DOC: &str = "docs/api/server-reference.md"; + +/// One `key = value # what it means` line of the template, under its `[section]`. +struct Key { + section: String, + name: String, + default: String, + what: String, +} + +/// The template's sections, each with the comment lines above it, and its keys, in order. +fn template() -> (Vec<(String, String)>, Vec) { + let (mut sections, mut keys, mut notes) = (Vec::new(), Vec::new(), Vec::<&str>::new()); + for line in config::TEMPLATE.lines().map(str::trim) { + if let Some(note) = line.strip_prefix('#') { + notes.push(note.trim()); + } else if let Some(section) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + sections.push((section.to_owned(), notes.join(" "))); + notes.clear(); + } else if let Some((name, rest)) = line.split_once(" = ") { + let (default, what) = rest.split_once(" # ").unwrap_or((rest, "")); + keys.push(Key { + section: sections.last().map(|(s, _)| s.clone()).unwrap_or_default(), + name: name.trim().to_owned(), + default: default.trim().to_owned(), + what: what.trim().to_owned(), + }); + } + } + (sections, keys) +} + +pub fn markdown() -> String { + let mut out = String::new(); + let w = &mut out; + let _ = writeln!( + w, + "" + ); + let _ = writeln!(w, "# Server reference\n"); + let _ = writeln!( + w, + "Everything `horizon-server` offers a host or a tool: its commands, every `server.toml` key and the \ + control API. How to host: [hosting.md](../hosting.md); the design: [server.md](../server.md); \ + stability and versions: [stability.md](stability.md).\n" + ); + + let _ = writeln!(w, "## Commands\n\n`horizon-server help` prints:\n"); + let _ = writeln!(w, "```text\n{}```\n", crate::HELP); + + let _ = writeln!(w, "## `server.toml`\n"); + let (sections, keys) = template(); + let _ = writeln!( + w, + "{} Unknown keys are refused. `horizon-server init` writes this file with these defaults; \ + `horizon-server config set SECTION.KEY=VALUE` changes it.\n", + sections[0].1 + ); + for (i, (section, note)) in sections.iter().enumerate() { + let _ = writeln!(w, "### `[{section}]`\n"); + if i > 0 && !note.is_empty() { + let _ = writeln!(w, "{note}\n"); + } + let rows: Vec<&Key> = keys.iter().filter(|k| &k.section == section).collect(); + if rows.is_empty() { + continue; + } + let _ = writeln!( + w, + "| Key | Default | In `server.properties` | What it does |\n| --- | --- | --- | --- |" + ); + for k in rows { + let vanilla = config::TYPED + .iter() + .find(|(s, n, _)| s == section && *n == k.name) + .map_or(String::new(), |(_, _, v)| format!("`{v}`")); + let _ = writeln!( + w, + "| `{}` | `{}` | {vanilla} | {} |", + k.name, k.default, k.what + ); + } + let _ = writeln!(w); + } + + let _ = writeln!(w, "## Control API (`/api/v1`)\n"); + let _ = writeln!( + w, + "HTTP and JSON on 127.0.0.1. A running server writes its address and a random token to \ + `.horizon/api.json`, readable only by its own user; every request sends \ + `Authorization: Bearer ` and a `Host` that is that address. Errors answer \ + `{{\"error\": \"...\"}}` with 400, 401, 403, 404, 405 or 409. The web panel uses the same \ + routes with its session.\n" + ); + let _ = writeln!( + w, + "| Method | Path | Takes | Answers | What it does |\n| --- | --- | --- | --- | --- |" + ); + for r in &ROUTES { + let takes = if r.takes.is_empty() { + "nothing" + } else { + r.takes + }; + let _ = writeln!( + w, + "| `{}` | `{}` | {takes} | {} | {} |", + r.method, r.path, r.answers, r.what + ); + } + out +} + +#[test] +fn the_server_reference_is_up_to_date() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(DOC); + let want = markdown(); + if std::env::var_os("HORIZON_WRITE_DOCS").is_some() { + std::fs::write(&path, &want).unwrap(); + } + let have = std::fs::read_to_string(&path) + .unwrap_or_default() + .replace("\r\n", "\n"); + assert!( + have == want, + "{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference" + ); +} + +/// The template, which the reference lists, holds every key `Config` reads and no other. +#[test] +fn the_reference_lists_every_setting() { + let config = toml::Value::try_from(config::Config::default()).unwrap(); + let mut listed: Vec = template() + .1 + .iter() + .map(|k| format!("{}.{}", k.section, k.name)) + .collect(); + let mut read: Vec = config + .as_table() + .unwrap() + .iter() + .filter_map(|(section, keys)| Some((section, keys.as_table()?))) + .flat_map(|(section, keys)| keys.keys().map(move |k| format!("{section}.{k}"))) + .collect(); + listed.sort(); + read.sort(); + assert_eq!(listed, read); + for key in template().1 { + assert!( + !key.what.is_empty(), + "{}.{} needs a `# what it does` comment in config::TEMPLATE", + key.section, + key.name + ); + } +} + +/// `ROUTES` holds exactly the endpoints the API's code answers. +#[test] +fn the_routes_table_matches_the_code() { + let source = include_str!("api.rs"); + let mut answered: Vec<(String, String)> = Vec::new(); + for method in ["GET", "POST"] { + let open = format!("(\"{method}\", \"/api/v1/"); + for (at, _) in source.match_indices(&open) { + let rest = &source[at + open.len()..]; + let path = &rest[..rest.find('"').unwrap()]; + if rest[path.len()..].starts_with("\")") { + answered.push((method.into(), format!("/api/v1/{path}"))); + } + } + } + let mut listed: Vec<(String, String)> = ROUTES + .iter() + .map(|r| (r.method.into(), r.path.into())) + .collect(); + answered.sort(); + listed.sort(); + assert_eq!(answered, listed); +} diff --git a/docs/api/server-reference.md b/docs/api/server-reference.md new file mode 100644 index 000000000..93fb9c7fe --- /dev/null +++ b/docs/api/server-reference.md @@ -0,0 +1,137 @@ + +# Server reference + +Everything `horizon-server` offers a host or a tool: its commands, every `server.toml` key and the control API. How to host: [hosting.md](../hosting.md); the design: [server.md](../server.md); stability and versions: [stability.md](stability.md). + +## Commands + +`horizon-server help` prints: + +```text +horizon-server: host a Horizon (Minecraft Java Edition 26.3) server + +Usage: horizon-server [--dir DIR] + + init Set up a server here: the EULA, the world, the port, who may join (asks, or takes flags: + --eula --world NAME --seed SEED --port N --whitelist/--no-whitelist --player NAME... --op NAME + --offline --yes) + run Run the server in the foreground; type console commands, Ctrl-C stops it cleanly. It + restarts the game if it crashes. + start Run the server in the background (its output goes to logs/supervisor.log) + stop, restart + status [--json] + Up or not, TPS, MSPT, players, RAM and CPU + cmd COMMAND... + Run a console command on the running server and print what it answers + logs [-f] [-n N] + The last N lines of the log (default 50); -f follows it + backup [NAME] + Zip the world and the settings into backups/ (a running server saves first and stays up) + backups [--json] + List the backups + restore BACKUP [--yes] + Put a backup's world in place (the current one is kept in backups/); a running + server restarts with it + service install|uninstall|print [--system] + Start the server at boot or login: a systemd unit on Linux (--system: for every user, + with sudo), a launchd agent on macOS, a task at logon on Windows + panel A one-time login link for the web panel ([panel] in server.toml; 127.0.0.1:8130 by + default) + update [--check [--json]] [--version TAG] [--timeout SECONDS] + Install the newest release (checked against GitHub's SHA-256), back up the world and + restart on it; goes back by itself if the game isn't up within the timeout (300 s). + --archive FILE --version NAME installs an archive you have + rollback Back to the release used before the update + config [--json] + The settings in server.toml + config set KEY=VALUE... [--json] + Change settings (for example world.difficulty=hard server.max_players=10), checked first; + a running server takes the difficulty, default game mode and whitelist at once, the rest + at its next start + import FILE Write server.toml from a vanilla server.properties + op NAME, deop NAME + whitelist on|off|list, whitelist add|remove NAME + Who may join (on the running server, or in the files when it is stopped) + + --dir DIR The server folder (default: the current folder) + +Settings live in server.toml; the server writes server.properties from it before every start. +``` + +## `server.toml` + +Horizon server settings (docs/server.md). The server writes them into server.properties before every start: edit this file, not that one. Unknown keys are refused. `horizon-server init` writes this file with these defaults; `horizon-server config set SECTION.KEY=VALUE` changes it. + +### `[server]` + +| Key | Default | In `server.properties` | What it does | +| --- | --- | --- | --- | +| `motd` | `"A Minecraft Server"` | `motd` | the line under the server's name in the server list | +| `port` | `25565` | `server-port` | the port players connect to | +| `bind` | `""` | `server-ip` | listen on every address, or only on this one (server-ip) | +| `max_players` | `20` | `max-players` | how many players can be online at once | +| `online_mode` | `true` | `online-mode` | check players' Minecraft accounts (false: anyone joins under any name) | +| `whitelist` | `true` | `white-list` | only whitelisted players and operators can join | +| `enforce_whitelist` | `false` | `enforce-whitelist` | kick online players taken off the whitelist | +| `view_distance` | `10` | `view-distance` | chunks sent around each player | +| `simulation_distance` | `10` | `simulation-distance` | chunks around each player where mobs, crops and redstone tick | + +### `[world]` + +| Key | Default | In `server.properties` | What it does | +| --- | --- | --- | --- | +| `name` | `"world"` | `level-name` | the world folder | +| `seed` | `""` | `level-seed` | empty: a random seed on the first start | +| `game_mode` | `"survival"` | `gamemode` | survival, creative, adventure or spectator | +| `difficulty` | `"easy"` | `difficulty` | peaceful, easy, normal or hard | +| `hardcore` | `false` | `hardcore` | one life: a player who dies becomes a spectator | + +### `[properties]` + +Any other server.properties key, spelled as vanilla spells it, for example: spawn-protection = 16 + +### `[engine]` + +| Key | Default | In `server.properties` | What it does | +| --- | --- | --- | --- | +| `path` | `""` | | the game program; empty: the one installed beside horizon-server | +| `resources` | `""` | | the prepared game files; empty: resources/ here, else the usual places | +| `restart_on_crash` | `true` | | start the game again when it crashes | + +### `[backups]` + +| Key | Default | In `server.properties` | What it does | +| --- | --- | --- | --- | +| `every` | `"6h"` | | automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off | +| `keep` | `12` | | automatic backups kept (backups made by hand are never deleted) | + +### `[updates]` + +| Key | Default | In `server.properties` | What it does | +| --- | --- | --- | --- | +| `keep` | `1` | | other releases kept by updates, besides the one in use and the one rollback goes to | + +### `[panel]` + +| Key | Default | In `server.properties` | What it does | +| --- | --- | --- | --- | +| `bind` | `"127.0.0.1:8130"` | | the web panel: this computer only, or a tailnet address (100.x); "" turns it off | +| `allow_public` | `false` | | true lets bind be any address; never put the panel on the open internet | + +## Control API (`/api/v1`) + +HTTP and JSON on 127.0.0.1. A running server writes its address and a random token to `.horizon/api.json`, readable only by its own user; every request sends `Authorization: Bearer ` and a `Host` that is that address. Errors answer `{"error": "..."}` with 400, 401, 403, 404, 405 or 409. The web panel uses the same routes with its session. + +| Method | Path | Takes | Answers | What it does | +| --- | --- | --- | --- | --- | +| `GET` | `/api/v1/status` | nothing | `phase`, `version`, `pid`, `uptime_seconds`, `engine_pid`, `engine_uptime_seconds`, `crashes`, `last_crash`, `sample` (TPS, MSPT, players, RAM, CPU), `update` | How the server does | +| `GET` | `/api/v1/metrics` | `?since=T` (seconds since 1970) | `{"samples": [...]}`: one a second, the last 10 minutes | The samples taken after T, for graphs | +| `GET` | `/api/v1/logs` | `?since=N` | `{"next": N, "lines": [...]}` | The log lines after the Nth (the last 2000 are kept) | +| `POST` | `/api/v1/command` | `{"line": ""}` | the lines the command logged | Run a console command on the running game | +| `POST` | `/api/v1/stop` | nothing | `{"ok": true}` | Stop the game and the supervisor | +| `POST` | `/api/v1/restart` | nothing | `{"ok": true}` | Restart the game | +| `GET` | `/api/v1/backups` | nothing | `{"backups": [{"name", "bytes"}]}` | List the backups | +| `POST` | `/api/v1/backups` | `{"name": "..."}` (optional) | `{"name": ""}` | Back up now (a running game saves first and stays up) | +| `POST` | `/api/v1/restore` | `{"name": ""}` | `{"name": ""}` | Restart the game with that backup's world (the current one is kept in backups/) | +| `POST` | `/api/v1/update` | `{"tag": "...", "timeout": seconds}` (both optional) | `{"ok": true}`; `status` says how it went (`update`) | Restart on that installed release, or download the newest first; goes back if the game isn't up within the timeout | +| `POST` | `/api/v1/panel/login` | nothing | `{"url": "..."}` | A one-time login link for the web panel (with the token only, not from the panel) |