Skip to main content

rust_web_server/ctl/
mod.rs

1//! `rws ctl` (18.20.0, #70): the admin API from the command line.
2//!
3//! ```text
4//! rws ctl status                         # version, uptime, listeners, disks
5//! rws ctl reload                         # waits; prints the errors and exits 1 on a bad config
6//! rws ctl drain <upstream> <backend> [--undo]
7//! rws ctl maintenance [on|off]
8//! rws ctl block <ip> | unblock <ip> | blocklist
9//! rws ctl purge <cache> [--path /p | --prefix /p/]
10//! rws ctl caches | upstreams
11//! rws ctl certs [renew]
12//! rws ctl logs [-n 100] [-f]
13//! rws ctl traffic [--week | --month | --hours N] [--host example.com]
14//! rws ctl whoami                         # the token's user and role (18.22.0)
15//! rws ctl audit [-n 50]                  # recent changes: who, what, when (18.22.0)
16//! rws ctl audit --verify [FILE]          # the audit log's hash chain; exit 1 if broken (18.33.0)
17//! ```
18//!
19//! The binary run as `rwsctl` (the packages install that symlink) acts as
20//! `rws ctl`. Where it connects: `--admin http://127.0.0.1:9901`, else
21//! `[admin] listen` of `RWS_CONFIG_FILE` / `rws.config.toml`, else
22//! `127.0.0.1:9901`. The token: `--token-file`, else the variable
23//! `[admin] token_env` names, else `RWS_ADMIN_TOKEN` — never an argument, so
24//! it can't show in `ps`. Output is for people; `--json` prints the API's
25//! JSON. Exit codes: 0 done, 1 refused or failed, 2 usage error, admin API
26//! unreachable or token refused.
27
28use std::io::{BufRead, BufReader, Read, Write};
29use std::net::TcpStream;
30use std::time::Duration;
31
32use crate::admin::{API_PREFIX, DEFAULT_TOKEN_ENV};
33use crate::entry_point::Config;
34use crate::mcp::json_rpc;
35
36/// Done.
37pub const EXIT_OK: i32 = 0;
38/// The server refused or the operation failed.
39pub const EXIT_FAILED: i32 = 1;
40/// Bad arguments, the admin API unreachable, or the token refused.
41pub const EXIT_UNUSABLE: i32 = 2;
42
43const DEFAULT_ADMIN: &str = "127.0.0.1:9901";
44
45const USAGE: &str = "\
46Usage: rws ctl [--admin <url>] [--token-file <path>] [--json] <command>
47
48Commands:
49  status                               version, uptime, listeners, disks
50  reload                               reload the configuration and wait for it
51  drain <upstream> <backend> [--undo]  stop sending new requests to a backend
52  maintenance [on|off]                 show or switch maintenance mode
53  block <ip> | unblock <ip>            change the IP blocklist
54  blocklist                            list blocked IPs
55  purge <cache> [--path P | --prefix P]  purge a cache (all of it by default)
56  caches | upstreams                   list caches / upstream backends
57  certs [renew]                        certificates and ACME state; renew orders now
58  logs [-n N] [-f]                     recent access-log lines; -f follows
59  traffic [--week|--month|--hours N] [--host H]  who visited
60  whoami                               the token's user and role
61  audit [-n N]                         recent changes: who did what, when
62  audit --verify [FILE]                check the audit log's hash chain (the server's, or a copy)
63
64The admin address comes from --admin, else [admin] listen in RWS_CONFIG_FILE or
65rws.config.toml, else 127.0.0.1:9901. The token from --token-file, else the
66variable [admin] token_env names, else RWS_ADMIN_TOKEN.
67Exit codes: 0 done, 1 refused or failed, 2 usage, unreachable or unauthorized.
68";
69
70/// What went wrong, and the exit code it means.
71#[derive(Debug)]
72struct Failure {
73    code: i32,
74    message: String,
75}
76
77fn usage(message: impl Into<String>) -> Failure {
78    Failure { code: EXIT_UNUSABLE, message: format!("{}\n\n{USAGE}", message.into()) }
79}
80
81fn failed(message: impl Into<String>) -> Failure {
82    Failure { code: EXIT_FAILED, message: message.into() }
83}
84
85fn unusable(message: impl Into<String>) -> Failure {
86    Failure { code: EXIT_UNUSABLE, message: message.into() }
87}
88
89/// Whether the program was started as `rwsctl` (its file name, any extension).
90pub fn invoked_as_rwsctl(argv0: &str) -> bool {
91    let name = argv0.rsplit(['/', '\\']).next().unwrap_or(argv0);
92    name == "rwsctl" || name.strip_suffix(".exe").is_some_and(|n| n.eq_ignore_ascii_case("rwsctl"))
93}
94
95/// The `rws ctl` arguments in `args` (a full `argv`): after `ctl`, or all
96/// of them when run as `rwsctl`. `None` for a normal server start.
97pub fn requested(args: &[String]) -> Option<&[String]> {
98    if args.first().is_some_and(|a| invoked_as_rwsctl(a)) {
99        return Some(&args[1..]);
100    }
101    (args.get(1).map(String::as_str) == Some("ctl")).then(|| &args[2..])
102}
103
104/// Run `rws ctl <args>`, writing to `out` and `err`; returns the exit code.
105pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
106    match parse(args).and_then(|cmd| execute(&cmd, out)) {
107        Ok(()) => EXIT_OK,
108        Err(f) => {
109            let _ = writeln!(err, "{}", f.message.trim_end());
110            f.code
111        }
112    }
113}
114
115/// Parsed command line.
116#[derive(Debug, Default)]
117struct Command {
118    admin: Option<String>,
119    token_file: Option<String>,
120    json: bool,
121    words: Vec<String>,
122    undo: bool,
123    verify: bool,
124    follow: bool,
125    lines: Option<usize>,
126    path: Option<String>,
127    prefix: Option<String>,
128    hours: Option<u64>,
129    host: Option<String>,
130}
131
132fn parse(args: &[String]) -> Result<Command, Failure> {
133    let mut cmd = Command::default();
134    let mut args = args.iter();
135    while let Some(arg) = args.next() {
136        let (flag, inline) = match arg.split_once('=') {
137            Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())),
138            _ => (arg.as_str(), None),
139        };
140        let mut value = |name: &str| inline.clone().or_else(|| args.next().cloned()).ok_or_else(|| usage(format!("{name} needs a value")));
141        match flag {
142            "--admin" => cmd.admin = Some(value("--admin")?),
143            "--token-file" => cmd.token_file = Some(value("--token-file")?),
144            "--json" => cmd.json = true,
145            "--undo" => cmd.undo = true,
146            "--verify" => cmd.verify = true,
147            "-f" | "--follow" => cmd.follow = true,
148            "-n" | "--lines" => cmd.lines = Some(value("-n")?.parse().map_err(|_| usage("-n takes a number"))?),
149            "--path" => cmd.path = Some(value("--path")?),
150            "--prefix" => cmd.prefix = Some(value("--prefix")?),
151            "--hours" => cmd.hours = Some(value("--hours")?.parse().map_err(|_| usage("--hours takes a number"))?),
152            "--week" => cmd.hours = Some(24 * 7),
153            "--month" => cmd.hours = Some(24 * 30),
154            "--host" => cmd.host = Some(value("--host")?),
155            "--token" => return Err(usage("the token is read from --token-file or RWS_ADMIN_TOKEN, never the command line (it would show in ps)")),
156            "-h" | "--help" | "help" if cmd.words.is_empty() => cmd.words.push("help".to_string()),
157            f if f.starts_with('-') && f.len() > 1 && f.parse::<f64>().is_err() => return Err(usage(format!("unknown option {f}"))),
158            _ => cmd.words.push(arg.clone()),
159        }
160    }
161    Ok(cmd)
162}
163
164/// Where the admin API is: `http://host:port`, no trailing slash.
165fn admin_base(cmd: &Command) -> String {
166    let configured = cmd.admin.clone().or_else(|| config_value(Config::RWS_CONFIG_ADMIN_LISTEN)).filter(|a| !a.trim().is_empty());
167    let address = configured.unwrap_or_else(|| DEFAULT_ADMIN.to_string());
168    let address = address.trim().trim_end_matches('/');
169    if address.starts_with("https://") {
170        return address.to_string(); // refused when connecting
171    }
172    let address = address.strip_prefix("http://").unwrap_or(address);
173    // A wildcard listen address is reached over loopback.
174    let address = match address.rsplit_once(':') {
175        Some(("0.0.0.0", port)) => format!("127.0.0.1:{port}"),
176        Some(("[::]", port)) => format!("[::1]:{port}"),
177        _ => address.to_string(),
178    };
179    format!("http://{address}")
180}
181
182/// A setting from the environment or the config file (`RWS_CONFIG_FILE`,
183/// else `rws.config.toml` here), without changing the environment.
184fn config_value(name: &str) -> Option<String> {
185    if let Ok(v) = std::env::var(name) {
186        if !v.trim().is_empty() {
187            return Some(v.trim().to_string());
188        }
189    }
190    let file = std::env::var("RWS_CONFIG_FILE").ok().filter(|f| !f.trim().is_empty()).unwrap_or_else(|| "rws.config.toml".to_string());
191    let text = std::fs::read(file).ok()?;
192    let settings = crate::entry_point::config_file::collect_settings(std::io::Cursor::new(&text[..]), String::new()).ok()?;
193    settings.settings.into_iter().rev().find(|(k, _)| k == name).map(|(_, v)| v.trim().to_string()).filter(|v| !v.is_empty())
194}
195
196fn token(cmd: &Command) -> Result<String, Failure> {
197    if let Some(path) = &cmd.token_file {
198        let token = std::fs::read_to_string(path).map_err(|e| unusable(format!("can't read the token file {path}: {e}")))?;
199        return Some(token.trim().to_string()).filter(|t| !t.is_empty()).ok_or_else(|| unusable(format!("the token file {path} is empty")));
200    }
201    let mut names = Vec::new();
202    if let Some(name) = config_value(Config::RWS_CONFIG_ADMIN_TOKEN_ENV) {
203        names.push(name);
204    }
205    names.push(DEFAULT_TOKEN_ENV.to_string());
206    for name in &names {
207        if let Ok(t) = std::env::var(name) {
208            if !t.trim().is_empty() {
209                return Ok(t.trim().to_string());
210            }
211        }
212    }
213    Err(unusable(format!("no admin token: set {} or pass --token-file", names.join(" or "))))
214}
215
216/// The admin API's answer.
217struct Reply {
218    status: u16,
219    body: String,
220}
221
222/// One request to the admin API (`path` under [`API_PREFIX`]).
223struct Api {
224    base: String,
225    token: String,
226}
227
228impl Api {
229    fn connect(&self, method: &str, path: &str, body: Option<&str>) -> Result<BufReader<TcpStream>, Failure> {
230        let host = self.base.trim_start_matches("http://");
231        if self.base.starts_with("https://") {
232            return Err(unusable("the admin API speaks plain HTTP: use http:// (an SSH tunnel for remote servers)"));
233        }
234        let addrs: Vec<std::net::SocketAddr> =
235            std::net::ToSocketAddrs::to_socket_addrs(host).map_err(|e| unusable(format!("admin API at {}: {e}", self.base)))?.collect();
236        let mut last = None;
237        let stream = addrs.iter().find_map(|a| match TcpStream::connect_timeout(a, Duration::from_secs(5)) {
238            Ok(s) => Some(s),
239            Err(e) => {
240                last = Some(e);
241                None
242            }
243        });
244        let mut stream = stream.ok_or_else(|| {
245            unusable(format!("can't reach the admin API at {}: {}", self.base, last.map_or("no address".to_string(), |e| e.to_string())))
246        })?;
247        let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
248        let body = body.unwrap_or("");
249        let content = if body.is_empty() { String::new() } else { format!("Content-Type: application/json\r\nContent-Length: {}\r\n", body.len()) };
250        let head = format!(
251            "{method} {API_PREFIX}{path} HTTP/1.1\r\nHost: {host}\r\nAuthorization: Bearer {}\r\nUser-Agent: rws-ctl/{}\r\nAccept: application/json, text/event-stream\r\n{content}Connection: close\r\n\r\n",
252            self.token,
253            env!("CARGO_PKG_VERSION"),
254        );
255        stream.write_all(head.as_bytes()).and_then(|_| stream.write_all(body.as_bytes())).map_err(|e| unusable(format!("admin API at {}: {e}", self.base)))?;
256        Ok(BufReader::new(stream))
257    }
258
259    /// Send, read the whole answer; 401/403 are [`EXIT_UNUSABLE`].
260    fn call(&self, method: &str, path: &str, body: Option<&str>) -> Result<Reply, Failure> {
261        let mut reader = self.connect(method, path, body)?;
262        let (status, chunked) = read_head(&mut reader).map_err(|e| unusable(format!("admin API at {}: {e}", self.base)))?;
263        let mut bytes = Vec::new();
264        if chunked {
265            Dechunk::new(reader).read_to_end(&mut bytes)
266        } else {
267            reader.read_to_end(&mut bytes)
268        }
269        .map_err(|e| unusable(format!("admin API at {}: {e}", self.base)))?;
270        let reply = Reply { status, body: String::from_utf8_lossy(&bytes).into_owned() };
271        match status {
272            401 | 403 => Err(unusable(format!("the admin API refused the token ({status})"))),
273            200..=299 => Ok(reply),
274            _ => Err(failed(api_error(&reply))),
275        }
276    }
277}
278
279/// `{"error":"…"}` of a refusal, else the status and body.
280fn api_error(reply: &Reply) -> String {
281    json_rpc::extract_str(&reply.body, "error").unwrap_or_else(|| format!("admin API answered {}: {}", reply.status, reply.body.trim()))
282}
283
284/// The status line and headers; whether the body is chunked.
285fn read_head(reader: &mut impl BufRead) -> std::io::Result<(u16, bool)> {
286    let mut line = String::new();
287    reader.read_line(&mut line)?;
288    let status = line.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| std::io::Error::other(format!("not an HTTP response: {:?}", line.trim())))?;
289    let mut chunked = false;
290    loop {
291        line.clear();
292        if reader.read_line(&mut line)? == 0 || line.trim().is_empty() {
293            break;
294        }
295        if let Some((name, value)) = line.split_once(':') {
296            if name.trim().eq_ignore_ascii_case("transfer-encoding") && value.to_ascii_lowercase().contains("chunked") {
297                chunked = true;
298            }
299        }
300    }
301    Ok((status, chunked))
302}
303
304/// A chunked body as plain bytes.
305struct Dechunk<R> {
306    inner: R,
307    left: usize,
308    done: bool,
309}
310
311impl<R: BufRead> Dechunk<R> {
312    fn new(inner: R) -> Self {
313        Dechunk { inner, left: 0, done: false }
314    }
315}
316
317impl<R: BufRead> Read for Dechunk<R> {
318    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
319        while self.left == 0 {
320            if self.done {
321                return Ok(0);
322            }
323            let mut line = String::new();
324            if self.inner.read_line(&mut line)? == 0 {
325                self.done = true;
326                return Ok(0);
327            }
328            let line = line.trim();
329            if line.is_empty() {
330                continue; // the CRLF after a chunk
331            }
332            let size = line.split(';').next().unwrap_or("");
333            self.left = usize::from_str_radix(size.trim(), 16).map_err(|_| std::io::Error::other("bad chunk size"))?;
334            if self.left == 0 {
335                self.done = true;
336                return Ok(0);
337            }
338        }
339        let want = buf.len().min(self.left);
340        let n = self.inner.read(&mut buf[..want])?;
341        if n == 0 {
342            self.done = true;
343        }
344        self.left -= n;
345        Ok(n)
346    }
347}
348
349// ── JSON helpers (the API's JSON is rws's own, so small helpers do) ──────────
350
351fn members(json: &str) -> Vec<(String, String)> {
352    json_rpc::object_members(json).unwrap_or_default()
353}
354
355fn member(json: &str, key: &str) -> String {
356    members(json).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).unwrap_or_default()
357}
358
359/// A raw JSON value as text: strings decoded, others as written.
360fn text(raw: &str) -> String {
361    let raw = raw.trim();
362    if raw.starts_with('"') {
363        json_rpc::extract_str(&format!(r#"{{"v":{raw}}}"#), "v").unwrap_or_default()
364    } else {
365        raw.to_string()
366    }
367}
368
369fn field(json: &str, key: &str) -> String {
370    text(&member(json, key))
371}
372
373fn elements(json: &str) -> Vec<String> {
374    json_rpc::split_array_elements(json.trim())
375}
376
377fn quoted(s: &str) -> String {
378    format!("\"{}\"", crate::core::json::escape(s))
379}
380
381fn percent_encode(s: &str) -> String {
382    let mut out = String::new();
383    for b in s.bytes() {
384        if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
385            out.push(b as char);
386        } else {
387            out.push_str(&format!("%{b:02X}"));
388        }
389    }
390    out
391}
392
393/// Rows as aligned columns.
394fn table(out: &mut dyn Write, header: &[&str], rows: &[Vec<String>]) -> std::io::Result<()> {
395    let mut widths: Vec<usize> = header.iter().map(|h| h.len()).collect();
396    for row in rows {
397        for (i, cell) in row.iter().enumerate() {
398            widths[i] = widths[i].max(cell.chars().count());
399        }
400    }
401    let line = |cells: Vec<String>| {
402        let last = cells.len() - 1;
403        cells.iter().enumerate().map(|(i, c)| if i == last { c.clone() } else { format!("{c:<w$}", w = widths[i]) }).collect::<Vec<_>>().join("  ")
404    };
405    writeln!(out, "{}", line(header.iter().map(|h| h.to_string()).collect()))?;
406    for row in rows {
407        writeln!(out, "{}", line(row.clone()))?;
408    }
409    Ok(())
410}
411
412fn duration(secs: u64) -> String {
413    match secs {
414        s if s < 60 => format!("{s}s"),
415        s if s < 3600 => format!("{}m {}s", s / 60, s % 60),
416        s if s < 86_400 => format!("{}h {}m", s / 3600, s % 3600 / 60),
417        s => format!("{}d {}h", s / 86_400, s % 86_400 / 3600),
418    }
419}
420
421fn bytes(n: u64) -> String {
422    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
423    let mut v = n as f64;
424    let mut unit = 0;
425    while v >= 1024.0 && unit < UNITS.len() - 1 {
426        v /= 1024.0;
427        unit += 1;
428    }
429    if unit == 0 { format!("{n} B") } else { format!("{v:.1} {}", UNITS[unit]) }
430}
431
432// ── Commands ─────────────────────────────────────────────────────────────────
433
434fn execute(cmd: &Command, out: &mut dyn Write) -> Result<(), Failure> {
435    let words: Vec<&str> = cmd.words.iter().map(String::as_str).collect();
436    if matches!(words.as_slice(), [] | ["help"]) {
437        let _ = write!(out, "{USAGE}");
438        return if words.is_empty() { Err(Failure { code: EXIT_UNUSABLE, message: String::new() }) } else { Ok(()) };
439    }
440    let io = |e: std::io::Error| failed(e.to_string());
441    // A copy of the audit log on this machine: checked here, no API.
442    if let (["audit", file], true) = (words.as_slice(), cmd.verify) {
443        let text = std::fs::read(file).map_err(|e| failed(format!("can't read {file}: {e}")))?;
444        let report = crate::admin::access::verify_chain(&String::from_utf8_lossy(&text));
445        if cmd.json {
446            writeln!(out, "{}", report.to_json()).map_err(io)?;
447        }
448        return audit_verdict(out, cmd.json, file, report.ok(), &report.to_json()).map_err(io)?;
449    }
450    let api = Api { base: admin_base(cmd), token: token(cmd)? };
451    let json_out = |out: &mut dyn Write, reply: &Reply| writeln!(out, "{}", reply.body.trim()).map_err(io);
452    match words.as_slice() {
453        ["status"] => {
454            let r = api.call("GET", "/status", None)?;
455            if cmd.json {
456                return json_out(out, &r);
457            }
458            print_status(out, &r.body).map_err(io)
459        }
460        ["reload"] => {
461            let r = api.call("POST", "/reload", None)?;
462            if cmd.json {
463                json_out(out, &r)?;
464            }
465            let errors: Vec<String> = elements(&member(&r.body, "errors")).iter().map(|e| text(e)).collect();
466            if member(&r.body, "applied") == "true" {
467                if !cmd.json {
468                    writeln!(out, "reloaded").map_err(io)?;
469                }
470                Ok(())
471            } else {
472                Err(failed(format!("reload refused, the old configuration stays:\n  {}", errors.join("\n  "))))
473            }
474        }
475        ["drain", upstream, backend] => {
476            let body = format!(r#"{{"backend":{},"drained":{}}}"#, quoted(backend), !cmd.undo);
477            let r = api.call("PUT", &format!("/upstreams/{}/drain", percent_encode(upstream)), Some(&body))?;
478            if cmd.json {
479                return json_out(out, &r);
480            }
481            writeln!(out, "{} {backend} of {upstream}", if cmd.undo { "undrained" } else { "drained" }).map_err(io)
482        }
483        ["maintenance"] => {
484            let r = api.call("GET", "/maintenance", None)?;
485            if cmd.json {
486                return json_out(out, &r);
487            }
488            writeln!(out, "maintenance {}", if member(&r.body, "active") == "true" { "on" } else { "off" }).map_err(io)
489        }
490        ["maintenance", state @ ("on" | "off")] => {
491            let r = api.call("PUT", "/maintenance", Some(&format!(r#"{{"active":{}}}"#, *state == "on")))?;
492            if cmd.json {
493                return json_out(out, &r);
494            }
495            writeln!(out, "maintenance {}", if member(&r.body, "active") == "true" { "on" } else { "off" }).map_err(io)
496        }
497        ["block", ip] => {
498            let r = api.call("POST", "/blocklist", Some(&format!(r#"{{"ip":{}}}"#, quoted(ip))))?;
499            if cmd.json {
500                return json_out(out, &r);
501            }
502            writeln!(out, "blocked {}", field(&r.body, "blocked")).map_err(io)
503        }
504        ["unblock", ip] => {
505            let r = api.call("DELETE", &format!("/blocklist/{}", percent_encode(ip)), None)?;
506            if cmd.json {
507                return json_out(out, &r);
508            }
509            writeln!(out, "unblocked {}", field(&r.body, "unblocked")).map_err(io)
510        }
511        ["blocklist"] => {
512            let r = api.call("GET", "/blocklist", None)?;
513            if cmd.json {
514                return json_out(out, &r);
515            }
516            let ips = elements(&r.body);
517            if ips.is_empty() {
518                return writeln!(out, "no blocked IPs").map_err(io);
519            }
520            ips.iter().try_for_each(|ip| writeln!(out, "{}", text(ip))).map_err(io)
521        }
522        ["purge", cache] => {
523            let mut path = format!("/caches/{}", percent_encode(cache));
524            if let Some(p) = &cmd.path {
525                path.push_str(&format!("?path={}", percent_encode(p)));
526            } else if let Some(p) = &cmd.prefix {
527                path.push_str(&format!("?prefix={}", percent_encode(p)));
528            }
529            let r = api.call("DELETE", &path, None)?;
530            if cmd.json {
531                return json_out(out, &r);
532            }
533            writeln!(out, "purged {} entries from {cache}", field(&r.body, "purged")).map_err(io)
534        }
535        ["caches"] => {
536            let r = api.call("GET", "/caches", None)?;
537            if cmd.json {
538                return json_out(out, &r);
539            }
540            let rows: Vec<Vec<String>> = elements(&r.body)
541                .iter()
542                .map(|c| {
543                    let size = field(c, "bytes").parse::<u64>().map(bytes).unwrap_or_else(|_| "-".to_string());
544                    vec![field(c, "name"), field(c, "kind"), field(c, "entries"), size, field(c, "hits"), field(c, "misses")]
545                })
546                .collect();
547            if rows.is_empty() {
548                return writeln!(out, "no caches").map_err(io);
549            }
550            table(out, &["NAME", "KIND", "ENTRIES", "SIZE", "HITS", "MISSES"], &rows).map_err(io)
551        }
552        ["upstreams"] => {
553            let r = api.call("GET", "/upstreams", None)?;
554            if cmd.json {
555                return json_out(out, &r);
556            }
557            let mut rows = Vec::new();
558            for u in elements(&r.body) {
559                for b in elements(&member(&u, "backends")) {
560                    let breaker = field(&b, "breaker");
561                    rows.push(vec![
562                        field(&u, "name"),
563                        field(&b, "url"),
564                        field(&b, "weight"),
565                        if field(&b, "healthy") == "true" { "yes" } else { "no" }.to_string(),
566                        if breaker == "null" { "-".to_string() } else { breaker },
567                        if field(&b, "drained") == "true" { "yes" } else { "no" }.to_string(),
568                    ]);
569                }
570            }
571            if rows.is_empty() {
572                return writeln!(out, "no upstreams").map_err(io);
573            }
574            table(out, &["UPSTREAM", "BACKEND", "WEIGHT", "HEALTHY", "BREAKER", "DRAINED"], &rows).map_err(io)
575        }
576        ["certs"] => {
577            let r = api.call("GET", "/certificates", None)?;
578            if cmd.json {
579                return json_out(out, &r);
580            }
581            print_certificates(out, &r.body).map_err(io)
582        }
583        ["certs", "renew"] => {
584            let r = api.call("POST", "/certificates/renew", None)?;
585            if cmd.json {
586                return json_out(out, &r);
587            }
588            writeln!(out, "renewal requested; `rws ctl certs` shows the outcome").map_err(io)
589        }
590        ["logs"] if cmd.follow => follow_logs(&api, out),
591        ["logs"] => {
592            let r = api.call("GET", &format!("/logs?n={}", cmd.lines.unwrap_or(100)), None)?;
593            if cmd.json {
594                return json_out(out, &r);
595            }
596            elements(&r.body).iter().try_for_each(|l| writeln!(out, "{}", text(l))).map_err(io)
597        }
598        ["traffic"] => {
599            let mut path = format!("/traffic?hours={}", cmd.hours.unwrap_or(24));
600            if let Some(h) = &cmd.host {
601                path.push_str(&format!("&host={}", percent_encode(h)));
602            }
603            let r = api.call("GET", &path, None)?;
604            if cmd.json {
605                return json_out(out, &r);
606            }
607            print_traffic(out, &r.body).map_err(io)
608        }
609        ["whoami"] => {
610            let r = api.call("GET", "/whoami", None)?;
611            if cmd.json {
612                return json_out(out, &r);
613            }
614            writeln!(out, "{} ({}, via {})", field(&r.body, "user"), field(&r.body, "role"), field(&r.body, "via")).map_err(io)
615        }
616        ["audit"] if cmd.verify => {
617            let r = api.call("GET", "/audit/verify", None)?;
618            if cmd.json {
619                json_out(out, &r)?;
620            }
621            audit_verdict(out, cmd.json, &field(&r.body, "file"), field(&r.body, "ok") == "true", &r.body).map_err(io)?
622        }
623        ["audit"] => {
624            let r = api.call("GET", &format!("/audit?n={}", cmd.lines.unwrap_or(50)), None)?;
625            if cmd.json {
626                return json_out(out, &r);
627            }
628            let rows: Vec<Vec<String>> = elements(&r.body)
629                .iter()
630                .map(|e| vec![field(e, "time"), format!("{} ({})", field(e, "user"), field(e, "role")), format!("{} {}", field(e, "method"), field(e, "path").trim_start_matches(API_PREFIX)), field(e, "status"), field(e, "address")])
631                .collect();
632            if rows.is_empty() {
633                return writeln!(out, "no changes recorded").map_err(io);
634            }
635            table(out, &["TIME", "USER", "CHANGE", "STATUS", "FROM"], &rows).map_err(io)
636        }
637        _ => Err(usage(format!("unknown command: {}", words.join(" ")))),
638    }
639}
640
641/// What `audit --verify` found, from a [`crate::admin::access::ChainReport`]
642/// as JSON: a line for people (unless `--json`), and exit 1 when broken.
643fn audit_verdict(out: &mut dyn Write, json: bool, file: &str, ok: bool, report: &str) -> std::io::Result<Result<(), Failure>> {
644    let n = |key: &str| field(report, key);
645    if !json {
646        if ok {
647            let before = match n("unchained").as_str() {
648                "0" => String::new(),
649                k => format!(", {k} from before the chain"),
650            };
651            let continues = if n("continues_from").is_empty() || n("continues_from") == "null" { "" } else { "; continues an earlier file" };
652            writeln!(out, "{file}: intact — {} entries, {} chained{before}{continues}", n("entries"), n("chained"))?;
653        } else {
654            let broken = member(report, "broken");
655            writeln!(out, "{file}: BROKEN at line {}: {}", field(&broken, "line"), field(&broken, "reason"))?;
656        }
657    }
658    Ok(if ok { Ok(()) } else { Err(failed("the audit log's hash chain is broken")) })
659}
660
661fn print_status(out: &mut dyn Write, body: &str) -> std::io::Result<()> {
662    let uptime = field(body, "uptime_secs").parse().map(duration).unwrap_or_default();
663    let ready = if field(body, "ready") == "true" { "ready" } else { "not ready" };
664    writeln!(out, "rws {}  pid {}  up {uptime}  {ready}", field(body, "version"), field(body, "pid"))?;
665    let listeners = member(body, "listeners");
666    let main = member(&listeners, "main");
667    writeln!(out, "main      {}{}", field(&main, "address"), if field(&main, "tls") == "true" { " (TLS)" } else { "" })?;
668    let redirect = field(&listeners, "redirect");
669    if redirect != "null" && !redirect.is_empty() {
670        writeln!(out, "redirect  :{redirect}")?;
671    }
672    writeln!(out, "admin     {}", field(&listeners, "admin"))?;
673    let features: Vec<String> = elements(&member(body, "features")).iter().map(|f| text(f)).collect();
674    writeln!(out, "features  {}", features.join(" "))?;
675    let cluster = member(body, "cluster");
676    if !cluster.is_empty() && cluster.trim() != "null" {
677        let nodes: Vec<String> = elements(&member(&cluster, "nodes")).iter().map(|n| text(n)).collect();
678        let pending = field(&cluster, "pending");
679        let pending = if pending == "0" || pending.is_empty() { String::new() } else { format!(", {pending} change(s) waiting") };
680        writeln!(out, "cluster   node {} · Redis {} · {} node(s): {}{pending}", field(&cluster, "node"), field(&cluster, "redis"), nodes.len(), nodes.join(", "))?;
681    }
682    let fleet = member(body, "fleet");
683    if !fleet.is_empty() && fleet.trim() != "null" {
684        let config = field(&fleet, "config");
685        let error = field(&fleet, "error");
686        let state = if error != "null" && !error.is_empty() { format!("refused: {error}") } else { format!("config {}", config.get(..12).unwrap_or(&config)) };
687        writeln!(out, "fleet     {} · {state}", field(&fleet, "source"))?;
688    }
689    for d in elements(&member(body, "disk")) {
690        let free = field(&d, "free_bytes").parse().map(bytes).unwrap_or_default();
691        let total = field(&d, "total_bytes").parse().map(bytes).unwrap_or_default();
692        let low = if field(&d, "low") == "true" { "  LOW" } else { "" };
693        writeln!(out, "disk      {}  {free} free of {total}{low}", field(&d, "dir"))?;
694    }
695    Ok(())
696}
697
698fn print_certificates(out: &mut dyn Write, body: &str) -> std::io::Result<()> {
699    let rows: Vec<Vec<String>> = elements(&member(body, "certificates"))
700        .iter()
701        .map(|c| vec![field(c, "name"), field(c, "expires"), field(c, "days_left"), field(c, "source")])
702        .collect();
703    if rows.is_empty() {
704        writeln!(out, "no certificates")?;
705    } else {
706        table(out, &["NAME", "EXPIRES", "DAYS LEFT", "SOURCE"], &rows)?;
707    }
708    let acme = member(body, "acme");
709    if field(&acme, "running") == "true" {
710        let last = member(&acme, "last_check");
711        if last.trim() == "null" || last.is_empty() {
712            writeln!(out, "ACME renewal: running, not checked yet")?;
713        } else {
714            writeln!(out, "ACME renewal: running, last check: {}", field(&last, "outcome"))?;
715        }
716    } else {
717        writeln!(out, "ACME renewal: off")?;
718    }
719    Ok(())
720}
721
722fn print_traffic(out: &mut dyn Write, body: &str) -> std::io::Result<()> {
723    let hours = field(body, "hours");
724    let host = field(body, "host");
725    let scope = if host == "null" || host.is_empty() { String::new() } else { format!(" for {host}") };
726    writeln!(out, "last {hours} h{scope}")?;
727    let totals = member(body, "totals");
728    let rows: Vec<Vec<String>> = [("people", "people"), ("ai", "AI agents"), ("bots", "bots")]
729        .iter()
730        .map(|(key, label)| {
731            let g = member(&totals, key);
732            vec![label.to_string(), field(&g, "requests"), field(&g, "pages"), field(&g, "visitors")]
733        })
734        .collect();
735    table(out, &["", "REQUESTS", "PAGES", "VISITORS"], &rows)?;
736    let pages = elements(&member(body, "people_pages"));
737    if !pages.is_empty() {
738        writeln!(out, "\ntop pages")?;
739        for pair in pages.iter().take(10) {
740            let pair = elements(pair);
741            if let [page, n] = pair.as_slice() {
742                writeln!(out, "  {:>8}  {}", n.trim(), text(page))?;
743            }
744        }
745    }
746    Ok(())
747}
748
749/// `logs -f`: the SSE stream's `data:` lines until the server goes away.
750fn follow_logs(api: &Api, out: &mut dyn Write) -> Result<(), Failure> {
751    let mut reader = api.connect("GET", "/logs/stream", None)?;
752    let (status, chunked) = read_head(&mut reader).map_err(|e| unusable(e.to_string()))?;
753    if status == 401 || status == 403 {
754        return Err(unusable(format!("the admin API refused the token ({status})")));
755    }
756    // Keep-alive comments arrive every 15 s; wait for them, not 30 s.
757    let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(60)));
758    let body: Box<dyn BufRead> = if chunked { Box::new(BufReader::new(Dechunk::new(reader))) } else { Box::new(reader) };
759    if status != 200 {
760        let mut text = String::new();
761        let mut body = body;
762        let _ = body.read_to_string(&mut text);
763        return Err(failed(api_error(&Reply { status, body: text })));
764    }
765    for line in body.lines() {
766        let line = line.map_err(|e| failed(format!("log stream ended: {e}")))?;
767        // Pings are events with empty data; comments start with ':'.
768        if let Some(data) = line.strip_prefix("data:") {
769            let data = data.strip_prefix(' ').unwrap_or(data);
770            if !data.is_empty() {
771                writeln!(out, "{data}").map_err(|e| failed(e.to_string()))?;
772                let _ = out.flush();
773            }
774        }
775    }
776    Ok(())
777}
778
779#[cfg(test)]
780mod tests;