feat(netbird): manifest-driven migration via reusable orchestrator primitives
Migrate the netbird stack (server/dashboard/proxy) off ~500 lines of per-app Rust to 3 declarative manifests, adding 4 reusable primitives: - SecretGenKind::Base64 (netbird relay authSecret + sqlite store encryptionKey) - GeneratedCert schema + ensure_manifest_certs (self-signed TLS so the dashboard gets a secure context for OIDC PKCE — issue #15; https proxy on 8087 preserved) - templated GeneratedFile render: {{HOST_IP}}/{{HOST_MDNS}}/{{NETWORK_GATEWAY}} (aardvark resolver for the #15 stale-IP fix) /{{secret:NAME}} (never logged) - legacy create_container now honours port.protocol (3478/udp STUN) install_netbird_stack routes via the orchestrator first (legacy kept as fallback, mirroring indeedhub); launch URL derives https://{host_ip}:8087 from host facts. Legacy Rust deletion deferred to post-live-verify. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3c36cf1c40
commit
a8b9b0f5e8
@@ -8,8 +8,9 @@ pub mod runtime;
|
||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{
|
||||
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
|
||||
GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks, ManifestError,
|
||||
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedCert,
|
||||
GeneratedFile, GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks,
|
||||
ManifestError,
|
||||
ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy,
|
||||
Volume,
|
||||
};
|
||||
|
||||
@@ -223,6 +223,19 @@ pub struct ContainerConfig {
|
||||
#[serde(default)]
|
||||
pub generated_secrets: Vec<GeneratedSecret>,
|
||||
|
||||
/// Self-signed TLS certificates the orchestrator materialises before the
|
||||
/// container is created (so a bind-mounted cert path resolves to a real
|
||||
/// file, not a stale/missing path). Like `generated_secrets`, this keeps an
|
||||
/// app data-driven: a service that needs a secure context (e.g. netbird's
|
||||
/// dashboard — OIDC PKCE / `window.crypto.subtle` only works over HTTPS,
|
||||
/// issue #15) declares the cert here instead of relying on per-app Rust.
|
||||
/// Idempotent: an entry whose `crt` and `key` already exist is left
|
||||
/// untouched. SAN/CN templates are rendered against host facts at apply time.
|
||||
///
|
||||
/// Example: `- { crt: /var/lib/archipelago/netbird/tls.crt, key: /var/lib/archipelago/netbird/tls.key }`
|
||||
#[serde(default)]
|
||||
pub generated_certs: Vec<GeneratedCert>,
|
||||
|
||||
/// Rootless-mapped UID:GID applied to the container's data directory
|
||||
/// (the `bind`-mounted host path with `target` inside the container's
|
||||
/// data root) before creation. Mirrors `SPEC_DATA_UID`.
|
||||
@@ -261,6 +274,11 @@ pub enum SecretGenKind {
|
||||
Hex16,
|
||||
/// 32 random bytes, lowercase hex (64 chars). Longer keys/cookies.
|
||||
Hex32,
|
||||
/// 32 random bytes, standard base64 (44 chars incl. padding). For services
|
||||
/// that require a base64-encoded key rather than hex — e.g. netbird's relay
|
||||
/// `authSecret` and the SQLite store `encryptionKey`, which base64-decode
|
||||
/// their configured value (hex would decode to the wrong bytes).
|
||||
Base64,
|
||||
/// A random password and its bcrypt hash. `<name>` holds the bcrypt hash
|
||||
/// (what a server is configured with); the plaintext is stored alongside as
|
||||
/// `<name>.pw` for any client that must authenticate. `secret_env` injects
|
||||
@@ -282,12 +300,31 @@ impl GeneratedSecret {
|
||||
/// (primary first). A consumer references one of these via `secret_env`.
|
||||
pub fn target_files(&self) -> Vec<String> {
|
||||
match self.kind {
|
||||
SecretGenKind::Hex16 | SecretGenKind::Hex32 => vec![self.name.clone()],
|
||||
SecretGenKind::Hex16 | SecretGenKind::Hex32 | SecretGenKind::Base64 => {
|
||||
vec![self.name.clone()]
|
||||
}
|
||||
SecretGenKind::Bcrypt => vec![self.name.clone(), format!("{}.pw", self.name)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A self-signed TLS certificate materialised by the orchestrator. See
|
||||
/// [`ContainerConfig::generated_certs`]. `crt`/`key` are absolute host paths
|
||||
/// (typically under `/var/lib/archipelago/<app>/`) that the container
|
||||
/// bind-mounts read-only. `common_name` and `sans` are rendered against host
|
||||
/// facts (`{{HOST_IP}}`) at apply time; when omitted they default to the
|
||||
/// node's host IP plus `IP:127.0.0.1,DNS:localhost` so the cert is valid for
|
||||
/// however the box is reached locally.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GeneratedCert {
|
||||
pub crt: String,
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub common_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sans: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_pull_policy() -> String {
|
||||
"if-not-present".to_string()
|
||||
}
|
||||
@@ -665,6 +702,18 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
// generated_certs: crt/key must be non-empty absolute paths with no
|
||||
// traversal (they become bind-mount sources, same safety bar as files).
|
||||
for (i, c) in self.app.container.generated_certs.iter().enumerate() {
|
||||
for (field, val) in [("crt", &c.crt), ("key", &c.key)] {
|
||||
if val.is_empty() || !val.starts_with('/') || val.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"container.generated_certs[{i}].{field} must be an absolute path with no '..', got '{val}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// data_uid: if set, must look like "NNNNN:NNNNN".
|
||||
if let Some(u) = &self.app.container.data_uid {
|
||||
let parts: Vec<&str> = u.split(':').collect();
|
||||
@@ -1711,6 +1760,7 @@ app:
|
||||
],
|
||||
secret_env: vec![],
|
||||
generated_secrets: vec![],
|
||||
generated_certs: vec![],
|
||||
data_uid: None,
|
||||
};
|
||||
let facts = HostFacts {
|
||||
@@ -1762,6 +1812,7 @@ app:
|
||||
},
|
||||
],
|
||||
generated_secrets: vec![],
|
||||
generated_certs: vec![],
|
||||
data_uid: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
@@ -1799,6 +1850,7 @@ app:
|
||||
secret_file: "bitcoin-rpc-password".to_string(),
|
||||
}],
|
||||
generated_secrets: vec![],
|
||||
generated_certs: vec![],
|
||||
data_uid: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
|
||||
@@ -124,7 +124,9 @@ impl PodmanClient {
|
||||
"nginx-proxy-manager" => "http://localhost:8081",
|
||||
"fedimint-gateway" => "http://localhost:8176",
|
||||
"endurain" => "http://localhost:8080",
|
||||
"netbird" => "http://localhost:8087",
|
||||
// HTTPS: netbird's dashboard needs a secure context for OIDC PKCE
|
||||
// (window.crypto.subtle), so the proxy serves TLS on 8087 (issue #15).
|
||||
"netbird" => "https://localhost:8087",
|
||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||
_ => return None,
|
||||
};
|
||||
@@ -275,10 +277,18 @@ impl PodmanClient {
|
||||
// Build the container spec for the API
|
||||
let mut port_mappings = Vec::new();
|
||||
for port in &manifest.app.ports {
|
||||
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
||||
// silently break relay discovery.
|
||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||
"udp" => "udp",
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
port_mappings.push(serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
"protocol": "tcp",
|
||||
"protocol": protocol,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user