//! 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());
}
}
neiam
/apollos-dbus
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
7.9 KiB · text
History
4f1f7f4