Move forgejo-nsc credentials into agenix
Some checks are pending
Build Rust / Cargo Test (push) Waiting to run
Build Site / Next.js Build (push) Waiting to run
Lint Governance / BEP Metadata (push) Waiting to run

This commit is contained in:
Conrad Kramer 2026-04-05 23:08:23 -07:00
parent e40a947223
commit 70607e874c
15 changed files with 172 additions and 1495 deletions

View file

@ -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})."

View file

@ -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

View file

@ -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 <user@host> SSH target forwarded to provision-forgejo-nsc.sh.
--ssh-key <path> SSH private key forwarded to provision-forgejo-nsc.sh.
--nsc-bin <path> 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."

View file

@ -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 <user@host> SSH target (default: root@git.burrow.net)
--ssh-key <path> 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
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
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)))."

View file

@ -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) => {

View file

@ -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<String>,
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<String> {
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<Mutex<HashMap<String, PendingOidcLogin>>>,
sessions: Arc<Mutex<HashMap<String, PortalSession>>>,
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<String>,
issued_at: Instant,
}
#[derive(Debug, Deserialize)]
struct OidcCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[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<String>,
}
#[derive(Clone)]
struct NamespaceSessionManager {
config: NamespacePortalConfig,
state: Arc<Mutex<NamespacePortalState>>,
}
#[derive(Clone, Debug, Default)]
struct NamespacePortalState {
active_login: Option<ActiveNamespaceLogin>,
last_error: Option<String>,
}
#[derive(Clone, Debug)]
struct ActiveNamespaceLogin {
login_url: String,
}
#[derive(Clone, Debug)]
struct NamespaceStatus {
linked: bool,
login_url: Option<String>,
last_error: Option<String>,
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<OidcDiscovery> {
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<AppState>, 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<AppState>) -> Result<Redirect, (StatusCode, String)> {
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<AppState>,
Query(query): Query<OidcCallbackQuery>,
) -> Result<Response, (StatusCode, String)> {
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(&params)
.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::<TokenResponse>()
.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::<UserInfo>()
.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<AppState>,
headers: HeaderMap,
) -> Result<Response, (StatusCode, String)> {
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<AppState>,
headers: HeaderMap,
) -> Result<Redirect, (StatusCode, String)> {
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<AppState>,
headers: HeaderMap,
) -> Result<Redirect, (StatusCode, String)> {
require_session(&state, &headers).await?;
state
.namespace
.refresh_token()
.await
.map_err(internal_error)?;
Ok(Redirect::to("/"))
}
fn render_login_page() -> String {
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Burrow Namespace Portal</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; background: #0b1020; color: #eef3ff; margin: 0; }
main { max-width: 32rem; margin: 8rem auto; padding: 2rem; background: rgba(19, 28, 52, 0.82); border-radius: 1.5rem; box-shadow: 0 24px 64px rgba(0,0,0,0.28); }
h1 { margin-top: 0; font-size: 1.8rem; }
p { color: #c3cee8; line-height: 1.5; }
a.button { display: inline-block; margin-top: 1rem; padding: 0.85rem 1.25rem; border-radius: 999px; text-decoration: none; color: #08201e; background: linear-gradient(135deg, #6df2d4, #7bd1ff); font-weight: 700; }
</style>
</head>
<body>
<main>
<h1>Burrow Namespace Portal</h1>
<p>Authenticate with <strong>burrow.net</strong> to manage the dedicated Namespace session that backs Forgejo NSC automation.</p>
<a class="button" href="/login">Sign in with burrow.net</a>
</main>
</body>
</html>"#
.to_owned()
}
fn render_dashboard(
config: &NamespacePortalConfig,
session: &PortalSession,
status: &NamespaceStatus,
) -> String {
let refresh = if status.login_url.is_some() {
r#"<meta http-equiv="refresh" content="3">"#
} else {
""
};
let login_action = if let Some(url) = &status.login_url {
format!(
"<section class=\"card\"><h2>Namespace Login In Progress</h2><p>Open the live Namespace URL below with the dedicated Burrow account. This page will refresh automatically until the server-side session is ready.</p><p><a class=\"link\" href=\"{}\">Open Namespace Login</a></p></section>",
escape_html(url)
)
} else if status.linked {
"<section class=\"card\"><h2>Namespace Linked</h2><p>The forge-owned NSC session is authenticated and ready to mint runner tokens.</p></section>".to_owned()
} else {
"<section class=\"card\"><h2>Namespace Not Linked</h2><p>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.</p></section>".to_owned()
};
let error = status
.last_error
.as_ref()
.map(|error| format!("<p class=\"error\">{}</p>", escape_html(error)))
.unwrap_or_default();
let token_state = if status.token_present {
"present"
} else {
"missing"
};
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Burrow Namespace Portal</title>
{refresh}
<style>
body {{ font-family: ui-sans-serif, system-ui, sans-serif; background: linear-gradient(180deg, #f4f7ff, #e9eefc); color: #1b2747; margin: 0; }}
main {{ max-width: 46rem; margin: 3rem auto; padding: 0 1rem 3rem; }}
header {{ display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }}
h1 {{ margin: 0; font-size: 1.8rem; }}
.subtle {{ color: #66718d; }}
.card {{ background: rgba(255,255,255,0.86); border-radius: 1.4rem; box-shadow: 0 18px 44px rgba(53, 73, 120, 0.12); padding: 1.25rem 1.35rem; margin-top: 1rem; }}
.actions {{ display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; }}
button {{ border: none; border-radius: 999px; padding: 0.85rem 1.2rem; font: inherit; font-weight: 700; background: linear-gradient(135deg, #6df2d4, #7bd1ff); color: #08201e; cursor: pointer; }}
.secondary {{ background: #eef2fb; color: #30405f; }}
.link {{ color: #0f63ff; font-weight: 700; text-decoration: none; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.9rem; }}
.metric {{ background: rgba(237, 242, 255, 0.95); border-radius: 1rem; padding: 0.9rem 1rem; }}
.label {{ font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: #7380a1; }}
.value {{ margin-top: 0.35rem; font-size: 1rem; font-weight: 700; }}
.error {{ color: #a81f43; font-weight: 600; }}
form {{ margin: 0; }}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Burrow Namespace Portal</h1>
<p class="subtle">Signed in as {email}. This page controls the forge-owned NSC session and token material for Forgejo Namespace runners.</p>
</div>
<form action="/logout" method="post"><button class="secondary" type="submit">Sign Out</button></form>
</header>
<section class="card">
<div class="grid">
<div class="metric"><div class="label">burrow.net identity</div><div class="value">{identity}</div></div>
<div class="metric"><div class="label">required group</div><div class="value">{group}</div></div>
<div class="metric"><div class="label">NSC token file</div><div class="value">{token_path}</div></div>
<div class="metric"><div class="label">current token</div><div class="value">{token_state}</div></div>
</div>
</section>
{login_action}
{error}
<section class="card">
<h2>Actions</h2>
<div class="actions">
<form action="/namespace/link/start" method="post"><button type="submit">Link Namespace</button></form>
<form action="/namespace/token/refresh" method="post"><button class="secondary" type="submit">Rotate NSC Token</button></form>
</div>
</section>
</main>
</body>
</html>"#,
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#"<!doctype html><html lang="en"><body><main><h1>Namespace Portal Error</h1><p>{}</p></main></body></html>"#,
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<Option<PortalSession>> {
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<PortalSession, (StatusCode, String)> {
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<String> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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<NamespaceStatus> {
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<String> {
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<String> {
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"));
}
}

View file

@ -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 = {

View file

@ -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 <agent@burrow.net>`.
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`

View file

@ -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;
};
}

View file

@ -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}

View file

@ -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}
'';
};
}

View file

@ -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;
}

Binary file not shown.

Binary file not shown.

View file

@ -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
Š2Wák*cS+ž+j9ƒ{° 4jñ;Š`wØd3­·«,‰"îgligÉЇþ¥ eèâ`Äü‘æ'¼ûßà'Ù®#Ñ× …"ò'(ò=LÁ¶SÀ3hºFjg¼ûYI·ŠFÐ|°Ê0$Fp<46>ÒÖ^¯Š`ª
QkñÇn˜¨œïUú“•¬®x7Ö8œbßÎ!Ìòß>nö?ú9^£ø!=Í® [a Ï` ¬Ï«¼_#޶<C5BD>?Tä̤¿@Ìø]öEçβµê¼ö­°[,Ûg퟇£Ëèàc<>àjöƒx}ö¹˜™.ÌžÿÛf4Àе5Ö•DôLH4Ìðý_H¯dwX‰åwX¿žðk÷ÜêRx7‰DMœ,0í½ 7ó˜â*ŠƒTU{Ã~ðä8yCÕûó¶ "™/oXÚCÅe8-¹“àulYtŸ ¹;ä§ÒDZdm¨¡ù ów÷×F…yÚiIæ†×öÏŽÉ…8F ¥Á¯ð}lÓø"ÒÜ´IÕøÕsuÿµ{L!ëÌ+Á™UBei¨_Zì~Œ D>åB)±ŠL§><º€R
ÓàÕÂ]Ô‹õ°:ùá`ùêªóe2ÿw˜ÌñâmP¹®ÚcSFÏføZ+Û·!þ_|V*ñŸ4®A¥ÿ‰õcAÂòÀ£ãdªx¤“H&©û
äQbË{z¹€vM¯ŸiS¹¯ fLÄŒc<Tñž²Û0d®‘ð€&ÉÕ÷¨<C3B7>¼‰R'
¸êþKo_a:<'˜ßcn
ŸŠæ”üø‡¦výmñ\?‡FPÀQNByj—tcßÀ<C39F><19>4WB}ÇÒYººsª¾!*M,@¦yKîðªöÇð$lŠ¥e<C2A5>ßÒ­¨Õµ âÀêVù\z3BûM³æ ¹‹&­rIÈþ|O„(pW)Š)
¥î•åŒÈ ÍÐm^“}uWàä*<2A>µ ®ߦOd ˆz<47 ³ú÷ä<C3B7>õ[×VePÀê½<>a¬wÿtB¬œ¶#?~ïôŒVF€J¸ÿã­w•»å:}ä