use std::collections::HashSet;
use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::noise::Psk;
pub const DEFAULT_MAX_CONNS: usize = 64;
fn default_max_conns() -> usize {
DEFAULT_MAX_CONNS
}
pub fn normalize_host(host: &str) -> String {
let host = host.trim();
host.strip_suffix('.').unwrap_or(host).to_ascii_lowercase()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tenant {
pub name: String,
pub psk: Psk,
pub sni: String,
#[serde(default = "default_max_conns")]
pub max_conns: usize,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollCode {
pub psk: Psk,
pub sni: String,
#[serde(default = "default_max_conns")]
pub max_conns: usize,
#[serde(default)]
pub expires_at: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Identity {
Tenant(Tenant),
Code(EnrollCode),
}
#[derive(Debug, Clone, Default)]
pub struct Candidates {
pub psks: Vec<Psk>,
ids: Vec<Identity>,
}
impl Candidates {
pub fn identity(&self, index: usize) -> &Identity {
&self.ids[index]
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TenantSet {
tenants: Vec<Tenant>,
codes: Vec<EnrollCode>,
}
impl TenantSet {
pub fn new(tenants: Vec<Tenant>, codes: Vec<EnrollCode>) -> Result<Self> {
let mut names = HashSet::new();
let mut hosts = HashSet::new();
let mut psks = HashSet::new();
for t in &tenants {
if t.name.is_empty() {
bail!("tenant has an empty name");
}
if !names.insert(t.name.clone()) {
bail!("duplicate tenant name '{}'", t.name);
}
check_entry(&t.sni, t.max_conns, &t.name)?;
if !hosts.insert(normalize_host(&t.sni)) {
bail!("duplicate sni '{}' (tenant '{}')", t.sni, t.name);
}
if !psks.insert(t.psk) {
bail!("tenant '{}' shares a PSK with another entry", t.name);
}
}
for c in &codes {
check_entry(&c.sni, c.max_conns, "enrollment code")?;
if !psks.insert(c.psk) {
bail!("an enrollment code shares a PSK with another entry");
}
}
Ok(Self { tenants, codes })
}
pub fn tenants(&self) -> &[Tenant] {
&self.tenants
}
pub fn codes(&self) -> &[EnrollCode] {
&self.codes
}
pub fn tenant(&self, name: &str) -> Option<&Tenant> {
self.tenants.iter().find(|t| t.name == name)
}
pub fn tenant_by_sni(&self, sni: &str) -> Option<&Tenant> {
let wanted = normalize_host(sni);
self.tenants
.iter()
.find(|t| !t.disabled && normalize_host(&t.sni) == wanted)
}
pub fn candidates(&self, now_unix: u64) -> Candidates {
let mut out = Candidates::default();
for t in self.tenants.iter().filter(|t| !t.disabled) {
out.psks.push(t.psk);
out.ids.push(Identity::Tenant(t.clone()));
}
for c in self
.codes
.iter()
.filter(|c| c.expires_at.is_none_or(|at| now_unix < at))
{
out.psks.push(c.psk);
out.ids.push(Identity::Code(c.clone()));
}
out
}
}
fn check_entry(sni: &str, max_conns: usize, what: &str) -> Result<()> {
if normalize_host(sni).is_empty() {
bail!("{what} has an empty sni");
}
if max_conns == 0 {
bail!("{what} has max_conns = 0");
}
Ok(())
}
fn random_hex(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::fill(&mut buf[..]);
buf.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn new_tenant_name() -> String {
format!("t-{}", random_hex(6))
}
pub fn new_token() -> String {
random_hex(32)
}
pub fn new_enroll_code(
sni: &str,
max_conns: usize,
expires_at: Option<u64>,
) -> (String, EnrollCode) {
let code = random_hex(16);
let entry = EnrollCode {
psk: crate::noise::derive_psk(&code),
sni: sni.to_string(),
max_conns,
expires_at,
};
(code, entry)
}
impl TenantSet {
pub fn with_tenant(&self, tenant: Tenant) -> Result<Self> {
let mut tenants = self.tenants.clone();
tenants.push(tenant);
Self::new(tenants, self.codes.clone())
}
pub fn with_code(&self, code: EnrollCode) -> Result<Self> {
let host = normalize_host(&code.sni);
if let Some(t) = self.tenants.iter().find(|t| normalize_host(&t.sni) == host) {
bail!("sni '{}' already belongs to tenant '{}'", code.sni, t.name);
}
let mut codes = self.codes.clone();
codes.push(code);
Self::new(self.tenants.clone(), codes)
}
pub fn without_tenant(&self, name: &str) -> Result<Self> {
if self.tenant(name).is_none() {
bail!("no tenant named '{name}'");
}
let tenants = self
.tenants
.iter()
.filter(|t| t.name != name)
.cloned()
.collect();
Self::new(tenants, self.codes.clone())
}
pub fn with_disabled(&self, name: &str, disabled: bool) -> Result<Self> {
if self.tenant(name).is_none() {
bail!("no tenant named '{name}'");
}
let mut tenants = self.tenants.clone();
for t in tenants.iter_mut().filter(|t| t.name == name) {
t.disabled = disabled;
}
Self::new(tenants, self.codes.clone())
}
pub fn redeem(
&self,
code_psk: &Psk,
now_unix: u64,
name: String,
psk: Psk,
) -> Result<(Self, Tenant)> {
let code = self
.codes
.iter()
.find(|c| c.psk == *code_psk && c.expires_at.is_none_or(|at| now_unix < at))
.context("unknown, used or expired enrollment code")?;
let tenant = Tenant {
name,
psk,
sni: code.sni.clone(),
max_conns: code.max_conns,
disabled: false,
};
let codes = self
.codes
.iter()
.filter(|c| c.psk != *code_psk && c.expires_at.is_none_or(|at| now_unix < at))
.cloned()
.collect();
let mut tenants = self.tenants.clone();
tenants.push(tenant.clone());
Ok((Self::new(tenants, codes)?, tenant))
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct TenantFile {
#[serde(default)]
tenant: Vec<Tenant>,
#[serde(default)]
code: Vec<EnrollCode>,
}
impl TenantSet {
pub fn from_toml(text: &str) -> Result<Self> {
let file: TenantFile = toml::from_str(text).context("parsing tenant file")?;
Self::new(file.tenant, file.code)
}
pub fn to_toml(&self) -> Result<String> {
let file = TenantFile {
tenant: self.tenants.clone(),
code: self.codes.clone(),
};
toml::to_string_pretty(&file).context("serialising tenant file")
}
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => Self::from_toml(&text)
.with_context(|| format!("loading tenant file {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::noise::derive_psk;
fn tenant(name: &str, token: &str, sni: &str) -> Tenant {
Tenant {
name: name.into(),
psk: derive_psk(token),
sni: sni.into(),
max_conns: 8,
disabled: false,
}
}
fn code(token: &str, sni: &str, expires_at: Option<u64>) -> EnrollCode {
EnrollCode {
psk: derive_psk(token),
sni: sni.into(),
max_conns: 8,
expires_at,
}
}
#[test]
fn sni_lookup_ignores_case_and_trailing_dot() {
let set = TenantSet::new(vec![tenant("a", "ta", "Abc.Example.com")], vec![]).unwrap();
assert_eq!(set.tenant_by_sni("abc.example.COM.").unwrap().name, "a");
assert!(set.tenant_by_sni("other.example.com").is_none());
}
#[test]
fn disabled_tenants_are_neither_routed_nor_candidates() {
let mut t = tenant("a", "ta", "a.example.com");
t.disabled = true;
let set = TenantSet::new(vec![t], vec![]).unwrap();
assert!(set.tenant_by_sni("a.example.com").is_none());
assert!(set.candidates(0).psks.is_empty());
}
#[test]
fn expired_codes_are_not_candidates() {
let set = TenantSet::new(
vec![],
vec![
code("fresh", "a.x", Some(200)),
code("stale", "b.x", Some(100)),
],
)
.unwrap();
let c = set.candidates(150);
assert_eq!(c.psks, vec![derive_psk("fresh")]);
assert!(matches!(c.identity(0), Identity::Code(_)));
assert!(set.candidates(100).psks.len() == 1, "expiry is exclusive");
}
#[test]
fn candidates_map_back_to_their_identity() {
let set = TenantSet::new(
vec![tenant("a", "ta", "a.x"), tenant("b", "tb", "b.x")],
vec![code("c1", "c.x", None)],
)
.unwrap();
let c = set.candidates(0);
let at = |token: &str| c.psks.iter().position(|p| *p == derive_psk(token)).unwrap();
assert!(matches!(c.identity(at("tb")), Identity::Tenant(t) if t.name == "b"));
assert!(matches!(c.identity(at("c1")), Identity::Code(_)));
}
#[test]
fn ambiguous_sets_are_rejected() {
let dup_name = TenantSet::new(
vec![tenant("a", "t1", "a.x"), tenant("a", "t2", "b.x")],
vec![],
);
let dup_host = TenantSet::new(
vec![tenant("a", "t1", "A.x"), tenant("b", "t2", "a.X")],
vec![],
);
let dup_psk = TenantSet::new(
vec![tenant("a", "same", "a.x"), tenant("b", "same", "b.x")],
vec![],
);
let code_psk = TenantSet::new(
vec![tenant("a", "same", "a.x")],
vec![code("same", "z.x", None)],
);
let empty_sni = TenantSet::new(vec![tenant("a", "t", " ")], vec![]);
let zero = {
let mut t = tenant("a", "t", "a.x");
t.max_conns = 0;
TenantSet::new(vec![t], vec![])
};
for (what, r) in [
("duplicate name", dup_name),
("duplicate host", dup_host),
("duplicate psk", dup_psk),
("code psk clash", code_psk),
("empty sni", empty_sni),
("zero max_conns", zero),
] {
assert!(r.is_err(), "{what} should be rejected");
}
}
#[test]
fn toml_round_trip_stores_the_psk_as_hex() {
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct File {
tenant: Vec<Tenant>,
}
let original = File {
tenant: vec![tenant("a", "ta", "a.x")],
};
let text = toml::to_string(&original).unwrap();
assert!(text.contains(&format!(
"psk = \"{}\"",
hex_of(derive_psk("ta").as_bytes())
)));
assert!(!text.contains("\"ta\""), "the token must never be stored");
assert_eq!(toml::from_str::<File>(&text).unwrap(), original);
}
#[test]
fn generated_names_tokens_and_codes_are_well_formed_and_unique() {
let mut seen = HashSet::new();
for _ in 0..1000 {
let name = new_tenant_name();
assert!(name.starts_with("t-") && name.len() == 14, "{name}");
let token = new_token();
assert_eq!(token.len(), 64);
let (code, entry) = new_enroll_code("a.x", 4, None);
assert_eq!(code.len(), 32);
assert_eq!(entry.psk, derive_psk(&code));
assert!(seen.insert(name) && seen.insert(token) && seen.insert(code));
}
}
#[test]
fn malformed_psk_is_rejected_on_load() {
let bad = "name = \"a\"\npsk = \"zz\"\nsni = \"a.x\"\n";
assert!(toml::from_str::<Tenant>(bad).is_err());
}
fn hex_of(b: &[u8; 32]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
}
#[cfg(test)]
mod redaction_tests {
use super::*;
#[test]
fn debug_output_never_contains_the_psk() {
let tenant = Tenant {
name: "t".into(),
psk: Psk::from_bytes([0xab; 32]),
sni: "a.example.com".into(),
max_conns: 1,
disabled: false,
};
let code = EnrollCode {
psk: Psk::from_bytes([0xcd; 32]),
sni: "a.example.com".into(),
max_conns: 1,
expires_at: None,
};
let shown = format!("{tenant:?} {code:?}");
assert!(shown.contains("<redacted>"));
assert!(!shown.contains("171") && !shown.contains("205"), "{shown}");
}
}
#[cfg(test)]
mod prune_tests {
use super::*;
#[test]
fn redeeming_drops_other_expired_codes() {
let code = |b: u8, expires_at| EnrollCode {
psk: Psk::from_bytes([b; 32]),
sni: format!("h{b}.example.com"),
max_conns: 1,
expires_at,
};
let set = TenantSet::new(
vec![],
vec![code(1, None), code(2, Some(50)), code(3, Some(500))],
)
.unwrap();
let (next, _) = set
.redeem(
&Psk::from_bytes([1; 32]),
100,
"t-x".into(),
Psk::from_bytes([9; 32]),
)
.unwrap();
let left: Vec<u8> = next.codes().iter().map(|c| c.psk.as_bytes()[0]).collect();
assert_eq!(left, vec![3]);
}
}