diff --git a/README.md b/README.md index 5e65b43f..0f580602 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,12 @@ 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 +thirdshift console install --output 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. +`console install` only prints a script; `--output ` saves it to that file instead, replacing any earlier copy, and writes nothing if the settings can't make 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. diff --git a/src/args.rs b/src/args.rs index b918b97f..a9c981b3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -25,7 +25,9 @@ pub enum Command { Console { no_sign_in: bool, }, - ConsoleInstall, + ConsoleInstall { + output: Option, + }, ConsolePassword(Option), ConsoleSignOut { email: Option, @@ -340,7 +342,14 @@ 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 == "install" => Ok(Command::ConsoleInstall { output: None }), + [action, flag, file] + if action == "install" && flag == "--output" && !file.starts_with('-') => + { + Ok(Command::ConsoleInstall { + output: Some(file.clone()), + }) + } [action] if action == "password" => Ok(Command::ConsolePassword(None)), [action, flag, email] if action == "password" && flag == "--email" && !email.starts_with('-') => @@ -352,7 +361,7 @@ pub fn parse(args: &[String]) -> Result { Ok(Command::ConsoleLog { json: true }) } _ => bail!( - "usage: thirdshift console [--no-sign-in] | install | password [--email
] | sign-out [--email
] [--json] | log [--json]" + "usage: thirdshift console [--no-sign-in] | install [--output ] | password [--email
] | sign-out [--email
] [--json] | log [--json]" ), }; } diff --git a/src/console.rs b/src/console.rs index e8039af7..27666ef7 100644 --- a/src/console.rs +++ b/src/console.rs @@ -5,7 +5,7 @@ mod auth; mod conversation; pub use account_commands::{password, print_log, sign_out}; mod install; -pub use install::install; +pub use install::{install, install_to}; mod live; mod logs; mod look; diff --git a/src/console/install.rs b/src/console/install.rs index 91c0ffd7..63e55228 100644 --- a/src/console/install.rs +++ b/src/console/install.rs @@ -1,10 +1,12 @@ //! Only this command knows about systemd and the machine's proxy layout. -//! It reads local settings and prints a script; it never installs anything. +//! It reads local settings and prints or saves a script; it never installs anything. +use std::fs; use std::path::Path; use std::process::Command; use anyhow::{Context, Result, ensure}; +use crate::args::shell_command; use crate::config::{self, UserConfig}; enum Proxy { @@ -94,7 +96,7 @@ pub fn install() -> Result { }; Ok(format!( r#"#!/bin/sh -# Save this script, then run: sudo sh install-console.sh +# Read this script, then run it with sudo sh. set -eu cat > /etc/systemd/system/thirdshift-console.service <<'THIRDSHIFT_UNIT' @@ -121,6 +123,17 @@ systemctl restart thirdshift-console.service )) } +/// Save the script for the owner to read; nothing is written when it can't be made. +pub fn install_to(path: &Path) -> Result { + let script = install()?; + fs::write(path, script).with_context(|| format!("can't write {}", path.display()))?; + let run = shell_command(vec!["sudo".into(), "sh".into(), path_text(path)?.into()]); + Ok(format!( + "Wrote {}. Read it, then run: {run}", + path.display() + )) +} + fn caddy(host: &str, port: u16) -> String { format!( r#"mkdir -p /etc/caddy/sites diff --git a/src/main.rs b/src/main.rs index dcf099ef..a7f1ceff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,7 +80,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 install [--output ] Print, or save, 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 @@ -145,7 +145,7 @@ fn main() -> ExitCode { } Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), - Ok(Command::ConsoleInstall) => { + Ok(Command::ConsoleInstall { output: None }) => { return match console::install() { Ok(script) => { print!("{script}"); @@ -154,6 +154,9 @@ fn main() -> ExitCode { Err(error) => failure(&error), }; } + Ok(Command::ConsoleInstall { output: Some(path) }) => { + return outcome(console::install_to(std::path::Path::new(&path))); + } Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), Ok(Command::ConsoleTheme { name, json }) => { return printed(console::theme(name.as_deref(), json)); diff --git a/src/setup.rs b/src/setup.rs index 2c1f185f..0e488354 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -190,7 +190,7 @@ fn ask_if( /// Installation is an owner-run command that prints the script for sudo. fn offer_console_install(outside: &mut impl Outside, answers: &Answers) { if answers.console.is_some() { - outside.say("To install the Console, run `thirdshift console install`; it prints a script to run with sudo.".into()); + outside.say("To install the Console, run `thirdshift console install --output install-console.sh`, read the script, then run `sudo sh install-console.sh`.".into()); } } diff --git a/tests/console_install.rs b/tests/console_install.rs index 619ea6db..c8dc7d16 100644 --- a/tests/console_install.rs +++ b/tests/console_install.rs @@ -40,8 +40,14 @@ impl Machine { } fn install(&self) -> Output { + self.install_with(&[]) + } + + fn install_with(&self, extra: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_thirdshift")) .args(["console", "install"]) + .args(extra) + .current_dir(self.root.path()) .env("HOME", self.path("home")) .env("PATH", self.path("bin")) .env("USER", "not-the-owner") @@ -300,3 +306,81 @@ fn unit_preserves_literal_paths_with_shell_and_systemd_metacharacters() { ); } } + +#[test] +fn output_saves_the_printed_script_and_names_the_sudo_command() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + let printed = machine.script(); + let output = machine.install_with(&["--output", "install-console.sh"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert_eq!( + fs::read_to_string(machine.path("install-console.sh")).unwrap(), + printed + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("sudo sh install-console.sh"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + // A second install replaces the saved script rather than refusing. + fs::write(machine.path("install-console.sh"), "stale").unwrap(); + assert!( + machine + .install_with(&["--output", "install-console.sh"]) + .status + .success() + ); + assert_eq!( + fs::read_to_string(machine.path("install-console.sh")).unwrap(), + printed + ); +} + +#[test] +fn output_saves_nothing_when_the_settings_are_unusable() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + machine.configure("", 9123); + let output = machine.install_with(&["--output", "install-console.sh"]); + assert!(!output.status.success()); + assert!(!machine.path("install-console.sh").exists()); + assert!(String::from_utf8_lossy(&output.stderr).contains("console.address")); +} + +#[test] +fn output_needs_a_file_name() { + let machine = Machine::new(); + for extra in [&["--output"][..], &["--output", "--output"], &["--json"]] { + let output = machine.install_with(extra); + assert!(!output.status.success(), "accepted {extra:?}"); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("install [--output ]"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn output_names_a_copyable_sudo_command_for_a_spaced_path() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + let output = machine.install_with(&["--output", "my script.sh"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("sudo sh 'my script.sh'"), "{stderr}"); + // The saved script must not tell its reader to run some other file. + let saved = fs::read_to_string(machine.path("my script.sh")).unwrap(); + assert!(!saved.contains("install-console.sh"), "{saved}"); +} diff --git a/tests/setup.rs b/tests/setup.rs index 8810f8b4..9d48ff21 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -375,7 +375,9 @@ fn setup_console_questions_use_email_to_keep_comments_and_offer_install() { assert!(text.contains(comment), "{text}"); } assert!( - result.stderr.contains("thirdshift console install"), + result + .stderr + .contains("thirdshift console install --output install-console.sh"), "{}", result.stderr );