diff --git a/Scripts/authentik-sync-namespace-portal-oidc.sh b/Scripts/authentik-sync-namespace-portal-oidc.sh new file mode 100644 index 0000000..a62b0cf --- /dev/null +++ b/Scripts/authentik-sync-namespace-portal-oidc.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG:-namespace}" +application_name="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME:-Namespace Portal}" +provider_name="${AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME:-Namespace Portal}" +template_slug="${AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID:-nsc.burrow.net}" +client_secret="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL:-https://nsc.burrow.net/}" +redirect_uris_json="${AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON:-[ + \"https://nsc.burrow.net/oauth/callback\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-namespace-portal-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG + AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME + AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME + AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG + AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID + AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET + AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL + AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg client_secret "$client_secret" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: (if $client_secret == "" then "public" else "confidential" end), + client_id: $client_id, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + } + + (if $client_secret == "" then {} else {client_secret: $client_secret} end)' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Namespace portal OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: false, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Namespace portal OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Namespace portal OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index f4d646d..d824f6d 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -84,6 +84,7 @@ base_services=( nsc_services=( forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service + burrow-namespace-portal.service ) tailnet_services=( @@ -173,5 +174,8 @@ if command -v curl >/dev/null 2>&1; then curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true fi + if [[ "${EXPECT_NSC}" == "1" ]]; then + curl -fsS -o /dev/null -H 'Host: nsc.burrow.net' -w 'namespace_portal %{http_code}\n' http://127.0.0.1/ + fi fi EOF diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap index c40db25..68b4195 100644 --- a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/rpc/response.rs expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" --- -{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"name":null,"mtu":null}},"id":0} +{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 4ab7700..01591e7 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,6 +5,8 @@ use clap::{Args, Parser, Subcommand}; mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; +#[cfg(target_os = "linux")] +mod namespace_portal; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; @@ -60,6 +62,12 @@ enum Commands { ReloadConfig(ReloadConfigArgs), /// Authentication server AuthServer, + #[cfg(target_os = "linux")] + /// Admin portal for forge-owned Namespace authentication and NSC token minting + NamespacePortal, + #[cfg(target_os = "linux")] + /// Refresh the forge-owned Namespace dev token once + NamespaceRefreshToken, /// Server Status ServerStatus, /// Tunnel Config @@ -283,9 +291,7 @@ async fn try_tailnet_discover(email: &str) -> Result<()> { let mut client = BurrowClient::from_uds().await?; let response = client .tailnet_client - .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { - email: email.to_owned(), - }) + .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() }) .await? .into_inner(); println!("Tailnet Discover Response: {:?}", response); @@ -370,13 +376,9 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul "tailnet ping received {} bytes from daemon packet stream", packet.payload.len() ); - if let Some(reply) = parse_icmp_echo_reply( - &packet.payload, - local_ip, - remote_ip, - identifier, - sequence, - )? { + if let Some(reply) = + parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)? + { break Ok::<_, anyhow::Error>(reply); } } @@ -464,8 +466,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R let egress_task = tokio::spawn(async move { while let Some(packet) = stack_stream.next().await { - let payload = - packet.context("failed to read outbound packet from userspace stack")?; + let payload = packet.context("failed to read outbound packet from userspace stack")?; log::debug!( "tailnet udp echo sending {} bytes into daemon packet stream", payload.len() @@ -484,9 +485,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R .send((message.as_bytes().to_vec(), local_addr, remote_addr)) .await .context("failed to send UDP echo probe into userspace stack")?; - log::debug!( - "tailnet udp echo probe queued from {local_addr} to {remote_addr}" - ); + log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}"); let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) .await @@ -516,7 +515,10 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R } #[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result { +fn select_tailnet_local_ip( + addresses: &[String], + remote_ip: std::net::IpAddr, +) -> Result { use anyhow::Context; let family_is_v4 = remote_ip.is_ipv4(); @@ -765,6 +767,10 @@ async fn main() -> Result<()> { Commands::ServerConfig => try_serverconfig().await?, Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, Commands::AuthServer => crate::auth::server::serve().await?, + #[cfg(target_os = "linux")] + Commands::NamespacePortal => crate::namespace_portal::serve().await?, + #[cfg(target_os = "linux")] + Commands::NamespaceRefreshToken => crate::namespace_portal::refresh_token_once().await?, Commands::ServerStatus => try_serverstatus().await?, Commands::TunnelConfig => try_tun_config().await?, Commands::NetworkAdd(args) => { diff --git a/burrow/src/namespace_portal.rs b/burrow/src/namespace_portal.rs new file mode 100644 index 0000000..eb20775 --- /dev/null +++ b/burrow/src/namespace_portal.rs @@ -0,0 +1,880 @@ +#![cfg(target_os = "linux")] + +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use axum::{ + extract::{Query, State}, + http::{ + header::{COOKIE, LOCATION, SET_COOKIE}, + HeaderMap, HeaderValue, StatusCode, + }, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::RngCore; +use reqwest::Url; +use ring::digest::{digest, SHA256}; +use serde::Deserialize; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + sync::Mutex, +}; + +const SESSION_COOKIE: &str = "burrow_namespace_portal_session"; +const OIDC_TIMEOUT: Duration = Duration::from_secs(600); +const AUTH_CHECK_DURATION: &str = "10m"; + +#[derive(Clone, Debug)] +pub struct NamespacePortalConfig { + pub listen: String, + pub public_base_url: String, + pub oidc_discovery_url: String, + pub oidc_client_id: String, + pub oidc_client_secret: Option, + pub allowed_group: String, + pub nsc_bin: String, + pub nsc_state_dir: PathBuf, + pub token_output_path: PathBuf, +} + +impl Default for NamespacePortalConfig { + fn default() -> Self { + Self { + listen: "127.0.0.1:9080".to_owned(), + public_base_url: "https://nsc.burrow.net".to_owned(), + oidc_discovery_url: + "https://auth.burrow.net/application/o/namespace/.well-known/openid-configuration" + .to_owned(), + oidc_client_id: "nsc.burrow.net".to_owned(), + oidc_client_secret: None, + allowed_group: "burrow-admins".to_owned(), + nsc_bin: "nsc".to_owned(), + nsc_state_dir: PathBuf::from("/var/lib/burrow/namespace-portal/nsc"), + token_output_path: PathBuf::from("/var/lib/burrow/intake/forgejo_nsc_token.txt"), + } + } +} + +impl NamespacePortalConfig { + pub fn from_env() -> Self { + let mut config = Self::default(); + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_LISTEN") { + config.listen = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_BASE_URL") { + config.public_base_url = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL") { + config.oidc_discovery_url = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID") { + config.oidc_client_id = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET") { + let value = value.trim().to_owned(); + if !value.is_empty() { + config.oidc_client_secret = Some(value); + } + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP") { + config.allowed_group = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_BIN") { + config.nsc_bin = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR") { + config.nsc_state_dir = PathBuf::from(value); + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH") { + config.token_output_path = PathBuf::from(value); + } + config + } + + fn callback_url(&self) -> Result { + let mut url = Url::parse(&self.public_base_url) + .with_context(|| format!("invalid public base url {}", self.public_base_url))?; + url.set_path("/oauth/callback"); + url.set_query(None); + Ok(url.to_string()) + } + + fn ensure_paths(&self) -> Result<()> { + fs::create_dir_all(&self.nsc_state_dir).with_context(|| { + format!( + "failed to create namespace portal state dir {}", + self.nsc_state_dir.display() + ) + })?; + if let Some(parent) = self.token_output_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("failed to create token output dir {}", parent.display()) + })?; + } + Ok(()) + } +} + +#[derive(Clone)] +struct AppState { + config: NamespacePortalConfig, + client: reqwest::Client, + oidc: OidcDiscovery, + pending_logins: Arc>>, + sessions: Arc>>, + namespace: NamespaceSessionManager, +} + +#[derive(Clone, Debug, Deserialize)] +struct OidcDiscovery { + authorization_endpoint: String, + token_endpoint: String, + userinfo_endpoint: String, +} + +#[derive(Clone, Debug)] +struct PendingOidcLogin { + verifier: String, + expires_at: Instant, +} + +#[derive(Clone, Debug)] +struct PortalSession { + email: String, + display_name: String, + groups: Vec, + issued_at: Instant, +} + +#[derive(Debug, Deserialize)] +struct OidcCallbackQuery { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Debug, Deserialize)] +struct UserInfo { + #[serde(default)] + email: String, + #[serde(default)] + name: String, + #[serde(default)] + preferred_username: String, + #[serde(default)] + groups: Vec, +} + +#[derive(Clone)] +struct NamespaceSessionManager { + config: NamespacePortalConfig, + state: Arc>, +} + +#[derive(Clone, Debug, Default)] +struct NamespacePortalState { + active_login: Option, + last_error: Option, +} + +#[derive(Clone, Debug)] +struct ActiveNamespaceLogin { + login_url: String, +} + +#[derive(Clone, Debug)] +struct NamespaceStatus { + linked: bool, + login_url: Option, + last_error: Option, + token_present: bool, +} + +pub async fn serve() -> Result<()> { + serve_with_config(NamespacePortalConfig::from_env()).await +} + +pub async fn refresh_token_once() -> Result<()> { + let config = NamespacePortalConfig::from_env(); + config.ensure_paths()?; + NamespaceSessionManager::new(config).refresh_token().await +} + +pub async fn serve_with_config(config: NamespacePortalConfig) -> Result<()> { + config.ensure_paths()?; + let oidc = fetch_oidc_discovery(&config.oidc_discovery_url).await?; + let listen = config.listen.clone(); + let app = Router::new() + .route("/", get(index)) + .route("/healthz", get(healthz)) + .route("/login", get(oidc_login)) + .route("/logout", post(logout)) + .route("/oauth/callback", get(oidc_callback)) + .route("/namespace/link/start", post(namespace_link_start)) + .route("/namespace/token/refresh", post(namespace_token_refresh)) + .with_state(AppState { + config: config.clone(), + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?, + oidc, + pending_logins: Arc::new(Mutex::new(HashMap::new())), + sessions: Arc::new(Mutex::new(HashMap::new())), + namespace: NamespaceSessionManager::new(config), + }); + + let listener = tokio::net::TcpListener::bind(&listen).await?; + log::info!("Starting Namespace portal on {}", listen); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn fetch_oidc_discovery(discovery_url: &str) -> Result { + reqwest::Client::new() + .get(discovery_url) + .send() + .await + .with_context(|| format!("failed to fetch oidc discovery {}", discovery_url))? + .error_for_status() + .with_context(|| format!("oidc discovery returned non-success {}", discovery_url))? + .json() + .await + .context("failed to decode oidc discovery document") +} + +async fn healthz() -> impl IntoResponse { + StatusCode::OK +} + +async fn index(State(state): State, headers: HeaderMap) -> Response { + match current_session(&state, &headers).await { + Ok(Some(session)) => { + let namespace_status = match state.namespace.status().await { + Ok(status) => status, + Err(err) => NamespaceStatus { + linked: false, + login_url: None, + last_error: Some(err.to_string()), + token_present: false, + }, + }; + Html(render_dashboard(&state.config, &session, &namespace_status)).into_response() + } + Ok(None) => Html(render_login_page()).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(render_error_page(&format!("session lookup failed: {err}"))), + ) + .into_response(), + } +} + +async fn oidc_login(State(state): State) -> Result { + prune_pending(&state).await; + let state_token = random_url_token(32); + let verifier = random_url_token(48); + let challenge = pkce_challenge(&verifier); + let callback_url = state.config.callback_url().map_err(internal_error)?; + + state.pending_logins.lock().await.insert( + state_token.clone(), + PendingOidcLogin { + verifier, + expires_at: Instant::now() + OIDC_TIMEOUT, + }, + ); + + let mut url = Url::parse(&state.oidc.authorization_endpoint).map_err(internal_error)?; + url.query_pairs_mut() + .append_pair("client_id", &state.config.oidc_client_id) + .append_pair("response_type", "code") + .append_pair("scope", "openid profile email groups") + .append_pair("redirect_uri", &callback_url) + .append_pair("state", &state_token) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256"); + Ok(Redirect::to(url.as_str())) +} + +async fn oidc_callback( + State(state): State, + Query(query): Query, +) -> Result { + if let Some(error) = query.error { + let description = query.error_description.unwrap_or_default(); + return Err(( + StatusCode::BAD_GATEWAY, + format!("oidc login failed: {error} {description}") + .trim() + .to_owned(), + )); + } + + let code = query + .code + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc code".to_owned()))?; + let state_token = query + .state + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc state".to_owned()))?; + + let verifier = { + let mut pending = state.pending_logins.lock().await; + let Some(login) = pending.remove(&state_token) else { + return Err((StatusCode::BAD_REQUEST, "unknown oidc state".to_owned())); + }; + if login.expires_at <= Instant::now() { + return Err((StatusCode::BAD_REQUEST, "expired oidc state".to_owned())); + } + login.verifier + }; + + let callback_url = state.config.callback_url().map_err(internal_error)?; + + let mut params = vec![ + ("grant_type", "authorization_code".to_owned()), + ("code", code), + ("client_id", state.config.oidc_client_id.clone()), + ("redirect_uri", callback_url), + ("code_verifier", verifier), + ]; + if let Some(secret) = &state.config.oidc_client_secret { + params.push(("client_secret", secret.clone())); + } + + let token = state + .client + .post(&state.oidc.token_endpoint) + .form(¶ms) + .send() + .await + .context("failed to exchange oidc code") + .map_err(internal_error)? + .error_for_status() + .context("oidc token endpoint returned non-success") + .map_err(internal_error)? + .json::() + .await + .context("failed to decode oidc token response") + .map_err(internal_error)?; + + let userinfo = state + .client + .get(&state.oidc.userinfo_endpoint) + .bearer_auth(&token.access_token) + .send() + .await + .context("failed to fetch oidc userinfo") + .map_err(internal_error)? + .error_for_status() + .context("oidc userinfo returned non-success") + .map_err(internal_error)? + .json::() + .await + .context("failed to decode oidc userinfo") + .map_err(internal_error)?; + + if !userinfo + .groups + .iter() + .any(|group| group == &state.config.allowed_group) + { + return Err(( + StatusCode::FORBIDDEN, + format!( + "authenticated user is not in required group {}", + state.config.allowed_group + ), + )); + } + + let session_id = random_url_token(32); + state.sessions.lock().await.insert( + session_id.clone(), + PortalSession { + email: userinfo.email.clone(), + display_name: display_name(&userinfo), + groups: userinfo.groups, + issued_at: Instant::now(), + }, + ); + + let mut response = Redirect::to("/").into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&session_cookie_value(&session_id)).map_err(internal_error)?, + ); + Ok(response) +} + +async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + if let Some(session_id) = session_cookie(&headers) { + state.sessions.lock().await.remove(&session_id); + } + let mut response = Redirect::to("/").into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_static( + "burrow_namespace_portal_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", + ), + ); + Ok(response) +} + +async fn namespace_link_start( + State(state): State, + headers: HeaderMap, +) -> Result { + require_session(&state, &headers).await?; + state + .namespace + .start_login() + .await + .map_err(internal_error)?; + Ok(Redirect::to("/")) +} + +async fn namespace_token_refresh( + State(state): State, + headers: HeaderMap, +) -> Result { + require_session(&state, &headers).await?; + state + .namespace + .refresh_token() + .await + .map_err(internal_error)?; + Ok(Redirect::to("/")) +} + +fn render_login_page() -> String { + r#" + + + + + Burrow Namespace Portal + + + +
+

Burrow Namespace Portal

+

Authenticate with burrow.net to manage the dedicated Namespace session that backs Forgejo NSC automation.

+ Sign in with burrow.net +
+ +"# + .to_owned() +} + +fn render_dashboard( + config: &NamespacePortalConfig, + session: &PortalSession, + status: &NamespaceStatus, +) -> String { + let refresh = if status.login_url.is_some() { + r#""# + } else { + "" + }; + let login_action = if let Some(url) = &status.login_url { + format!( + "

Namespace Login In Progress

Open the live Namespace URL below with the dedicated Burrow account. This page will refresh automatically until the server-side session is ready.

Open Namespace Login

", + escape_html(url) + ) + } else if status.linked { + "

Namespace Linked

The forge-owned NSC session is authenticated and ready to mint runner tokens.

".to_owned() + } else { + "

Namespace Not Linked

Start a server-side Namespace login. The portal will produce a Namespace URL, and completing that browser flow will authenticate the forge-owned NSC state directory.

".to_owned() + }; + let error = status + .last_error + .as_ref() + .map(|error| format!("

{}

", escape_html(error))) + .unwrap_or_default(); + let token_state = if status.token_present { + "present" + } else { + "missing" + }; + format!( + r#" + + + + + Burrow Namespace Portal + {refresh} + + + +
+
+
+

Burrow Namespace Portal

+

Signed in as {email}. This page controls the forge-owned NSC session and token material for Forgejo Namespace runners.

+
+
+
+ +
+
+
burrow.net identity
{identity}
+
required group
{group}
+
NSC token file
{token_path}
+
current token
{token_state}
+
+
+ + {login_action} + {error} + +
+

Actions

+
+
+
+
+
+
+ +"#, + refresh = refresh, + email = escape_html(&session.email), + identity = escape_html(&session.display_name), + group = escape_html(&config.allowed_group), + token_path = escape_html(&config.token_output_path.display().to_string()), + token_state = token_state, + login_action = login_action, + error = error, + ) +} + +fn render_error_page(message: &str) -> String { + format!( + r#"

Namespace Portal Error

{}

"#, + escape_html(message) + ) +} + +fn display_name(userinfo: &UserInfo) -> String { + if !userinfo.name.trim().is_empty() { + return userinfo.name.trim().to_owned(); + } + if !userinfo.preferred_username.trim().is_empty() { + return userinfo.preferred_username.trim().to_owned(); + } + userinfo.email.clone() +} + +async fn current_session(state: &AppState, headers: &HeaderMap) -> Result> { + let Some(session_id) = session_cookie(headers) else { + return Ok(None); + }; + Ok(state.sessions.lock().await.get(&session_id).cloned()) +} + +async fn require_session( + state: &AppState, + headers: &HeaderMap, +) -> Result { + current_session(state, headers) + .await + .map_err(internal_error)? + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "sign-in required".to_owned())) +} + +async fn prune_pending(state: &AppState) { + state + .pending_logins + .lock() + .await + .retain(|_, login| login.expires_at > Instant::now()); +} + +fn session_cookie(headers: &HeaderMap) -> Option { + let cookie_header = headers.get(COOKIE)?.to_str().ok()?; + for pair in cookie_header.split(';') { + let mut parts = pair.trim().splitn(2, '='); + let name = parts.next()?.trim(); + let value = parts.next()?.trim(); + if name == SESSION_COOKIE && !value.is_empty() { + return Some(value.to_owned()); + } + } + None +} + +fn session_cookie_value(session_id: &str) -> String { + format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax") +} + +fn random_url_token(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = digest(&SHA256, verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest.as_ref()) +} + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn internal_error(err: impl std::fmt::Display) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} + +impl NamespaceSessionManager { + fn new(config: NamespacePortalConfig) -> Self { + Self { + config, + state: Arc::new(Mutex::new(NamespacePortalState::default())), + } + } + + async fn status(&self) -> Result { + let linked = self.check_login().await.is_ok(); + let state = self.state.lock().await.clone(); + let token_present = tokio::fs::metadata(&self.config.token_output_path) + .await + .is_ok(); + Ok(NamespaceStatus { + linked, + login_url: state.active_login.map(|login| login.login_url), + last_error: state.last_error, + token_present, + }) + } + + async fn start_login(&self) -> Result { + if self.check_login().await.is_ok() { + self.refresh_token().await?; + return Ok("already linked".to_owned()); + } + + { + let state = self.state.lock().await; + if let Some(active) = &state.active_login { + return Ok(active.login_url.clone()); + } + } + + self.config.ensure_paths()?; + let mut command = self.base_command(); + command + .args(["auth", "login", "--browser=false"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn().context("failed to spawn nsc auth login")?; + let stdout = child + .stdout + .take() + .context("nsc auth login stdout was not piped")?; + let mut lines = BufReader::new(stdout).lines(); + let mut login_url = None; + while let Some(line) = lines.next_line().await? { + if let Some(candidate) = extract_namespace_login_url(&line) { + login_url = Some(candidate); + break; + } + } + + let login_url = login_url + .ok_or_else(|| anyhow!("nsc auth login did not emit a Namespace login URL"))?; + { + let mut state = self.state.lock().await; + state.active_login = Some(ActiveNamespaceLogin { login_url: login_url.clone() }); + state.last_error = None; + } + + let manager = self.clone(); + tokio::spawn(async move { + let outcome = child.wait().await; + let mut state = manager.state.lock().await; + state.active_login = None; + match outcome { + Ok(status) if status.success() => { + drop(state); + if let Err(err) = manager.refresh_token().await { + manager.state.lock().await.last_error = Some(format!( + "Namespace login finished, but token refresh failed: {err}" + )); + } + } + Ok(status) => { + state.last_error = Some(format!( + "Namespace login command exited with status {}", + status + )); + } + Err(err) => { + state.last_error = Some(format!("Namespace login command failed: {err}")); + } + } + }); + + Ok(login_url) + } + + async fn refresh_token(&self) -> Result<()> { + self.config.ensure_paths()?; + self.check_login().await?; + let mut command = self.base_command(); + command.args([ + "auth", + "generate-dev-token", + "--output_to", + self.config + .token_output_path + .to_str() + .ok_or_else(|| anyhow!("token output path is not valid UTF-8"))?, + ]); + let output = command + .output() + .await + .context("failed to run nsc token refresh")?; + if !output.status.success() { + bail!( + "nsc auth generate-dev-token failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + #[cfg(target_family = "unix")] + { + use std::os::unix::fs::PermissionsExt; + + let perms = fs::Permissions::from_mode(0o440); + fs::set_permissions(&self.config.token_output_path, perms).with_context(|| { + format!( + "failed to set permissions on {}", + self.config.token_output_path.display() + ) + })?; + } + self.state.lock().await.last_error = None; + Ok(()) + } + + async fn check_login(&self) -> Result<()> { + let mut command = self.base_command(); + command.args(["auth", "check-login", "--duration", AUTH_CHECK_DURATION]); + let output = command + .output() + .await + .context("failed to run nsc auth check-login")?; + if output.status.success() { + return Ok(()); + } + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + + fn base_command(&self) -> Command { + let mut command = Command::new(&self.config.nsc_bin); + let home = self.config.nsc_state_dir.join("home"); + let data = self.config.nsc_state_dir.join("data"); + let cache = self.config.nsc_state_dir.join("cache"); + let config = self.config.nsc_state_dir.join("config"); + let _ = fs::create_dir_all(&home); + let _ = fs::create_dir_all(&data); + let _ = fs::create_dir_all(&cache); + let _ = fs::create_dir_all(&config); + command + .env("HOME", &home) + .env("XDG_DATA_HOME", &data) + .env("XDG_CACHE_HOME", &cache) + .env("XDG_CONFIG_HOME", &config); + command + } +} + +fn extract_namespace_login_url(line: &str) -> Option { + line.split_whitespace() + .find(|token| token.starts_with("https://")) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_namespace_login_url_from_output() { + let url = extract_namespace_login_url( + " https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o", + ); + assert_eq!( + url.as_deref(), + Some("https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o") + ); + } + + #[test] + fn pkce_challenge_is_stable() { + assert_eq!( + pkce_challenge("hello"), + "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" + ); + } + + #[test] + fn parses_session_cookie() { + let mut headers = HeaderMap::new(); + headers.insert( + COOKIE, + HeaderValue::from_static( + "something=else; burrow_namespace_portal_session=session123; another=value", + ), + ); + assert_eq!(session_cookie(&headers).as_deref(), Some("session123")); + } +} diff --git a/flake.nix b/flake.nix index 1e91dcc..0bba0b1 100644 --- a/flake.nix +++ b/flake.nix @@ -94,6 +94,7 @@ pkgs.stdenvNoCC.mkDerivation { pname = "nsc"; inherit version src; + meta.mainProgram = "nsc"; dontConfigure = true; dontBuild = true; unpackPhase = '' @@ -144,6 +145,35 @@ subPackages = [ "./cmd/forgejo-nsc-autoscaler" ]; vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs="; }; + burrowSrc = lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + p = toString path; + name = builtins.baseNameOf path; + hasDir = dir: lib.hasInfix "/${dir}/" p || lib.hasSuffix "/${dir}" p; + in + !(hasDir ".git" || hasDir "target" || hasDir "node_modules" || name == "result"); + }; + burrowPkg = pkgs.rustPlatform.buildRustPackage { + pname = "burrow"; + version = "0.1.0"; + src = burrowSrc; + cargoLock = { + lockFile = ./Cargo.lock; + outputHashes = { + "tracing-oslog-0.1.2" = "sha256-DjJDiPCTn43zJmmOfuRnyti8iQf9qoXICMKIx4bAG3I="; + }; + }; + cargoBuildFlags = [ + "-p" + "burrow" + "--bin" + "burrow" + ]; + nativeBuildInputs = [ pkgs.protobuf ]; + meta.mainProgram = "burrow"; + }; in { devShells.default = pkgs.mkShell { @@ -171,6 +201,7 @@ packages = { agenix = agenix.packages.${system}.agenix; + burrow = burrowPkg; hcloud-upload-image = hcloudUploadImagePkg; forgejo-nsc-dispatcher = forgejoNscDispatcher; forgejo-nsc-autoscaler = forgejoNscAutoscaler; @@ -183,6 +214,7 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; + nixosModules.burrow-namespace-portal = import ./nixos/modules/burrow-namespace-portal.nix; nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; diff --git a/nixos/README.md b/nixos/README.md index c79d8ce..13fe76d 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -12,6 +12,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC +- `modules/burrow-namespace-portal.nix`: small admin portal for forge-owned Namespace authentication and NSC token refresh - `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets - `hetzner-cloud-config.yaml`: desired Hetzner host shape - `keys/contact_at_burrow_net.pub`: initial operator SSH public key @@ -24,6 +25,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host - `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists - `../Scripts/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host +- `../Scripts/authentik-sync-namespace-portal-oidc.sh`: reconcile the Authentik OIDC app used by `nsc.burrow.net` ## Intended Flow @@ -33,10 +35,11 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. 6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module. -7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. -8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. -9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. -10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. +7. Visit `https://nsc.burrow.net/` as a Burrow admin to link the forge-owned Namespace session and rotate `/var/lib/burrow/intake/forgejo_nsc_token.txt` without relying on a personal local `nsc` login. +8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. +9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, `nsc.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. +10. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. +11. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. ## Current Constraints diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 75b76d4..aecdbfa 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,6 +33,7 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale + self.nixosModules.burrow-namespace-portal ]; system.stateVersion = "24.11"; @@ -89,8 +90,8 @@ in }; networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net ''; services.burrow.forge = { @@ -140,4 +141,11 @@ in enable = true; oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; }; + + services.burrow.namespacePortal = { + enable = true; + domain = "nsc.burrow.net"; + baseUrl = "https://nsc.burrow.net"; + adminGroup = contributors.groups.admins; + }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 1616b36..e2ee18d 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,6 +10,7 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; + namespacePortalOidcSyncScript = ../../Scripts/authentik-sync-namespace-portal-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; @@ -138,6 +139,30 @@ in description = "Authentik application slug for Tailscale custom OIDC sign-in."; }; + namespacePortalDomain = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Public domain for the Burrow Namespace portal."; + }; + + namespacePortalProviderSlug = lib.mkOption { + type = lib.types.str; + default = "namespace"; + description = "Authentik application slug for the Namespace portal."; + }; + + namespacePortalClientId = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Client ID Authentik should present to the Namespace portal."; + }; + + namespacePortalClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional host-local file containing the Authentik Namespace portal OIDC client secret."; + }; + tailscaleClientId = lib.mkOption { type = lib.types.str; default = "tailscale.burrow.net"; @@ -708,6 +733,56 @@ EOF ''; }; + systemd.services.burrow-authentik-namespace-portal-oidc = { + description = "Reconcile the Burrow Authentik Namespace portal OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = + [ + namespacePortalOidcSyncScript + cfg.envFile + ] + ++ lib.optionals (cfg.namespacePortalClientSecretFile != null) [ cfg.namespacePortalClientSecretFile ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.namespacePortalProviderSlug} + export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME="Namespace Portal" + export AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME="Namespace Portal" + export AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID=${lib.escapeShellArg cfg.namespacePortalClientId} + ${lib.optionalString (cfg.namespacePortalClientSecretFile != null) '' + export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.namespacePortalClientSecretFile})" + ''} + export AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL=https://${cfg.namespacePortalDomain}/ + export AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON='["https://${cfg.namespacePortalDomain}/oauth/callback"]' + + ${pkgs.bash}/bin/bash ${namespacePortalOidcSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-namespace-portal.nix b/nixos/modules/burrow-namespace-portal.nix new file mode 100644 index 0000000..2eb7b24 --- /dev/null +++ b/nixos/modules/burrow-namespace-portal.nix @@ -0,0 +1,126 @@ +{ config, lib, pkgs, self, ... }: + +let + cfg = config.services.burrow.namespacePortal; + burrowExe = lib.getExe self.packages.${pkgs.system}.burrow; + nscExe = lib.getExe self.packages.${pkgs.system}.nsc; +in +{ + options.services.burrow.namespacePortal = { + enable = lib.mkEnableOption "the Burrow Namespace authentication portal"; + + domain = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Public domain for the Namespace portal."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9080; + description = "Local listen port for the Namespace portal."; + }; + + baseUrl = lib.mkOption { + type = lib.types.str; + default = "https://nsc.burrow.net"; + description = "Public base URL for redirects."; + }; + + oidcProviderSlug = lib.mkOption { + type = lib.types.str; + default = "namespace"; + description = "Authentik provider slug used for the portal."; + }; + + oidcClientId = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "OIDC client ID used by the portal."; + }; + + oidcClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional host-local OIDC client secret for the portal."; + }; + + adminGroup = lib.mkOption { + type = lib.types.str; + default = "burrow-admins"; + description = "Authentik group required to access the portal."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/namespace-portal"; + description = "Persistent state directory for the portal-owned NSC session."; + }; + + tokenOutputPath = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; + description = "Path where refreshed NSC tokens should be written."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = config.services.forgejo-nsc.enable; + message = "services.burrow.namespacePortal requires services.forgejo-nsc.enable"; + } + ]; + + systemd.tmpfiles.rules = [ + "d ${cfg.stateDir} 0750 forgejo-nsc forgejo-nsc -" + "d ${cfg.stateDir}/nsc 0750 forgejo-nsc forgejo-nsc -" + ]; + + systemd.services.burrow-namespace-portal = { + description = "Burrow Namespace authentication portal"; + after = [ + "network-online.target" + "burrow-authentik-ready.service" + ]; + wants = [ + "network-online.target" + "burrow-authentik-ready.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + self.packages.${pkgs.system}.burrow + self.packages.${pkgs.system}.nsc + pkgs.coreutils + ]; + serviceConfig = { + Type = "simple"; + User = "forgejo-nsc"; + Group = "forgejo-nsc"; + WorkingDirectory = cfg.stateDir; + Restart = "on-failure"; + RestartSec = "2s"; + }; + script = '' + set -euo pipefail + export BURROW_NAMESPACE_PORTAL_LISTEN=127.0.0.1:${toString cfg.port} + export BURROW_NAMESPACE_PORTAL_BASE_URL=${lib.escapeShellArg cfg.baseUrl} + export BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL=${lib.escapeShellArg "https://${config.services.burrow.authentik.domain}/application/o/${cfg.oidcProviderSlug}/.well-known/openid-configuration"} + export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID=${lib.escapeShellArg cfg.oidcClientId} + export BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP=${lib.escapeShellArg cfg.adminGroup} + export BURROW_NAMESPACE_PORTAL_NSC_BIN=${lib.escapeShellArg nscExe} + export BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR=${lib.escapeShellArg "${cfg.stateDir}/nsc"} + export BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH=${lib.escapeShellArg cfg.tokenOutputPath} + ${lib.optionalString (cfg.oidcClientSecretFile != null) '' + export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" + ''} + exec ${burrowExe} namespace-portal + ''; + }; + + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:${toString cfg.port} + ''; + }; +}