use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TunnelKind {
#[default]
Wireguard,
Tailscale,
Mullvad,
None,
}
impl TunnelKind {
pub fn as_str(self) -> &'static str {
match self {
TunnelKind::Wireguard => "wireguard",
TunnelKind::Tailscale => "tailscale",
TunnelKind::Mullvad => "mullvad",
TunnelKind::None => "none",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Front,
Gunnar,
Holger,
Box(BoxRole),
}
impl Kind {
pub fn is_product(self) -> bool {
matches!(self, Kind::Gunnar | Kind::Holger)
}
pub fn is_elected(self) -> bool {
self.is_product()
}
pub fn is_box(self) -> bool {
!matches!(self, Kind::Front)
}
pub fn named(name: &str) -> Result<Kind, SeedError> {
Ok(match name {
"front" => Kind::Front,
"gunnar" => Kind::Gunnar,
"holger" => Kind::Holger,
other => Kind::Box(BoxRole::new(other)?),
})
}
pub fn as_str(&self) -> &str {
match self {
Kind::Front => "front",
Kind::Gunnar => "gunnar",
Kind::Holger => "holger",
Kind::Box(r) => r.as_str(),
}
}
}
impl Serialize for Kind {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Kind {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Kind, D::Error> {
let name = String::deserialize(d)?;
Kind::named(&name).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct BoxRole {
len: u8,
name: [u8; BoxRole::MAX],
}
impl BoxRole {
pub const MAX: usize = 32;
pub fn new(name: &str) -> Result<BoxRole, SeedError> {
let bad = |why: String| Err(SeedError::Bad("kind", why));
if matches!(name, "front" | "gunnar" | "holger") {
return bad(format!("`{name}` is an old kind, not a product's box kind"));
}
let b = name.as_bytes();
if b.is_empty() || b.len() > Self::MAX {
return bad(format!("`{name}` is not 1 to {} bytes", Self::MAX));
}
if !b[0].is_ascii_lowercase() || !b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-') {
return bad(format!("`{name}` is not a box kind (lowercase letters, digits and `-`, a letter first)"));
}
let mut role = BoxRole { len: b.len() as u8, name: [0; Self::MAX] };
role.name[..b.len()].copy_from_slice(b);
Ok(role)
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.name[..self.len as usize]).unwrap_or_default()
}
}
impl fmt::Debug for BoxRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self.as_str(), f)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Front {
#[serde(default)]
pub door: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seed {
pub estate: String,
#[serde(default)]
pub tunnel: TunnelKind,
#[serde(default)]
pub front: Front,
pub kind: Kind,
pub installer_ca_pem: String,
pub bootstrap_cert_pem: String,
pub bootstrap_key_pem: String,
pub peers_expected: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mullvad: Option<MullvadSeed>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tailscale: Option<TailscaleSeed>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installer_link: Option<LinkSeed>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkSeed {
pub installer_pubkey: String,
pub vm_private: String,
#[serde(default = "link_default_port")]
pub port: u16,
}
fn link_default_port() -> u16 {
crate::installer_link::LINK_PORT
}
fn looks_like_key(s: &str) -> bool {
let t = s.trim();
t.len() == 44 && t.ends_with('=') && t[..43].chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')
}
pub const FEED_MULLVAD_ACCOUNT: &str = "mullvad_account";
pub const FEED_TAILSCALE_AUTHKEY: &str = "tailscale_authkey";
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct MullvadSeed {
#[serde(default)]
pub location: String,
#[serde(default = "mullvad_default_port")]
pub port: u16,
}
fn mullvad_default_port() -> u16 {
51820
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TailscaleSeed {
#[serde(default = "tailscale_default_control")]
pub control_url: String,
}
impl Default for TailscaleSeed {
fn default() -> Self {
TailscaleSeed { control_url: tailscale_default_control() }
}
}
fn tailscale_default_control() -> String {
"https://controlplane.tailscale.com".into()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedError {
NotToml(String),
Bad(&'static str, String),
}
impl fmt::Display for SeedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SeedError::NotToml(e) => write!(f, "REFUSED [seed-not-toml] the seed is not TOML: {e}"),
SeedError::Bad(field, why) => write!(f, "REFUSED [seed-{field}] the seed's `{field}` is unusable: {why}"),
}
}
}
impl std::error::Error for SeedError {}
impl Seed {
pub fn parse(text: &str) -> Result<Seed, SeedError> {
let s: Seed = toml::from_str(text).map_err(|e| SeedError::NotToml(e.to_string()))?;
s.check()?;
Ok(s)
}
pub fn render(&self) -> Result<String, SeedError> {
self.check()?;
toml::to_string(self).map_err(|e| SeedError::NotToml(e.to_string()))
}
pub fn door_addr(&self) -> Result<std::net::SocketAddrV4, SeedError> {
self.front.door.parse().map_err(|_| SeedError::Bad("front.door", format!("`{}` is not ipv4:port", self.front.door)))
}
pub fn check(&self) -> Result<(), SeedError> {
if self.estate.trim().is_empty() {
return Err(SeedError::Bad("estate", "empty".into()));
}
let pem = |field: &'static str, text: &str, begin: &str| -> Result<(), SeedError> {
if text.contains(begin) {
Ok(())
} else {
Err(SeedError::Bad(field, format!("has no `{begin}`")))
}
};
pem("installer_ca_pem", &self.installer_ca_pem, "-----BEGIN CERTIFICATE-----")?;
pem("bootstrap_cert_pem", &self.bootstrap_cert_pem, "-----BEGIN CERTIFICATE-----")?;
pem("bootstrap_key_pem", &self.bootstrap_key_pem, "PRIVATE KEY-----")?;
if self.peers_expected < 2 {
return Err(SeedError::Bad("peers_expected", "an estate is at least a front and one box".into()));
}
if !self.front.door.trim().is_empty() {
self.door_addr()?;
}
if let Some(m) = &self.mullvad {
if m.location.chars().any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')) {
return Err(SeedError::Bad("mullvad.location", format!("`{}` is not a Mullvad location code (`se-sto`) or country (`se`)", m.location)));
}
}
if let Some(t) = &self.tailscale {
if !t.control_url.starts_with("https://") {
return Err(SeedError::Bad("tailscale.control_url", format!("`{}` is not an https:// URL", t.control_url)));
}
}
if let Err(e @ crate::tunnel::TunnelError::InstallerLinkNone) = crate::installer_link::reach(self.tunnel) {
return Err(SeedError::Bad("tunnel", e.to_string()));
}
match &self.installer_link {
Some(l) => {
if !looks_like_key(&l.installer_pubkey) {
return Err(SeedError::Bad("installer_link.installer_pubkey", "not a WireGuard key (44 base64)".into()));
}
if !looks_like_key(&l.vm_private) {
return Err(SeedError::Bad("installer_link.vm_private", "not a WireGuard key (44 base64)".into()));
}
if l.port == 0 {
return Err(SeedError::Bad("installer_link.port", "0".into()));
}
}
None => {
return Err(SeedError::Bad(
"installer_link",
format!("REFUSED [installer-link-none] a `{}` medium carries no installer link: the installer reaches every VM through its WireGuard link only", self.tunnel.as_str()),
))
}
}
Ok(())
}
}
pub const SEED_FROM_METADATA: &str = "nordisk-pairing-seed-from = \"upcloud-metadata\"";
pub fn is_metadata_marker(text: &str) -> bool {
let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect();
lines == [SEED_FROM_METADATA]
}
pub mod metadata;
#[cfg(test)]
pub(crate) mod tests {
#[test]
fn the_metadata_marker_is_its_one_line() {
assert_eq!(SEED_FROM_METADATA, "nordisk-pairing-seed-from = \"upcloud-metadata\"");
assert!(is_metadata_marker(&format!("{SEED_FROM_METADATA}\n")));
assert!(is_metadata_marker(&format!("# a release medium\n\n {SEED_FROM_METADATA} \n")));
assert!(!is_metadata_marker("estate = \"acme\"\n[installer_link]\ninstaller_pubkey = \"x\"\n"));
assert!(!is_metadata_marker(&format!("{SEED_FROM_METADATA}\nestate = \"acme\"\n")), "a seed beside the marker is a seed");
assert!(!is_metadata_marker(""));
assert!(!is_metadata_marker("nordisk-pairing-seed-from = \"elsewhere\"\n"), "another source is not this marker");
}
use super::*;
pub(crate) fn test_link() -> LinkSeed {
LinkSeed { installer_pubkey: format!("{}=", "A".repeat(43)), vm_private: format!("{}=", "B".repeat(43)), port: 51821 }
}
pub(crate) fn a_seed(kind: Kind) -> (Seed, crate::mtls::InstallerCa) {
let ca = crate::mtls::InstallerCa::mint("gunnar.rs", std::time::Duration::from_secs(3600)).unwrap();
let boot = ca.issue_bootstrap(std::time::Duration::from_secs(3600)).unwrap();
let s = Seed {
estate: "gunnar.rs".into(),
tunnel: TunnelKind::Wireguard,
front: Front { door: "10.13.8.179:8474".into() },
kind,
installer_ca_pem: ca.cert_pem.clone(),
bootstrap_cert_pem: boot.cert_pem,
bootstrap_key_pem: boot.key_pem,
peers_expected: 3,
mullvad: None,
tailscale: None,
installer_link: Some(test_link()),
};
(s, ca)
}
#[test]
fn a_seed_round_trips_through_toml_names_no_box_and_wireguard_is_the_default() {
let (s, _) = a_seed(Kind::Gunnar);
let text = s.render().unwrap();
assert!(!text.contains("overlay") && !text.contains("\nname"), "nothing per box in a seed");
assert_eq!(Seed::parse(&text).unwrap(), s);
let without = text.replace("tunnel = \"wireguard\"\n", "");
assert_eq!(Seed::parse(&without).unwrap().tunnel, TunnelKind::Wireguard);
}
#[test]
fn a_seed_carries_no_tunnel_key_or_endpoint_and_an_old_one_still_parses() {
let (s, _) = a_seed(Kind::Gunnar);
let text = s.render().unwrap();
assert!(!text.contains("tunnel_pubkey") && !text.contains("endpoint"), "per-lay values are fed, not seeded:\n{text}");
let old = text.replace("[front]\n", "[front]\ntunnel_pubkey = \"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=\"\nendpoint = \"95.111.211.189:51820\"\n");
assert!(old.contains("tunnel_pubkey"), "the fixture really is an old seed:\n{old}");
assert_eq!(Seed::parse(&old).unwrap(), s, "the old fields are ignored");
}
#[test]
fn a_missing_certificate_and_a_bad_door_are_refused_by_name_and_no_tunnel_key_is_asked_for() {
let (mut s, _) = a_seed(Kind::Gunnar);
s.installer_ca_pem = "nope".into();
assert!(matches!(s.check(), Err(SeedError::Bad("installer_ca_pem", _))));
let (mut s, _) = a_seed(Kind::Gunnar);
s.bootstrap_key_pem.clear();
assert!(matches!(s.check(), Err(SeedError::Bad("bootstrap_key_pem", _))));
let (mut s, _) = a_seed(Kind::Gunnar);
s.front.door = "front:8474".into();
assert!(matches!(s.check(), Err(SeedError::Bad("front.door", _))));
let (mut s, _) = a_seed(Kind::Front);
assert_eq!(s.check(), Ok(()), "a WireGuard seed needs no front key: the door feeds it");
s.tunnel = TunnelKind::None;
assert!(matches!(s.check(), Err(SeedError::Bad("tunnel", ref why)) if why.starts_with("REFUSED [installer-link-none]")), "WireGuard + mutual TLS always");
s.tunnel = TunnelKind::Wireguard;
s.installer_link = None;
assert!(matches!(s.check(), Err(SeedError::Bad("installer_link", _))), "every medium carries the link");
}
#[test]
fn the_mullvad_and_tailscale_sections_are_optional_public_and_checked_by_name() {
let (mut s, _) = a_seed(Kind::Gunnar);
assert!(!s.render().unwrap().contains("[mullvad]"), "absent sections are not written");
s.tunnel = TunnelKind::Mullvad;
s.mullvad = Some(MullvadSeed { location: "se-sto".into(), port: 51820 });
let text = s.render().unwrap();
assert!(text.contains("[mullvad]") && !text.contains("account"), "no credential in a seed:\n{text}");
assert_eq!(Seed::parse(&text).unwrap(), s);
let bare = text.replace("port = 51820\n", "");
assert_eq!(Seed::parse(&bare).unwrap().mullvad.unwrap().port, 51820, "the default port");
s.mullvad = Some(MullvadSeed { location: "Stockholm; rm".into(), port: 51820 });
assert!(matches!(s.check(), Err(SeedError::Bad("mullvad.location", _))));
s.mullvad = None;
assert_eq!(s.check(), Ok(()), "mullvad's mesh is WireGuard via the front, whose key the door feeds");
s.tunnel = TunnelKind::Tailscale;
assert_eq!(s.check(), Ok(()), "tailscale needs no front WireGuard key");
s.tailscale = Some(TailscaleSeed { control_url: "http://plain".into() });
assert!(matches!(s.check(), Err(SeedError::Bad("tailscale.control_url", _))));
}
#[test]
fn a_box_kind_is_named_by_its_product_and_round_trips_through_toml() {
let fe = Kind::named("fe").unwrap();
assert_eq!(fe, Kind::Box(BoxRole::new("fe").unwrap()));
assert_eq!(fe.as_str(), "fe");
let (mut s, _) = a_seed(fe);
let text = s.render().unwrap();
assert!(text.contains("kind = \"fe\""), "{text}");
assert_eq!(Seed::parse(&text).unwrap().kind, fe);
for (k, name) in [(Kind::Front, "front"), (Kind::Gunnar, "gunnar"), (Kind::Holger, "holger")] {
s.kind = k;
assert!(s.render().unwrap().contains(&format!("kind = \"{name}\"")), "{name} keeps its spelling");
assert_eq!(Kind::named(name).unwrap(), k, "a reserved name is its old kind, never a box");
assert_eq!(k.as_str(), name);
}
let copied = fe;
assert_eq!(copied, fe, "Kind stays Copy");
}
#[test]
fn a_box_kind_name_is_checked_by_name() {
for bad in ["", "Fe", "f e", "1fe", "-fe", "fé", &"a".repeat(BoxRole::MAX + 1)] {
assert!(matches!(Kind::named(bad), Err(SeedError::Bad("kind", _))), "`{bad}` is refused");
}
assert!(BoxRole::new(&"a".repeat(BoxRole::MAX)).is_ok());
for reserved in ["front", "gunnar", "holger"] {
assert!(matches!(BoxRole::new(reserved), Err(SeedError::Bad("kind", _))), "`{reserved}` is an old kind");
}
let (s, _) = a_seed(Kind::Gunnar);
let text = s.render().unwrap().replace("kind = \"gunnar\"", "kind = \"Lake\"");
assert!(matches!(Seed::parse(&text), Err(SeedError::NotToml(e)) if e.contains("kind")), "a bad kind in a seed is refused");
}
#[test]
fn a_box_kind_is_a_box_and_is_not_elected() {
let lake = Kind::named("be").unwrap();
assert!(lake.is_box() && !lake.is_product() && !lake.is_elected());
assert!(Kind::Gunnar.is_product() && Kind::Holger.is_product() && !Kind::Front.is_product());
assert!(Kind::Gunnar.is_box() && Kind::Holger.is_box() && !Kind::Front.is_box());
assert!(Kind::Gunnar.is_elected() && Kind::Holger.is_elected());
assert_eq!(format!("{lake:?}"), "Box(\"be\")");
}
}