399 lines
13 KiB
Rust
399 lines
13 KiB
Rust
//! Private enrollment for the optional manifest-owned public-web router.
|
|
//! Secrets never enter website state, status responses, or generated content.
|
|
use anyhow::{bail, Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use std::path::Path;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::sync::Mutex;
|
|
|
|
static LOCK: Mutex<()> = Mutex::const_new(());
|
|
#[derive(Clone, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Enrollment {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub node_id: String,
|
|
pub transport_token: String,
|
|
pub enrollment_token: String,
|
|
pub ca_pem: String,
|
|
pub tls_server_name: String,
|
|
pub domains: Vec<String>,
|
|
}
|
|
#[derive(Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Config {
|
|
schema: u32,
|
|
gateway: Enrollment,
|
|
certificate_mode: String,
|
|
routes: Vec<WebsiteRoute>,
|
|
}
|
|
#[derive(Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct WebsiteRoute {
|
|
#[serde(default)]
|
|
app_id: Option<String>,
|
|
id: String,
|
|
domain: String,
|
|
fips_address: String,
|
|
port: u16,
|
|
}
|
|
fn name(value: &str) -> bool {
|
|
!value.is_empty()
|
|
&& value.len() <= 48
|
|
&& value
|
|
.bytes()
|
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
|
&& value.as_bytes()[0] != b'-'
|
|
}
|
|
impl Enrollment {
|
|
fn validate(&self) -> Result<()> {
|
|
for host in [&self.host, &self.tls_server_name] {
|
|
if host.parse::<std::net::IpAddr>().is_err() {
|
|
anyhow::ensure!(
|
|
super::hostname(host)? == *host,
|
|
"Use a lowercase gateway hostname"
|
|
);
|
|
}
|
|
}
|
|
anyhow::ensure!(
|
|
self.port >= 1024 && name(&self.node_id),
|
|
"Invalid gateway port or node enrollment name"
|
|
);
|
|
for token in [&self.transport_token, &self.enrollment_token] {
|
|
anyhow::ensure!(
|
|
(32..=256).contains(&token.len()) && !token.chars().any(char::is_control),
|
|
"Invalid gateway credential"
|
|
);
|
|
}
|
|
anyhow::ensure!(
|
|
self.ca_pem.len() <= 16384
|
|
&& self.ca_pem.starts_with("-----BEGIN CERTIFICATE-----")
|
|
&& !self.ca_pem.contains("PRIVATE KEY"),
|
|
"Supply the gateway CA certificate, never a private key"
|
|
);
|
|
reqwest::Certificate::from_pem(self.ca_pem.as_bytes())
|
|
.context("Invalid gateway CA certificate")?;
|
|
anyhow::ensure!(
|
|
!self.domains.is_empty() && self.domains.len() <= 32,
|
|
"Gateway enrollment needs assigned domains"
|
|
);
|
|
for domain in &self.domains {
|
|
anyhow::ensure!(
|
|
super::hostname(domain)? == *domain,
|
|
"Use lowercase assigned domains"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
async fn load(root: &Path) -> Result<Option<Config>> {
|
|
let path = root.join("public-web-router/config/router.json");
|
|
match tokio::fs::read(path).await {
|
|
Ok(bytes) => {
|
|
anyhow::ensure!(bytes.len() <= 131072, "Gateway configuration exceeds limit");
|
|
Ok(Some(
|
|
serde_json::from_slice(&bytes).context("Invalid private gateway configuration")?,
|
|
))
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
async fn store(root: &Path, config: &Config) -> Result<()> {
|
|
anyhow::ensure!(
|
|
config.routes.len() <= 32,
|
|
"Gateway supports at most 32 routes"
|
|
);
|
|
let dir = root.join("public-web-router/config");
|
|
tokio::fs::create_dir_all(&dir).await?;
|
|
let bytes = serde_json::to_vec(config)?;
|
|
anyhow::ensure!(bytes.len() <= 131072, "Gateway configuration exceeds limit");
|
|
let stage = dir.join(format!(".router-{}", uuid::Uuid::new_v4()));
|
|
let mut opts = tokio::fs::OpenOptions::new();
|
|
opts.create_new(true).write(true);
|
|
#[cfg(unix)]
|
|
opts.mode(0o600);
|
|
let mut file = opts.open(&stage).await?;
|
|
file.write_all(&bytes).await?;
|
|
file.sync_all().await?;
|
|
tokio::fs::rename(&stage, dir.join("router.json")).await?;
|
|
tokio::fs::File::open(&dir).await?.sync_all().await?;
|
|
Ok(())
|
|
}
|
|
fn public_status(config: Option<&Config>) -> Value {
|
|
match config {
|
|
None => json!({"configured":false,"routes":[],"externally_verified":false}),
|
|
Some(c) => {
|
|
json!({"configured":true,"host":c.gateway.host,"port":c.gateway.port,"domains":c.gateway.domains,"certificate_mode":c.certificate_mode,"routes":c.routes.iter().map(|r| json!({"id":r.id,"domain":r.domain})).collect::<Vec<_>>(),"externally_verified":false})
|
|
}
|
|
}
|
|
}
|
|
pub async fn status(root: &Path) -> Result<Value> {
|
|
Ok(public_status(load(root).await?.as_ref()))
|
|
}
|
|
pub async fn configure(root: &Path, enrollment: Enrollment, mode: String) -> Result<Value> {
|
|
let _guard = LOCK.lock().await;
|
|
enrollment.validate()?;
|
|
anyhow::ensure!(
|
|
matches!(mode.as_str(), "public" | "test"),
|
|
"Choose public or test certificates"
|
|
);
|
|
// A changed enrollment never silently sends existing sites to a new gateway.
|
|
let config = Config {
|
|
schema: 1,
|
|
gateway: enrollment,
|
|
certificate_mode: mode,
|
|
routes: vec![],
|
|
};
|
|
store(root, &config).await?;
|
|
Ok(public_status(Some(&config)))
|
|
}
|
|
pub async fn route(
|
|
root: &Path,
|
|
id: &str,
|
|
enabled: bool,
|
|
fips: Option<std::net::Ipv6Addr>,
|
|
) -> Result<Value> {
|
|
let _guard = LOCK.lock().await;
|
|
let mut config = load(root).await?.context("Connect your gateway first")?;
|
|
if enabled {
|
|
let state = super::load(root).await?;
|
|
let project = state
|
|
.projects
|
|
.get(id)
|
|
.context("Website project not found")?;
|
|
anyhow::ensure!(
|
|
project.routes.contains(&super::Route::PublicWeb),
|
|
"Select public web and save this website first"
|
|
);
|
|
let domain = project
|
|
.domain
|
|
.as_ref()
|
|
.context("Save this website's domain first")?
|
|
.hostname
|
|
.clone();
|
|
anyhow::ensure!(
|
|
config.gateway.domains.contains(&domain),
|
|
"This domain is not assigned by your gateway enrollment"
|
|
);
|
|
let publication = project
|
|
.fips_publication
|
|
.as_ref()
|
|
.context("Publish the website upstream first")?;
|
|
anyhow::ensure!(
|
|
(32000..32032).contains(&publication.port),
|
|
"Invalid website listener"
|
|
);
|
|
let address = fips.context("FIPS is unavailable; start the node connection first")?;
|
|
anyhow::ensure!(address.octets()[0] == 0xfd, "FIPS must use a ULA address");
|
|
if config
|
|
.routes
|
|
.iter()
|
|
.any(|r| r.domain == domain && r.id != id)
|
|
{
|
|
bail!("This domain already routes another website");
|
|
}
|
|
config.routes.retain(|r| r.id != id);
|
|
config.routes.push(WebsiteRoute {
|
|
app_id: None,
|
|
id: id.to_owned(),
|
|
domain,
|
|
fips_address: address.to_string(),
|
|
port: publication.port,
|
|
});
|
|
} else {
|
|
config.routes.retain(|r| r.id != id);
|
|
}
|
|
store(root, &config).await?;
|
|
Ok(public_status(Some(&config)))
|
|
}
|
|
/// Caller resolves the port from the live, guest-enabled catalogue app gate.
|
|
pub async fn app_route(
|
|
root: &Path,
|
|
app_id: &str,
|
|
domain: &str,
|
|
enabled: bool,
|
|
address: Option<std::net::Ipv6Addr>,
|
|
port: Option<u16>,
|
|
) -> Result<Value> {
|
|
let _guard = LOCK.lock().await;
|
|
anyhow::ensure!(name(app_id), "Invalid app identity");
|
|
let mut config = load(root).await?.context("Connect your gateway first")?;
|
|
let id = format!("app-{app_id}");
|
|
anyhow::ensure!(name(&id), "App identity is too long for a gateway route");
|
|
if enabled {
|
|
let domain = super::hostname(domain)?;
|
|
anyhow::ensure!(
|
|
config.gateway.domains.contains(&domain),
|
|
"This domain is not assigned by your gateway enrollment"
|
|
);
|
|
anyhow::ensure!(
|
|
!config
|
|
.routes
|
|
.iter()
|
|
.any(|r| r.domain == domain && r.id != id),
|
|
"This domain already routes another service"
|
|
);
|
|
let address = address.context("FIPS is unavailable")?;
|
|
anyhow::ensure!(address.octets()[0] == 0xfd, "FIPS must use a ULA address");
|
|
let port = port.context("This app does not currently allow guest sharing")?;
|
|
anyhow::ensure!(port >= 1024, "Invalid gated app port");
|
|
config.routes.retain(|r| r.id != id);
|
|
config.routes.push(WebsiteRoute {
|
|
id,
|
|
app_id: Some(app_id.to_owned()),
|
|
domain,
|
|
fips_address: address.to_string(),
|
|
port,
|
|
});
|
|
} else {
|
|
config.routes.retain(|r| r.id != id);
|
|
}
|
|
anyhow::ensure!(
|
|
config.routes.len() <= 32,
|
|
"Gateway supports at most 32 routes"
|
|
);
|
|
store(root, &config).await?;
|
|
Ok(public_status(Some(&config)))
|
|
}
|
|
pub async fn disconnect(root: &Path) -> Result<Value> {
|
|
let _guard = LOCK.lock().await;
|
|
let path = root.join("public-web-router/config/router.json");
|
|
match tokio::fs::remove_file(path).await {
|
|
Ok(()) => (),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
Ok(public_status(None))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
fn config() -> Config {
|
|
Config {
|
|
schema: 1,
|
|
gateway: Enrollment {
|
|
host: "gateway.example".into(),
|
|
port: 7400,
|
|
node_id: "node-a".into(),
|
|
transport_token: "secret-transport-value".repeat(3),
|
|
enrollment_token: "secret-enrollment-value".repeat(3),
|
|
ca_pem: "test-certificate".into(),
|
|
tls_server_name: "gateway.example".into(),
|
|
domains: vec!["site.example".into()],
|
|
},
|
|
certificate_mode: "test".into(),
|
|
routes: vec![],
|
|
}
|
|
}
|
|
#[tokio::test]
|
|
async fn private_enrollment_is_never_returned_and_disconnect_preserves_certificates() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let c = config();
|
|
store(dir.path(), &c).await.unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
assert_eq!(
|
|
tokio::fs::metadata(dir.path().join("public-web-router/config/router.json"))
|
|
.await
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o600
|
|
);
|
|
}
|
|
let status = status(dir.path()).await.unwrap().to_string();
|
|
assert!(!status.contains("secret"));
|
|
assert!(!status.contains("test-certificate"));
|
|
assert!(status.contains("gateway.example"));
|
|
let data = dir.path().join("public-web-router/data");
|
|
tokio::fs::create_dir_all(&data).await.unwrap();
|
|
tokio::fs::write(data.join("certificate-marker"), b"preserve")
|
|
.await
|
|
.unwrap();
|
|
disconnect(dir.path()).await.unwrap();
|
|
assert!(load(dir.path()).await.unwrap().is_none());
|
|
assert_eq!(
|
|
tokio::fs::read(data.join("certificate-marker"))
|
|
.await
|
|
.unwrap(),
|
|
b"preserve"
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn refuses_routing_unsaved_projects_and_never_accepts_raw_targets() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
store(dir.path(), &config()).await.unwrap();
|
|
assert!(route(
|
|
dir.path(),
|
|
"missing",
|
|
true,
|
|
Some("fd00::1".parse().unwrap())
|
|
)
|
|
.await
|
|
.is_err());
|
|
assert!(load(dir.path()).await.unwrap().unwrap().routes.is_empty());
|
|
}
|
|
#[tokio::test]
|
|
async fn app_routes_require_resolved_guest_port_and_assigned_domain() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
store(dir.path(), &config()).await.unwrap();
|
|
let address = Some("fd00::1".parse().unwrap());
|
|
assert!(app_route(
|
|
dir.path(),
|
|
"photoprism",
|
|
"site.example",
|
|
true,
|
|
address,
|
|
None
|
|
)
|
|
.await
|
|
.is_err());
|
|
assert!(app_route(
|
|
dir.path(),
|
|
"photoprism",
|
|
"unassigned.example",
|
|
true,
|
|
address,
|
|
Some(2342)
|
|
)
|
|
.await
|
|
.is_err());
|
|
app_route(
|
|
dir.path(),
|
|
"photoprism",
|
|
"site.example",
|
|
true,
|
|
address,
|
|
Some(2342),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
load(dir.path()).await.unwrap().unwrap().routes[0]
|
|
.app_id
|
|
.as_deref(),
|
|
Some("photoprism")
|
|
);
|
|
app_route(dir.path(), "photoprism", "", false, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert!(load(dir.path()).await.unwrap().unwrap().routes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn enrollment_rejects_invalid_certificates_and_names() {
|
|
let mut c = config();
|
|
assert!(c.gateway.validate().is_err());
|
|
c.gateway.node_id = "../another-node".into();
|
|
assert!(c.gateway.validate().is_err());
|
|
assert!(!name(""));
|
|
assert!(!name("-node"));
|
|
assert!(name("node-a"));
|
|
}
|
|
}
|