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