wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! What the daemon was started with.
//!
//! The daemon serves the MACHINE, so it takes no workspace: it registers them
//! as commands arrive and as the operator adds them. `--workspace` remains as
//! the one a start may name so that the first page has something on it, and it
//! is optional for the same reason — a daemon started at login belongs to no
//! directory in particular.
//!
//! Everything else is required. The command line carries the policy — which
//! port the workbench is on, which port peers dial — and a daemon that guessed
//! one of them would be running under settings nobody chose.

use std::net::{IpAddr, Ipv4Addr};
use std::path::PathBuf;

/// A fully-validated daemon configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
    /// A workspace to register at startup, absolute.
    ///
    /// `None` for a daemon started without one: it registers workspaces as
    /// commands are run in them. Not "the daemon's workspace" — there is no
    /// such thing any more.
    pub workspace_root: Option<PathBuf>,
    /// The address the workbench is served on.
    ///
    /// Loopback unless something asked otherwise. The workbench has no
    /// authentication: it reads and edits the workspace and runs git on it —
    /// so reaching it is reaching this machine, and that is not
    /// something to be one default away from.
    pub listen: IpAddr,
    /// The port the workbench is served on.
    pub http_port: u16,
    /// The address PEERS reach this machine on.
    ///
    /// Every interface by default, which is the opposite of the workbench and
    /// deliberately so — the two listeners protect different things:
    ///
    /// * The workbench is unauthenticated HTTP. Anyone who reaches it has this
    ///   machine, so it stays on loopback until somebody says otherwise.
    /// * The peer port answers only machines whose ed25519 identity this one
    ///   has been paired with by hand, over a session that is key-exchanged
    ///   and encrypted. An unpaired connection gets no further than the
    ///   handshake, and there is nothing in it to read.
    ///
    /// A peer listener on loopback can be reached by no peer at all, which
    /// makes the feature it exists for impossible without a tunnel. So it
    /// binds where peers are, and `--peer-listen` narrows it.
    pub peer_listen: IpAddr,
    /// The port peers connect to. Fixed rather than floating: a peer is
    /// reached at an address a person typed once, and a port that moved with
    /// every restart would have to be re-typed.
    pub peer_port: u16,
    /// Whether to log what the workbench is doing.
    pub debug: bool,
    /// A process to die with, when something embeds this daemon.
    ///
    /// `None` for the ordinary daemon, which outlives every command line —
    /// that is what it is for. An application that spawns its own (the desktop
    /// clients) passes its pid so the daemon does not survive it, since
    /// nothing would ever stop it afterwards.
    pub parent_pid: Option<u32>,
}

impl Config {
    /// Parse `--workspace`, `--port`, `--listen`, `--peer-listen`,
    /// `--peer-port`, `--debug` and `--parent-pid`.
    ///
    /// A missing flag, a missing value, a relative workspace, a port of zero
    /// and an unknown flag are all refusals. A daemon is started by a program
    /// (see `wsb::daemon::launch`), so a malformed argument is a defect in the
    /// caller rather than a typo to be lenient about.
    pub fn from_args<I>(args: I) -> Result<Self, String>
    where
        I: IntoIterator<Item = String>,
    {
        let mut workspace: Option<String> = None;
        let mut port: Option<String> = None;
        let mut peer_port: Option<String> = None;
        let mut listen: Option<String> = None;
        let mut peer_listen: Option<String> = None;
        let mut debug = false;
        let mut parent: Option<String> = None;

        let mut iter = args.into_iter();
        while let Some(arg) = iter.next() {
            match arg.as_str() {
                "--workspace" => workspace = Some(expect_value(&mut iter, "--workspace")?),
                "--port" => port = Some(expect_value(&mut iter, "--port")?),
                "--peer-port" => peer_port = Some(expect_value(&mut iter, "--peer-port")?),
                "--listen" => listen = Some(expect_value(&mut iter, "--listen")?),
                "--peer-listen" => {
                    peer_listen = Some(expect_value(&mut iter, "--peer-listen")?)
                }
                "--debug" => debug = true,
                "--parent-pid" => parent = Some(expect_value(&mut iter, "--parent-pid")?),
                other => return Err(format!("unknown argument: {other}")),
            }
        }

        let workspace_root = match workspace {
            None => None,
            Some(raw) => {
                let root = PathBuf::from(raw);
                if !root.is_absolute() {
                    return Err(format!("--workspace must be absolute, got {}", root.display()));
                }
                if !root.is_dir() {
                    return Err(format!(
                        "--workspace names {}, which is not a directory",
                        root.display()
                    ));
                }
                Some(root)
            }
        };

        let port = port.ok_or_else(|| "--port is required".to_string())?;
        let http_port: u16 = port
            .parse()
            .map_err(|e| format!("--port must be a TCP port number (1-65535): {e}"))?;
        if http_port == 0 {
            return Err("--port must not be 0: the workbench is served at a known port".to_string());
        }

        // The one argument with a default, and the default is the safe end of
        // it: a daemon started with no opinion about who may reach it is
        // reachable only from this machine.
        let listen: IpAddr = match listen {
            Some(address) => address.parse().map_err(|e| {
                format!("--listen must be an IP address to bind (e.g. 127.0.0.1 or 0.0.0.0): {e}")
            })?,
            None => IpAddr::V4(Ipv4Addr::LOCALHOST),
        };

        // The peer listener's default is the other end: every interface. See
        // `Config::peer_listen` — this one is authenticated and encrypted, and
        // one that only loopback can reach is one no peer can.
        let peer_listen: IpAddr = match peer_listen {
            Some(address) => address.parse().map_err(|e| {
                format!(
                    "--peer-listen must be an IP address to bind (e.g. 0.0.0.0 or 192.168.1.10): {e}"
                )
            })?,
            None => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
        };

        // Zero means "do not listen for peers", which is a deliberate choice a
        // machine that is not part of a peer relationship can make.
        let peer_port: u16 = match peer_port {
            Some(value) => value
                .parse()
                .map_err(|e| format!("--peer-port must be a TCP port number (0-65535): {e}"))?,
            None => crate::peer::service::DEFAULT_PEER_PORT,
        };
        if peer_port == http_port {
            return Err(format!(
                "--peer-port and --port are both {peer_port}; the workbench and the peer                  listener cannot share one port"
            ));
        }

        let parent_pid = match parent {
            Some(pid) => {
                let pid: u32 = pid
                    .parse()
                    .map_err(|e| format!("--parent-pid must be a process id: {e}"))?;
                if pid == 0 {
                    return Err("--parent-pid must not be 0".to_string());
                }
                Some(pid)
            }
            None => None,
        };

        Ok(Self {
            workspace_root,
            peer_port,
            peer_listen,
            listen,
            http_port,
            debug,
            parent_pid,
        })
    }
}

fn expect_value<I>(iter: &mut I, flag: &str) -> Result<String, String>
where
    I: Iterator<Item = String>,
{
    iter.next()
        .ok_or_else(|| format!("{flag} requires a value"))
}

#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;