Move forgejo-nsc credentials into agenix
This commit is contained in:
parent
e40a947223
commit
70607e874c
15 changed files with 172 additions and 1495 deletions
|
|
@ -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})."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
112
Scripts/seal-forgejo-nsc-secrets.sh
Executable file
112
Scripts/seal-forgejo-nsc-secrets.sh
Executable 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."
|
||||
|
|
@ -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)))."
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(¶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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
BIN
secrets/infra/forgejo-nsc-autoscaler-config.age
Normal file
BIN
secrets/infra/forgejo-nsc-autoscaler-config.age
Normal file
Binary file not shown.
BIN
secrets/infra/forgejo-nsc-dispatcher-config.age
Normal file
BIN
secrets/infra/forgejo-nsc-dispatcher-config.age
Normal file
Binary file not shown.
15
secrets/infra/forgejo-nsc-token.age
Normal file
15
secrets/infra/forgejo-nsc-token.age
Normal 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{Ã~ðä8–yCÕûó¶ "™/oXÚCÅe8-¹“àulYtŸ¹;ä§Ò–DZdm¨¡ù
ów÷×F…yÚiIæ†×öÏŽ›É…8F ¥Á¯ð}lÓø"ÒÜ´IÕøÕsuÿ‹µ{L!’ëÌ+Á™UBei¨_Zì~Œ D>åB)±Š‹L§><º€R
|
||||
ÓàÕÂ]Ô‹õ°:ùá`ùꂪóe2ÿw˜Ìñâm’P¹®Ú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ÀQNB›yj—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•»å:}ä
|
||||
Loading…
Add table
Add a link
Reference in a new issue