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:
archipelago
2026-06-23 13:39:53 -04:00
co-authored by Claude Opus 4.8
parent 3c36cf1c40
commit a8b9b0f5e8
10 changed files with 695 additions and 21 deletions
+3 -2
View File
@@ -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,
};
+53 -1
View File
@@ -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 {
+12 -2
View File
@@ -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,
}));
}