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=(
|
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=(
|
||||||
|
|
@ -165,6 +164,14 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then
|
||||||
test -s /run/agenix/burrowHeadscaleOidcClientSecret
|
test -s /run/agenix/burrowHeadscaleOidcClientSecret
|
||||||
fi
|
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
|
if command -v curl >/dev/null 2>&1; then
|
||||||
echo "== http-local =="
|
echo "== http-local =="
|
||||||
curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login
|
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 -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
|
||||||
|
|
|
||||||
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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
usage() {
|
echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2
|
||||||
cat <<'EOF'
|
echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2
|
||||||
Usage: Scripts/sync-forgejo-nsc-config.sh [options]
|
echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2
|
||||||
|
|
||||||
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
|
|
||||||
exit 1
|
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;
|
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;
|
||||||
|
|
@ -62,12 +60,6 @@ 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
|
||||||
|
|
@ -767,10 +759,6 @@ 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) => {
|
||||||
|
|
|
||||||
|
|
@ -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-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";
|
||||||
specialArgs = {
|
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
|
- 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,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/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/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/seal-forgejo-nsc-secrets.sh`: encrypt forgejo-nsc runtime inputs into the agenix secrets consumed by `burrow-forge`
|
||||||
- `../Scripts/authentik-sync-namespace-portal-oidc.sh`: reconcile the Authentik OIDC app used by `nsc.burrow.net`
|
|
||||||
|
|
||||||
## Intended Flow
|
## 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/`.
|
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.
|
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 to refresh `intake/forgejo_nsc_token.txt`, `intake/forgejo_nsc_dispatcher.yaml`, and `intake/forgejo_nsc_autoscaler.yaml`.
|
||||||
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.
|
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`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`.
|
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`, `nsc.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME.
|
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.
|
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`.
|
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
|
||||||
|
|
||||||
- `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.
|
- 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:
|
- 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`
|
- `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-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";
|
||||||
|
|
@ -88,10 +87,28 @@ in
|
||||||
group = "root";
|
group = "root";
|
||||||
mode = "0400";
|
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 = ''
|
networking.extraHosts = ''
|
||||||
127.0.0.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 nsc.burrow.net
|
::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
||||||
'';
|
'';
|
||||||
|
|
||||||
services.burrow.forge = {
|
services.burrow.forge = {
|
||||||
|
|
@ -113,13 +130,13 @@ in
|
||||||
|
|
||||||
services.forgejo-nsc = {
|
services.forgejo-nsc = {
|
||||||
enable = true;
|
enable = true;
|
||||||
nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt";
|
nscTokenFile = config.age.secrets.burrowForgejoNscToken.path;
|
||||||
dispatcher = {
|
dispatcher = {
|
||||||
configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml";
|
configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path;
|
||||||
};
|
};
|
||||||
autoscaler = {
|
autoscaler = {
|
||||||
enable = true;
|
enable = true;
|
||||||
configFile = "/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml";
|
configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.path;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -141,11 +158,4 @@ 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,7 +10,6 @@ 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;
|
||||||
|
|
@ -139,30 +138,6 @@ 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";
|
||||||
|
|
@ -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 = ''
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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-google-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients;
|
"secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients;
|
||||||
"secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
"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/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/tailscale-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