wsb 0.255.6379

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Plugins: programs this machine's owner installed, which wsb lets use its
//! services.
//!
//! The first service a plugin can use is the peer bridge. A plugin offers
//! named services, `<plugin>.<service>`, and a paired machine's copy of the
//! same plugin can ask for them over the authenticated, encrypted connection
//! the two machines already hold, so a plugin never opens a second one. wsb
//! carries the bytes and proves who sent them; what they mean is the
//! plugin's business.
//!
//! # Who may be a plugin
//!
//! Only a name the owner enabled on this machine (`wsb plugin add <name>`).
//! The list is kept in [`ENABLED_FILE`] in the machine directory, beside the
//! daemon's rendezvous file. A plugin registers with the running daemon by
//! presenting the daemon's control secret, which only this account can read,
//! and a name nobody enabled is refused even when the secret is right. That
//! keeps the set of things wsb carries traffic for a list somebody wrote,
//! rather than whatever happened to ask.
//!
//! # What reaches a plugin
//!
//! For each request from a peer: the service asked for, the body, and the
//! peer's identity as the handshake proved it (its id, its name and its key's
//! fingerprint). Whether to answer that machine is the plugin's decision,
//! made with facts wsb verified rather than claims the peer made.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// The file, in the machine directory, that lists the plugins the owner
/// enabled.
pub const ENABLED_FILE: &str = "plugins.json";

/// The most a plugin request or reply may carry, in bytes of body.
///
/// A plugin answers questions, it does not move trees: a secret, a machine's
/// platform. Bounded so that a peer cannot make this machine hold an
/// arbitrarily large message for a plugin, or the reverse.
pub const MAX_BODY: usize = 1024 * 1024;

/// A plugin's name: lowercase letters, digits and `-`, starting with a letter.
///
/// One word, because it is the first half of every service name and the
/// thing a person types to enable it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PluginName(String);

impl PluginName {
    pub fn parse(raw: &str) -> Result<Self, String> {
        if is_word(raw) {
            Ok(Self(raw.to_string()))
        } else {
            Err(format!(
                "'{raw}' is not a plugin name: lowercase letters, digits and '-', \
                 starting with a letter"
            ))
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl TryFrom<String> for PluginName {
    type Error = String;
    fn try_from(raw: String) -> Result<Self, String> {
        Self::parse(&raw)
    }
}

impl From<PluginName> for String {
    fn from(name: PluginName) -> String {
        name.0
    }
}

impl std::fmt::Display for PluginName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// One service a plugin offers: `<plugin>.<service>`, each half a
/// [`PluginName`]-shaped word.
///
/// The plugin's name comes first so that a service always says whose it is:
/// two plugins can each offer a `status`, and a plugin can register only
/// services under its own name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PluginService(String);

impl PluginService {
    pub fn parse(raw: &str) -> Result<Self, String> {
        match raw.split_once('.') {
            Some((plugin, service)) if is_word(plugin) && is_word(service) => {
                Ok(Self(raw.to_string()))
            }
            _ => Err(format!(
                "'{raw}' is not a plugin service: <plugin>.<service>, each lowercase \
                 letters, digits and '-', starting with a letter"
            )),
        }
    }

    /// The plugin this service belongs to.
    pub fn plugin(&self) -> PluginName {
        let (plugin, _) = self.0.split_once('.').expect("a parsed service has a plugin");
        PluginName(plugin.to_string())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl TryFrom<String> for PluginService {
    type Error = String;
    fn try_from(raw: String) -> Result<Self, String> {
        Self::parse(&raw)
    }
}

impl From<PluginService> for String {
    fn from(service: PluginService) -> String {
        service.0
    }
}

impl std::fmt::Display for PluginService {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

fn is_word(raw: &str) -> bool {
    let mut chars = raw.chars();
    matches!(chars.next(), Some(first) if first.is_ascii_lowercase())
        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EnabledFile {
    #[serde(default)]
    plugins: BTreeSet<PluginName>,
}

fn enabled_path(machine_dir: &Path) -> PathBuf {
    machine_dir.join(ENABLED_FILE)
}

/// The plugins the owner enabled on this machine.
///
/// No file is no plugins, which is every machine nobody enabled one on. A
/// file that cannot be read is an error, not an empty list: a list that
/// silently became empty would refuse the plugins somebody enabled, with a
/// reason that blamed them.
pub fn enabled(machine_dir: &Path) -> Result<BTreeSet<PluginName>, String> {
    let path = enabled_path(machine_dir);
    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
        Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
    };
    let file: EnabledFile = serde_json::from_str(&text)
        .map_err(|error| format!("{} is not a plugin list: {error}", path.display()))?;
    Ok(file.plugins)
}

/// Enable a plugin on this machine. Enabling one already enabled is not an
/// error; the answer is whether it was newly enabled.
pub fn enable(machine_dir: &Path, plugin: &PluginName) -> Result<bool, String> {
    let mut plugins = enabled(machine_dir)?;
    let added = plugins.insert(plugin.clone());
    if added {
        write(machine_dir, plugins)?;
    }
    Ok(added)
}

/// Disable a plugin on this machine. The answer is whether it was enabled.
pub fn disable(machine_dir: &Path, plugin: &PluginName) -> Result<bool, String> {
    let mut plugins = enabled(machine_dir)?;
    let removed = plugins.remove(plugin);
    if removed {
        write(machine_dir, plugins)?;
    }
    Ok(removed)
}

fn write(machine_dir: &Path, plugins: BTreeSet<PluginName>) -> Result<(), String> {
    std::fs::create_dir_all(machine_dir)
        .map_err(|error| format!("cannot create {}: {error}", machine_dir.display()))?;
    let path = enabled_path(machine_dir);
    let text = serde_json::to_string_pretty(&EnabledFile { plugins })
        .map_err(|error| format!("cannot write the plugin list: {error}"))?;
    // Whole or not at all: a half-written list would refuse every plugin on
    // its next read.
    let staged = path.with_extension("json.tmp");
    std::fs::write(&staged, format!("{text}\n"))
        .map_err(|error| format!("cannot write {}: {error}", staged.display()))?;
    std::fs::rename(&staged, &path)
        .map_err(|error| format!("cannot write {}: {error}", path.display()))
}

/// The authenticated peer a plugin request came from, as the handshake
/// proved it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Caller {
    /// The peer's id: what its key derives to.
    pub id: String,
    /// The name it was paired under.
    pub name: String,
    /// Its key's fingerprint, as `wsb peer list` shows it.
    pub fingerprint: String,
}

/// What a running plugin tells the daemon when it registers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Registration {
    pub plugin: PluginName,
    /// Every service it answers, each under its own name.
    pub services: Vec<PluginService>,
    /// Where it answers: a loopback `http://` URL. Calls to it are POSTs.
    pub endpoint: String,
    /// The bearer token the daemon presents on every call, which the plugin
    /// minted, so that it can tell the daemon's calls from anyone else's.
    pub token: String,
    /// The plugin's process. Its registration stands while the process does.
    pub pid: u32,
}

impl Registration {
    /// Every check a registration must pass that does not depend on the
    /// machine: services under the plugin's own name, a loopback endpoint, a
    /// token worth presenting.
    pub fn check(&self) -> Result<(), String> {
        if self.services.is_empty() {
            return Err(format!("plugin {} registers no services", self.plugin));
        }
        if let Some(foreign) = self.services.iter().find(|service| service.plugin() != self.plugin) {
            return Err(format!(
                "plugin {} cannot register {foreign}: a plugin registers only services under its own name",
                self.plugin
            ));
        }
        check_loopback(&self.endpoint)?;
        if self.token.len() < 32 {
            return Err(format!(
                "plugin {} registered a token of {} characters; at least 32 are needed",
                self.plugin,
                self.token.len()
            ));
        }
        Ok(())
    }
}

/// A plugin is reached on this machine only: `http://127.0.0.1:<port>/…` or
/// `http://[::1]:<port>/…`. Anything else would send a peer's request, and
/// the bearer token with it, somewhere off the machine.
fn check_loopback(endpoint: &str) -> Result<(), String> {
    let rest = endpoint
        .strip_prefix("http://")
        .ok_or_else(|| format!("plugin endpoint {endpoint} is not an http:// URL on this machine"))?;
    let host = if let Some(bracketed) = rest.strip_prefix('[') {
        bracketed.split(']').next().unwrap_or_default()
    } else {
        rest.split([':', '/']).next().unwrap_or_default()
    };
    let loopback = host
        .parse::<std::net::IpAddr>()
        .map(|address| address.is_loopback())
        .unwrap_or(false);
    if loopback {
        Ok(())
    } else {
        Err(format!(
            "plugin endpoint {endpoint} is not on this machine: it must name 127.0.0.1 or [::1]"
        ))
    }
}

/// What the daemon sends a plugin, for one request from a peer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Call {
    pub service: PluginService,
    pub caller: Caller,
    /// What the peer sent, as it sent it: base64 of the plugin's own bytes.
    pub body: String,
}

/// What a plugin answers a call with.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum Answer {
    /// The reply, base64, which the asking machine's plugin receives.
    Reply { body: String },
    /// The plugin will not answer this caller or this request, and why.
    Refused { detail: String },
}

/// A plugin asking its local daemon to carry a request to a paired machine.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SendRequest {
    /// The paired machine, by id.
    pub peer: String,
    pub service: PluginService,
    /// The plugin's own bytes, base64.
    pub body: String,
}

/// What came back from the paired machine.
///
/// Either way the peer answered: a machine that could not be reached is an
/// error from the daemon instead, so a plugin can tell "it said no" from "it
/// was not there".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum Sent {
    /// The plugin on the other machine replied; base64.
    Reply { body: String },
    /// The request was refused, by that machine or before it was sent: the
    /// service switched off, no plugin offering it, the plugin stopped, or the
    /// plugin itself declining. `code` is the refusal's name.
    Refused { code: String, detail: String },
}

/// A plugin the daemon counts as running: what it answers and where, never
/// its token — a token is what proves a call came from the daemon, and it is
/// shown to nobody.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunningPlugin {
    pub plugin: PluginName,
    pub services: Vec<PluginService>,
    pub endpoint: String,
    pub pid: u32,
}

/// What the daemon answers [`route::LIST`] with: the plugins the owner
/// enabled on this machine, and the ones running now.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginList {
    pub enabled: BTreeSet<PluginName>,
    pub running: Vec<RunningPlugin>,
}

/// The daemon's routes for plugins, under its control API. Every one needs
/// the control secret.
pub mod route {
    pub const LIST: &str = "/api/daemon/plugins";
    pub const REGISTER: &str = "/api/daemon/plugins/register";
    pub const SEND: &str = "/api/daemon/plugins/send";
}

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