diff --git a/Scripts/authentik-sync-namespace-portal-oidc.sh b/Scripts/authentik-sync-namespace-portal-oidc.sh deleted file mode 100644 index a62b0cf..0000000 --- a/Scripts/authentik-sync-namespace-portal-oidc.sh +++ /dev/null @@ -1,246 +0,0 @@ -#!/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 d824f6d..0f79bf4 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -84,7 +84,6 @@ base_services=( nsc_services=( forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - burrow-namespace-portal.service ) tailnet_services=( @@ -165,6 +164,14 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then test -s /run/agenix/burrowHeadscaleOidcClientSecret fi +if [[ "${EXPECT_NSC}" == "1" ]]; then + echo "== agenix-nsc ==" + ls -l /run/agenix || true + test -s /run/agenix/burrowForgejoNscToken + test -s /run/agenix/burrowForgejoNscDispatcherConfig + test -s /run/agenix/burrowForgejoNscAutoscalerConfig +fi + if command -v curl >/dev/null 2>&1; then echo "== http-local ==" curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login @@ -174,8 +181,5 @@ 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/Scripts/seal-forgejo-nsc-secrets.sh b/Scripts/seal-forgejo-nsc-secrets.sh new file mode 100755 index 0000000..a6b3918 --- /dev/null +++ b/Scripts/seal-forgejo-nsc-secrets.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: Scripts/seal-forgejo-nsc-secrets.sh [options] + +Encrypt Burrow forgejo-nsc runtime inputs from intake/ into the agenix secrets +consumed by burrow-forge. + +Options: + --provision Re-render the local intake files before sealing. + --host SSH target forwarded to provision-forgejo-nsc.sh. + --ssh-key SSH private key forwarded to provision-forgejo-nsc.sh. + --nsc-bin Override the nsc binary for provisioning. + -h, --help Show this help text. +EOF +} + +PROVISION=0 +HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +NSC_BIN="${NSC_BIN:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --provision) + PROVISION=1 + shift + ;; + --host) + HOST="${2:?missing value for --host}" + shift 2 + ;; + --ssh-key) + SSH_KEY="${2:?missing value for --ssh-key}" + shift 2 + ;; + --nsc-bin) + NSC_BIN="${2:?missing value for --nsc-bin}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd age +require_cmd nix +require_cmd python3 + +if [[ "${PROVISION}" -eq 1 ]]; then + provision_args=(--host "${HOST}" --ssh-key "${SSH_KEY}") + if [[ -n "${NSC_BIN}" ]]; then + provision_args+=(--nsc-bin "${NSC_BIN}") + fi + "${SCRIPT_DIR}/provision-forgejo-nsc.sh" "${provision_args[@]}" +fi + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "${tmpdir}" +} +trap cleanup EXIT + +seal_secret() { + local target="$1" + local source_path="$2" + recipients_file="${tmpdir}/$(basename "${target}").recipients" + if [[ ! -s "${source_path}" ]]; then + echo "required runtime input missing or empty: ${source_path}" >&2 + exit 1 + fi + nix eval --impure --json --expr "let s = import ${REPO_ROOT}/secrets.nix; in s.\"${target}\".publicKeys" \ + | python3 -c 'import json, sys; [print(item) for item in json.load(sys.stdin)]' \ + > "${recipients_file}" + + age -R "${recipients_file}" -o "${REPO_ROOT}/${target}" "${source_path}" +} + +seal_secret "secrets/infra/forgejo-nsc-token.age" "${REPO_ROOT}/intake/forgejo_nsc_token.txt" +seal_secret "secrets/infra/forgejo-nsc-dispatcher-config.age" "${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" +seal_secret "secrets/infra/forgejo-nsc-autoscaler-config.age" "${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" + +chmod 600 \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-token.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-dispatcher-config.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-autoscaler-config.age" + +echo "Sealed forgejo-nsc runtime inputs into:" +printf ' %s\n' \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-token.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-dispatcher-config.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-autoscaler-config.age" +echo "Deploy burrow-forge to apply the new CI credentials." diff --git a/Scripts/sync-forgejo-nsc-config.sh b/Scripts/sync-forgejo-nsc-config.sh index 77581f8..2ce7114 100755 --- a/Scripts/sync-forgejo-nsc-config.sh +++ b/Scripts/sync-forgejo-nsc-config.sh @@ -1,132 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -usage() { - cat <<'EOF' -Usage: Scripts/sync-forgejo-nsc-config.sh [options] - -Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and -restart the dispatcher/autoscaler units. - -Options: - --host SSH target (default: root@git.burrow.net) - --ssh-key SSH private key (default: intake/agent_at_burrow_net_ed25519) - --rotate-pat Re-render the intake files before syncing. - --no-restart Copy files only. - -h, --help Show this help text. -EOF -} - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - -HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" -KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" -ROTATE_PAT=0 -NO_RESTART=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --host) - HOST="${2:?missing value for --host}" - shift 2 - ;; - --ssh-key) - SSH_KEY="${2:?missing value for --ssh-key}" - shift 2 - ;; - --rotate-pat) - ROTATE_PAT=1 - shift - ;; - --no-restart) - NO_RESTART=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown option: $1" >&2 - usage >&2 - exit 64 - ;; - esac -done - -mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" - -burrow_require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - exit 1 - fi -} - -burrow_require_cmd ssh -burrow_require_cmd scp - -if [[ ! -f "${SSH_KEY}" ]]; then - echo "forge SSH key not found: ${SSH_KEY}" >&2 - exit 1 -fi - -if [[ "${ROTATE_PAT}" -eq 1 ]]; then - "${SCRIPT_DIR}/provision-forgejo-nsc.sh" --host "${HOST}" --ssh-key "${SSH_KEY}" -fi - -token_file="${REPO_ROOT}/intake/forgejo_nsc_token.txt" -dispatcher_file="${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" -autoscaler_file="${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" - -for path in "${token_file}" "${dispatcher_file}" "${autoscaler_file}"; do - if [[ ! -s "${path}" ]]; then - echo "required runtime input missing or empty: ${path}" >&2 - exit 1 - fi -done - -ssh_opts=( - -i "${SSH_KEY}" - -o IdentitiesOnly=yes - -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" - -o StrictHostKeyChecking=accept-new -) - -remote_tmp="$(ssh "${ssh_opts[@]}" "${HOST}" "mktemp -d")" -cleanup() { - if [[ -n "${remote_tmp:-}" ]]; then - ssh "${ssh_opts[@]}" "${HOST}" "rm -rf '${remote_tmp}'" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -scp "${ssh_opts[@]}" \ - "${token_file}" \ - "${dispatcher_file}" \ - "${autoscaler_file}" \ - "${HOST}:${remote_tmp}/" - -ssh "${ssh_opts[@]}" "${HOST}" " - set -euo pipefail - install -d -m 0755 /var/lib/burrow/intake - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${token_file}")' /var/lib/burrow/intake/forgejo_nsc_token.txt - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${dispatcher_file}")' /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${autoscaler_file}")' /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml -" - -if [[ "${NO_RESTART}" -eq 0 ]]; then - ssh "${ssh_opts[@]}" "${HOST}" " - set -euo pipefail - systemctl restart forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - systemctl is-active forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - ls -l \ - /var/lib/burrow/intake/forgejo_nsc_token.txt \ - /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml \ - /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml - " -fi - -echo "forgejo-nsc runtime sync complete (host=${HOST}, restarted=$((1 - NO_RESTART)))." +echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2 +echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2 +echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2 +exit 1 diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 01591e7..cfa2085 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,8 +5,6 @@ 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; @@ -62,12 +60,6 @@ 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 @@ -767,10 +759,6 @@ 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 deleted file mode 100644 index eb20775..0000000 --- a/burrow/src/namespace_portal.rs +++ /dev/null @@ -1,880 +0,0 @@ -#![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 0bba0b1..1974f17 100644 --- a/flake.nix +++ b/flake.nix @@ -214,8 +214,6 @@ 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"; specialArgs = { diff --git a/nixos/README.md b/nixos/README.md index 13fe76d..23907f3 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -12,7 +12,6 @@ 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,8 +23,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `../Scripts/cloudflare-upsert-a-record.sh`: upsert DNS-only Cloudflare `A` records for Burrow host cutovers - `../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` +- `../Scripts/seal-forgejo-nsc-secrets.sh`: encrypt forgejo-nsc runtime inputs into the agenix secrets consumed by `burrow-forge` ## Intended Flow @@ -34,16 +32,17 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`. 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. 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. +6. Run `Scripts/provision-forgejo-nsc.sh` locally to refresh `intake/forgejo_nsc_token.txt`, `intake/forgejo_nsc_dispatcher.yaml`, and `intake/forgejo_nsc_autoscaler.yaml`. +7. Run `Scripts/seal-forgejo-nsc-secrets.sh` to encrypt those runtime inputs into the agenix secrets used by `burrow-forge`. +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`, `secrets/infra/headscale-oidc-client-secret.age`, `secrets/infra/forgejo-nsc-token.age`, `secrets/infra/forgejo-nsc-dispatcher-config.age`, and `secrets/infra/forgejo-nsc-autoscaler-config.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`, 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 -- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`, and `Scripts/check-forge-host.sh --expect-nsc` passes locally against that host. +- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`. +- `services.forgejo-nsc` now expects agenix-backed runtime inputs at `/run/agenix/burrowForgejoNscToken`, `/run/agenix/burrowForgejoNscDispatcherConfig`, and `/run/agenix/burrowForgejoNscAutoscalerConfig`. - Authentik and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host. - Public Burrow forge cutover completed on March 15, 2026: - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21` diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index aecdbfa..7f6af22 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,7 +33,6 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale - self.nixosModules.burrow-namespace-portal ]; system.stateVersion = "24.11"; @@ -88,10 +87,28 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowForgejoNscToken = { + file = ../../../secrets/infra/forgejo-nsc-token.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; + age.secrets.burrowForgejoNscDispatcherConfig = { + file = ../../../secrets/infra/forgejo-nsc-dispatcher-config.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; + age.secrets.burrowForgejoNscAutoscalerConfig = { + file = ../../../secrets/infra/forgejo-nsc-autoscaler-config.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; networking.extraHosts = '' - 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 + 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 ''; services.burrow.forge = { @@ -113,13 +130,13 @@ in services.forgejo-nsc = { enable = true; - nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; + nscTokenFile = config.age.secrets.burrowForgejoNscToken.path; dispatcher = { - configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml"; + configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path; }; autoscaler = { enable = true; - configFile = "/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml"; + configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.path; }; }; @@ -141,11 +158,4 @@ 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 e2ee18d..1616b36 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,7 +10,6 @@ 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; @@ -139,30 +138,6 @@ 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"; @@ -733,56 +708,6 @@ 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 deleted file mode 100644 index 2eb7b24..0000000 --- a/nixos/modules/burrow-namespace-portal.nix +++ /dev/null @@ -1,126 +0,0 @@ -{ 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} - ''; - }; -} diff --git a/secrets.nix b/secrets.nix index c0b9b53..a8fb923 100644 --- a/secrets.nix +++ b/secrets.nix @@ -16,6 +16,9 @@ in "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-autoscaler-config.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/forgejo-nsc-autoscaler-config.age b/secrets/infra/forgejo-nsc-autoscaler-config.age new file mode 100644 index 0000000..28e3d4a Binary files /dev/null and b/secrets/infra/forgejo-nsc-autoscaler-config.age differ diff --git a/secrets/infra/forgejo-nsc-dispatcher-config.age b/secrets/infra/forgejo-nsc-dispatcher-config.age new file mode 100644 index 0000000..5ef71b5 Binary files /dev/null and b/secrets/infra/forgejo-nsc-dispatcher-config.age differ diff --git a/secrets/infra/forgejo-nsc-token.age b/secrets/infra/forgejo-nsc-token.age new file mode 100644 index 0000000..ff8c278 --- /dev/null +++ b/secrets/infra/forgejo-nsc-token.age @@ -0,0 +1,15 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q yCjzc3QW91l62Y+U2YZqLpTkiZyTJAxQQCiZ+DxHiWI +mG/+2fppo3RITeohTM/Dm1M6fsErtxhOgIeI2FqvoUs +-> ssh-ed25519 IrZmAg +Y59O8SVATZfe8Vu2gis1KNWcL34Ct7M3G34XNURczw +GGkVYcmoUtJRx4zftjLFID2wLtNtCgGVnYuMN8XF74s +-> X25519 xqDMDV9XRhSPlFy2IJPBfpUGuNA9gpX73kg8Pnj48VI +TPZZNrRUK+FzruetDFuJcTzed03d7gkxOv8QAZshBn8 +--- PRD84efdrqDmPeRA8zi0D2V8RmT0tFVbDIVD6U/4KVo +2Wk*cS++j9{4j;`wd3,"gligЇ e`''# "'(=LS3hFjgYIF|0$Fp^` +QknUx78b!>n?9^!=ͮ [a ` ϫ_#?T@]Eβ[,g퟇cjx}.̞f45֕DLH4_HdwXwXkRx7DM,0 7*TU{~ä8yC "/oXCe8-ulYt ;ҖDZdm wFyiIώɅ8F}l"Isu{L!+UBei_Z~D>B)L>