Add forge-owned Namespace auth portal
This commit is contained in:
parent
64103abbea
commit
e40a947223
10 changed files with 1403 additions and 23 deletions
246
Scripts/authentik-sync-namespace-portal-oidc.sh
Normal file
246
Scripts/authentik-sync-namespace-portal-oidc.sh
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}"
|
||||||
|
bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
|
||||||
|
application_slug="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG:-namespace}"
|
||||||
|
application_name="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME:-Namespace Portal}"
|
||||||
|
provider_name="${AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME:-Namespace Portal}"
|
||||||
|
template_slug="${AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG:-ts}"
|
||||||
|
client_id="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID:-nsc.burrow.net}"
|
||||||
|
client_secret="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET:-}"
|
||||||
|
launch_url="${AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL:-https://nsc.burrow.net/}"
|
||||||
|
redirect_uris_json="${AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON:-[
|
||||||
|
\"https://nsc.burrow.net/oauth/callback\"
|
||||||
|
]}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: Scripts/authentik-sync-namespace-portal-oidc.sh
|
||||||
|
|
||||||
|
Required environment:
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN
|
||||||
|
|
||||||
|
Optional environment:
|
||||||
|
AUTHENTIK_URL
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL
|
||||||
|
AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$bootstrap_token" ]]; then
|
||||||
|
echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then
|
||||||
|
echo "error: AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local method="$1"
|
||||||
|
local path="$2"
|
||||||
|
local data="${3:-}"
|
||||||
|
|
||||||
|
if [[ -n "$data" ]]; then
|
||||||
|
curl -fsS \
|
||||||
|
-X "$method" \
|
||||||
|
-H "Authorization: Bearer ${bootstrap_token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" \
|
||||||
|
"${authentik_url}${path}"
|
||||||
|
else
|
||||||
|
curl -fsS \
|
||||||
|
-X "$method" \
|
||||||
|
-H "Authorization: Bearer ${bootstrap_token}" \
|
||||||
|
"${authentik_url}${path}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
api_with_status() {
|
||||||
|
local method="$1"
|
||||||
|
local path="$2"
|
||||||
|
local data="${3:-}"
|
||||||
|
local response_file status
|
||||||
|
|
||||||
|
response_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$response_file"' RETURN
|
||||||
|
|
||||||
|
if [[ -n "$data" ]]; then
|
||||||
|
status="$(
|
||||||
|
curl -sS \
|
||||||
|
-o "$response_file" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-X "$method" \
|
||||||
|
-H "Authorization: Bearer ${bootstrap_token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" \
|
||||||
|
"${authentik_url}${path}"
|
||||||
|
)"
|
||||||
|
else
|
||||||
|
status="$(
|
||||||
|
curl -sS \
|
||||||
|
-o "$response_file" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-X "$method" \
|
||||||
|
-H "Authorization: Bearer ${bootstrap_token}" \
|
||||||
|
"${authentik_url}${path}"
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$status"
|
||||||
|
cat "$response_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_authentik() {
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "error: Authentik did not become ready at ${authentik_url}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_authentik
|
||||||
|
|
||||||
|
template_provider="$(
|
||||||
|
api GET "/api/v3/providers/oauth2/?page_size=200" \
|
||||||
|
| jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \
|
||||||
|
| head -n1
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "$template_provider" ]]; then
|
||||||
|
echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')"
|
||||||
|
invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')"
|
||||||
|
property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')"
|
||||||
|
signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')"
|
||||||
|
|
||||||
|
provider_payload="$(
|
||||||
|
jq -n \
|
||||||
|
--arg name "$provider_name" \
|
||||||
|
--arg authorization_flow "$authorization_flow" \
|
||||||
|
--arg invalidation_flow "$invalidation_flow" \
|
||||||
|
--arg client_id "$client_id" \
|
||||||
|
--arg client_secret "$client_secret" \
|
||||||
|
--arg signing_key "$signing_key" \
|
||||||
|
--argjson property_mappings "$property_mappings" \
|
||||||
|
--argjson redirect_uris "$redirect_uris_json" \
|
||||||
|
'{
|
||||||
|
name: $name,
|
||||||
|
authorization_flow: $authorization_flow,
|
||||||
|
invalidation_flow: $invalidation_flow,
|
||||||
|
client_type: (if $client_secret == "" then "public" else "confidential" end),
|
||||||
|
client_id: $client_id,
|
||||||
|
include_claims_in_id_token: true,
|
||||||
|
redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})),
|
||||||
|
property_mappings: $property_mappings,
|
||||||
|
signing_key: $signing_key,
|
||||||
|
issuer_mode: "per_provider",
|
||||||
|
sub_mode: "hashed_user_id"
|
||||||
|
}
|
||||||
|
+ (if $client_secret == "" then {} else {client_secret: $client_secret} end)'
|
||||||
|
)"
|
||||||
|
|
||||||
|
existing_provider="$(
|
||||||
|
api GET "/api/v3/providers/oauth2/?page_size=200" \
|
||||||
|
| jq -c \
|
||||||
|
--arg application_slug "$application_slug" \
|
||||||
|
--arg provider_name "$provider_name" \
|
||||||
|
'.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \
|
||||||
|
| head -n1
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$existing_provider" ]]; then
|
||||||
|
provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')"
|
||||||
|
api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null
|
||||||
|
else
|
||||||
|
provider_pk="$(
|
||||||
|
api POST "/api/v3/providers/oauth2/" "$provider_payload" \
|
||||||
|
| jq -r '.pk // empty'
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${provider_pk:-}" ]]; then
|
||||||
|
echo "error: Namespace portal OIDC provider did not return a primary key" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
application_payload="$(
|
||||||
|
jq -n \
|
||||||
|
--arg name "$application_name" \
|
||||||
|
--arg slug "$application_slug" \
|
||||||
|
--arg provider "$provider_pk" \
|
||||||
|
--arg launch_url "$launch_url" \
|
||||||
|
'{
|
||||||
|
name: $name,
|
||||||
|
slug: $slug,
|
||||||
|
provider: ($provider | tonumber),
|
||||||
|
meta_launch_url: $launch_url,
|
||||||
|
open_in_new_tab: false,
|
||||||
|
policy_engine_mode: "any"
|
||||||
|
}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
existing_application="$(
|
||||||
|
api GET "/api/v3/core/applications/?page_size=200" \
|
||||||
|
| jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \
|
||||||
|
| head -n1
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$existing_application" ]]; then
|
||||||
|
application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')"
|
||||||
|
else
|
||||||
|
create_application_result="$(
|
||||||
|
api_with_status POST "/api/v3/core/applications/" "$application_payload"
|
||||||
|
)"
|
||||||
|
create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')"
|
||||||
|
create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')"
|
||||||
|
|
||||||
|
if [[ "$create_application_status" =~ ^20[01]$ ]]; then
|
||||||
|
application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')"
|
||||||
|
elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e '
|
||||||
|
(.slug // [] | index("Application with this slug already exists.")) != null
|
||||||
|
or (.provider // [] | index("Application with this provider already exists.")) != null
|
||||||
|
' >/dev/null; then
|
||||||
|
application_pk="existing-duplicate"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$create_application_body" >&2
|
||||||
|
echo "error: could not reconcile Authentik application ${application_slug}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${application_pk:-}" ]]; then
|
||||||
|
echo "error: Namespace portal OIDC application did not return a primary key" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then
|
||||||
|
echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "warning: Namespace portal OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2
|
||||||
|
echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})."
|
||||||
|
|
@ -84,6 +84,7 @@ base_services=(
|
||||||
nsc_services=(
|
nsc_services=(
|
||||||
forgejo-nsc-dispatcher.service
|
forgejo-nsc-dispatcher.service
|
||||||
forgejo-nsc-autoscaler.service
|
forgejo-nsc-autoscaler.service
|
||||||
|
burrow-namespace-portal.service
|
||||||
)
|
)
|
||||||
|
|
||||||
tailnet_services=(
|
tailnet_services=(
|
||||||
|
|
@ -173,5 +174,8 @@ if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/
|
curl -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
|
curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true
|
||||||
fi
|
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
|
fi
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
source: burrow/src/daemon/rpc/response.rs
|
source: burrow/src/daemon/rpc/response.rs
|
||||||
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(DaemonResponseData::ServerConfig(ServerConfig::default()))))?"
|
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(DaemonResponseData::ServerConfig(ServerConfig::default()))))?"
|
||||||
---
|
---
|
||||||
{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"name":null,"mtu":null}},"id":0}
|
{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ use clap::{Args, Parser, Subcommand};
|
||||||
mod control;
|
mod control;
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
||||||
mod daemon;
|
mod daemon;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod namespace_portal;
|
||||||
pub(crate) mod tracing;
|
pub(crate) mod tracing;
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
||||||
mod wireguard;
|
mod wireguard;
|
||||||
|
|
@ -60,6 +62,12 @@ enum Commands {
|
||||||
ReloadConfig(ReloadConfigArgs),
|
ReloadConfig(ReloadConfigArgs),
|
||||||
/// Authentication server
|
/// Authentication server
|
||||||
AuthServer,
|
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
|
/// Server Status
|
||||||
ServerStatus,
|
ServerStatus,
|
||||||
/// Tunnel Config
|
/// Tunnel Config
|
||||||
|
|
@ -283,9 +291,7 @@ async fn try_tailnet_discover(email: &str) -> Result<()> {
|
||||||
let mut client = BurrowClient::from_uds().await?;
|
let mut client = BurrowClient::from_uds().await?;
|
||||||
let response = client
|
let response = client
|
||||||
.tailnet_client
|
.tailnet_client
|
||||||
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest {
|
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() })
|
||||||
email: email.to_owned(),
|
|
||||||
})
|
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
println!("Tailnet Discover Response: {:?}", response);
|
println!("Tailnet Discover Response: {:?}", response);
|
||||||
|
|
@ -370,13 +376,9 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul
|
||||||
"tailnet ping received {} bytes from daemon packet stream",
|
"tailnet ping received {} bytes from daemon packet stream",
|
||||||
packet.payload.len()
|
packet.payload.len()
|
||||||
);
|
);
|
||||||
if let Some(reply) = parse_icmp_echo_reply(
|
if let Some(reply) =
|
||||||
&packet.payload,
|
parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)?
|
||||||
local_ip,
|
{
|
||||||
remote_ip,
|
|
||||||
identifier,
|
|
||||||
sequence,
|
|
||||||
)? {
|
|
||||||
break Ok::<_, anyhow::Error>(reply);
|
break Ok::<_, anyhow::Error>(reply);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -464,8 +466,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
|
||||||
|
|
||||||
let egress_task = tokio::spawn(async move {
|
let egress_task = tokio::spawn(async move {
|
||||||
while let Some(packet) = stack_stream.next().await {
|
while let Some(packet) = stack_stream.next().await {
|
||||||
let payload =
|
let payload = packet.context("failed to read outbound packet from userspace stack")?;
|
||||||
packet.context("failed to read outbound packet from userspace stack")?;
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"tailnet udp echo sending {} bytes into daemon packet stream",
|
"tailnet udp echo sending {} bytes into daemon packet stream",
|
||||||
payload.len()
|
payload.len()
|
||||||
|
|
@ -484,9 +485,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
|
||||||
.send((message.as_bytes().to_vec(), local_addr, remote_addr))
|
.send((message.as_bytes().to_vec(), local_addr, remote_addr))
|
||||||
.await
|
.await
|
||||||
.context("failed to send UDP echo probe into userspace stack")?;
|
.context("failed to send UDP echo probe into userspace stack")?;
|
||||||
log::debug!(
|
log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}");
|
||||||
"tailnet udp echo probe queued from {local_addr} to {remote_addr}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next())
|
let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next())
|
||||||
.await
|
.await
|
||||||
|
|
@ -516,7 +515,10 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
||||||
fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result<std::net::IpAddr> {
|
fn select_tailnet_local_ip(
|
||||||
|
addresses: &[String],
|
||||||
|
remote_ip: std::net::IpAddr,
|
||||||
|
) -> Result<std::net::IpAddr> {
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
let family_is_v4 = remote_ip.is_ipv4();
|
let family_is_v4 = remote_ip.is_ipv4();
|
||||||
|
|
@ -765,6 +767,10 @@ async fn main() -> Result<()> {
|
||||||
Commands::ServerConfig => try_serverconfig().await?,
|
Commands::ServerConfig => try_serverconfig().await?,
|
||||||
Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?,
|
Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?,
|
||||||
Commands::AuthServer => crate::auth::server::serve().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::ServerStatus => try_serverstatus().await?,
|
||||||
Commands::TunnelConfig => try_tun_config().await?,
|
Commands::TunnelConfig => try_tun_config().await?,
|
||||||
Commands::NetworkAdd(args) => {
|
Commands::NetworkAdd(args) => {
|
||||||
|
|
|
||||||
880
burrow/src/namespace_portal.rs
Normal file
880
burrow/src/namespace_portal.rs
Normal file
|
|
@ -0,0 +1,880 @@
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
env, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Stdio,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::{
|
||||||
|
header::{COOKIE, LOCATION, SET_COOKIE},
|
||||||
|
HeaderMap, HeaderValue, StatusCode,
|
||||||
|
},
|
||||||
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
|
use rand::RngCore;
|
||||||
|
use reqwest::Url;
|
||||||
|
use ring::digest::{digest, SHA256};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, BufReader},
|
||||||
|
process::Command,
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_COOKIE: &str = "burrow_namespace_portal_session";
|
||||||
|
const OIDC_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
const AUTH_CHECK_DURATION: &str = "10m";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct NamespacePortalConfig {
|
||||||
|
pub listen: String,
|
||||||
|
pub public_base_url: String,
|
||||||
|
pub oidc_discovery_url: String,
|
||||||
|
pub oidc_client_id: String,
|
||||||
|
pub oidc_client_secret: Option<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(¶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::<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('&', "&")
|
||||||
|
.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<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
32
flake.nix
32
flake.nix
|
|
@ -94,6 +94,7 @@
|
||||||
pkgs.stdenvNoCC.mkDerivation {
|
pkgs.stdenvNoCC.mkDerivation {
|
||||||
pname = "nsc";
|
pname = "nsc";
|
||||||
inherit version src;
|
inherit version src;
|
||||||
|
meta.mainProgram = "nsc";
|
||||||
dontConfigure = true;
|
dontConfigure = true;
|
||||||
dontBuild = true;
|
dontBuild = true;
|
||||||
unpackPhase = ''
|
unpackPhase = ''
|
||||||
|
|
@ -144,6 +145,35 @@
|
||||||
subPackages = [ "./cmd/forgejo-nsc-autoscaler" ];
|
subPackages = [ "./cmd/forgejo-nsc-autoscaler" ];
|
||||||
vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs=";
|
vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs=";
|
||||||
};
|
};
|
||||||
|
burrowSrc = lib.cleanSourceWith {
|
||||||
|
src = ./.;
|
||||||
|
filter = path: type:
|
||||||
|
let
|
||||||
|
p = toString path;
|
||||||
|
name = builtins.baseNameOf path;
|
||||||
|
hasDir = dir: lib.hasInfix "/${dir}/" p || lib.hasSuffix "/${dir}" p;
|
||||||
|
in
|
||||||
|
!(hasDir ".git" || hasDir "target" || hasDir "node_modules" || name == "result");
|
||||||
|
};
|
||||||
|
burrowPkg = pkgs.rustPlatform.buildRustPackage {
|
||||||
|
pname = "burrow";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = burrowSrc;
|
||||||
|
cargoLock = {
|
||||||
|
lockFile = ./Cargo.lock;
|
||||||
|
outputHashes = {
|
||||||
|
"tracing-oslog-0.1.2" = "sha256-DjJDiPCTn43zJmmOfuRnyti8iQf9qoXICMKIx4bAG3I=";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
cargoBuildFlags = [
|
||||||
|
"-p"
|
||||||
|
"burrow"
|
||||||
|
"--bin"
|
||||||
|
"burrow"
|
||||||
|
];
|
||||||
|
nativeBuildInputs = [ pkgs.protobuf ];
|
||||||
|
meta.mainProgram = "burrow";
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
|
|
@ -171,6 +201,7 @@
|
||||||
packages =
|
packages =
|
||||||
{
|
{
|
||||||
agenix = agenix.packages.${system}.agenix;
|
agenix = agenix.packages.${system}.agenix;
|
||||||
|
burrow = burrowPkg;
|
||||||
hcloud-upload-image = hcloudUploadImagePkg;
|
hcloud-upload-image = hcloudUploadImagePkg;
|
||||||
forgejo-nsc-dispatcher = forgejoNscDispatcher;
|
forgejo-nsc-dispatcher = forgejoNscDispatcher;
|
||||||
forgejo-nsc-autoscaler = forgejoNscAutoscaler;
|
forgejo-nsc-autoscaler = forgejoNscAutoscaler;
|
||||||
|
|
@ -183,6 +214,7 @@
|
||||||
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
|
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
|
||||||
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
|
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
|
||||||
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.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 {
|
nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B
|
||||||
- upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input
|
- 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-authentik.nix`: minimal Authentik IdP for Burrow control planes
|
||||||
- `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC
|
- `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
|
- `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets
|
||||||
- `hetzner-cloud-config.yaml`: desired Hetzner host shape
|
- `hetzner-cloud-config.yaml`: desired Hetzner host shape
|
||||||
- `keys/contact_at_burrow_net.pub`: initial operator SSH public key
|
- `keys/contact_at_burrow_net.pub`: initial operator SSH public key
|
||||||
|
|
@ -24,6 +25,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B
|
||||||
- `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host
|
- `../Scripts/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/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/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host
|
||||||
|
- `../Scripts/authentik-sync-namespace-portal-oidc.sh`: reconcile the Authentik OIDC app used by `nsc.burrow.net`
|
||||||
|
|
||||||
## Intended Flow
|
## Intended Flow
|
||||||
|
|
||||||
|
|
@ -33,10 +35,11 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B
|
||||||
4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account.
|
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>`.
|
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.
|
6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module.
|
||||||
7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`.
|
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. 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.
|
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/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace.
|
9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, `nsc.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME.
|
||||||
10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`.
|
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
|
## Current Constraints
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ in
|
||||||
self.nixosModules.burrow-forgejo-nsc
|
self.nixosModules.burrow-forgejo-nsc
|
||||||
self.nixosModules.burrow-authentik
|
self.nixosModules.burrow-authentik
|
||||||
self.nixosModules.burrow-headscale
|
self.nixosModules.burrow-headscale
|
||||||
|
self.nixosModules.burrow-namespace-portal
|
||||||
];
|
];
|
||||||
|
|
||||||
system.stateVersion = "24.11";
|
system.stateVersion = "24.11";
|
||||||
|
|
@ -89,8 +90,8 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.extraHosts = ''
|
networking.extraHosts = ''
|
||||||
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net
|
||||||
::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net
|
||||||
'';
|
'';
|
||||||
|
|
||||||
services.burrow.forge = {
|
services.burrow.forge = {
|
||||||
|
|
@ -140,4 +141,11 @@ in
|
||||||
enable = true;
|
enable = true;
|
||||||
oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
|
oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
services.burrow.namespacePortal = {
|
||||||
|
enable = true;
|
||||||
|
domain = "nsc.burrow.net";
|
||||||
|
baseUrl = "https://nsc.burrow.net";
|
||||||
|
adminGroup = contributors.groups.admins;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ let
|
||||||
dataVolume = "burrow-authentik-data:/data";
|
dataVolume = "burrow-authentik-data:/data";
|
||||||
directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh;
|
directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh;
|
||||||
forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh;
|
forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh;
|
||||||
|
namespacePortalOidcSyncScript = ../../Scripts/authentik-sync-namespace-portal-oidc.sh;
|
||||||
tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh;
|
tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh;
|
||||||
googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh;
|
googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh;
|
||||||
tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh;
|
tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh;
|
||||||
|
|
@ -138,6 +139,30 @@ in
|
||||||
description = "Authentik application slug for Tailscale custom OIDC sign-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 {
|
tailscaleClientId = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "tailscale.burrow.net";
|
default = "tailscale.burrow.net";
|
||||||
|
|
@ -708,6 +733,56 @@ EOF
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
systemd.services.burrow-authentik-namespace-portal-oidc = {
|
||||||
|
description = "Reconcile the Burrow Authentik Namespace portal OIDC application";
|
||||||
|
after = [
|
||||||
|
"burrow-authentik-ready.service"
|
||||||
|
"network-online.target"
|
||||||
|
];
|
||||||
|
wants = [
|
||||||
|
"burrow-authentik-ready.service"
|
||||||
|
"network-online.target"
|
||||||
|
];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
restartTriggers =
|
||||||
|
[
|
||||||
|
namespacePortalOidcSyncScript
|
||||||
|
cfg.envFile
|
||||||
|
]
|
||||||
|
++ lib.optionals (cfg.namespacePortalClientSecretFile != null) [ cfg.namespacePortalClientSecretFile ];
|
||||||
|
path = [
|
||||||
|
pkgs.bash
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.curl
|
||||||
|
pkgs.jq
|
||||||
|
];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
User = "root";
|
||||||
|
Group = "root";
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
set -a
|
||||||
|
source ${lib.escapeShellArg cfg.envFile}
|
||||||
|
set +a
|
||||||
|
|
||||||
|
export AUTHENTIK_URL=https://${cfg.domain}
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.namespacePortalProviderSlug}
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME="Namespace Portal"
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME="Namespace Portal"
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug}
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID=${lib.escapeShellArg cfg.namespacePortalClientId}
|
||||||
|
${lib.optionalString (cfg.namespacePortalClientSecretFile != null) ''
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.namespacePortalClientSecretFile})"
|
||||||
|
''}
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL=https://${cfg.namespacePortalDomain}/
|
||||||
|
export AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON='["https://${cfg.namespacePortalDomain}/oauth/callback"]'
|
||||||
|
|
||||||
|
${pkgs.bash}/bin/bash ${namespacePortalOidcSyncScript}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
|
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
reverse_proxy 127.0.0.1:${toString cfg.port}
|
reverse_proxy 127.0.0.1:${toString cfg.port}
|
||||||
|
|
|
||||||
126
nixos/modules/burrow-namespace-portal.nix
Normal file
126
nixos/modules/burrow-namespace-portal.nix
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
{ config, lib, pkgs, self, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.burrow.namespacePortal;
|
||||||
|
burrowExe = lib.getExe self.packages.${pkgs.system}.burrow;
|
||||||
|
nscExe = lib.getExe self.packages.${pkgs.system}.nsc;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.burrow.namespacePortal = {
|
||||||
|
enable = lib.mkEnableOption "the Burrow Namespace authentication portal";
|
||||||
|
|
||||||
|
domain = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "nsc.burrow.net";
|
||||||
|
description = "Public domain for the Namespace portal.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 9080;
|
||||||
|
description = "Local listen port for the Namespace portal.";
|
||||||
|
};
|
||||||
|
|
||||||
|
baseUrl = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "https://nsc.burrow.net";
|
||||||
|
description = "Public base URL for redirects.";
|
||||||
|
};
|
||||||
|
|
||||||
|
oidcProviderSlug = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "namespace";
|
||||||
|
description = "Authentik provider slug used for the portal.";
|
||||||
|
};
|
||||||
|
|
||||||
|
oidcClientId = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "nsc.burrow.net";
|
||||||
|
description = "OIDC client ID used by the portal.";
|
||||||
|
};
|
||||||
|
|
||||||
|
oidcClientSecretFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional host-local OIDC client secret for the portal.";
|
||||||
|
};
|
||||||
|
|
||||||
|
adminGroup = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "burrow-admins";
|
||||||
|
description = "Authentik group required to access the portal.";
|
||||||
|
};
|
||||||
|
|
||||||
|
stateDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/var/lib/burrow/namespace-portal";
|
||||||
|
description = "Persistent state directory for the portal-owned NSC session.";
|
||||||
|
};
|
||||||
|
|
||||||
|
tokenOutputPath = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/var/lib/burrow/intake/forgejo_nsc_token.txt";
|
||||||
|
description = "Path where refreshed NSC tokens should be written.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = config.services.forgejo-nsc.enable;
|
||||||
|
message = "services.burrow.namespacePortal requires services.forgejo-nsc.enable";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${cfg.stateDir} 0750 forgejo-nsc forgejo-nsc -"
|
||||||
|
"d ${cfg.stateDir}/nsc 0750 forgejo-nsc forgejo-nsc -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.burrow-namespace-portal = {
|
||||||
|
description = "Burrow Namespace authentication portal";
|
||||||
|
after = [
|
||||||
|
"network-online.target"
|
||||||
|
"burrow-authentik-ready.service"
|
||||||
|
];
|
||||||
|
wants = [
|
||||||
|
"network-online.target"
|
||||||
|
"burrow-authentik-ready.service"
|
||||||
|
];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
path = [
|
||||||
|
self.packages.${pkgs.system}.burrow
|
||||||
|
self.packages.${pkgs.system}.nsc
|
||||||
|
pkgs.coreutils
|
||||||
|
];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = "forgejo-nsc";
|
||||||
|
Group = "forgejo-nsc";
|
||||||
|
WorkingDirectory = cfg.stateDir;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "2s";
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
export BURROW_NAMESPACE_PORTAL_LISTEN=127.0.0.1:${toString cfg.port}
|
||||||
|
export BURROW_NAMESPACE_PORTAL_BASE_URL=${lib.escapeShellArg cfg.baseUrl}
|
||||||
|
export BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL=${lib.escapeShellArg "https://${config.services.burrow.authentik.domain}/application/o/${cfg.oidcProviderSlug}/.well-known/openid-configuration"}
|
||||||
|
export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID=${lib.escapeShellArg cfg.oidcClientId}
|
||||||
|
export BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP=${lib.escapeShellArg cfg.adminGroup}
|
||||||
|
export BURROW_NAMESPACE_PORTAL_NSC_BIN=${lib.escapeShellArg nscExe}
|
||||||
|
export BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR=${lib.escapeShellArg "${cfg.stateDir}/nsc"}
|
||||||
|
export BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH=${lib.escapeShellArg cfg.tokenOutputPath}
|
||||||
|
${lib.optionalString (cfg.oidcClientSecretFile != null) ''
|
||||||
|
export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})"
|
||||||
|
''}
|
||||||
|
exec ${burrowExe} namespace-portal
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
|
||||||
|
encode gzip zstd
|
||||||
|
reverse_proxy 127.0.0.1:${toString cfg.port}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue