7.9 KiB · text History 5fae34f
//! What to connect to.
//!
//! One file, `~/.config/apollos-dbus/config.toml`, holding any number of
//! Ankyrae and Keryxiae. Either can be given as the pairing link the Ankyra
//! page draws as a QR -- `apolloscrib://ankyra?host=..&topic=..&user=..` --
//! with fields alongside overriding what the link says.

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

use anyhow::{Context, Result, bail};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    /// How long a query may go unheard before it is marked stale.
    #[serde(default = "default_stale_after")]
    pub stale_after_secs: u64,
    /// The MQTT client id prefix. An Ankyra counts its consumers by client id
    /// and, with auto-registration off, refuses one it has not been told of --
    /// so this is the id to add there. Defaults to `apollos-dbus-<hostname>`.
    pub client_id: Option<String>,
    #[serde(default)]
    pub ankyra: Vec<SourceConfig>,
    #[serde(default)]
    pub keryx: Vec<SourceConfig>,
}

fn default_stale_after() -> u64 {
    15 * 60
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceConfig {
    /// What the source is called on the bus. Defaults to the topic or id.
    pub name: Option<String>,
    pub link: Option<String>,
    pub host: Option<String>,
    pub port: Option<u16>,
    pub username: Option<String>,
    pub password: Option<String>,
    pub tls: Option<bool>,
    /// An Ankyra's topic: the rabbit user's own.
    pub topic: Option<String>,
    /// A Keryx's id, as in `cfg/keryxiae/<id>`.
    pub id: Option<u32>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct Broker {
    pub host: String,
    pub port: u16,
    pub username: String,
    pub password: String,
    pub tls: bool,
}

#[derive(Debug, Clone, PartialEq)]
pub enum Kind {
    Ankyra { topic: String },
    Keryx { id: u32 },
}

#[derive(Debug, Clone, PartialEq)]
pub struct Source {
    pub name: String,
    pub broker: Broker,
    pub kind: Kind,
}

impl Source {
    /// How a query names where it came from: `ankyra:home`, `keryx:desk`.
    pub fn label(&self) -> String {
        match self.kind {
            Kind::Ankyra { .. } => format!("ankyra:{}", self.name),
            Kind::Keryx { .. } => format!("keryx:{}", self.name),
        }
    }
}

pub fn default_path() -> PathBuf {
    dirs::config_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join("apollos-dbus")
        .join("config.toml")
}

impl Config {
    pub fn load(path: &Path) -> Result<Config> {
        let text =
            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
        toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))
    }

    pub fn sources(&self) -> Result<Vec<Source>> {
        let ankyrae = self.ankyra.iter().map(|c| c.resolve(false));
        let keryxiae = self.keryx.iter().map(|c| c.resolve(true));
        let sources: Vec<Source> = ankyrae.chain(keryxiae).collect::<Result<_>>()?;

        let mut labels: Vec<String> = sources.iter().map(Source::label).collect();
        labels.sort();
        if let Some(pair) = labels.windows(2).find(|w| w[0] == w[1]) {
            bail!("two sources are both called {}; give one a name", pair[0]);
        }
        Ok(sources)
    }
}

/// What a pairing link carries. Everything but host and topic is optional,
/// as it is on the phone: an unauthenticated broker on 1883 is a real setup.
#[derive(Debug, Default, PartialEq)]
struct Pairing {
    host: Option<String>,
    port: Option<u16>,
    username: Option<String>,
    password: Option<String>,
    topic: Option<String>,
    tls: Option<bool>,
}

fn parse_link(link: &str) -> Result<Pairing> {
    let url = url::Url::parse(link).with_context(|| format!("not a link: {link}"))?;
    if url.scheme() != "apolloscrib" || url.host_str() != Some("ankyra") {
        bail!("not an apolloscrib://ankyra link: {link}");
    }
    let mut p = Pairing::default();
    for (k, v) in url.query_pairs() {
        let v = v.trim().to_string();
        if v.is_empty() {
            continue;
        }
        match k.as_ref() {
            "host" => p.host = Some(v),
            "port" => p.port = Some(v.parse().with_context(|| format!("port {v}"))?),
            "user" => p.username = Some(v),
            "pass" => p.password = Some(v),
            "topic" => p.topic = Some(v),
            "tls" => p.tls = Some(v == "1"),
            _ => {}
        }
    }
    Ok(p)
}

impl SourceConfig {
    fn resolve(&self, keryx: bool) -> Result<Source> {
        let link = match &self.link {
            Some(l) => parse_link(l)?,
            None => Pairing::default(),
        };
        let what = if keryx { "keryx" } else { "ankyra" };

        let host = self
            .host
            .clone()
            .or(link.host)
            .with_context(|| format!("an {what} entry has no host"))?;
        let broker = Broker {
            host,
            port: self.port.or(link.port).unwrap_or(1883),
            username: self.username.clone().or(link.username).unwrap_or_default(),
            password: self.password.clone().or(link.password).unwrap_or_default(),
            tls: self.tls.or(link.tls).unwrap_or(false),
        };

        // A Keryx is reached with any rabbit user its owner has, which is most
        // easily an Ankyra's -- so a Keryx entry may carry an Ankyra's link,
        // and that link's topic means nothing to it.
        let (kind, fallback) = if keryx {
            let id = self.id.with_context(|| "a keryx entry has no id")?;
            (Kind::Keryx { id }, id.to_string())
        } else {
            let topic = self
                .topic
                .clone()
                .or(link.topic)
                .with_context(|| "an ankyra entry has no topic")?;
            (
                Kind::Ankyra {
                    topic: topic.clone(),
                },
                topic,
            )
        };

        Ok(Source {
            name: self.name.clone().unwrap_or(fallback),
            broker,
            kind,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_link_is_enough_for_an_ankyra() {
        let cfg: Config = toml::from_str(
            r#"
            [[ankyra]]
            link = "apolloscrib://ankyra?host=mq.example&port=8883&user=u&pass=p&topic=Chocolate-Purple-Healer&tls=1"
            "#,
        )
        .unwrap();
        let sources = cfg.sources().unwrap();
        assert_eq!(
            sources[0],
            Source {
                name: "Chocolate-Purple-Healer".into(),
                broker: Broker {
                    host: "mq.example".into(),
                    port: 8883,
                    username: "u".into(),
                    password: "p".into(),
                    tls: true
                },
                kind: Kind::Ankyra {
                    topic: "Chocolate-Purple-Healer".into()
                },
            }
        );
    }

    #[test]
    fn a_keryx_borrows_an_ankyras_link_but_not_its_topic() {
        let cfg: Config = toml::from_str(
            r#"
            [[keryx]]
            name = "desk"
            id = 7
            link = "apolloscrib://ankyra?host=mq&topic=T"
            port = 1884
            "#,
        )
        .unwrap();
        let s = &cfg.sources().unwrap()[0];
        assert_eq!(s.kind, Kind::Keryx { id: 7 });
        assert_eq!(s.broker.port, 1884, "a field beside the link wins");
        assert_eq!(s.label(), "keryx:desk");
    }

    #[test]
    fn two_sources_may_not_share_a_name() {
        let cfg: Config = toml::from_str(
            r#"
            [[ankyra]]
            host = "a"
            topic = "T"
            [[ankyra]]
            host = "b"
            topic = "T"
            "#,
        )
        .unwrap();
        assert!(cfg.sources().is_err());
    }

    #[test]
    fn a_link_that_is_not_ours_is_refused() {
        assert!(parse_link("https://example.com/?host=x").is_err());
    }
}