diff --git a/README.md b/README.md index 541bcc5f..303f4aa1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,59 @@ Use `thirdshift help` for the command reference, or read [Run behavior](docs/gui The operating system schedules Architect, Pickup and Security runs. See [scheduling](docs/guide.md#on-a-schedule), [Weeding](docs/guide.md#weeding) and [Fencing](docs/guide.md#fencing). +## Console on a server + +Set the machine's address, fixed localhost port and sign-in allowlist in `~/.thirdshift/config.toml`: + +```toml +[console] +address = "https://console.example.com" # a DNS name, with or without https://; no port or path +port = 8711 +allowed_emails = ["you@example.com"] +``` + +Point that DNS name at the machine, then run these commands as the owner: + +```sh +thirdshift console password --email you@example.com +thirdshift console install > install-console.sh +# Read the script, then install it: +sudo sh install-console.sh +``` + +`console install` only prints a script. On Linux it prefers a running Caddy or Apache service; otherwise it checks for `caddy`, `apache2ctl` or `httpd` on PATH. The script writes `thirdshift-console.service`, running the installed binary as you with your HOME and automatic restarts. Only the script needs root. It enables the service, then restarts the proxy. + +For Caddy it writes `/etc/caddy/sites/thirdshift-console.caddy`, imports the sites folder from `/etc/caddy/Caddyfile` if needed, and disables proxy buffering with `flush_interval -1`. Caddy handles HTTPS. It **restarts**, rather than reloads, Caddy to preserve HTTP/3 on Android. For Apache it writes the port-80 site under `/etc/apache2/sites-available` or `/etc/httpd/conf.d`, preserves Host, forwards the request protocol and disables gzip on the Console's event streams. Install certbot with its Apache plugin first; the script runs `certbot --apache -d `. Debian's module/site helpers are included. On an `httpd` machine, enable `proxy`, `proxy_http`, `headers`, `ssl` and `setenvif` and permit proxy connections in the host's SELinux policy first. + +With neither proxy, the script installs a localhost-only service with `--no-sign-in` and prints an SSH tunnel command. No address or accounts are needed for that mode. Run the tunnel on your computer and open the printed `http://localhost:` address. On other operating systems, run `thirdshift console --no-sign-in` directly and use the same tunnel; only the Linux installer assumes systemd. + +### nginx + +For nginx, use the generated service but remove `--no-sign-in` from its `ExecStart` before installing it. Configure the allowlist and password above. Obtain an HTTPS certificate for your DNS name, then add a site like this, adjusting the name, certificate paths and port: + +```nginx +server { + listen 443 ssl; + server_name console.example.com; + ssl_certificate /etc/letsencrypt/live/console.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/console.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8711; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + gzip off; + proxy_read_timeout 1h; + } +} +``` + +Run `sudo nginx -t`, then `sudo systemctl restart nginx` on a systemd host. The Console still binds only to 127.0.0.1; nginx terminates HTTPS and passes live updates without buffering. + ## Documentation - [Configuration](docs/guide.md#user-config), [Harness, Model and Effort](docs/guide.md#harness-model-and-effort), and [email notifications](docs/guide.md#email). diff --git a/console/README.md b/console/README.md index c0421ac9..545bc526 100644 --- a/console/README.md +++ b/console/README.md @@ -21,12 +21,16 @@ stdout. To require sign-in, set the allowlist in `~/.thirdshift/config.toml`: ```toml [console] +address = "https://console.example.com" # used by console install port = 8711 allowed_emails = ["you@example.com"] other_consoles = ["https://console.example.test"] theme = "focus-auto" # also focus-light or focus-dark ``` +For a persistent service behind Caddy, Apache or nginx, see the root README's +[server installation instructions](../README.md#console-on-a-server). + Run `thirdshift console password --email you@example.com` in a terminal to set the password, then `thirdshift console`. Password entry is hidden and asks twice. With exactly one allowed address, `--email` can be omitted. An empty diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 7ae9c1d7..86c3cfcd 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -27,6 +27,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Console account, Console log, sign-in | `src/console/accounts.rs` — durable accounts, sessions, slowdown and account events; `src/console/auth.rs` — HTTP authorization and CSRF; `src/console/account_commands.rs` — CLI; `src/terminal.rs` — shared hidden terminal input | `src/console/accounts/tests.rs`, `tests/console/accounts.rs` | +| Console installation, proxy site, system service | `src/console/install.rs` — printed Linux installation script; `src/config/console.rs` — address and port | `tests/console_install.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | diff --git a/src/args.rs b/src/args.rs index bcb15181..1f2f772a 100644 --- a/src/args.rs +++ b/src/args.rs @@ -25,6 +25,7 @@ pub enum Command { Console { no_sign_in: bool, }, + ConsoleInstall, ConsolePassword(Option), ConsoleSignOut { email: Option, @@ -282,6 +283,7 @@ pub fn parse(args: &[String]) -> Result { return match &args[1..] { [] => Ok(Command::Console { no_sign_in: false }), [flag] if flag == "--no-sign-in" => Ok(Command::Console { no_sign_in: true }), + [action] if action == "install" => Ok(Command::ConsoleInstall), [action] if action == "password" => Ok(Command::ConsolePassword(None)), [action, flag, email] if action == "password" && flag == "--email" && !email.starts_with('-') => @@ -293,7 +295,7 @@ pub fn parse(args: &[String]) -> Result { Ok(Command::ConsoleLog { json: true }) } _ => bail!( - "usage: thirdshift console [--no-sign-in] | password [--email
] | sign-out [--email
] [--json] | log [--json]" + "usage: thirdshift console [--no-sign-in] | install | password [--email
] | sign-out [--email
] [--json] | log [--json]" ), }; } diff --git a/src/config.rs b/src/config.rs index d4818803..b2843dcc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -790,6 +790,7 @@ days = 7 # days to keep ended problems and Resolved; default 7 silent_minutes = 10 # minutes without growth in either log before a Run is Silent; default 10 [console] +address = "" # HTTPS address for console install, e.g. https://console.example.com; default blank port = 8711 # localhost port for the Console; 0 chooses a free port; default 8711 allowed_emails = [] # addresses that may sign in; empty requires --no-sign-in; default [] other_consoles = [] # http or https addresses linked in the header, never fetched; default [] @@ -973,6 +974,7 @@ mod tests { "logs.waiting_line_minutes", "inbox.days", "inbox.silent_minutes", + "console.address", "console.port", "console.allowed_emails", "console.other_consoles", diff --git a/src/config/console.rs b/src/config/console.rs index 7269c2f8..85aea889 100644 --- a/src/config/console.rs +++ b/src/config/console.rs @@ -5,6 +5,7 @@ use serde::Deserialize; #[derive(Debug, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ConsoleSettings { + pub address: String, pub port: u16, pub allowed_emails: Vec, pub other_consoles: Vec, @@ -14,6 +15,7 @@ pub struct ConsoleSettings { impl Default for ConsoleSettings { fn default() -> Self { Self { + address: String::new(), port: 8711, allowed_emails: Vec::new(), other_consoles: Vec::new(), @@ -23,7 +25,34 @@ impl Default for ConsoleSettings { } impl ConsoleSettings { + /// The HTTPS site's DNS name, accepted with or without `https://`. + pub fn hostname(&self) -> Result<&str> { + let host = self + .address + .strip_prefix("https://") + .unwrap_or(&self.address); + let host = host.strip_suffix('/').unwrap_or(host); + ensure!( + !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }), + "console.address must be an HTTPS DNS name, such as https://console.example.com, without a port or path" + ); + Ok(host) + } + pub(super) fn validate(&self) -> Result<()> { + if !self.address.is_empty() { + self.hostname()?; + } for email in &self.allowed_emails { ensure!( email.contains('@') && !email.chars().any(char::is_whitespace), diff --git a/src/console.rs b/src/console.rs index 93b8f6d6..85dbe1ee 100644 --- a/src/console.rs +++ b/src/console.rs @@ -4,6 +4,8 @@ mod accounts; mod auth; mod conversation; pub use account_commands::{password, print_log, sign_out}; +mod install; +pub use install::install; mod live; mod logs; mod peek; diff --git a/src/console/install.rs b/src/console/install.rs new file mode 100644 index 00000000..91c0ffd7 --- /dev/null +++ b/src/console/install.rs @@ -0,0 +1,218 @@ +//! Only this command knows about systemd and the machine's proxy layout. +//! It reads local settings and prints a script; it never installs anything. +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; + +use crate::config::{self, UserConfig}; + +enum Proxy { + Caddy, + Apache(ApacheLayout), +} + +enum ApacheLayout { + Debian, + RedHat, +} + +impl Proxy { + fn detect() -> Option { + // Prefer a running server when both packages are installed. On a + // machine without a running systemd manager, fall back to PATH. + const CANDIDATES: [(&str, &str, Proxy); 3] = [ + ("caddy", "caddy", Proxy::Caddy), + ("apache2", "apache2ctl", Proxy::Apache(ApacheLayout::Debian)), + ("httpd", "httpd", Proxy::Apache(ApacheLayout::RedHat)), + ]; + for (service, _, proxy) in CANDIDATES { + if Command::new("systemctl") + .args(["is-active", "--quiet", service]) + .output() + .is_ok_and(|output| output.status.success()) + { + return Some(proxy); + } + } + CANDIDATES + .into_iter() + .find(|(_, executable, _)| available(executable)) + .map(|(_, _, proxy)| proxy) + } +} + +pub fn install() -> Result { + ensure!( + cfg!(target_os = "linux"), + "console install currently supports Linux; run thirdshift console manually on this platform" + ); + ensure!( + identity("-u")? != "0", + "run thirdshift console install as the owner, without sudo; only the printed script needs sudo" + ); + let owner = identity("-un")?; + ensure!( + owner + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)), + "unsupported owner name" + ); + let config = UserConfig::load()?; + ensure!( + config.console.port != 0, + "console install needs a fixed, nonzero console.port" + ); + let home = config::home()?; + ensure!(home.is_absolute(), "HOME must be an absolute path"); + let binary = std::env::current_exe().context("can't locate thirdshift")?; + let proxy = Proxy::detect(); + let mode = if proxy.is_none() { " --no-sign-in" } else { "" }; + let environment = unit_word(&format!("HOME={}", path_text(&home)?))?; + let executable = unit_word(path_text(&binary)?)?; + let (site, finish) = if let Some(proxy) = proxy { + let host = config.console.hostname()?; + ensure!( + !config.console.allowed_emails.is_empty(), + "set console.allowed_emails before installing a public Console" + ); + match proxy { + Proxy::Caddy => ( + caddy(host, config.console.port), + "systemctl restart caddy\n".into(), + ), + Proxy::Apache(layout) => apache(host, config.console.port, layout), + } + } else { + ( + String::new(), + format!( + "# No Caddy or Apache found. From your own computer, replace SERVER with this machine's SSH host:\n# ssh -N -L {port}:127.0.0.1:{port} {owner}@SERVER\n# Then open http://localhost:{port} (the service is localhost-only, without sign-in).\n", + port = config.console.port + ), + ) + }; + Ok(format!( + r#"#!/bin/sh +# Save this script, then run: sudo sh install-console.sh +set -eu + +cat > /etc/systemd/system/thirdshift-console.service <<'THIRDSHIFT_UNIT' +[Unit] +Description=thirdshift Console +After=network.target + +[Service] +User={owner} +Environment={environment} +ExecStart={executable} console{mode} +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +THIRDSHIFT_UNIT + +systemctl daemon-reload +systemctl enable thirdshift-console.service +systemctl restart thirdshift-console.service + +{site}{finish}"# + )) +} + +fn caddy(host: &str, port: u16) -> String { + format!( + r#"mkdir -p /etc/caddy/sites +cat > /etc/caddy/sites/thirdshift-console.caddy <<'THIRDSHIFT_SITE' +{host} {{ + reverse_proxy 127.0.0.1:{port} {{ + flush_interval -1 + }} +}} +THIRDSHIFT_SITE +# Include the sites folder once, preserving existing sites. +if ! grep -Eq '^[[:space:]]*import[[:space:]]+(/etc/caddy/)?sites/\*(\.caddy)?[[:space:]]*(#.*)?$' /etc/caddy/Caddyfile; then + printf '\nimport /etc/caddy/sites/*.caddy\n' >> /etc/caddy/Caddyfile +fi +caddy validate --config /etc/caddy/Caddyfile +"# + ) +} + +fn apache(host: &str, port: u16, layout: ApacheLayout) -> (String, String) { + let (directory, enable, check, service) = match layout { + ApacheLayout::RedHat => ("/etc/httpd/conf.d", "", "httpd -t", "httpd"), + ApacheLayout::Debian => ( + "/etc/apache2/sites-available", + "a2enmod proxy proxy_http headers ssl setenvif\na2ensite thirdshift-console.conf\n", + "apache2ctl configtest", + "apache2", + ), + }; + let site = format!( + r#"mkdir -p {directory} +cat > {directory}/thirdshift-console.conf <<'THIRDSHIFT_SITE' + + ServerName {host} + ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto "expr=%{{REQUEST_SCHEME}}" + ProxyPass / http://127.0.0.1:{port}/ flushpackets=on + ProxyPassReverse / http://127.0.0.1:{port}/ + SetEnvIf Request_URI "^/(events$|logs/[^/]+/stream/)" no-gzip=1 + +THIRDSHIFT_SITE +{enable}{check} +certbot --apache -d {host} +"# + ); + (site, format!("systemctl restart {service}\n")) +} + +fn available(name: &str) -> bool { + std::env::var_os("PATH").is_some_and(|paths| { + std::env::split_paths(&paths).any(|directory| { + let path = directory.join(name); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata().is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + } + #[cfg(not(unix))] + path.is_file() + }) + }) +} + +fn identity(flag: &str) -> Result { + let output = Command::new("id") + .arg(flag) + .output() + .context("can't identify the Console owner")?; + ensure!(output.status.success(), "can't identify the Console owner"); + let value = String::from_utf8(output.stdout)?; + ensure!(!value.trim().is_empty(), "can't identify the Console owner"); + Ok(value.trim().to_owned()) +} + +fn path_text(path: &Path) -> Result<&str> { + path.to_str() + .context("console install requires UTF-8 paths") +} + +/// systemd quotes are separate from shell quotes; percent expands specifiers. +fn unit_word(value: &str) -> Result { + ensure!( + !value.chars().any(char::is_control), + "console install cannot use control characters in paths" + ); + Ok(format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") + )) +} diff --git a/src/main.rs b/src/main.rs index eb83ef0f..a286a181 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,7 @@ usage: thirdshift Run the factory on the iss thirdshift setup Choose defaults and write the User config thirdshift status [--all] [--json] List this machine's Commands thirdshift console [--no-sign-in] Serve this machine's Console on localhost + thirdshift console install Print a service and proxy install script for sudo thirdshift console password [--email
] Set a Console password privately thirdshift console sign-out [--email
] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log @@ -139,6 +140,15 @@ fn main() -> ExitCode { } Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), + Ok(Command::ConsoleInstall) => { + return match console::install() { + Ok(script) => { + print!("{script}"); + ExitCode::SUCCESS + } + Err(error) => failure(&error), + }; + } Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), Ok(Command::ConsoleSignOut { email, json }) => { return match console::sign_out(email.as_deref(), json) { diff --git a/src/setup.rs b/src/setup.rs index 6099e6d0..991ac553 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1050,6 +1050,7 @@ days = 7 silent_minutes = 10 [console] +address = \"\" port = 8711 allowed_emails = [] other_consoles = [] diff --git a/tests/console_install.rs b/tests/console_install.rs new file mode 100644 index 00000000..619ea6db --- /dev/null +++ b/tests/console_install.rs @@ -0,0 +1,302 @@ +//! Installation is exercised through the binary and its printed shell script. +#![cfg(target_os = "linux")] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::{Command, Output}; + +struct Machine { + root: tempfile::TempDir, +} + +impl Machine { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("bin")).unwrap(); + fs::create_dir_all(root.path().join("home/.thirdshift")).unwrap(); + let machine = Self { root }; + // Identity is real; proxy/service commands stay inside this fixture. + std::os::unix::fs::symlink("/usr/bin/id", machine.path("bin/id")).unwrap(); + machine.configure("https://console.example.test", 9123); + machine + } + + fn path(&self, relative: &str) -> std::path::PathBuf { + self.root.path().join(relative) + } + + fn configure(&self, address: &str, port: u16) { + fs::write( + self.path("home/.thirdshift/config.toml"), + format!("[console]\naddress = {address:?}\nport = {port}\nallowed_emails = [\"owner@example.test\"]\n"), + ) + .unwrap(); + } + + fn tool(&self, name: &str, script: &str) { + let path = self.path(&format!("bin/{name}")); + fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + + fn install(&self) -> Output { + Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(["console", "install"]) + .env("HOME", self.path("home")) + .env("PATH", self.path("bin")) + .env("USER", "not-the-owner") + .output() + .unwrap() + } + + fn script(&self) -> String { + let output = self.install(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let script = String::from_utf8(output.stdout).unwrap(); + let path = self.path("install.sh"); + fs::write(&path, &script).unwrap(); + let syntax = Command::new("/bin/sh") + .arg("-n") + .arg(path) + .output() + .unwrap(); + assert!( + syntax.status.success(), + "{}", + String::from_utf8_lossy(&syntax.stderr) + ); + script + } +} + +#[test] +fn caddy_script_installs_an_owner_service_and_streaming_site_without_running_them() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + // Detection may ask about running services, but must never install one. + machine.tool("systemctl", "[ \"$1\" = is-active ] || exit 99\nexit 1"); + let script = machine.script(); + let owner = Command::new("id").arg("-un").output().unwrap(); + let owner = String::from_utf8(owner.stdout).unwrap(); + assert!( + script.contains(&format!("User={}\n", owner.trim())), + "{script}" + ); + assert!(script.contains("Restart=always"), "{script}"); + assert!( + script.contains(&format!( + "ExecStart=\"{}\" console", + env!("CARGO_BIN_EXE_thirdshift") + )), + "{script}" + ); + assert!( + script.contains(&format!("HOME={}", machine.path("home").display())), + "{script}" + ); + assert!( + script.contains("/etc/caddy/sites/thirdshift-console.caddy"), + "{script}" + ); + assert!(script.contains("console.example.test {"), "{script}"); + assert!(script.contains("reverse_proxy 127.0.0.1:9123"), "{script}"); + assert!(script.contains("flush_interval -1"), "{script}"); + assert!( + script.contains("systemctl enable thirdshift-console.service"), + "{script}" + ); + assert!(script.contains("systemctl restart caddy"), "{script}"); + assert!(!script.contains("systemctl reload"), "{script}"); + assert!(!machine.path("home/.thirdshift/console").exists()); +} + +#[test] +fn apache_script_preserves_host_and_protocol_and_certifies_the_streaming_site() { + let machine = Machine::new(); + machine.tool("apache2ctl", "exit 0"); + let script = machine.script(); + for required in [ + "/etc/apache2/sites-available/thirdshift-console.conf", + "", + "ServerName console.example.test", + "ProxyPreserveHost On", + "RequestHeader set X-Forwarded-Proto \"expr=%{REQUEST_SCHEME}\"", + "ProxyPass / http://127.0.0.1:9123/ flushpackets=on", + "ProxyPassReverse / http://127.0.0.1:9123/", + "SetEnvIf Request_URI \"^/(events$|logs/[^/]+/stream/)\" no-gzip=1", + "a2enmod proxy proxy_http headers ssl setenvif", + "a2ensite thirdshift-console.conf", + "certbot --apache -d console.example.test", + "systemctl restart apache2", + ] { + assert!(script.contains(required), "missing {required}:\n{script}"); + } + let enabled = script + .find("systemctl enable thirdshift-console.service") + .unwrap(); + assert!(enabled < script.find("systemctl restart apache2").unwrap()); + assert!(!script.contains("/etc/caddy")); +} + +#[test] +fn without_a_proxy_the_service_and_ssh_tunnel_work_without_an_address_or_accounts() { + let machine = Machine::new(); + fs::write( + machine.path("home/.thirdshift/config.toml"), + "[console]\nport = 9123\n", + ) + .unwrap(); + let script = machine.script(); + assert!( + script.contains("/etc/systemd/system/thirdshift-console.service"), + "{script}" + ); + assert!(script.contains("console --no-sign-in\n"), "{script}"); + assert!(script.contains("ssh -N -L 9123:127.0.0.1:9123"), "{script}"); + assert!(script.contains("http://localhost:9123"), "{script}"); + assert!(!script.contains("certbot"), "{script}"); + assert!(!script.contains("/etc/caddy"), "{script}"); + assert!(!script.contains("/etc/apache2"), "{script}"); +} + +#[test] +fn a_running_apache_wins_over_an_installed_caddy() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + machine.tool("apache2ctl", "exit 0"); + machine.tool( + "systemctl", + "[ \"$1\" = is-active ] && [ \"$3\" = apache2 ]", + ); + let script = machine.script(); + assert!(script.contains("systemctl restart apache2"), "{script}"); + assert!(!script.contains("/etc/caddy"), "{script}"); +} + +#[test] +fn reinstall_restarts_the_console_before_exposing_it_through_the_proxy() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + let script = machine.script(); + // enable --now leaves an already running tunnel service in --no-sign-in + // mode. Replacing its unit must apply the new command before public access. + let restart = script + .find("systemctl restart thirdshift-console.service") + .expect("the replaced service must restart even if it was already running"); + assert!(script.find("systemctl daemon-reload").unwrap() < restart); + assert!(restart < script.find("systemctl restart caddy").unwrap()); +} + +#[test] +fn reinstall_restarts_console_before_certbot_can_activate_the_public_site() { + let machine = Machine::new(); + machine.tool("apache2ctl", "exit 0"); + let script = machine.script(); + assert!( + script + .find("systemctl restart thirdshift-console.service") + .unwrap() + < script.find("certbot --apache").unwrap(), + "certbot can reload Apache, so the Console must already require sign-in:\n{script}" + ); +} + +#[test] +fn rocky_apache_uses_httpd_and_its_conf_directory() { + let machine = Machine::new(); + machine.tool("httpd", "exit 0"); + let script = machine.script(); + assert!( + script.contains("/etc/httpd/conf.d/thirdshift-console.conf"), + "{script}" + ); + assert!(script.contains("httpd -t"), "{script}"); + assert!( + script.contains("certbot --apache -d console.example.test"), + "{script}" + ); + assert!(script.contains("systemctl restart httpd"), "{script}"); + assert!(!script.contains("a2en"), "{script}"); +} + +#[test] +fn install_rejects_unusable_public_settings_without_printing_a_partial_script() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + for address in [ + "", + "http://console.example.test", + "https://console.example.test/path", + "bad;echo injected", + "https://console.example.test:8443", + ] { + machine.configure(address, 9123); + let output = machine.install(); + assert!(!output.status.success(), "accepted {address}"); + assert!(output.stdout.is_empty(), "partial script for {address}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("console.address")); + } + machine.configure("console.example.test", 0); + let output = machine.install(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("console.port")); + fs::write( + machine.path("home/.thirdshift/config.toml"), + "[console]\naddress = \"console.example.test\"\n", + ) + .unwrap(); + let output = machine.install(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("console.allowed_emails")); +} + +#[test] +fn unit_preserves_literal_paths_with_shell_and_systemd_metacharacters() { + let machine = Machine::new(); + let home = machine.path("home space%$\"'\\"); + fs::rename(machine.path("home"), &home).unwrap(); + let binary = machine.path("thirdshift space%$binary"); + fs::copy(env!("CARGO_BIN_EXE_thirdshift"), &binary).unwrap(); + let output = Command::new(&binary) + .args(["console", "install"]) + .env("HOME", &home) + .env("PATH", machine.path("bin")) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let script = String::from_utf8(output.stdout).unwrap(); + assert!(script.contains("home space%%$\\\"'\\\\"), "{script}"); + assert!( + script.contains("thirdshift space%%$binary\" console"), + "{script}" + ); + let unit = script + .split_once("<<'THIRDSHIFT_UNIT'\n") + .unwrap() + .1 + .split_once("THIRDSHIFT_UNIT\n") + .unwrap() + .0; + let unit_path = machine.path("thirdshift-console.service"); + fs::write(&unit_path, unit).unwrap(); + if std::path::Path::new("/usr/bin/systemd-analyze").exists() { + let output = Command::new("/usr/bin/systemd-analyze") + .args(["verify", "--man=no"]) + .arg(unit_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } +}