From 03415e579b14d9555634114b661f74d222cb8f21 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Thu, 19 Mar 2026 00:28:18 -0700 Subject: [PATCH] Rotate operator secrets into agenix and deepen caches --- .forgejo/workflows/build-apple.yml | 4 + .forgejo/workflows/build-rust.yml | 3 +- .gitignore | 1 + .../NetworkExtension/libburrow/build-rust.sh | 7 +- Makefile | 7 +- Scripts/_burrow-secrets.sh | 78 ++++++++++++++++++ Scripts/bootstrap-forge-intake.sh | 49 +++++++++--- Scripts/check-forge-host.sh | 24 ++++-- Scripts/cloudflare-upsert-a-record.sh | 27 +++++-- Scripts/forge-deploy.sh | 29 ++++--- Scripts/hcloud-upload-nixos-image.sh | 24 +++++- Scripts/hetzner-forge.sh | 24 ++++-- Scripts/nsc-build-and-upload-image.sh | 24 +++++- Scripts/provision-forgejo-nsc.sh | 79 ++++++++++++++----- Scripts/sync-forgejo-nsc-config.sh | 56 ++++++++++--- Tools/forwardemail-custom-s3.sh | 52 +++++++++--- Tools/forwardemail-hetzner-storage.py | 36 ++++++++- docs/FORWARDEMAIL.md | 19 +++-- nixos/README.md | 4 +- secrets/README.md | 8 ++ secrets/cloudflare/api-token.age | 7 ++ secrets/forwardemail/api-token.age | 7 ++ secrets/forwardemail/hetzner-s3-secret.age | 7 ++ secrets/forwardemail/hetzner-s3-user.age | 7 ++ secrets/hetzner/api-token.age | 7 ++ secrets/secrets.nix | 6 ++ services/forgejo-nsc/README.md | 18 ++--- services/forgejo-nsc/internal/nsc/macos.go | 38 ++++++--- 28 files changed, 526 insertions(+), 126 deletions(-) create mode 100644 Scripts/_burrow-secrets.sh create mode 100644 secrets/cloudflare/api-token.age create mode 100644 secrets/forwardemail/api-token.age create mode 100644 secrets/forwardemail/hetzner-s3-secret.age create mode 100644 secrets/forwardemail/hetzner-s3-user.age create mode 100644 secrets/hetzner/api-token.age diff --git a/.forgejo/workflows/build-apple.yml b/.forgejo/workflows/build-apple.yml index c2b1502..460b6b8 100644 --- a/.forgejo/workflows/build-apple.yml +++ b/.forgejo/workflows/build-apple.yml @@ -75,14 +75,18 @@ jobs: cache_root="${NSC_CACHE_PATH:-${HOME}/.cache/burrow}" mkdir -p \ "${cache_root}/cargo" \ + "${cache_root}/cargo-target/${{ matrix.cache-id }}" \ "${cache_root}/rustup" \ "${cache_root}/sccache" \ + "${cache_root}/homebrew" \ "${cache_root}/apple/PackageCache" \ "${cache_root}/apple/SourcePackages" \ "${cache_root}/apple/DerivedData/${{ matrix.cache-id }}" echo "CARGO_HOME=${cache_root}/cargo" >> "${GITHUB_ENV}" + echo "CARGO_TARGET_DIR=${cache_root}/cargo-target/${{ matrix.cache-id }}" >> "${GITHUB_ENV}" echo "RUSTUP_HOME=${cache_root}/rustup" >> "${GITHUB_ENV}" echo "SCCACHE_DIR=${cache_root}/sccache" >> "${GITHUB_ENV}" + echo "HOMEBREW_CACHE=${cache_root}/homebrew" >> "${GITHUB_ENV}" echo "APPLE_PACKAGE_CACHE=${cache_root}/apple/PackageCache" >> "${GITHUB_ENV}" echo "APPLE_SOURCE_PACKAGES=${cache_root}/apple/SourcePackages" >> "${GITHUB_ENV}" echo "APPLE_DERIVED_DATA=${cache_root}/apple/DerivedData/${{ matrix.cache-id }}" >> "${GITHUB_ENV}" diff --git a/.forgejo/workflows/build-rust.yml b/.forgejo/workflows/build-rust.yml index 7fd2667..d70dcf0 100644 --- a/.forgejo/workflows/build-rust.yml +++ b/.forgejo/workflows/build-rust.yml @@ -33,9 +33,10 @@ jobs: run: | set -euo pipefail cache_root="${HOME}/.cache/burrow" - mkdir -p "${cache_root}/cargo" "${cache_root}/sccache" + mkdir -p "${cache_root}/cargo" "${cache_root}/sccache" "${cache_root}/cargo-target/build-rust" echo "CARGO_HOME=${cache_root}/cargo" >> "${GITHUB_ENV}" echo "SCCACHE_DIR=${cache_root}/sccache" >> "${GITHUB_ENV}" + echo "CARGO_TARGET_DIR=${cache_root}/cargo-target/build-rust" >> "${GITHUB_ENV}" - name: Test shell: bash diff --git a/.gitignore b/.gitignore index 3c80ef9..3ce64aa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ target/ .idea/ tmp/ +intake/ *.db *.sqlite3 diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index 258351c..031e6bc 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -74,16 +74,17 @@ CARGO_PATH="$(dirname $PROTOC):$CARGO_PATH" # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. EXTRA_ENV=() -for VAR_NAME in HOME CARGO_HOME RUSTUP_HOME RUSTC_WRAPPER SCCACHE_DIR CARGO_INCREMENTAL; do +for VAR_NAME in HOME CARGO_HOME CARGO_TARGET_DIR RUSTUP_HOME RUSTC_WRAPPER SCCACHE_DIR CARGO_INCREMENTAL; do if [[ -n "${!VAR_NAME:-}" ]]; then EXTRA_ENV+=("${VAR_NAME}=${!VAR_NAME}") fi done -env -i PATH="$CARGO_PATH" PROTOC="$PROTOC" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" MACOSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" "${EXTRA_ENV[@]}" cargo build "${CARGO_ARGS[@]}" +EFFECTIVE_CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${CONFIGURATION_TEMP_DIR}/target}" +env -i PATH="$CARGO_PATH" PROTOC="$PROTOC" CARGO_TARGET_DIR="${EFFECTIVE_CARGO_TARGET_DIR}" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" MACOSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" "${EXTRA_ENV[@]}" cargo build "${CARGO_ARGS[@]}" mkdir -p "${BUILT_PRODUCTS_DIR}" # Use `lipo` to merge the architectures together into BUILT_PRODUCTS_DIR /usr/bin/xcrun --sdk $PLATFORM_NAME lipo \ - -create $(printf "${CONFIGURATION_TEMP_DIR}/target/%q/${CARGO_TARGET_SUBDIR}/libburrow.a " "${RUST_TARGETS[@]}") \ + -create $(printf "${EFFECTIVE_CARGO_TARGET_DIR}/%q/${CARGO_TARGET_SUBDIR}/libburrow.a " "${RUST_TARGETS[@]}") \ -output "${BUILT_PRODUCTS_DIR}/libburrow.a" diff --git a/Makefile b/Makefile index 1f15f36..6738052 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,12 @@ SECRETS := forgejo/admin-password \ forgejo/agent-ssh-key \ forgejo/nsc-token \ forgejo/nsc-dispatcher-config \ - forgejo/nsc-autoscaler-config + forgejo/nsc-autoscaler-config \ + cloudflare/api-token \ + hetzner/api-token \ + forwardemail/api-token \ + forwardemail/hetzner-s3-user \ + forwardemail/hetzner-s3-secret tun := $(shell ifconfig -l | sed 's/ /\n/g' | grep utun | tail -n 1) cargo_console := env RUST_BACKTRACE=1 RUST_LOG=debug RUSTFLAGS='--cfg tokio_unstable' cargo run --all-features -- diff --git a/Scripts/_burrow-secrets.sh b/Scripts/_burrow-secrets.sh new file mode 100644 index 0000000..2ecd282 --- /dev/null +++ b/Scripts/_burrow-secrets.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +BURROW_SECRET_TMPFILES=() + +burrow_cleanup_secret_tmpfiles() { + local path + for path in "${BURROW_SECRET_TMPFILES[@]:-}"; do + [[ -n "${path}" ]] && rm -f "${path}" >/dev/null 2>&1 || true + done + BURROW_SECRET_TMPFILES=() +} + +burrow_decrypt_age_secret_to_temp() { + local repo_root="$1" + local secret_path="$2" + local tmp_file + + if [[ ! -f "${secret_path}" ]]; then + echo "age secret not found: ${secret_path}" >&2 + return 1 + fi + + tmp_file="$(mktemp "${TMPDIR:-/tmp}/burrow-secret.XXXXXX")" + nix --extra-experimental-features "nix-command flakes" run "${repo_root}#agenix" -- -d "${secret_path}" > "${tmp_file}" + chmod 600 "${tmp_file}" + BURROW_SECRET_TMPFILES+=("${tmp_file}") + printf '%s\n' "${tmp_file}" +} + +burrow_resolve_secret_file() { + local repo_root="$1" + local explicit_path="$2" + local intake_path="$3" + local age_path="$4" + local fallback_path="${5:-}" + + if [[ -n "${explicit_path}" ]]; then + if [[ ! -s "${explicit_path}" ]]; then + echo "required file missing or empty: ${explicit_path}" >&2 + return 1 + fi + printf '%s\n' "${explicit_path}" + return 0 + fi + + if [[ -n "${intake_path}" && -s "${intake_path}" ]]; then + printf '%s\n' "${intake_path}" + return 0 + fi + + if [[ -n "${age_path}" && -f "${age_path}" ]]; then + burrow_decrypt_age_secret_to_temp "${repo_root}" "${age_path}" + return 0 + fi + + if [[ -n "${fallback_path}" && -s "${fallback_path}" ]]; then + printf '%s\n' "${fallback_path}" + return 0 + fi + + return 1 +} + +burrow_encrypt_secret_from_file() { + local repo_root="$1" + local secret_path="$2" + local source_path="$3" + + if [[ ! -s "${source_path}" ]]; then + echo "secret source missing or empty: ${source_path}" >&2 + return 1 + fi + + SECRET_SOURCE_FILE="${source_path}" \ + EDITOR="${repo_root}/Scripts/agenix-load-file.sh" \ + nix --extra-experimental-features "nix-command flakes" run "${repo_root}#agenix" -- -e "${secret_path}" +} diff --git a/Scripts/bootstrap-forge-intake.sh b/Scripts/bootstrap-forge-intake.sh index 0cc1d91..b927083 100644 --- a/Scripts/bootstrap-forge-intake.sh +++ b/Scripts/bootstrap-forge-intake.sh @@ -3,6 +3,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" usage() { cat <<'EOF' @@ -10,27 +12,33 @@ Usage: Scripts/bootstrap-forge-intake.sh [options] Copy the minimum Burrow forge bootstrap secrets onto the target host under /var/lib/burrow/intake with the ownership expected by the NixOS services. +Legacy path only: the current forge runtime consumes agenix secrets directly. Options: --host SSH target (default: root@git.burrow.net) --ssh-key SSH private key used to reach the host - (default: intake/agent_at_burrow_net_ed25519) + (default: secrets/forgejo/agent-ssh-key.age, then intake/) --password-file Forgejo admin bootstrap password file - (default: intake/forgejo_pass_contact_at_burrow_net.txt) + (default: secrets/forgejo/admin-password.age, then intake/) --agent-key-file Agent SSH private key copied for runner bootstrap - (default: intake/agent_at_burrow_net_ed25519) + (default: secrets/forgejo/agent-ssh-key.age, then intake/) --no-verify Skip remote ls/stat verification after install -h, --help Show this help text EOF } HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" -PASSWORD_FILE="${BURROW_FORGE_PASSWORD_FILE:-${REPO_ROOT}/intake/forgejo_pass_contact_at_burrow_net.txt}" -AGENT_KEY_FILE="${BURROW_FORGE_AGENT_KEY_FILE:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-}" +PASSWORD_FILE="${BURROW_FORGE_PASSWORD_FILE:-}" +AGENT_KEY_FILE="${BURROW_FORGE_AGENT_KEY_FILE:-}" KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" VERIFY=1 +cleanup() { + burrow_cleanup_secret_tmpfiles +} +trap cleanup EXIT + while [[ $# -gt 0 ]]; do case "$1" in --host) @@ -67,12 +75,29 @@ done mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" -for path in "${SSH_KEY}" "${PASSWORD_FILE}" "${AGENT_KEY_FILE}"; do - if [[ ! -s "${path}" ]]; then - echo "required file missing or empty: ${path}" >&2 - exit 1 - fi -done +SSH_KEY="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${SSH_KEY}" \ + "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" \ + "${REPO_ROOT}/secrets/forgejo/agent-ssh-key.age" \ + "${HOME}/.ssh/agent_at_burrow_net_ed25519" +)" +PASSWORD_FILE="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${PASSWORD_FILE}" \ + "${REPO_ROOT}/intake/forgejo_pass_contact_at_burrow_net.txt" \ + "${REPO_ROOT}/secrets/forgejo/admin-password.age" +)" +AGENT_KEY_FILE="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${AGENT_KEY_FILE}" \ + "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" \ + "${REPO_ROOT}/secrets/forgejo/agent-ssh-key.age" \ + "${HOME}/.ssh/agent_at_burrow_net_ed25519" +)" ssh_opts=( -i "${SSH_KEY}" diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index ddfb83a..05ddeca 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -3,6 +3,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" usage() { cat <<'EOF' @@ -12,17 +14,22 @@ Run a post-boot verification pass against the Burrow forge host. Options: --host SSH target (default: root@git.burrow.net) - --ssh-key SSH private key (default: intake/agent_at_burrow_net_ed25519) + --ssh-key SSH private key (default: secrets/forgejo/agent-ssh-key.age, then intake/) --expect-nsc Fail if forgejo-nsc services are not active -h, --help Show this help text EOF } HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-}" KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" EXPECT_NSC=0 +cleanup() { + burrow_cleanup_secret_tmpfiles +} +trap cleanup EXIT + while [[ $# -gt 0 ]]; do case "$1" in --host) @@ -51,10 +58,17 @@ done mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" -if [[ ! -f "${SSH_KEY}" ]]; then - echo "forge SSH key not found: ${SSH_KEY}" >&2 +SSH_KEY="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${SSH_KEY}" \ + "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" \ + "${REPO_ROOT}/secrets/forgejo/agent-ssh-key.age" \ + "${HOME}/.ssh/agent_at_burrow_net_ed25519" +)" || { + echo "forge SSH key could not be resolved" >&2 exit 1 -fi +} ssh \ -i "${SSH_KEY}" \ diff --git a/Scripts/cloudflare-upsert-a-record.sh b/Scripts/cloudflare-upsert-a-record.sh index 88745af..af4cef4 100755 --- a/Scripts/cloudflare-upsert-a-record.sh +++ b/Scripts/cloudflare-upsert-a-record.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" + usage() { cat <<'EOF' Usage: Scripts/cloudflare-upsert-a-record.sh --zone --name --ipv4
[options] @@ -13,7 +18,7 @@ Options: --name Fully-qualified DNS record name --ipv4
IPv4 address for the A record --token-file Cloudflare API token file - default: intake/cloudflare-token.txt + default: secrets/cloudflare/api-token.age, then intake/cloudflare-token.txt --ttl Record TTL, or auto default: auto --proxied Whether to proxy through Cloudflare @@ -25,10 +30,15 @@ EOF ZONE_NAME="" RECORD_NAME="" IPV4="" -TOKEN_FILE="intake/cloudflare-token.txt" +TOKEN_FILE="${CLOUDFLARE_TOKEN_FILE:-}" TTL_VALUE="auto" PROXIED="false" +cleanup() { + burrow_cleanup_secret_tmpfiles +} +trap cleanup EXIT + while [[ $# -gt 0 ]]; do case "$1" in --zone) @@ -71,11 +81,16 @@ if [[ -z "${ZONE_NAME}" || -z "${RECORD_NAME}" || -z "${IPV4}" ]]; then usage >&2 exit 2 fi - -if [[ ! -f "${TOKEN_FILE}" ]]; then - echo "Cloudflare token file not found: ${TOKEN_FILE}" >&2 +TOKEN_FILE="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${TOKEN_FILE}" \ + "${REPO_ROOT}/intake/cloudflare-token.txt" \ + "${REPO_ROOT}/secrets/cloudflare/api-token.age" +)" || { + echo "Cloudflare token file could not be resolved" >&2 exit 1 -fi +} if [[ ! "${IPV4}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then echo "Invalid IPv4 address: ${IPV4}" >&2 diff --git a/Scripts/forge-deploy.sh b/Scripts/forge-deploy.sh index 5c4b959..1a7eec7 100755 --- a/Scripts/forge-deploy.sh +++ b/Scripts/forge-deploy.sh @@ -5,6 +5,8 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=Scripts/_burrow-flake.sh source "${SCRIPT_DIR}/_burrow-flake.sh" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" usage() { cat <<'EOF' @@ -18,7 +20,7 @@ Defaults: Environment: BURROW_FORGE_HOST root@git.burrow.net - BURROW_FORGE_SSH_KEY intake/agent_at_burrow_net_ed25519 + BURROW_FORGE_SSH_KEY explicit path, otherwise secrets/forgejo/agent-ssh-key.age EOF } @@ -28,6 +30,7 @@ ALLOW_DIRTY=0 BURROW_FLAKE_TMPDIRS=() cleanup() { + burrow_cleanup_secret_tmpfiles burrow_cleanup_flake_tmpdirs } trap cleanup EXIT @@ -71,21 +74,17 @@ if [[ ${ALLOW_DIRTY} -ne 1 ]] && [[ -n "$(git status --short)" ]]; then fi FORGE_HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -FORGE_SSH_KEY="${BURROW_FORGE_SSH_KEY:-}" - -if [[ -z "${FORGE_SSH_KEY}" ]]; then - if [[ -f "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" ]]; then - FORGE_SSH_KEY="${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" - else - FORGE_SSH_KEY="${HOME}/.ssh/agent_at_burrow_net_ed25519" - fi -fi - -if [[ ! -f "${FORGE_SSH_KEY}" ]]; then - echo "Forge SSH key not found at ${FORGE_SSH_KEY}." >&2 - echo "Set BURROW_FORGE_SSH_KEY or place the agent key in intake/." >&2 +FORGE_SSH_KEY="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${BURROW_FORGE_SSH_KEY:-}" \ + "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" \ + "${REPO_ROOT}/secrets/forgejo/agent-ssh-key.age" \ + "${HOME}/.ssh/agent_at_burrow_net_ed25519" +)" || { + echo "Unable to resolve the forge SSH key." >&2 exit 1 -fi +} FORGE_KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" mkdir -p "$(dirname "${FORGE_KNOWN_HOSTS_FILE}")" diff --git a/Scripts/hcloud-upload-nixos-image.sh b/Scripts/hcloud-upload-nixos-image.sh index 2590519..36f1e3b 100755 --- a/Scripts/hcloud-upload-nixos-image.sh +++ b/Scripts/hcloud-upload-nixos-image.sh @@ -6,12 +6,14 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=Scripts/_burrow-flake.sh source "${SCRIPT_DIR}/_burrow-flake.sh" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" DEFAULT_CONFIG="burrow-forge" DEFAULT_FLAKE="." DEFAULT_LOCATION="hel1" DEFAULT_ARCHITECTURE="x86" -DEFAULT_TOKEN_FILE="${REPO_ROOT}/intake/hetzner-api-token.txt" +DEFAULT_TOKEN_FILE="" CONFIG="${HCLOUD_IMAGE_CONFIG:-${DEFAULT_CONFIG}}" FLAKE="${HCLOUD_IMAGE_FLAKE:-${DEFAULT_FLAKE}}" @@ -30,6 +32,13 @@ NIX_BUILD_FLAGS=() BURROW_FLAKE_TMPDIRS=() LOCAL_STORE_DIR="" +cleanup() { + burrow_cleanup_secret_tmpfiles + burrow_cleanup_flake_tmpdirs +} + +trap cleanup EXIT + usage() { cat <<'EOF' Usage: Scripts/hcloud-upload-nixos-image.sh [options] @@ -42,7 +51,7 @@ Options: --location Hetzner location for the temporary upload server (default: hel1) --architecture CPU architecture of the image (default: x86) --server-type Hetzner server type for the temporary upload server - --token-file Hetzner API token file (default: intake/hetzner-api-token.txt) + --token-file Hetzner API token file (default: secrets/hetzner/api-token.age, then intake/hetzner-api-token.txt) --artifact-path Prebuilt raw image artifact to upload directly --output-hash Stable hash label for --artifact-path uploads --builder-spec Complete builders string passed to nix build @@ -125,6 +134,17 @@ while [[ $# -gt 0 ]]; do esac done +TOKEN_FILE="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${TOKEN_FILE}" \ + "${REPO_ROOT}/intake/hetzner-api-token.txt" \ + "${REPO_ROOT}/secrets/hetzner/api-token.age" +)" || { + echo "Hetzner API token file could not be resolved" >&2 + exit 1 +} + cleanup() { burrow_cleanup_flake_tmpdirs if [[ -n "${LOCAL_STORE_DIR}" && -d "${LOCAL_STORE_DIR}" ]]; then diff --git a/Scripts/hetzner-forge.sh b/Scripts/hetzner-forge.sh index cfce7eb..73e1953 100755 --- a/Scripts/hetzner-forge.sh +++ b/Scripts/hetzner-forge.sh @@ -2,6 +2,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" usage() { cat <<'EOF' @@ -31,7 +34,7 @@ Options: -h, --help Show this help text. Environment: - HCLOUD_TOKEN_FILE Defaults to intake/hetzner-api-token.txt + HCLOUD_TOKEN_FILE Defaults to secrets/hetzner/api-token.age, then intake/hetzner-api-token.txt EOF } @@ -43,10 +46,15 @@ IMAGE="ubuntu-24.04" CONFIG="burrow-forge" FLAKE="." UPLOAD_LOCATION="" -TOKEN_FILE="${HCLOUD_TOKEN_FILE:-intake/hetzner-api-token.txt}" +TOKEN_FILE="${HCLOUD_TOKEN_FILE:-}" YES=0 SSH_KEYS=("contact@burrow.net" "agent@burrow.net") +cleanup() { + burrow_cleanup_secret_tmpfiles +} +trap cleanup EXIT + if [[ $# -gt 0 ]]; then case "$1" in show|create|delete|recreate|build-image|create-from-image|recreate-from-image) @@ -110,10 +118,16 @@ while [[ $# -gt 0 ]]; do esac done -if [[ ! -f "${TOKEN_FILE}" ]]; then - echo "Hetzner API token file not found: ${TOKEN_FILE}" >&2 +TOKEN_FILE="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${TOKEN_FILE}" \ + "${REPO_ROOT}/intake/hetzner-api-token.txt" \ + "${REPO_ROOT}/secrets/hetzner/api-token.age" +)" || { + echo "Hetzner API token file could not be resolved" >&2 exit 1 -fi +} if [[ -z "${UPLOAD_LOCATION}" ]]; then UPLOAD_LOCATION="${LOCATION}" diff --git a/Scripts/nsc-build-and-upload-image.sh b/Scripts/nsc-build-and-upload-image.sh index 6fb99a9..27badb6 100755 --- a/Scripts/nsc-build-and-upload-image.sh +++ b/Scripts/nsc-build-and-upload-image.sh @@ -6,11 +6,13 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=Scripts/_burrow-flake.sh source "${SCRIPT_DIR}/_burrow-flake.sh" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" CONFIG="${HCLOUD_IMAGE_CONFIG:-burrow-forge}" FLAKE="${HCLOUD_IMAGE_FLAKE:-.}" LOCATION="${HCLOUD_IMAGE_LOCATION:-hel1}" -TOKEN_FILE="${HCLOUD_TOKEN_FILE:-${REPO_ROOT}/intake/hetzner-api-token.txt}" +TOKEN_FILE="${HCLOUD_TOKEN_FILE:-}" NSC_SSH_HOST="${NSC_SSH_HOST:-ssh.ord2.namespace.so}" NSC_MACHINE_TYPE="${NSC_MACHINE_TYPE:-linux/amd64:32x64}" NSC_BUILDER_DURATION="${NSC_BUILDER_DURATION:-4h}" @@ -26,6 +28,13 @@ EXTRA_LABELS=() BURROW_FLAKE_TMPDIRS=() BUILDER_ID="" +cleanup() { + burrow_cleanup_secret_tmpfiles + burrow_cleanup_flake_tmpdirs +} + +trap cleanup EXIT + usage() { cat <<'EOF' Usage: Scripts/nsc-build-and-upload-image.sh [options] @@ -37,7 +46,7 @@ Options: --config images.-raw output to build (default: burrow-forge) --flake Flake path to build from (default: .) --location Hetzner upload location (default: hel1) - --token-file Hetzner API token file (default: intake/hetzner-api-token.txt) + --token-file Hetzner API token file (default: secrets/hetzner/api-token.age, then intake/hetzner-api-token.txt) --machine-type Namespace machine type (default: linux/amd64:32x64) --ssh-host Namespace SSH endpoint (default: ssh.ord2.namespace.so) --duration Namespace builder lifetime (default: 4h) @@ -126,6 +135,17 @@ while [[ $# -gt 0 ]]; do esac done +TOKEN_FILE="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${TOKEN_FILE}" \ + "${REPO_ROOT}/intake/hetzner-api-token.txt" \ + "${REPO_ROOT}/secrets/hetzner/api-token.age" +)" || { + echo "Hetzner API token file could not be resolved" >&2 + exit 1 +} + cleanup() { if [[ -n "${BUILDER_ID}" && -n "${NSC_BIN}" ]]; then "${NSC_BIN}" destroy "${BUILDER_ID}" --force >/dev/null 2>&1 || true diff --git a/Scripts/provision-forgejo-nsc.sh b/Scripts/provision-forgejo-nsc.sh index 9e6e4b5..c85b993 100755 --- a/Scripts/provision-forgejo-nsc.sh +++ b/Scripts/provision-forgejo-nsc.sh @@ -6,31 +6,35 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=Scripts/_burrow-flake.sh source "${SCRIPT_DIR}/_burrow-flake.sh" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" usage() { cat <<'EOF' Usage: Scripts/provision-forgejo-nsc.sh [options] -Generate Burrow forgejo-nsc runtime inputs in intake/ and optionally refresh the -Namespace token from the currently logged-in namespace account. +Generate Burrow forgejo-nsc runtime inputs and refresh the authoritative +`secrets/forgejo/*.age` files, optionally refreshing the Namespace token from +the currently logged-in namespace account. Options: --host SSH target used to mint the Forgejo PAT. Default: root@git.burrow.net --ssh-key SSH private key for the forge host. - Default: intake/agent_at_burrow_net_ed25519 + Default: secrets/forgejo/agent-ssh-key.age, then intake/ --nsc-bin Override the nsc binary. - --no-refresh-token Reuse intake/forgejo_nsc_token.txt if it already exists. + --no-refresh-token Reuse the existing encrypted Namespace token if it already exists. --token-name Forgejo PAT name prefix (default: forgejo-nsc) --contact-user Forgejo username used for PAT creation (default: contact) --scope-owner Forgejo org/user owner for the default NSC scope (default: hackclub) --scope-name Forgejo repository name for the default NSC scope (default: burrow) + --write-intake Also write plaintext runtime inputs to intake/ for local debugging. -h, --help Show this help text. EOF } HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-}" NSC_BIN="${NSC_BIN:-}" KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" REFRESH_TOKEN=1 @@ -39,8 +43,12 @@ CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}" SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-hackclub}" SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}" BURROW_FLAKE_TMPDIRS=() +WRITE_INTAKE=0 +TMP_DIR="" cleanup() { + [[ -n "${TMP_DIR}" ]] && rm -rf "${TMP_DIR}" >/dev/null 2>&1 || true + burrow_cleanup_secret_tmpfiles burrow_cleanup_flake_tmpdirs } trap cleanup EXIT @@ -79,6 +87,10 @@ while [[ $# -gt 0 ]]; do SCOPE_NAME="${2:?missing value for --scope-name}" shift 2 ;; + --write-intake) + WRITE_INTAKE=1 + shift + ;; -h|--help) usage exit 0 @@ -97,13 +109,15 @@ burrow_require_cmd nix burrow_require_cmd ssh burrow_require_cmd python3 -if [[ ! -f "${SSH_KEY}" ]]; then - echo "forge SSH key not found: ${SSH_KEY}" >&2 - exit 1 -fi - -mkdir -p "${REPO_ROOT}/intake" -chmod 700 "${REPO_ROOT}/intake" +SSH_KEY="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${SSH_KEY}" \ + "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" \ + "${REPO_ROOT}/secrets/forgejo/agent-ssh-key.age" \ + "${HOME}/.ssh/agent_at_burrow_net_ed25519" +)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/burrow-forgejo-nsc.XXXXXX")" flake_ref="$(burrow_prepare_flake_ref "${REPO_ROOT}")" if [[ -z "${NSC_BIN}" ]]; then @@ -128,13 +142,16 @@ if [[ ! -x "${NSC_BIN}" ]]; then exit 1 fi -token_file="${REPO_ROOT}/intake/forgejo_nsc_token.txt" -dispatcher_out="${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" -autoscaler_out="${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" +token_file="${TMP_DIR}/forgejo_nsc_token.txt" +dispatcher_out="${TMP_DIR}/forgejo_nsc_dispatcher.yaml" +autoscaler_out="${TMP_DIR}/forgejo_nsc_autoscaler.yaml" dispatcher_src="${REPO_ROOT}/services/forgejo-nsc/deploy/dispatcher.yaml" autoscaler_src="${REPO_ROOT}/services/forgejo-nsc/deploy/autoscaler.yaml" +token_secret="${REPO_ROOT}/secrets/forgejo/nsc-token.age" +dispatcher_secret="${REPO_ROOT}/secrets/forgejo/nsc-dispatcher-config.age" +autoscaler_secret="${REPO_ROOT}/secrets/forgejo/nsc-autoscaler-config.age" -if [[ "${REFRESH_TOKEN}" -eq 1 || ! -s "${token_file}" ]]; then +if [[ "${REFRESH_TOKEN}" -eq 1 ]]; then "${NSC_BIN}" auth check-login --duration 20m >/dev/null raw_token_file="$(mktemp)" trap 'rm -f "${raw_token_file}"; cleanup' EXIT @@ -155,7 +172,13 @@ Path(os.environ["TOKEN_FILE"]).write_text( PY rm -f "${raw_token_file}" chmod 600 "${token_file}" -elif [[ -s "${token_file}" ]]; then +elif [[ -f "${token_secret}" ]]; then + burrow_decrypt_age_secret_to_temp "${REPO_ROOT}" "${token_secret}" > "${token_file}" +elif [[ -s "${REPO_ROOT}/intake/forgejo_nsc_token.txt" ]]; then + cp "${REPO_ROOT}/intake/forgejo_nsc_token.txt" "${token_file}" +fi + +if [[ -s "${token_file}" ]]; then TOKEN_FILE="${token_file}" python3 - <<'PY' import json import os @@ -271,6 +294,24 @@ PY chmod 600 "${dispatcher_out}" "${autoscaler_out}" -echo "Rendered intake/forgejo_nsc_token.txt, intake/forgejo_nsc_dispatcher.yaml, and intake/forgejo_nsc_autoscaler.yaml." -echo "Re-encrypt them into secrets/forgejo/{nsc-token,nsc-dispatcher-config,nsc-autoscaler-config}.age before deploying the forge host." +burrow_encrypt_secret_from_file "${REPO_ROOT}" "${token_secret}" "${token_file}" +burrow_encrypt_secret_from_file "${REPO_ROOT}" "${dispatcher_secret}" "${dispatcher_out}" +burrow_encrypt_secret_from_file "${REPO_ROOT}" "${autoscaler_secret}" "${autoscaler_out}" + +if [[ "${WRITE_INTAKE}" -eq 1 ]]; then + mkdir -p "${REPO_ROOT}/intake" + chmod 700 "${REPO_ROOT}/intake" + cp "${token_file}" "${REPO_ROOT}/intake/forgejo_nsc_token.txt" + cp "${dispatcher_out}" "${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" + cp "${autoscaler_out}" "${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" + chmod 600 \ + "${REPO_ROOT}/intake/forgejo_nsc_token.txt" \ + "${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" \ + "${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" +fi + +echo "Updated secrets/forgejo/{nsc-token,nsc-dispatcher-config,nsc-autoscaler-config}.age." +if [[ "${WRITE_INTAKE}" -eq 1 ]]; then + echo "Also refreshed intake/forgejo_nsc_{token,dispatcher,autoscaler} for local debugging." +fi echo "Minted Forgejo PAT ${token_name} for ${CONTACT_USER} on ${HOST}." diff --git a/Scripts/sync-forgejo-nsc-config.sh b/Scripts/sync-forgejo-nsc-config.sh index 77581f8..baa4960 100755 --- a/Scripts/sync-forgejo-nsc-config.sh +++ b/Scripts/sync-forgejo-nsc-config.sh @@ -5,12 +5,12 @@ usage() { cat <<'EOF' Usage: Scripts/sync-forgejo-nsc-config.sh [options] -Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and +Copy Burrow forgejo-nsc runtime inputs from age secrets or intake/ onto the forge host and restart the dispatcher/autoscaler units. Options: --host SSH target (default: root@git.burrow.net) - --ssh-key SSH private key (default: intake/agent_at_burrow_net_ed25519) + --ssh-key SSH private key (default: secrets/forgejo/agent-ssh-key.age, then intake/) --rotate-pat Re-render the intake files before syncing. --no-restart Copy files only. -h, --help Show this help text. @@ -19,12 +19,21 @@ EOF SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${SCRIPT_DIR}/_burrow-secrets.sh" HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-}" KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" ROTATE_PAT=0 NO_RESTART=0 +TMP_DIR="" + +cleanup() { + [[ -n "${TMP_DIR}" ]] && rm -rf "${TMP_DIR}" >/dev/null 2>&1 || true + burrow_cleanup_secret_tmpfiles +} +trap cleanup EXIT while [[ $# -gt 0 ]]; do case "$1" in @@ -68,18 +77,41 @@ burrow_require_cmd() { 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 +SSH_KEY="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${SSH_KEY}" \ + "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" \ + "${REPO_ROOT}/secrets/forgejo/agent-ssh-key.age" \ + "${HOME}/.ssh/agent_at_burrow_net_ed25519" +)" 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" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/burrow-nsc-sync.XXXXXX")" +token_file="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "" \ + "${REPO_ROOT}/intake/forgejo_nsc_token.txt" \ + "${REPO_ROOT}/secrets/forgejo/nsc-token.age" +)" +dispatcher_file="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "" \ + "${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" \ + "${REPO_ROOT}/secrets/forgejo/nsc-dispatcher-config.age" +)" +autoscaler_file="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "" \ + "${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" \ + "${REPO_ROOT}/secrets/forgejo/nsc-autoscaler-config.age" +)" for path in "${token_file}" "${dispatcher_file}" "${autoscaler_file}"; do if [[ ! -s "${path}" ]]; then @@ -96,12 +128,12 @@ ssh_opts=( ) remote_tmp="$(ssh "${ssh_opts[@]}" "${HOST}" "mktemp -d")" -cleanup() { +cleanup_remote() { if [[ -n "${remote_tmp:-}" ]]; then ssh "${ssh_opts[@]}" "${HOST}" "rm -rf '${remote_tmp}'" >/dev/null 2>&1 || true fi } -trap cleanup EXIT +trap 'cleanup_remote; cleanup' EXIT scp "${ssh_opts[@]}" \ "${token_file}" \ diff --git a/Tools/forwardemail-custom-s3.sh b/Tools/forwardemail-custom-s3.sh index 5f39ddd..4640bc8 100755 --- a/Tools/forwardemail-custom-s3.sh +++ b/Tools/forwardemail-custom-s3.sh @@ -3,17 +3,22 @@ set -euo pipefail umask 077 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=Scripts/_burrow-secrets.sh +source "${REPO_ROOT}/Scripts/_burrow-secrets.sh" + usage() { cat <<'EOF' Usage: Tools/forwardemail-custom-s3.sh \ --domain burrow.net \ - --api-token-file intake/forwardemail_api_token.txt \ + --api-token-file secrets/forwardemail/api-token.age \ --s3-endpoint https:// \ --s3-region \ --s3-bucket \ - --s3-access-key-file intake/hetzner-s3-user.txt \ - --s3-secret-key-file intake/hetzner-s3-secret.txt + --s3-access-key-file secrets/forwardemail/hetzner-s3-user.age \ + --s3-secret-key-file secrets/forwardemail/hetzner-s3-secret.age Options: --domain Forward Email domain to update. @@ -54,13 +59,18 @@ read_secret() { printf '%s' "$value" } +cleanup() { + burrow_cleanup_secret_tmpfiles +} +trap cleanup EXIT + domain="" -api_token_file="" +api_token_file="${FORWARDEMAIL_API_TOKEN_FILE:-}" s3_endpoint="" s3_region="" s3_bucket="" -s3_access_key_file="" -s3_secret_key_file="" +s3_access_key_file="${FORWARDEMAIL_S3_ACCESS_KEY_FILE:-}" +s3_secret_key_file="${FORWARDEMAIL_S3_SECRET_KEY_FILE:-}" test_only=false while [[ $# -gt 0 ]]; do @@ -108,16 +118,38 @@ while [[ $# -gt 0 ]]; do done [[ -n "$domain" ]] || fail "--domain is required" -[[ -n "$api_token_file" ]] || fail "--api-token-file is required" [[ -n "$s3_endpoint" || "$test_only" == true ]] || fail "--s3-endpoint is required unless --test-only is set" [[ -n "$s3_region" || "$test_only" == true ]] || fail "--s3-region is required unless --test-only is set" [[ -n "$s3_bucket" || "$test_only" == true ]] || fail "--s3-bucket is required unless --test-only is set" -[[ -n "$s3_access_key_file" || "$test_only" == true ]] || fail "--s3-access-key-file is required unless --test-only is set" -[[ -n "$s3_secret_key_file" || "$test_only" == true ]] || fail "--s3-secret-key-file is required unless --test-only is set" - +api_token_file="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${api_token_file}" \ + "${REPO_ROOT}/intake/forwardemail_api_token.txt" \ + "${REPO_ROOT}/secrets/forwardemail/api-token.age" +)" || fail "unable to resolve Forward Email API token file" require_file "$api_token_file" api_token="$(read_secret "$api_token_file")" +if [[ "$test_only" != true ]]; then + s3_access_key_file="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${s3_access_key_file}" \ + "${REPO_ROOT}/intake/hetzner-s3-user.txt" \ + "${REPO_ROOT}/secrets/forwardemail/hetzner-s3-user.age" + )" || fail "unable to resolve Hetzner S3 access key file" + s3_secret_key_file="$( + burrow_resolve_secret_file \ + "${REPO_ROOT}" \ + "${s3_secret_key_file}" \ + "${REPO_ROOT}/intake/hetzner-s3-secret.txt" \ + "${REPO_ROOT}/secrets/forwardemail/hetzner-s3-secret.age" + )" || fail "unable to resolve Hetzner S3 secret key file" + require_file "$s3_access_key_file" + require_file "$s3_secret_key_file" +fi + if [[ "$test_only" == false ]]; then require_file "$s3_access_key_file" require_file "$s3_secret_key_file" diff --git a/Tools/forwardemail-hetzner-storage.py b/Tools/forwardemail-hetzner-storage.py index 3a2a941..2c5ff82 100755 --- a/Tools/forwardemail-hetzner-storage.py +++ b/Tools/forwardemail-hetzner-storage.py @@ -6,6 +6,7 @@ import argparse import datetime as dt import hashlib import hmac +import subprocess import sys import textwrap from pathlib import Path @@ -13,11 +14,38 @@ from urllib.parse import urlencode, urlparse import requests +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def default_secret_path(age_rel: str, intake_rel: str) -> str: + age_path = REPO_ROOT / age_rel + if age_path.exists(): + return str(age_path) + return intake_rel + def read_secret(path: str) -> str: - value = Path(path).read_text(encoding="utf-8").strip() + file_path = Path(path) + if not file_path.is_absolute(): + file_path = REPO_ROOT / file_path + if file_path.suffix == ".age": + value = subprocess.check_output( + [ + "nix", + "--extra-experimental-features", + "nix-command flakes", + "run", + f"{REPO_ROOT}#agenix", + "--", + "-d", + str(file_path), + ], + text=True, + ).strip() + else: + value = file_path.read_text(encoding="utf-8").strip() if not value: - raise SystemExit(f"error: empty secret file: {path}") + raise SystemExit(f"error: empty secret file: {file_path}") return value @@ -212,12 +240,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--region", default="hel1", help="S3 region.") parser.add_argument( "--access-key-file", - default="intake/hetzner-s3-user.txt", + default=default_secret_path("secrets/forwardemail/hetzner-s3-user.age", "intake/hetzner-s3-user.txt"), help="File containing the S3 access key id.", ) parser.add_argument( "--secret-key-file", - default="intake/hetzner-s3-secret.txt", + default=default_secret_path("secrets/forwardemail/hetzner-s3-secret.age", "intake/hetzner-s3-secret.txt"), help="File containing the S3 secret key.", ) parser.add_argument( diff --git a/docs/FORWARDEMAIL.md b/docs/FORWARDEMAIL.md index 798f3e5..d7ffb34 100644 --- a/docs/FORWARDEMAIL.md +++ b/docs/FORWARDEMAIL.md @@ -26,11 +26,14 @@ Forward Email also documents these operational constraints: ## Burrow Secret Layout -Present in `intake/` today: +Authoritative secrets now live in: -- `intake/forwardemail_api_token.txt` -- `intake/hetzner-s3-user.txt` -- `intake/hetzner-s3-secret.txt` +- `secrets/forwardemail/api-token.age` +- `secrets/forwardemail/hetzner-s3-user.age` +- `secrets/forwardemail/hetzner-s3-secret.age` + +Legacy plaintext `intake/` files may still exist locally for debugging, but the +tooling now prefers the age-encrypted files above. - Hetzner public S3 endpoint for Forward Email: `https://hel1.your-objectstorage.com` - Hetzner object storage region: `hel1` - Hetzner bucket used for Forward Email backups: `burrow` @@ -69,12 +72,12 @@ Example: ```sh Tools/forwardemail-custom-s3.sh \ --domain burrow.net \ - --api-token-file intake/forwardemail_api_token.txt \ + --api-token-file secrets/forwardemail/api-token.age \ --s3-endpoint https://hel1.your-objectstorage.com \ --s3-region hel1 \ --s3-bucket burrow \ - --s3-access-key-file intake/hetzner-s3-user.txt \ - --s3-secret-key-file intake/hetzner-s3-secret.txt + --s3-access-key-file secrets/forwardemail/hetzner-s3-user.age \ + --s3-secret-key-file secrets/forwardemail/hetzner-s3-secret.age ``` Retest an existing domain configuration without rewriting it: @@ -82,7 +85,7 @@ Retest an existing domain configuration without rewriting it: ```sh Tools/forwardemail-custom-s3.sh \ --domain burrow.net \ - --api-token-file intake/forwardemail_api_token.txt \ + --api-token-file secrets/forwardemail/api-token.age \ --test-only ``` diff --git a/nixos/README.md b/nixos/README.md index aa0fff6..ebdb2dc 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -29,7 +29,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 3. Encrypt the Forgejo admin password and agent SSH key into `secrets/forgejo/{admin-password,agent-ssh-key}.age`. 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account from the agenix secret path. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. -6. Run `Scripts/provision-forgejo-nsc.sh` locally, re-encrypt the resulting NSC token + configs into `secrets/forgejo/*.age`, then deploy with `Scripts/forge-deploy.sh` so agenix updates the live forgejo-nsc runtime paths. +6. Run `Scripts/provision-forgejo-nsc.sh` locally to refresh `secrets/forgejo/{nsc-token,nsc-dispatcher-config,nsc-autoscaler-config}.age`, then deploy with `Scripts/forge-deploy.sh` so agenix updates the live forgejo-nsc runtime paths. 7. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 8. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 9. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. @@ -43,7 +43,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `https://burrow.net` returns the root forge landing response - `https://git.burrow.net` returns the live Forgejo front door - `https://nsc-autoscaler.burrow.net` terminates TLS on Caddy and returns the expected application-level `404` for `/` -- The Cloudflare token currently in `intake/cloudflare-token.txt` is an account-scoped token: `POST /accounts//tokens/verify` succeeds, while `POST /user/tokens/verify` returns `Invalid API Token`. +- The Cloudflare token now lives in `secrets/cloudflare/api-token.age`; the current token is account-scoped: `POST /accounts//tokens/verify` succeeds, while `POST /user/tokens/verify` returns `Invalid API Token`. - `burrow.rs` still resolves publicly to a Vercel `DEPLOYMENT_NOT_FOUND` response. - Both domains publish Forward Email MX/TXT records. - Forward Email custom S3 is live on both domains against the Hetzner `burrow` bucket and the public regional endpoint `https://hel1.your-objectstorage.com`. diff --git a/secrets/README.md b/secrets/README.md index f7d67f5..706b374 100644 --- a/secrets/README.md +++ b/secrets/README.md @@ -9,11 +9,19 @@ For the Forgejo Namespace Cloud runtime: - `secrets/forgejo/nsc-token.age` - `secrets/forgejo/nsc-dispatcher-config.age` - `secrets/forgejo/nsc-autoscaler-config.age` +- `secrets/cloudflare/api-token.age` +- `secrets/hetzner/api-token.age` +- `secrets/forwardemail/api-token.age` +- `secrets/forwardemail/hetzner-s3-user.age` +- `secrets/forwardemail/hetzner-s3-secret.age` Use: - `make secret name=forgejo/nsc-token` - `make secret-file name=forgejo/agent-ssh-key file=/path/to/source` +- `Scripts/provision-forgejo-nsc.sh` to refresh the Forgejo Namespace token and runtime configs in `secrets/forgejo/*.age` +- `make secret-file name=cloudflare/api-token file=/path/to/cloudflare-token.txt` +- `make secret-file name=hetzner/api-token file=/path/to/hetzner-api-token.txt` The forge host decrypts these files at activation time and feeds the resulting paths into `services.burrow.forge`, `services.burrow.forgeRunner`, and diff --git a/secrets/cloudflare/api-token.age b/secrets/cloudflare/api-token.age new file mode 100644 index 0000000..caf8135 --- /dev/null +++ b/secrets/cloudflare/api-token.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q rX5+bmtxyHNgD+xNdHkB1fKdjUlrX275DaKTIHssYyA +KwbfKHx14QXRKBIGWwJDR8+DONyCdVssh8Ti8mdajyQ +-> ssh-ed25519 IrZmAg SOG/KvURA6PrxVhtZyIbazFGNQZyp0BR4MH+YInHGB4 +79pENXhtLwlCQVnqkPEzoFgrXMmTqRsfs4ULluTevWA +--- gDA64KNbgN+eGHsQbIbKvhOg1T/Nqui6I/wy2MK8VWE +[|V{['E .{CǶ {ha \ No newline at end of file diff --git a/secrets/forwardemail/api-token.age b/secrets/forwardemail/api-token.age new file mode 100644 index 0000000..4d4ea15 --- /dev/null +++ b/secrets/forwardemail/api-token.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q ICuXuDsZiw1ShfUX9qjq8bCkeNdsbHWnG4e+3ZOC3jg +wswxqzQtf7jumSYB8ZeQzRBpMrBPVsUnWOYsmlDvpSs +-> ssh-ed25519 IrZmAg Xrvp/tXzXrHF1+NxgTZs9nNufyxtTq5NoYT5gaW6p1M +UWGlhZpV19CWMR9abp30vkQwZUMb/ylvInGEBlDdjjE +--- qhAaAECwhmAY4g3/e+Dz9RvL1MBQkHGWyoe1NkdTuqA +d?)<36F:a˝ ųֲ \ No newline at end of file diff --git a/secrets/forwardemail/hetzner-s3-secret.age b/secrets/forwardemail/hetzner-s3-secret.age new file mode 100644 index 0000000..55b5be3 --- /dev/null +++ b/secrets/forwardemail/hetzner-s3-secret.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q jwJzvmXUV5rCB6ku7ILLQUDInuQJL2gN+pjmX/ccXWE +q9OSyVhTuzERRRZZOCQzbwAwLOvOFIT/l9MxJ0V3UTo +-> ssh-ed25519 IrZmAg 8IutYG3CnNP9gw5fTFOaXm1Ue4i/cVs1apA88bNs9mo +daaf+6HoE3bmUEKR8/zu9jKTstVFCXqBlBxBdNVpQ90 +--- gRGNkWqoh+lZWpDG7yNLd4fjoX2jCyHTWbzImzoFGko +R@+fu9RBX2 [I \ No newline at end of file diff --git a/secrets/forwardemail/hetzner-s3-user.age b/secrets/forwardemail/hetzner-s3-user.age new file mode 100644 index 0000000..733d6e8 --- /dev/null +++ b/secrets/forwardemail/hetzner-s3-user.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q jwyFpeVX18Q/1vnK2A1gwETTTH/QDUmW7vhCA+E/1lc +vtG1Ra+hR0cc/o9oJw7YTWMc2+JmrehzBE5QkIHQMKY +-> ssh-ed25519 IrZmAg KljcDNRlBmn7ElVfXq/E2prFHnRQD2TkQY9Vto+OQUA +T37sFc3xVrhky6e0n4KbsX18/fBqP3VjS/mNbxX6bfI +--- lvSjWGriUCYC14eI2eH9MdO2cB76Pe3gWD7pidw8Qjo +s&x*4}z&F \ No newline at end of file diff --git a/secrets/hetzner/api-token.age b/secrets/hetzner/api-token.age new file mode 100644 index 0000000..a409a7d --- /dev/null +++ b/secrets/hetzner/api-token.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q pEJA2VJkPC+NzA9yFvBrpXHD8qFMTD9iIHYSkx8P2RI +AGE1QJya77d92ERA1yQYylvZPNAJEQKoCL32BY5XBzo +-> ssh-ed25519 IrZmAg VMpoTBpNG/TAlnbJ2APwc4VMt2CX5rQwlrrihtmojFo +caOwayLgVDGPrjqLLH8hHHQ3Fy2WeRI2tf+R02HFqx0 +--- Ey1DYpyA4lnVqPaabNsEuSihl4fvZ2vpSc/IRGZwYBw +U2Q*mFޞ|^EV" \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 9d40bf3..4a78a69 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -3,6 +3,7 @@ let agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net"; forge = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAlkGo4lwpwIIZ0J01KjTuJuf/U/wGgy4/aKwPIUzutL root@burrow-forge"; + operatorSecrets = [ contact agent ]; forgeAutomation = [ contact agent forge ]; in { "secrets/forgejo/admin-password.age".publicKeys = forgeAutomation; @@ -10,4 +11,9 @@ in { "secrets/forgejo/nsc-token.age".publicKeys = forgeAutomation; "secrets/forgejo/nsc-dispatcher-config.age".publicKeys = forgeAutomation; "secrets/forgejo/nsc-autoscaler-config.age".publicKeys = forgeAutomation; + "secrets/cloudflare/api-token.age".publicKeys = operatorSecrets; + "secrets/hetzner/api-token.age".publicKeys = operatorSecrets; + "secrets/forwardemail/api-token.age".publicKeys = operatorSecrets; + "secrets/forwardemail/hetzner-s3-user.age".publicKeys = operatorSecrets; + "secrets/forwardemail/hetzner-s3-secret.age".publicKeys = operatorSecrets; } diff --git a/services/forgejo-nsc/README.md b/services/forgejo-nsc/README.md index 5b2926b..f928973 100644 --- a/services/forgejo-nsc/README.md +++ b/services/forgejo-nsc/README.md @@ -155,11 +155,12 @@ instances: ``` For Burrow, use `Scripts/provision-forgejo-nsc.sh` to mint the Forgejo PAT, -generate a Namespace token from the logged-in namespace account, and render -bootstrap artifacts into `intake/forgejo_nsc_{dispatcher,autoscaler}.yaml` plus -`intake/forgejo_nsc_token.txt`. The token file is emitted as JSON with a -`bearer_token` field so both the Compute API path and the `nsc` CLI fallback can -consume the same secret material. +generate a Namespace token from the logged-in Namespace account, and refresh +`secrets/forgejo/{nsc-token,nsc-dispatcher-config,nsc-autoscaler-config}.age`. +The token file is emitted as JSON with a `bearer_token` field so both the +Compute API path and the `nsc` CLI fallback can consume the same secret +material. Use `--write-intake` only when you explicitly need local plaintext +debug copies. Long-lived runtime state is now sourced from age-encrypted files: @@ -169,10 +170,9 @@ Long-lived runtime state is now sourced from age-encrypted files: - `secrets/forgejo/nsc-dispatcher-config.age` - `secrets/forgejo/nsc-autoscaler-config.age` -After refreshing the intake files, re-encrypt them into `secrets/forgejo/*.age` -and deploy the forge host so `config.age.secrets.*` updates the live paths for -`services.burrow.forge`, `services.burrow.forgeRunner`, and -`services.burrow.forgejoNsc`. +After refreshing the encrypted secrets, deploy the forge host so +`config.age.secrets.*` updates the live paths for `services.burrow.forge`, +`services.burrow.forgeRunner`, and `services.burrow.forgejoNsc`. Run it next to the dispatcher: diff --git a/services/forgejo-nsc/internal/nsc/macos.go b/services/forgejo-nsc/internal/nsc/macos.go index b87e954..c54fb20 100644 --- a/services/forgejo-nsc/internal/nsc/macos.go +++ b/services/forgejo-nsc/internal/nsc/macos.go @@ -602,6 +602,18 @@ if ! mkdir -p "/Users/runner/.cache/act" 2>/dev/null; then fi export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH}" +cache_root="${NSC_CACHE_PATH:-$HOME/.cache/burrow}" +mkdir -p \ + "${cache_root}/bin" \ + "${cache_root}/downloads" \ + "${cache_root}/go/path" \ + "${cache_root}/go/mod" \ + "${cache_root}/go/build" \ + "${cache_root}/homebrew" +export HOMEBREW_CACHE="${cache_root}/homebrew" +export GOPATH="${cache_root}/go/path" +export GOMODCACHE="${cache_root}/go/mod" +export GOCACHE="${cache_root}/go/build" if ! command -v curl >/dev/null 2>&1; then echo "curl is required" >&2 @@ -622,14 +634,18 @@ export PATH="${PWD}/bin:${PATH}" runner_version="v12.6.4" runner_src_tgz="forgejo-runner-${runner_version}.tar.gz" +runner_src_tgz_path="${cache_root}/downloads/${runner_src_tgz}" runner_src_url="https://code.forgejo.org/forgejo/runner/archive/${runner_version}.tar.gz" runner_src_dir="forgejo-runner-src" +runner_bin_cache="${cache_root}/bin/forgejo-runner-${runner_version}" -if ! command -v forgejo-runner >/dev/null 2>&1; then +if [[ ! -x "${runner_bin_cache}" ]]; then rm -rf "${runner_src_dir}" mkdir -p "${runner_src_dir}" - curl -fsSL "${runner_src_url}" -o "${runner_src_tgz}" - tar -xzf "${runner_src_tgz}" -C "${runner_src_dir}" --strip-components=1 + if [[ ! -f "${runner_src_tgz_path}" ]]; then + curl -fsSL "${runner_src_url}" -o "${runner_src_tgz_path}" + fi + tar -xzf "${runner_src_tgz_path}" -C "${runner_src_dir}" --strip-components=1 toolchain="$(grep -E '^toolchain ' "${runner_src_dir}/go.mod" | awk '{print $2}' | head -n 1 || true)" if [ -z "${toolchain}" ]; then @@ -639,21 +655,23 @@ if ! command -v forgejo-runner >/dev/null 2>&1; then if ! command -v go >/dev/null 2>&1; then go_tgz="${toolchain}.darwin-arm64.tar.gz" go_url="https://go.dev/dl/${go_tgz}" - curl -fsSL "${go_url}" -o "${go_tgz}" - tar -xzf "${go_tgz}" + go_tgz_path="${cache_root}/downloads/${go_tgz}" + if [[ ! -f "${go_tgz_path}" ]]; then + curl -fsSL "${go_url}" -o "${go_tgz_path}" + fi + tar -xzf "${go_tgz_path}" export GOROOT="${PWD}/go" export PATH="${GOROOT}/bin:${PATH}" fi - export GOPATH="${PWD}/.gopath" - export GOMODCACHE="${PWD}/.gomodcache" - export GOCACHE="${PWD}/.gocache" mkdir -p "${GOPATH}" "${GOMODCACHE}" "${GOCACHE}" - (cd "${runner_src_dir}" && go build -o "${workdir}/bin/forgejo-runner" .) - chmod +x "${workdir}/bin/forgejo-runner" + (cd "${runner_src_dir}" && go build -o "${runner_bin_cache}" .) + chmod +x "${runner_bin_cache}" fi +ln -sf "${runner_bin_cache}" "${workdir}/bin/forgejo-runner" + cat > runner.yaml <<'EOF' log: level: info