diff --git a/.bazelignore b/.bazelignore deleted file mode 100644 index 92d46f3..0000000 --- a/.bazelignore +++ /dev/null @@ -1,10 +0,0 @@ -.build -.derived-data -.git -.nscloud-cache -Apple/build -ci-artifacts -dist -publish -release -target diff --git a/.bazelversion b/.bazelversion deleted file mode 100644 index 47da986..0000000 --- a/.bazelversion +++ /dev/null @@ -1 +0,0 @@ -9.1.0 diff --git a/.forgejo/actions/decrypt-release-secrets/action.yml b/.forgejo/actions/decrypt-release-secrets/action.yml deleted file mode 100644 index f9b8590..0000000 --- a/.forgejo/actions/decrypt-release-secrets/action.yml +++ /dev/null @@ -1,327 +0,0 @@ -name: Decrypt Release Secrets -description: Decrypts age-sealed Burrow release secrets and exports CI paths/env. -inputs: - root: - description: Repository root - required: false - default: . -runs: - using: composite - steps: - - shell: bash - run: | - set -euo pipefail - - ROOT_INPUT='${{ inputs.root }}' - ROOT="$(cd "$ROOT_INPUT" && pwd)" - - resolve_nix_bin() { - local candidate - for candidate in \ - "${NIX_BIN:-}" \ - "$(command -v nix 2>/dev/null || true)" \ - /nix/var/nix/profiles/default/bin/nix \ - "${HOME:-}/.nix-profile/bin/nix" \ - "${HOME:-}/.local/state/nix/profile/bin/nix" \ - /Users/runner/.nix-profile/bin/nix \ - /Users/runner/.local/state/nix/profile/bin/nix \ - /home/runner/.nix-profile/bin/nix \ - /home/runner/.local/state/nix/profile/bin/nix \ - ; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - return 1 - } - - resolve_decrypt_tool() { - local candidate - for candidate in \ - "${AGE_BIN:-}" \ - "$(command -v age 2>/dev/null || true)" \ - /opt/homebrew/bin/age \ - /usr/local/bin/age \ - /usr/bin/age \ - /run/current-system/sw/bin/age \ - /etc/profiles/per-user/runner/bin/age \ - "${HOME:-}/.nix-profile/bin/age" \ - "${HOME:-}/.local/state/nix/profile/bin/age" \ - /Users/runner/.nix-profile/bin/age \ - /Users/runner/.local/state/nix/profile/bin/age \ - /home/runner/.nix-profile/bin/age \ - /home/runner/.local/state/nix/profile/bin/age \ - ; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf 'age:%s\n' "$candidate" - return 0 - fi - done - for candidate in \ - "${AGENIX_BIN:-}" \ - "$(command -v agenix 2>/dev/null || true)" \ - ; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf 'agenix:%s\n' "$candidate" - return 0 - fi - done - local nix_bin - nix_bin="$(resolve_nix_bin || true)" - if [[ -n "$nix_bin" ]]; then - local built - built="$("$nix_bin" --extra-experimental-features 'nix-command flakes' build --no-link "$ROOT#age" --print-out-paths 2>/dev/null | tail -n 1 || true)" - if [[ -n "$built" && -x "$built/bin/age" ]]; then - printf 'age:%s\n' "$built/bin/age" - return 0 - fi - built="$("$nix_bin" --extra-experimental-features 'nix-command flakes' build --no-link "$ROOT#agenix" --print-out-paths 2>/dev/null | tail -n 1 || true)" - if [[ -n "$built" && -x "$built/bin/agenix" ]]; then - printf 'agenix:%s\n' "$built/bin/agenix" - return 0 - fi - fi - echo "::error ::age or agenix is required to decrypt release secrets but neither is available." >&2 - exit 1 - } - - decrypt_tool="$(resolve_decrypt_tool)" - decrypt_kind="${decrypt_tool%%:*}" - decrypt_bin="${decrypt_tool#*:}" - KEY="${AGE_IDENTITY_PATH:-}" - if [[ -z "$KEY" || ! -s "$KEY" ]]; then - echo "::error ::Missing age identity. Run Scripts/resolve-age-identity.sh before this action." >&2 - exit 1 - fi - - decrypt_age_file() { - local file_path="$1" - if [[ "$decrypt_kind" == "agenix" ]]; then - local agenix_path="$file_path" - if [[ "$file_path" == "$ROOT/"* ]]; then - agenix_path="${file_path#"$ROOT"/}" - fi - (cd "$ROOT" && "$decrypt_bin" --identity "$KEY" --decrypt "$agenix_path") - else - "$decrypt_bin" --decrypt -i "$KEY" "$file_path" - fi - } - - write_env() { - local name="$1" - local value="$2" - echo "${name}=${value}" >> "$GITHUB_ENV" - } - - mask_multiline() { - local value="$1" - echo "::add-mask::${value}" - while IFS= read -r line; do - [[ -n "$line" ]] || continue - echo "::add-mask::${line}" - done <<<"$value" - } - - decrypt_env_optional() { - local name="$1" - local file="$2" - if [[ ! -f "$file" ]]; then - echo "::notice ::Skipping optional release secret ${file}" - return 0 - fi - local value - value="$(decrypt_age_file "$file")" - mask_multiline "$value" - { - echo "${name}<> "$GITHUB_ENV" - } - - decrypt_file_optional() { - local name="$1" - local file="$2" - local suffix="$3" - if [[ ! -f "$file" ]]; then - echo "::notice ::Skipping optional release secret ${file}" - return 0 - fi - local temp_dir="${RUNNER_TEMP:-/tmp}" - local temp_file - temp_file="$(mktemp "${temp_dir}/${name}.XXXXXX")" - if [[ -n "$suffix" ]]; then - local with_suffix="${temp_file}.${suffix}" - mv "$temp_file" "$with_suffix" - temp_file="$with_suffix" - fi - decrypt_age_file "$file" > "$temp_file" - chmod 600 "$temp_file" - write_env "$name" "$temp_file" - } - - decrypt_dotenv_optional() { - local file="$1" - if [[ ! -f "$file" ]]; then - echo "::notice ::Skipping optional release secret ${file}" - return 0 - fi - local temp_file - temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-release-dotenv.XXXXXX")" - decrypt_age_file "$file" > "$temp_file" - # shellcheck disable=SC1090 - set -a - source "$temp_file" - set +a - rm -f "$temp_file" - - if [[ -z "${MINIO_ROOT_USER:-}" || -z "${MINIO_ROOT_PASSWORD:-}" ]]; then - echo "::error ::${file} must define MINIO_ROOT_USER and MINIO_ROOT_PASSWORD." >&2 - exit 1 - fi - echo "::add-mask::${MINIO_ROOT_USER}" - echo "::add-mask::${MINIO_ROOT_PASSWORD}" - { - echo "AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER}" - echo "AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}" - echo "AWS_DEFAULT_REGION=${BLOB_REGION:-hel1}" - echo "AWS_REGION=${BLOB_REGION:-hel1}" - echo "AWS_S3_ENDPOINT_URL=https://${BLOB_ENDPOINT:-hel1.your-objectstorage.com}" - } >> "$GITHUB_ENV" - } - - decode_base64_stream() { - if command -v base64 >/dev/null 2>&1; then - base64 -d 2>/dev/null || base64 -D - elif command -v openssl >/dev/null 2>&1; then - openssl base64 -d -A - else - echo "::error ::base64 or openssl is required to decode release secrets." >&2 - return 127 - fi - } - - decode_base64_to_file() { - local output="$1" - decode_base64_stream > "$output" - } - - decrypt_appstore_connect() { - local file="$ROOT/secrets/apple/appstore-connect.env.age" - if [[ ! -f "$file" ]]; then - echo "::notice ::Skipping optional release secret ${file}" - return 0 - fi - - local temp_file key_path key_value - temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-asc-env.XXXXXX")" - decrypt_age_file "$file" > "$temp_file" - # shellcheck disable=SC1090 - set -a - source "$temp_file" - set +a - rm -f "$temp_file" - - if [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" ]]; then - echo "::error ::${file} must define ASC_API_KEY_ID and ASC_API_ISSUER_ID." >&2 - exit 1 - fi - - key_path="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-asc-key.XXXXXX.p8")" - if [[ -n "${ASC_API_KEY_BASE64:-}" ]]; then - printf '%s' "$ASC_API_KEY_BASE64" | decode_base64_to_file "$key_path" - elif [[ -n "${ASC_API_KEY_P8_BASE64:-}" ]]; then - printf '%s' "$ASC_API_KEY_P8_BASE64" | decode_base64_to_file "$key_path" - elif [[ -n "${APPSTORE_KEY:-}" ]]; then - printf '%s\n' "$APPSTORE_KEY" > "$key_path" - elif [[ -n "${ASC_API_KEY:-}" ]]; then - printf '%s\n' "$ASC_API_KEY" > "$key_path" - else - echo "::error ::${file} must define ASC_API_KEY_BASE64 or ASC_API_KEY." >&2 - exit 1 - fi - chmod 600 "$key_path" - key_value="$(cat "$key_path")" - mask_multiline "$key_value" - echo "::add-mask::${ASC_API_KEY_ID}" - echo "::add-mask::${ASC_API_ISSUER_ID}" - write_env ASC_API_KEY_ID "$ASC_API_KEY_ID" - write_env ASC_API_ISSUER_ID "$ASC_API_ISSUER_ID" - write_env ASC_API_KEY_PATH "$key_path" - } - - decrypt_distribution_signing() { - local file="$ROOT/secrets/apple/distribution-signing.env.age" - if [[ ! -f "$file" ]]; then - echo "::notice ::Skipping optional release secret ${file}" - return 0 - fi - - local temp_file cert_path cert_password cert_base64 - temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-signing-env.XXXXXX")" - decrypt_age_file "$file" > "$temp_file" - # shellcheck disable=SC1090 - set -a - source "$temp_file" - set +a - rm -f "$temp_file" - - cert_base64="${APPLE_SIGNING_CERTIFICATE_BASE64:-${APPLE_DISTRIBUTION_CERTIFICATE_BASE64:-}}" - cert_password="${APPLE_SIGNING_CERTIFICATE_PASSWORD:-${APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD:-}}" - if [[ -z "$cert_password" && -n "${APPLE_SIGNING_CERTIFICATE_PASSWORD_BASE64:-}" ]]; then - cert_password="$(printf '%s' "$APPLE_SIGNING_CERTIFICATE_PASSWORD_BASE64" | decode_base64_stream)" - fi - if [[ -z "$cert_base64" || -z "$cert_password" ]]; then - echo "::error ::${file} must define APPLE_SIGNING_CERTIFICATE_BASE64 and APPLE_SIGNING_CERTIFICATE_PASSWORD." >&2 - exit 1 - fi - - cert_path="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-apple-signing.XXXXXX.p12")" - printf '%s' "$cert_base64" | decode_base64_to_file "$cert_path" - chmod 600 "$cert_path" - echo "::add-mask::${cert_password}" - write_env APPLE_SIGNING_CERTIFICATE_PATH "$cert_path" - write_env APPLE_SIGNING_CERTIFICATE_PASSWORD "$cert_password" - write_env APPLE_KEYCHAIN_PASSWORD "$cert_password" - } - - decrypt_sparkle() { - local file="$ROOT/secrets/apple/sparkle.env.age" - if [[ ! -f "$file" ]]; then - echo "::notice ::Skipping optional release secret ${file}" - return 0 - fi - - local temp_file key_path key_base64 key_value - temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-sparkle-env.XXXXXX")" - decrypt_age_file "$file" > "$temp_file" - # shellcheck disable=SC1090 - set -a - source "$temp_file" - set +a - rm -f "$temp_file" - - key_base64="${SPARKLE_EDDSA_KEY_BASE64:-${SPARKLE_PRIVATE_KEY_BASE64:-}}" - key_path="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-sparkle-eddsa.XXXXXX.key")" - if [[ -n "$key_base64" ]]; then - printf '%s' "$key_base64" | decode_base64_to_file "$key_path" - elif [[ -n "${SPARKLE_EDDSA_KEY:-}" ]]; then - printf '%s\n' "$SPARKLE_EDDSA_KEY" > "$key_path" - elif [[ -n "${SPARKLE_PRIVATE_KEY:-}" ]]; then - printf '%s\n' "$SPARKLE_PRIVATE_KEY" > "$key_path" - else - echo "::error ::${file} must define SPARKLE_EDDSA_KEY_BASE64 or SPARKLE_EDDSA_KEY." >&2 - exit 1 - fi - chmod 600 "$key_path" - key_value="$(cat "$key_path")" - mask_multiline "$key_value" - write_env SPARKLE_EDDSA_KEY_PATH "$key_path" - } - - decrypt_appstore_connect - decrypt_distribution_signing - decrypt_sparkle - - decrypt_dotenv_optional "$ROOT/secrets/hetzner/object-storage.age" diff --git a/.forgejo/workflows/apple-developer-id-kms-csr.yml b/.forgejo/workflows/apple-developer-id-kms-csr.yml deleted file mode 100644 index 4c8901b..0000000 --- a/.forgejo/workflows/apple-developer-id-kms-csr.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: "Apple: Developer ID KMS CSR" -run-name: "Apple: Developer ID KMS CSR (${{ github.event.inputs.kms_key || 'apple-developer-id-application' }})" - -on: - workflow_dispatch: - inputs: - kms_key: - description: Google KMS key name in the Burrow identity key ring - required: false - default: apple-developer-id-application - kms_version: - description: Google KMS key version - required: false - default: "1" - common_name: - description: CSR common name - required: false - default: Burrow Developer ID Application - email_address: - description: CSR email address - required: false - default: release@burrow.net - -permissions: - contents: read - -concurrency: - group: apple-developer-id-kms-csr-${{ github.ref }} - cancel-in-progress: false - -env: - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_KMS_LOCATION: global - BURROW_KMS_KEYRING: burrow-identity - -jobs: - csr: - name: Generate CSR - runs-on: [self-hosted, linux, x86_64, burrow-forge] - timeout-minutes: 20 - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Authenticate Google WIF - shell: bash - run: nix develop .#ci -c Scripts/ci/google-wif-auth.sh - - - name: Generate KMS-backed CSR - shell: bash - env: - BURROW_APPLE_DEVELOPER_ID_KMS_KEY: ${{ github.event.inputs.kms_key || 'apple-developer-id-application' }} - BURROW_KMS_VERSION: ${{ github.event.inputs.kms_version || '1' }} - BURROW_APPLE_DEVELOPER_ID_CSR_COMMON_NAME: ${{ github.event.inputs.common_name || 'Burrow Developer ID Application' }} - BURROW_APPLE_DEVELOPER_ID_CSR_EMAIL: ${{ github.event.inputs.email_address || 'release@burrow.net' }} - run: | - set -euo pipefail - mkdir -p dist/apple-developer-id - nix develop .#ci -c Scripts/apple/google-kms-csr.py \ - --output dist/apple-developer-id/developer-id-application.certSigningRequest \ - --public-key-output dist/apple-developer-id/google-kms-public-key.pem - if command -v openssl >/dev/null 2>&1; then - openssl req -in dist/apple-developer-id/developer-id-application.certSigningRequest -noout -verify - fi - cat > dist/apple-developer-id/README.txt <<'EOF' - This CSR is backed by a non-exportable Google Cloud KMS key in the Burrow - identity key ring. Apple currently documents Developer ID certificate creation - through the Developer website or Xcode, not App Store Connect API creation. - - Upload developer-id-application.certSigningRequest in: - Certificates, Identifiers & Profiles -> Certificates -> + -> Developer ID -> - Developer ID Application. - - Keep the returned .cer file with release artifacts. The private key remains in - Google Cloud KMS and is never exported as a p12. - EOF - - - name: Upload CSR Artifact - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-developer-id-kms-csr - path: dist/apple-developer-id diff --git a/.forgejo/workflows/apple-distribute-testers.yml b/.forgejo/workflows/apple-distribute-testers.yml deleted file mode 100644 index f108237..0000000 --- a/.forgejo/workflows/apple-distribute-testers.yml +++ /dev/null @@ -1,530 +0,0 @@ -name: "Apple: Distribute Testers" -run-name: "Apple: Distribute Testers (${{ github.ref_name }})" - -on: - workflow_dispatch: - inputs: - build_number_override: - description: Optional explicit build number - required: false - default: "" - upload_app_store: - description: Upload iOS artifact to App Store Connect and TestFlight - required: false - default: "true" - upload_sparkle: - description: Publish Sparkle appcast and macOS artifact - required: false - default: "true" - sparkle_channel: - description: Sparkle channel, defaults to build- - required: false - default: "" - sparkle_point_main: - description: Update Sparkle main channel pointers - required: false - default: "true" - testflight_distribute_external: - description: Distribute to external TestFlight groups - required: false - default: "false" - testflight_groups: - description: Optional comma-separated TestFlight groups - required: false - default: "" - ios_uses_non_exempt_encryption: - description: Set true only when the iOS build uses non-exempt encryption - required: false - default: "false" - -permissions: - actions: write - contents: read - -concurrency: - group: burrow-apple-distribute-testers-${{ github.ref }} - cancel-in-progress: true - -env: - BURROW_RELEASE_GCS_BUCKET: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }} - BURROW_RELEASE_GCS_BACKUP: ${{ vars.BURROW_RELEASE_GCS_BACKUP || 'true' }} - BURROW_RELEASE_GARAGE_BUCKET: ${{ vars.BURROW_RELEASE_GARAGE_BUCKET || 'burrow-releases' }} - BURROW_RELEASE_REQUIRE_GARAGE: ${{ vars.BURROW_RELEASE_REQUIRE_GARAGE || 'true' }} - BURROW_GARAGE_ENDPOINT: ${{ vars.BURROW_GARAGE_ENDPOINT || 'https://objects.burrow.net' }} - BURROW_GARAGE_REGION: ${{ vars.BURROW_GARAGE_REGION || 'garage' }} - AWS_ACCESS_KEY_ID: ${{ secrets.BURROW_GARAGE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.BURROW_GARAGE_SECRET_ACCESS_KEY }} - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }} - BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID: ${{ vars.BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID || 'net.burrow.app.network' }} - BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID: ${{ vars.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID || '3G42677598' }} - BURROW_DEVELOPER_ID_CERTIFICATE_ID: ${{ vars.BURROW_DEVELOPER_ID_CERTIFICATE_ID || '9JKN6HXBHC' }} - BURROW_APPLE_SIGNING_MODE: google-kms-staged - BURROW_NOTARIZE_MACOS: ${{ vars.BURROW_NOTARIZE_MACOS || 'true' }} - BURROW_SPARKLE_SIGN_WITH_KMS: ${{ vars.BURROW_SPARKLE_SIGN_WITH_KMS || 'true' }} - BURROW_SPARKLE_KMS_KEY: ${{ vars.BURROW_SPARKLE_KMS_KEY || 'sparkle-ed25519' }} - BURROW_SPARKLE_KMS_VERSION: ${{ vars.BURROW_SPARKLE_KMS_VERSION || '1' }} - BURROW_SPARKLE_FEED_URL: ${{ vars.BURROW_SPARKLE_FEED_URL || 'https://releases.burrow.net/sparkle/appcast.xml' }} - BURROW_SPARKLE_PUBLIC_ED_KEY: ${{ vars.BURROW_SPARKLE_PUBLIC_ED_KEY || 'uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=' }} - BURROW_KMS_LOCATION: ${{ vars.BURROW_KMS_LOCATION || 'global' }} - BURROW_KMS_KEYRING: ${{ vars.BURROW_KMS_KEYRING || 'burrow-identity' }} - TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS: ${{ vars.TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS || '7200' }} - BURROW_VERSION_REQUIRE_REMOTE: "1" - -jobs: - prepare: - name: Prepare Apple Distribution - runs-on: namespace-profile-linux-medium-apple-v2 - outputs: - build_number: ${{ steps.plan.outputs.build_number }} - sparkle_channel: ${{ steps.plan.outputs.sparkle_channel }} - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Fetch Build Tags - shell: bash - run: git fetch --force --prune origin "+refs/tags/builds/*:refs/tags/builds/*" - - - name: Compute Apple Distribution Plan - id: plan - shell: bash - env: - BUILD_NUMBER_OVERRIDE: ${{ github.event.inputs.build_number_override || '' }} - SPARKLE_CHANNEL_INPUT: ${{ github.event.inputs.sparkle_channel || '' }} - run: | - set -euo pipefail - if [[ -n "$BUILD_NUMBER_OVERRIDE" ]]; then - build_number="$BUILD_NUMBER_OVERRIDE" - else - build_number="$(Scripts/version.sh pre-increment)" - fi - channel="$SPARKLE_CHANNEL_INPUT" - if [[ -z "$channel" ]]; then - channel="build-${build_number}" - fi - echo "build_number=${build_number}" >> "$GITHUB_OUTPUT" - echo "sparkle_channel=${channel}" >> "$GITHUB_OUTPUT" - printf 'build_number=%s\nsparkle_channel=%s\n' "$build_number" "$channel" - - build-ios: - name: Build Apple iOS - needs: prepare - runs-on: namespace-profile-macos-large-apple-v2 - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - submodules: recursive - - - name: Select Apple SDK - shell: bash - run: | - set -euo pipefail - xcrun --find swiftc - xcrun --sdk iphoneos --show-sdk-path - xcrun --sdk iphonesimulator --show-sdk-path - - - name: Prepare Rust Apple Targets - shell: bash - run: | - set -euo pipefail - if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - export PATH="$HOME/.cargo/bin:$PATH" - fi - rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios - - - name: Bootstrap Runner Environment - shell: bash - run: | - set -euo pipefail - runner_home="${RUNNER_HOME:-}" - if [[ -z "$runner_home" ]]; then - runner_home="$(python3 -c 'import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)' 2>/dev/null || true)" - if [[ -z "$runner_home" ]]; then - runner_home="$PWD/.home" - fi - fi - mkdir -p "$runner_home" "$PWD/.tmp" - { - echo "HOME=$runner_home" - echo "XDG_CACHE_HOME=$runner_home/.cache" - echo "XDG_CONFIG_HOME=$runner_home/.config" - echo "XDG_DATA_HOME=$runner_home/.local/share" - echo "TMPDIR=$PWD/.tmp" - echo "TMP=$PWD/.tmp" - } >> "$GITHUB_ENV" - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Namespace Cache - shell: bash - run: | - set -euo pipefail - bash Scripts/ci/nscloud-cache.sh bazel - - - name: Build Staged iOS Artifact - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - bazel_args=() - if [[ -n "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" && -f "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" ]]; then - bazel_args+=("--bazelrc=${BURROW_BAZEL_NSCCACHE_BAZELRC}") - fi - NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)" - ci_path="$("$NIX_BIN" develop .#ci --command bash -lc 'printf "%s" "$PATH"')" - ci_protoc="$("$NIX_BIN" develop .#ci --command bash -lc 'command -v protoc')" - "$NIX_BIN" develop .#ci --command bazel "${bazel_args[@]}" build \ - --verbose_failures \ - --show_timestamps \ - --experimental_ui_max_stdouterr_bytes=16777216 \ - --action_env=PATH="$ci_path" \ - --action_env=PROTOC="$ci_protoc" \ - --action_env=BUILD_WORKSPACE_DIRECTORY="$PWD" \ - --action_env=BUILD_NUMBER="$BUILD_NUMBER" \ - --action_env=BURROW_RELEASE_OUT="$PWD/dist/builds/$BUILD_NUMBER" \ - --action_env=BURROW_APPLE_SIGNING_MODE="$BURROW_APPLE_SIGNING_MODE" \ - --action_env=BURROW_APPLE_REQUIRE_SIGNING=true \ - --action_env=BURROW_APPLE_BUNDLE_ID="$BURROW_APPLE_BUNDLE_ID" \ - --action_env=BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID="$BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID" \ - --action_env=HOME="$HOME" \ - --action_env=XDG_CACHE_HOME="$XDG_CACHE_HOME" \ - //bazel/apple:release_ios_stamp - - - name: Upload Staged iOS Artifact - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-apple-ios-${{ needs.prepare.outputs.build_number }} - path: | - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-iOS-${{ needs.prepare.outputs.build_number }}.unsigned.ipa - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-iOS-${{ needs.prepare.outputs.build_number }}.unsigned.ipa.sha256 - if-no-files-found: error - - build-macos: - name: Build Apple macOS - needs: prepare - runs-on: namespace-profile-macos-large-apple-v2 - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - submodules: recursive - - - name: Select Apple SDK - shell: bash - run: | - set -euo pipefail - xcrun --find swiftc - xcrun --sdk macosx --show-sdk-path - - - name: Prepare Rust Apple Targets - shell: bash - run: | - set -euo pipefail - if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - export PATH="$HOME/.cargo/bin:$PATH" - fi - rustup target add aarch64-apple-darwin x86_64-apple-darwin - - - name: Bootstrap Runner Environment - shell: bash - run: | - set -euo pipefail - runner_home="${RUNNER_HOME:-}" - if [[ -z "$runner_home" ]]; then - runner_home="$(python3 -c 'import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)' 2>/dev/null || true)" - if [[ -z "$runner_home" ]]; then - runner_home="$PWD/.home" - fi - fi - mkdir -p "$runner_home" "$PWD/.tmp" - { - echo "HOME=$runner_home" - echo "XDG_CACHE_HOME=$runner_home/.cache" - echo "XDG_CONFIG_HOME=$runner_home/.config" - echo "XDG_DATA_HOME=$runner_home/.local/share" - echo "TMPDIR=$PWD/.tmp" - echo "TMP=$PWD/.tmp" - } >> "$GITHUB_ENV" - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Namespace Cache - shell: bash - run: | - set -euo pipefail - bash Scripts/ci/nscloud-cache.sh bazel - - - name: Build Staged macOS Artifact - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - bazel_args=() - if [[ -n "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" && -f "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" ]]; then - bazel_args+=("--bazelrc=${BURROW_BAZEL_NSCCACHE_BAZELRC}") - fi - NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)" - ci_path="$("$NIX_BIN" develop .#ci --command bash -lc 'printf "%s" "$PATH"')" - ci_protoc="$("$NIX_BIN" develop .#ci --command bash -lc 'command -v protoc')" - "$NIX_BIN" develop .#ci --command bazel "${bazel_args[@]}" build \ - --verbose_failures \ - --show_timestamps \ - --experimental_ui_max_stdouterr_bytes=16777216 \ - --action_env=PATH="$ci_path" \ - --action_env=PROTOC="$ci_protoc" \ - --action_env=BUILD_WORKSPACE_DIRECTORY="$PWD" \ - --action_env=BUILD_NUMBER="$BUILD_NUMBER" \ - --action_env=BURROW_RELEASE_OUT="$PWD/dist/builds/$BUILD_NUMBER" \ - --action_env=BURROW_APPLE_SIGNING_MODE="$BURROW_APPLE_SIGNING_MODE" \ - --action_env=BURROW_APPLE_REQUIRE_SIGNING=true \ - --action_env=BURROW_APPLE_BUNDLE_ID="$BURROW_APPLE_BUNDLE_ID" \ - --action_env=BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID="$BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID" \ - --action_env=BURROW_SPARKLE_FEED_URL="$BURROW_SPARKLE_FEED_URL" \ - --action_env=BURROW_SPARKLE_PUBLIC_ED_KEY="$BURROW_SPARKLE_PUBLIC_ED_KEY" \ - --action_env=HOME="$HOME" \ - --action_env=XDG_CACHE_HOME="$XDG_CACHE_HOME" \ - //bazel/apple:release_macos_stamp - - - name: Upload Staged macOS Artifact - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-apple-macos-${{ needs.prepare.outputs.build_number }} - path: | - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-macOS-${{ needs.prepare.outputs.build_number }}.unsigned.zip - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-macOS-${{ needs.prepare.outputs.build_number }}.unsigned.zip.sha256 - if-no-files-found: error - - sign-apple: - name: Sign Apple Artifacts - needs: - - prepare - - build-ios - - build-macos - runs-on: namespace-profile-linux-medium-apple-v2 - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Configure Age - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Validate App Store Credentials - shell: bash - run: Scripts/ci/check-release-config.sh app-store - - - name: Authenticate Google WIF - shell: bash - run: nix develop .#ci -c Scripts/ci/google-wif-auth.sh - - - name: Download Staged Apple Artifacts - uses: https://code.forgejo.org/actions/download-artifact@v3 - with: - path: dist/downloaded - - - name: Prepare Staged Apple Directory - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - mkdir -p "dist/builds/${BUILD_NUMBER}/apple" - find dist/downloaded -type f -name 'Burrow-*.unsigned.*' -exec cp -v {} "dist/builds/${BUILD_NUMBER}/apple/" \; - find "dist/builds/${BUILD_NUMBER}/apple" -type f -print | sort - - - name: Sync Apple Provisioning Profiles - shell: bash - run: nix develop .#ci -c Scripts/ci/sync-apple-provisioning-profiles.sh all - - - name: Sign Apple Artifacts With KMS - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - SPARKLE_CHANNEL: ${{ needs.prepare.outputs.sparkle_channel }} - run: nix develop .#ci -c Scripts/apple/sign-release-artifacts-kms.sh all - - - name: Upload Signed Apple Artifacts To Release Storage - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: nix develop .#ci -c Scripts/ci/upload-release-storage.sh - - - name: Upload Signed Apple Artifacts - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-apple-signed-${{ needs.prepare.outputs.build_number }} - path: | - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-iOS-${{ needs.prepare.outputs.build_number }}.ipa - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-iOS-${{ needs.prepare.outputs.build_number }}.ipa.sha256 - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-macOS-${{ needs.prepare.outputs.build_number }}.zip - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/Burrow-macOS-${{ needs.prepare.outputs.build_number }}.zip.sha256 - dist/builds/${{ needs.prepare.outputs.build_number }}/sparkle/** - if-no-files-found: error - - upload-testflight: - name: Upload TestFlight - needs: - - prepare - - sign-apple - if: ${{ github.event.inputs.upload_app_store == 'true' }} - runs-on: namespace-profile-macos-large-testflight - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Configure Age - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Download Signed Apple Artifacts - uses: https://code.forgejo.org/actions/download-artifact@v3 - with: - name: burrow-apple-signed-${{ needs.prepare.outputs.build_number }} - path: publish - - - name: Upload iOS IPA To App Store Connect - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - Scripts/ci/check-release-config.sh app-store - key_dir="${RUNNER_TEMP:-/tmp}/burrow-asc" - altool_key_dir="${HOME}/.appstoreconnect/private_keys" - mkdir -p "$key_dir" "$altool_key_dir" - key_path="$key_dir/AuthKey_${ASC_API_KEY_ID}.p8" - if [[ -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then - cp "$ASC_API_KEY_PATH" "$key_path" - else - printf '%s' "$ASC_API_KEY_BASE64" | base64 --decode > "$key_path" - fi - cp "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8" - chmod 600 "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8" - ipa="publish/apple/Burrow-iOS-${BUILD_NUMBER}.ipa" - if [[ ! -s "$ipa" ]]; then - echo "::error ::No signed iOS IPA found at $ipa" >&2 - find publish -type f -print | sort - exit 1 - fi - upload_log="${RUNNER_TEMP:-/tmp}/burrow-altool-ios-${BUILD_NUMBER}.log" - set +e - xcrun altool --upload-app \ - --type ios \ - --file "$ipa" \ - --apiKey "$ASC_API_KEY_ID" \ - --apiIssuer "$ASC_API_ISSUER_ID" 2>&1 | tee "$upload_log" - altool_status=${PIPESTATUS[0]} - set -e - if (( altool_status != 0 )) || grep -Eq 'UPLOAD FAILED|Validation failed|ERROR:' "$upload_log"; then - echo "::error ::altool reported upload validation failure for $ipa" >&2 - exit 1 - fi - - - name: Distribute iOS Build To TestFlight - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ github.event.inputs.testflight_distribute_external || 'false' }} - TESTFLIGHT_GROUPS: ${{ github.event.inputs.testflight_groups || '' }} - IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ github.event.inputs.ios_uses_non_exempt_encryption || 'false' }} - run: Scripts/ci/distribute-testflight.sh - - publish-sparkle: - name: Publish Sparkle - needs: - - prepare - - sign-apple - if: ${{ github.event.inputs.upload_sparkle == 'true' }} - runs-on: namespace-profile-linux-medium-apple-v2 - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Authenticate Google WIF - shell: bash - run: nix develop .#ci -c Scripts/ci/google-wif-auth.sh - - - name: Download Signed Apple Artifacts - uses: https://code.forgejo.org/actions/download-artifact@v3 - with: - name: burrow-apple-signed-${{ needs.prepare.outputs.build_number }} - path: publish - - - name: Publish Sparkle Channel - shell: bash - env: - CHANNEL: ${{ needs.prepare.outputs.sparkle_channel }} - SPARKLE_POINT_MAIN: ${{ github.event.inputs.sparkle_point_main || 'true' }} - run: | - set -euo pipefail - if [[ ! -f "publish/sparkle/${CHANNEL}/appcast.xml" ]]; then - echo "::error ::No Sparkle appcast staged for channel ${CHANNEL}" >&2 - find publish -type f -print | sort - exit 1 - fi - nix develop .#ci -c gcloud storage rsync --recursive "publish/sparkle/${CHANNEL}" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/${CHANNEL}" - if [[ "$SPARKLE_POINT_MAIN" == "true" ]]; then - printf '%s\n' "$CHANNEL" > main-channel.txt - nix develop .#ci -c gcloud storage cp main-channel.txt "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/main-channel.txt" - nix develop .#ci -c gcloud storage cp "publish/sparkle/${CHANNEL}/appcast.xml" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/appcast.xml" - nix develop .#ci -c gcloud storage rsync --recursive "publish/sparkle/${CHANNEL}" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/default" - fi diff --git a/.forgejo/workflows/apple-ios-distribution-kms-csr.yml b/.forgejo/workflows/apple-ios-distribution-kms-csr.yml deleted file mode 100644 index d9cb614..0000000 --- a/.forgejo/workflows/apple-ios-distribution-kms-csr.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: "Apple: iOS Distribution KMS CSR" -run-name: "Apple: iOS Distribution KMS CSR (${{ github.event.inputs.kms_key || 'apple-ios-distribution' }})" - -on: - workflow_dispatch: - inputs: - kms_key: - description: Google KMS key name in the Burrow identity key ring - required: false - default: apple-ios-distribution - kms_version: - description: Google KMS key version - required: false - default: "1" - common_name: - description: CSR common name - required: false - default: Burrow iOS Distribution - email_address: - description: CSR email address - required: false - default: release@burrow.net - -permissions: - contents: read - -concurrency: - group: apple-ios-distribution-kms-csr-${{ github.ref }} - cancel-in-progress: false - -env: - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_KMS_LOCATION: global - BURROW_KMS_KEYRING: burrow-identity - -jobs: - csr: - name: Generate CSR - runs-on: [self-hosted, linux, x86_64, burrow-forge] - timeout-minutes: 20 - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Authenticate Google WIF - shell: bash - run: nix develop .#ci -c Scripts/ci/google-wif-auth.sh - - - name: Generate KMS-backed CSR - shell: bash - run: | - set -euo pipefail - mkdir -p dist/apple-ios-distribution - nix develop .#ci -c Scripts/apple/google-kms-csr.py \ - --kms-key "${{ github.event.inputs.kms_key || 'apple-ios-distribution' }}" \ - --kms-version "${{ github.event.inputs.kms_version || '1' }}" \ - --common-name "${{ github.event.inputs.common_name || 'Burrow iOS Distribution' }}" \ - --email-address "${{ github.event.inputs.email_address || 'release@burrow.net' }}" \ - --output dist/apple-ios-distribution/ios-distribution.certSigningRequest \ - --public-key-output dist/apple-ios-distribution/google-kms-public-key.pem - if command -v openssl >/dev/null 2>&1; then - openssl req -in dist/apple-ios-distribution/ios-distribution.certSigningRequest -noout -verify - fi - cat > dist/apple-ios-distribution/README.txt <<'EOF' - This CSR is backed by a non-exportable Google Cloud KMS key in the Burrow - identity key ring. - - Create an iOS Distribution certificate explicitly through App Store Connect: - - Scripts/apple/create-asc-certificate.mjs \ - --certificate-type IOS_DISTRIBUTION \ - --csr-file ios-distribution.certSigningRequest \ - --out-dir Apple/Certificates - - Keep the returned .cer file with release artifacts. The private key remains in - Google Cloud KMS and is never exported as a p12. - EOF - - - name: Upload CSR Artifact - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-ios-distribution-kms-csr - path: dist/apple-ios-distribution diff --git a/.forgejo/workflows/backup-garage-storage.yml b/.forgejo/workflows/backup-garage-storage.yml deleted file mode 100644 index 77d08aa..0000000 --- a/.forgejo/workflows/backup-garage-storage.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: "Backup: Garage Storage" -run-name: "Backup Garage Storage (${{ github.event.inputs.set || 'all' }})" - -on: - schedule: - - cron: "17 9 * * *" - workflow_dispatch: - inputs: - set: - description: "Comma-separated backup set: all, releases, packages, nix-cache" - required: false - default: all - delete_unmatched: - description: Delete destination objects that are missing from Garage - required: false - default: "false" - -permissions: - contents: read - -concurrency: - group: backup-garage-storage-${{ github.event.inputs.set || 'all' }} - cancel-in-progress: false - -jobs: - backup: - name: Mirror Garage To GCS - runs-on: namespace-profile-linux-medium - timeout-minutes: 90 - env: - BURROW_GARAGE_ENDPOINT: ${{ vars.BURROW_GARAGE_ENDPOINT || 'https://objects.burrow.net' }} - BURROW_GARAGE_REGION: ${{ vars.BURROW_GARAGE_REGION || 'garage' }} - BURROW_GARAGE_BACKUP_SET: ${{ github.event.inputs.set || 'all' }} - BURROW_GARAGE_BACKUP_DELETE: ${{ github.event.inputs.delete_unmatched || 'false' }} - BURROW_RELEASE_GARAGE_BUCKET: ${{ vars.BURROW_RELEASE_GARAGE_BUCKET || 'burrow-releases' }} - BURROW_PACKAGE_GARAGE_BUCKET: ${{ vars.BURROW_PACKAGE_GARAGE_BUCKET || 'burrow-packages' }} - BURROW_NIX_CACHE_GARAGE_BUCKET: ${{ vars.BURROW_NIX_CACHE_GARAGE_BUCKET || 'attic' }} - BURROW_RELEASE_GCS_BUCKET: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }} - BURROW_PACKAGE_GCS_BUCKET: ${{ vars.BURROW_PACKAGE_GCS_BUCKET || 'burrow-net-packages' }} - BURROW_NIX_CACHE_GCS_BUCKET: ${{ vars.BURROW_NIX_CACHE_GCS_BUCKET || 'burrow-net-nix-cache' }} - AWS_ACCESS_KEY_ID: ${{ secrets.BURROW_GARAGE_BACKUP_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.BURROW_GARAGE_BACKUP_SECRET_ACCESS_KEY }} - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Mirror Garage Buckets To GCS - shell: bash - run: nix develop .#ci -c Scripts/ci/backup-garage-to-gcs.sh diff --git a/.forgejo/workflows/build-android.yml b/.forgejo/workflows/build-android.yml deleted file mode 100644 index 0cc61cd..0000000 --- a/.forgejo/workflows/build-android.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: "Build: Android" -run-name: "Build: Android (${{ github.ref_name }})" - -on: - push: - branches: - - main - paths: - - ".forgejo/workflows/build-android.yml" - - "Android/**" - - "BUILD.bazel" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "bazel/android/**" - - "Cargo.lock" - - "Cargo.toml" - - "crates/burrow-core/**" - - "crates/burrow-mobile-ffi/**" - - "rust-toolchain.toml" - - "Scripts/ci/nscloud-cache.sh" - pull_request: - paths: - - ".forgejo/workflows/build-android.yml" - - "Android/**" - - "BUILD.bazel" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "bazel/android/**" - - "Cargo.lock" - - "Cargo.toml" - - "crates/burrow-core/**" - - "crates/burrow-mobile-ffi/**" - - "rust-toolchain.toml" - - "Scripts/ci/nscloud-cache.sh" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: burrow-build-android-${{ github.ref }} - cancel-in-progress: true - -env: - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - -jobs: - stub: - name: Android Rust Core Stub - runs-on: namespace-profile-linux-medium - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Configure Cache - shell: bash - run: | - set -euo pipefail - bash Scripts/ci/ensure-nix.sh - nix develop .#ci -c Scripts/ci/nscloud-cache.sh bazel - - - name: Build Android Stub Check - shell: bash - run: | - set -euo pipefail - if [[ -n "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" ]]; then - bazel --bazelrc="$BURROW_BAZEL_NSCCACHE_BAZELRC" build //bazel/android:check_stub_stamp - else - bazel build //bazel/android:check_stub_stamp - fi diff --git a/.forgejo/workflows/build-apple.yml b/.forgejo/workflows/build-apple.yml new file mode 100644 index 0000000..fd69acc --- /dev/null +++ b/.forgejo/workflows/build-apple.yml @@ -0,0 +1,159 @@ +name: Build Apple + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + build: + name: Build App (${{ matrix.platform }}) + runs-on: namespace-profile-macos-large + strategy: + fail-fast: false + matrix: + include: + - platform: macOS + cache-id: macos + destination: platform=macOS + rust-targets: x86_64-apple-darwin,aarch64-apple-darwin + - platform: iOS Simulator + cache-id: ios-simulator + destination: platform=iOS Simulator,name=iPhone 17 Pro + rust-targets: aarch64-apple-ios-sim,x86_64-apple-ios + env: + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: short + RUSTC_WRAPPER: sccache + SCCACHE_CACHE_SIZE: 20G + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + submodules: recursive + + - name: Select Xcode + shell: bash + run: | + set -euo pipefail + candidates=( + "/Applications/Xcode_26.1.app/Contents/Developer" + "/Applications/Xcode_26_1.app/Contents/Developer" + "/Applications/Xcode.app/Contents/Developer" + "/Applications/Xcode/Xcode.app/Contents/Developer" + ) + selected="" + for candidate in "${candidates[@]}"; do + if [[ -d "$candidate" ]]; then + selected="$candidate" + break + fi + done + if [[ -z "$selected" ]] && command -v xcode-select >/dev/null 2>&1; then + selected="$(xcode-select -p)" + fi + if [[ -z "$selected" ]]; then + echo "::error ::Unable to locate an Xcode toolchain" >&2 + exit 1 + fi + echo "DEVELOPER_DIR=$selected" >> "$GITHUB_ENV" + DEVELOPER_DIR="$selected" /usr/bin/xcodebuild -version || true + + - name: Prepare Cache Dirs + shell: bash + run: | + set -euo pipefail + cache_root="${NSC_CACHE_PATH:-${HOME}/.cache/burrow}" + shared_root="${NSC_SHARED_CACHE_PATH:-${cache_root}/shared}" + lane_root="${NSC_LANE_CACHE_PATH:-${cache_root}/lane/${{ matrix.cache-id }}}" + mkdir -p \ + "${shared_root}/cargo" \ + "${shared_root}/rustup" \ + "${shared_root}/sccache" \ + "${shared_root}/homebrew" \ + "${shared_root}/apple/PackageCache" \ + "${shared_root}/apple/SourcePackages" \ + "${lane_root}/cargo-target" \ + "${lane_root}/DerivedData" + echo "CARGO_HOME=${shared_root}/cargo" >> "${GITHUB_ENV}" + echo "CARGO_TARGET_DIR=${lane_root}/cargo-target" >> "${GITHUB_ENV}" + echo "RUSTUP_HOME=${shared_root}/rustup" >> "${GITHUB_ENV}" + echo "SCCACHE_DIR=${shared_root}/sccache" >> "${GITHUB_ENV}" + echo "HOMEBREW_CACHE=${shared_root}/homebrew" >> "${GITHUB_ENV}" + echo "APPLE_PACKAGE_CACHE=${shared_root}/apple/PackageCache" >> "${GITHUB_ENV}" + echo "APPLE_SOURCE_PACKAGES=${shared_root}/apple/SourcePackages" >> "${GITHUB_ENV}" + echo "APPLE_DERIVED_DATA=${lane_root}/DerivedData" >> "${GITHUB_ENV}" + df -h "${shared_root}" "${lane_root}" || true + + - name: Install Rust + shell: bash + run: | + set -euo pipefail + + export PATH="${CARGO_HOME}/bin:${PATH}" + + if ! command -v rustup >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.93.1 + else + rustup set profile minimal + rustup toolchain install 1.93.1 + rustup default 1.93.1 + fi + + mkdir -p "${CARGO_HOME}/bin" + echo "${CARGO_HOME}/bin" >> "${GITHUB_PATH}" + export PATH="${CARGO_HOME}/bin:${PATH}" + + rustup show active-toolchain + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + cargo_bin="$(rustup which --toolchain "${toolchain}" cargo)" + rustc_bin="$(rustup which --toolchain "${toolchain}" rustc)" + + targets='${{ matrix.rust-targets }}' + for target in ${targets//,/ }; do + rustup target add --toolchain "${toolchain}" "${target}" + done + + "${rustc_bin}" --version + "${cargo_bin}" --version + + - name: Install Protobuf + shell: bash + run: | + set -euo pipefail + if ! command -v protoc >/dev/null 2>&1; then + brew install protobuf + fi + if ! command -v sccache >/dev/null 2>&1; then + brew install sccache + fi + + - name: Build + shell: bash + working-directory: Apple + run: | + set -euo pipefail + xcodebuild build \ + -project Burrow.xcodeproj \ + -scheme App \ + -destination '${{ matrix.destination }}' \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -onlyUsePackageVersionsFromResolvedFile \ + -clonedSourcePackagesDirPath "$APPLE_SOURCE_PACKAGES" \ + -packageCachePath "$APPLE_PACKAGE_CACHE" \ + -derivedDataPath "$APPLE_DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + DEVELOPMENT_TEAM="" diff --git a/.forgejo/workflows/build-release.yml b/.forgejo/workflows/build-release.yml deleted file mode 100644 index 85c2e76..0000000 --- a/.forgejo/workflows/build-release.yml +++ /dev/null @@ -1,480 +0,0 @@ -name: "Build: Release" -run-name: "Build: Release (${{ github.ref_name }})" - -on: - workflow_dispatch: - inputs: - build_number_override: - description: Optional explicit build number - required: false - default: "" - upload_app_store: - description: Trigger downstream App Store Connect upload workflow - required: false - default: "true" - upload_sparkle: - description: Trigger downstream Sparkle publish workflow - required: false - default: "true" - upload_microsoft_store: - description: Trigger downstream Microsoft Store beta upload workflow - required: false - default: "false" - sparkle_point_main: - description: Update Sparkle main channel pointers - required: false - default: "true" - testflight_distribute_external: - description: Distribute to external TestFlight groups - required: false - default: "false" - testflight_groups: - description: Optional comma-separated TestFlight groups for external distribution - required: false - default: "" - ios_uses_non_exempt_encryption: - description: Set true only when the iOS build uses non-exempt encryption - required: false - default: "false" - push: - tags: - - "release/*" - -permissions: - actions: write - contents: write - -concurrency: - group: burrow-build-release-${{ github.ref }} - cancel-in-progress: true - -env: - BURROW_RELEASE_GCS_BUCKET: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }} - BURROW_RELEASE_GCS_BACKUP: ${{ vars.BURROW_RELEASE_GCS_BACKUP || 'true' }} - BURROW_RELEASE_GARAGE_BUCKET: ${{ vars.BURROW_RELEASE_GARAGE_BUCKET || 'burrow-releases' }} - BURROW_RELEASE_REQUIRE_GARAGE: ${{ vars.BURROW_RELEASE_REQUIRE_GARAGE || 'true' }} - BURROW_GARAGE_ENDPOINT: ${{ vars.BURROW_GARAGE_ENDPOINT || 'https://objects.burrow.net' }} - BURROW_GARAGE_REGION: ${{ vars.BURROW_GARAGE_REGION || 'garage' }} - AWS_ACCESS_KEY_ID: ${{ secrets.BURROW_GARAGE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.BURROW_GARAGE_SECRET_ACCESS_KEY }} - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }} - BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID: ${{ vars.BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID || 'net.burrow.app.network' }} - BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID: ${{ vars.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID || '3G42677598' }} - BURROW_DEVELOPER_ID_CERTIFICATE_ID: ${{ vars.BURROW_DEVELOPER_ID_CERTIFICATE_ID || '9JKN6HXBHC' }} - BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS: ${{ vars.BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS || 'false' }} - BURROW_APPLE_ENABLE_CAPABILITIES: ${{ vars.BURROW_APPLE_ENABLE_CAPABILITIES || 'false' }} - BURROW_APPLE_SIGNING_MODE: ${{ vars.BURROW_APPLE_SIGNING_MODE || 'google-kms-staged' }} - BURROW_NOTARIZE_MACOS: ${{ vars.BURROW_NOTARIZE_MACOS || 'true' }} - BURROW_SPARKLE_SIGN_WITH_KMS: ${{ vars.BURROW_SPARKLE_SIGN_WITH_KMS || 'true' }} - BURROW_SPARKLE_KMS_KEY: ${{ vars.BURROW_SPARKLE_KMS_KEY || 'sparkle-ed25519' }} - BURROW_SPARKLE_KMS_VERSION: ${{ vars.BURROW_SPARKLE_KMS_VERSION || '1' }} - BURROW_SPARKLE_FEED_URL: ${{ vars.BURROW_SPARKLE_FEED_URL || 'https://releases.burrow.net/sparkle/appcast.xml' }} - BURROW_SPARKLE_PUBLIC_ED_KEY: ${{ vars.BURROW_SPARKLE_PUBLIC_ED_KEY || 'uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=' }} - BURROW_VERSION_REQUIRE_REMOTE: "1" - -jobs: - prepare: - name: Prepare - runs-on: namespace-profile-linux-medium - outputs: - do_release: ${{ steps.plan.outputs.do_release }} - build_number: ${{ steps.plan.outputs.build_number }} - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Fetch Build Tags - shell: bash - run: git fetch --force --prune origin "+refs/tags/builds/*:refs/tags/builds/*" - - - name: Compute Release Plan - id: plan - shell: bash - env: - BURROW_BUILD_NUMBER_OVERRIDE: ${{ github.event.inputs.build_number_override || '' }} - run: | - set -euo pipefail - status="$(Scripts/version.sh status)" - if [[ "$status" == "clean" ]]; then - echo "do_release=false" >> "$GITHUB_OUTPUT" - echo "build_number=" >> "$GITHUB_OUTPUT" - echo "No unreleased changes." - exit 0 - fi - build_number="$(Scripts/version.sh pre-increment)" - echo "do_release=true" >> "$GITHUB_OUTPUT" - echo "build_number=${build_number}" >> "$GITHUB_OUTPUT" - - build-linux: - name: Build (Linux) - needs: prepare - if: ${{ needs.prepare.outputs.do_release == 'true' }} - runs-on: namespace-profile-linux-medium - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Build Linux Release - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - bash Scripts/ci/ensure-nix.sh - nix develop .#ci -c Scripts/ci/nscloud-cache.sh nix - nix develop .#ci -c Scripts/ci/package-release-artifacts.sh linux - - - name: Upload Linux Artifacts - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-linux-${{ needs.prepare.outputs.build_number }} - path: dist/builds/${{ needs.prepare.outputs.build_number }}/linux/* - if-no-files-found: error - - - name: Upload Linux Artifacts To Release Storage - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - bash Scripts/ci/ensure-nix.sh - nix develop .#ci -c Scripts/ci/upload-release-storage.sh - - build-windows: - name: Build (Windows Stub) - needs: prepare - if: ${{ needs.prepare.outputs.do_release == 'true' }} - runs-on: namespace-profile-windows-large - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Build Rust Stub - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { - Invoke-WebRequest -Uri "https://win.rustup.rs" -OutFile "rustup-init.exe" - .\rustup-init.exe -y --profile minimal --default-toolchain stable - $env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path" - } - cargo build --locked --release -p burrow-windows-stub - New-Item -ItemType Directory -Force -Path "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc" | Out-Null - Copy-Item "target/release/burrow.exe" "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc/burrow.exe" - Copy-Item "README.md" "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc/README.md" - Compress-Archive -Path "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc" -DestinationPath "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip" -Force - Get-FileHash "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip" -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip" } | Set-Content "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip.sha256" - - - name: Upload Windows Artifacts - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-windows-${{ needs.prepare.outputs.build_number }} - path: dist/builds/${{ needs.prepare.outputs.build_number }}/windows/* - if-no-files-found: error - - build-apple: - name: Build (Apple ${{ matrix.platform_name }}) - needs: prepare - if: ${{ needs.prepare.outputs.do_release == 'true' }} - runs-on: namespace-profile-macos-large - strategy: - fail-fast: false - max-parallel: 2 - matrix: - include: - - platform: ios - platform_name: iOS - bazel_target: //bazel/apple:release_ios_stamp - - platform: macos - platform_name: macOS - bazel_target: //bazel/apple:release_macos_stamp - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - submodules: recursive - - - name: Select Apple SDK - shell: bash - run: | - set -euo pipefail - if ! command -v xcrun >/dev/null 2>&1; then - echo "::error ::xcrun is required for Apple release builds" >&2 - exit 1 - fi - xcrun --find swiftc - xcrun --sdk macosx --show-sdk-path - xcrun --sdk iphoneos --show-sdk-path - xcrun --sdk iphonesimulator --show-sdk-path - - - name: Prepare Rust Apple Targets - shell: bash - env: - PLATFORM: ${{ matrix.platform }} - run: | - set -euo pipefail - if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - export PATH="$HOME/.cargo/bin:$PATH" - fi - case "$PLATFORM" in - ios) - rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios - ;; - macos) - rustup target add aarch64-apple-darwin x86_64-apple-darwin - ;; - *) - echo "::error ::unsupported Apple platform ${PLATFORM}" >&2 - exit 1 - ;; - esac - - - name: Bootstrap Runner Environment - shell: bash - run: | - set -euo pipefail - runner_home="${RUNNER_HOME:-}" - if [[ -z "$runner_home" ]]; then - runner_home="$(python3 -c 'import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)' 2>/dev/null || true)" - if [[ -z "$runner_home" ]]; then - runner_home="$PWD/.home" - fi - fi - mkdir -p "$runner_home" "$PWD/.tmp" - { - echo "HOME=$runner_home" - echo "XDG_CACHE_HOME=$runner_home/.cache" - echo "XDG_CONFIG_HOME=$runner_home/.config" - echo "XDG_DATA_HOME=$runner_home/.local/share" - echo "TMPDIR=$PWD/.tmp" - echo "TMP=$PWD/.tmp" - } >> "$GITHUB_ENV" - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Namespace Cache - shell: bash - run: | - set -euo pipefail - NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)" - "$NIX_BIN" develop .#ci --command bash Scripts/ci/nscloud-cache.sh bazel - - - name: Configure Age - id: age_apple - continue-on-error: true - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - if: ${{ steps.age_apple.outcome == 'success' }} - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Sync Apple Provisioning Profiles - shell: bash - env: - PLATFORM: ${{ matrix.platform }} - run: | - set -euo pipefail - NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)" - "$NIX_BIN" develop .#ci --command Scripts/ci/sync-apple-provisioning-profiles.sh "$PLATFORM" - - - name: Install Apple Signing Assets - shell: bash - run: Scripts/ci/install-apple-signing-assets.sh - - - name: Build Apple Release - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - PLATFORM: ${{ matrix.platform }} - UPLOAD_APP_STORE: ${{ github.event.inputs.upload_app_store || 'true' }} - UPLOAD_SPARKLE: ${{ github.event.inputs.upload_sparkle || 'true' }} - SPARKLE_CHANNEL: build-${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - require_signing=false - if [[ "$UPLOAD_APP_STORE" == "true" && "$PLATFORM" == "ios" ]]; then - require_signing=true - fi - if [[ "$UPLOAD_SPARKLE" == "true" && "$PLATFORM" == "macos" ]]; then - require_signing=true - fi - - bazel_args=() - if [[ -n "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" && -f "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" ]]; then - bazel_args+=("--bazelrc=${BURROW_BAZEL_NSCCACHE_BAZELRC}") - fi - - NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)" - ci_path="$("$NIX_BIN" develop .#ci --command bash -lc 'printf "%s" "$PATH"')" - ci_protoc="$("$NIX_BIN" develop .#ci --command bash -lc 'command -v protoc')" - "$NIX_BIN" develop .#ci --command bazel "${bazel_args[@]}" build \ - --verbose_failures \ - --show_timestamps \ - --experimental_ui_max_stdouterr_bytes=16777216 \ - --action_env=PATH="$ci_path" \ - --action_env=PROTOC="$ci_protoc" \ - --action_env=BUILD_WORKSPACE_DIRECTORY="$PWD" \ - --action_env=BUILD_NUMBER="$BUILD_NUMBER" \ - --action_env=BURROW_RELEASE_OUT="$PWD/dist/builds/$BUILD_NUMBER" \ - --action_env=BURROW_APPLE_SIGNING_READY="${BURROW_APPLE_SIGNING_READY:-0}" \ - --action_env=BURROW_APPLE_SIGNING_MODE="${BURROW_APPLE_SIGNING_MODE:-keychain}" \ - --action_env=BURROW_APPLE_REQUIRE_SIGNING="$require_signing" \ - --action_env=BURROW_APPLE_BUNDLE_ID="${BURROW_APPLE_BUNDLE_ID:-}" \ - --action_env=BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-}" \ - --action_env=BURROW_APPLE_KEYCHAIN_PATH="${BURROW_APPLE_KEYCHAIN_PATH:-}" \ - --action_env=BURROW_NOTARIZE_MACOS="${BURROW_NOTARIZE_MACOS:-false}" \ - --action_env=BURROW_SPARKLE_SIGN_WITH_KMS="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}" \ - --action_env=BURROW_SPARKLE_KMS_KEY="${BURROW_SPARKLE_KMS_KEY:-sparkle-ed25519}" \ - --action_env=BURROW_SPARKLE_KMS_VERSION="${BURROW_SPARKLE_KMS_VERSION:-1}" \ - --action_env=BURROW_SPARKLE_FEED_URL="${BURROW_SPARKLE_FEED_URL:-https://releases.burrow.net/sparkle/appcast.xml}" \ - --action_env=BURROW_SPARKLE_PUBLIC_ED_KEY="${BURROW_SPARKLE_PUBLIC_ED_KEY:-}" \ - --action_env=GOOGLE_CLOUD_PROJECT="${GOOGLE_CLOUD_PROJECT:-}" \ - --action_env=ASC_API_KEY_ID="${ASC_API_KEY_ID:-}" \ - --action_env=ASC_API_ISSUER_ID="${ASC_API_ISSUER_ID:-}" \ - --action_env=ASC_API_KEY_PATH="${ASC_API_KEY_PATH:-}" \ - --action_env=SPARKLE_CHANNEL="$SPARKLE_CHANNEL" \ - --action_env=HOME="$HOME" \ - --action_env=XDG_CACHE_HOME="$XDG_CACHE_HOME" \ - "${{ matrix.bazel_target }}" - - - name: Upload Apple Artifacts - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-apple-${{ matrix.platform }}-${{ needs.prepare.outputs.build_number }} - path: | - dist/builds/${{ needs.prepare.outputs.build_number }}/apple/* - dist/builds/${{ needs.prepare.outputs.build_number }}/sparkle/** - if-no-files-found: error - - - name: Upload Apple Artifacts To Release Storage - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - bash Scripts/ci/ensure-nix.sh - nix develop .#ci -c Scripts/ci/upload-release-storage.sh - - publish: - name: Publish (Forgejo) - needs: - - prepare - - build-linux - - build-windows - - build-apple - if: ${{ always() && needs.prepare.outputs.do_release == 'true' }} - runs-on: namespace-profile-linux-medium - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Download Artifacts - uses: https://code.forgejo.org/actions/download-artifact@v3 - with: - path: dist/downloaded - - - name: Prepare Release Assets - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - mkdir -p dist - mkdir -p "dist/builds/${BUILD_NUMBER}/windows" - find dist/downloaded -path '*/burrow-windows-*/*' -type f -exec cp {} "dist/builds/${BUILD_NUMBER}/windows/" \; - find dist/downloaded -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.sha256' -o -name '*.ipa' -o -name '*.pkg' -o -name '*.dmg' -o -name 'README*.txt' \) -exec cp {} dist/ \; - rm -rf dist/downloaded - find dist -maxdepth 1 -type f -print | sort - - - name: Upload Windows Artifacts To Release Storage - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - if ! find "dist/builds/${BUILD_NUMBER}/windows" -type f -print -quit | grep -q .; then - echo "::warning ::No Windows artifacts staged for release storage upload." - exit 0 - fi - bash Scripts/ci/ensure-nix.sh - nix develop .#ci -c Scripts/ci/upload-release-storage.sh - - - name: Tag Build - shell: bash - env: - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - run: | - set -euo pipefail - tag="builds/${BUILD_NUMBER}" - head_commit="$(git rev-parse HEAD)" - remote_commit="$(git ls-remote --tags --refs origin "refs/tags/${tag}" | awk 'NR==1 { print $1 }')" - if [[ -n "$remote_commit" && "$remote_commit" != "$head_commit" ]]; then - echo "::error ::Remote tag ${tag} already points at ${remote_commit}, not ${head_commit}" >&2 - exit 1 - fi - git tag -f "$tag" "$head_commit" - git push --quiet --no-verify origin "refs/tags/${tag}:refs/tags/${tag}" - - - name: Publish Forgejo Release - shell: bash - env: - RELEASE_TAG: builds/${{ needs.prepare.outputs.build_number }} - API_URL: ${{ github.api_url }} - REPOSITORY: ${{ github.repository }} - TOKEN: ${{ github.token }} - run: Scripts/ci/publish-forgejo-release.sh - - - name: Dispatch Store Uploads - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - UPLOAD_APP_STORE: ${{ github.event.inputs.upload_app_store || 'true' }} - UPLOAD_SPARKLE: ${{ github.event.inputs.upload_sparkle || 'true' }} - UPLOAD_MICROSOFT_STORE: ${{ github.event.inputs.upload_microsoft_store || 'false' }} - SPARKLE_POINT_MAIN: ${{ github.event.inputs.sparkle_point_main || 'true' }} - TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ github.event.inputs.testflight_distribute_external || 'false' }} - TESTFLIGHT_GROUPS: ${{ github.event.inputs.testflight_groups || '' }} - IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ github.event.inputs.ios_uses_non_exempt_encryption || 'false' }} - run: | - set -euo pipefail - inputs="$(python3 -c 'import json, os; print(json.dumps({"build_number":os.environ["BUILD_NUMBER"],"upload_app_store":os.environ["UPLOAD_APP_STORE"],"upload_sparkle":os.environ["UPLOAD_SPARKLE"],"upload_microsoft_store":os.environ["UPLOAD_MICROSOFT_STORE"],"sparkle_point_main":os.environ["SPARKLE_POINT_MAIN"],"testflight_distribute_external":os.environ["TESTFLIGHT_DISTRIBUTE_EXTERNAL"],"testflight_groups":os.environ["TESTFLIGHT_GROUPS"],"ios_uses_non_exempt_encryption":os.environ["IOS_USES_NON_EXEMPT_ENCRYPTION"]}))')" - curl -fsS \ - -X POST \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"ref\":\"main\",\"inputs\":${inputs}}" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/publish-store-uploads.yml/dispatches" diff --git a/.forgejo/workflows/build-rust.yml b/.forgejo/workflows/build-rust.yml index 9ed49e1..53191ab 100644 --- a/.forgejo/workflows/build-rust.yml +++ b/.forgejo/workflows/build-rust.yml @@ -16,27 +16,50 @@ concurrency: jobs: rust: name: Cargo Test - runs-on: [self-hosted, linux, x86_64, burrow-forge] + runs-on: namespace-profile-linux-medium + env: + CARGO_INCREMENTAL: 0 + NIX_CONFIG: | + experimental-features = nix-command flakes + accept-flake-config = true + RUSTC_WRAPPER: sccache + SCCACHE_CACHE_SIZE: 20G steps: - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Prepare Cache Dirs shell: bash run: | set -euo pipefail - repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - if [ ! -d .git ]; then - git init . - fi - if git remote get-url origin >/dev/null 2>&1; then - git remote set-url origin "${repo_url}" - else - git remote add origin "${repo_url}" - fi - git fetch --force --tags origin "${GITHUB_SHA}" - git checkout --force --detach FETCH_HEAD - git clean -ffdqx + cache_root="${NSC_CACHE_PATH:-${HOME}/.cache/burrow}" + shared_root="${NSC_SHARED_CACHE_PATH:-${cache_root}/shared}" + lane_root="${NSC_LANE_CACHE_PATH:-${cache_root}/lane/build-rust}" + mkdir -p \ + "${shared_root}/cargo" \ + "${shared_root}/sccache" \ + "${shared_root}/xdg" \ + "${lane_root}/cargo-target" + echo "CARGO_HOME=${shared_root}/cargo" >> "${GITHUB_ENV}" + echo "SCCACHE_DIR=${shared_root}/sccache" >> "${GITHUB_ENV}" + echo "XDG_CACHE_HOME=${shared_root}/xdg" >> "${GITHUB_ENV}" + echo "CARGO_TARGET_DIR=${lane_root}/cargo-target" >> "${GITHUB_ENV}" + { + echo 'NIX_CONFIG<> "${GITHUB_ENV}" + df -h /nix "${shared_root}" "${lane_root}" || true - name: Test shell: bash run: | set -euo pipefail - nix develop .#ci -c cargo test --workspace --all-features + nix develop .#ci -c bash -euo pipefail -c ' + sccache --zero-stats >/dev/null 2>&1 || true + cargo test --workspace --all-features + sccache --show-stats || true + ' diff --git a/.forgejo/workflows/build-site.yml b/.forgejo/workflows/build-site.yml index 67be5bb..ea4d58e 100644 --- a/.forgejo/workflows/build-site.yml +++ b/.forgejo/workflows/build-site.yml @@ -16,27 +16,48 @@ concurrency: jobs: site: name: Next.js Build - runs-on: [self-hosted, linux, x86_64, burrow-forge] + runs-on: namespace-profile-linux-medium + env: + NIX_CONFIG: | + experimental-features = nix-command flakes + accept-flake-config = true steps: - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Prepare Cache Dirs shell: bash run: | set -euo pipefail - repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - if [ ! -d .git ]; then - git init . - fi - if git remote get-url origin >/dev/null 2>&1; then - git remote set-url origin "${repo_url}" - else - git remote add origin "${repo_url}" - fi - git fetch --force --tags origin "${GITHUB_SHA}" - git checkout --force --detach FETCH_HEAD - git clean -ffdqx + cache_root="${NSC_CACHE_PATH:-${HOME}/.cache/burrow}" + shared_root="${NSC_SHARED_CACHE_PATH:-${cache_root}/shared}" + lane_root="${NSC_LANE_CACHE_PATH:-${cache_root}/lane/build-site}" + mkdir -p \ + "${shared_root}/npm" \ + "${shared_root}/xdg" \ + "${lane_root}/next-cache" + echo "NPM_CONFIG_CACHE=${shared_root}/npm" >> "${GITHUB_ENV}" + echo "XDG_CACHE_HOME=${shared_root}/xdg" >> "${GITHUB_ENV}" + echo "NEXT_CACHE_DIR=${lane_root}/next-cache" >> "${GITHUB_ENV}" + { + echo 'NIX_CONFIG<> "${GITHUB_ENV}" + df -h /nix "${shared_root}" "${lane_root}" || true - name: Build shell: bash run: | set -euo pipefail - nix develop .#ci -c bash -lc 'cd site && npm ci --no-audit --no-fund && npm run build' + nix develop .#ci -c bash -euo pipefail -c ' + mkdir -p site/.next + rm -rf site/.next/cache + ln -sfn "${NEXT_CACHE_DIR}" site/.next/cache + cd site + npm install + npm run build + ' diff --git a/.forgejo/workflows/distribute-testflight.yml b/.forgejo/workflows/distribute-testflight.yml deleted file mode 100644 index 8962c9a..0000000 --- a/.forgejo/workflows/distribute-testflight.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: "Apple: Distribute TestFlight" -run-name: "Apple: Distribute TestFlight (Build ${{ inputs.build_number }})" - -on: - workflow_dispatch: - inputs: - build_number: - description: Already-uploaded App Store Connect build number - required: true - testflight_distribute_external: - description: Distribute to external TestFlight groups - required: false - default: "false" - testflight_groups: - description: Optional comma-separated TestFlight groups - required: false - default: "" - ios_uses_non_exempt_encryption: - description: Set true only when the iOS build uses non-exempt encryption - required: false - default: "false" - -permissions: - contents: read - -concurrency: - group: burrow-distribute-testflight-${{ inputs.build_number }} - cancel-in-progress: true - -env: - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }} - TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS: ${{ vars.TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS || '7200' }} - -jobs: - distribute-ios-testflight: - name: Distribute (TestFlight iOS Testers) - runs-on: namespace-profile-linux-testflight - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Configure Age - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Validate App Store Credentials - shell: bash - run: Scripts/ci/check-release-config.sh app-store - - - name: Distribute iOS Build To TestFlight - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ inputs.testflight_distribute_external || 'false' }} - TESTFLIGHT_GROUPS: ${{ inputs.testflight_groups || '' }} - IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ inputs.ios_uses_non_exempt_encryption || 'false' }} - run: Scripts/ci/distribute-testflight.sh diff --git a/.forgejo/workflows/infra-opentofu.yml b/.forgejo/workflows/infra-opentofu.yml deleted file mode 100644 index dad4f01..0000000 --- a/.forgejo/workflows/infra-opentofu.yml +++ /dev/null @@ -1,237 +0,0 @@ -name: "Infra: OpenTofu" -run-name: "Infra: OpenTofu (${{ github.event.inputs.stack || 'grafana' }} ${{ github.event.inputs.mode || 'validate' }})" - -on: - push: - branches: - - main - paths: - - ".forgejo/workflows/infra-opentofu.yml" - - "Scripts/grafana-tofu.sh" - - "Scripts/identity-tofu.sh" - - "Scripts/openbao-tofu.sh" - - "Scripts/releases-tofu.sh" - - "infra/grafana/**" - - "infra/identity/**" - - "infra/openbao/**" - - "infra/releases/**" - - "services/grafana/**" - workflow_dispatch: - inputs: - stack: - description: OpenTofu stack to run - required: true - default: grafana - type: choice - options: - - grafana - - identity - - openbao - - releases - mode: - description: OpenTofu mode - required: true - default: plan - type: choice - options: - - fmt - - validate - - plan - - apply - - import - apply_confirmation: - description: Type apply-burrow--tofu to run apply - required: false - default: "" - import_address: - description: OpenTofu resource address for import mode - required: false - default: "" - import_id: - description: Provider import id for import mode - required: false - default: "" - -permissions: - contents: read - -concurrency: - group: infra-opentofu-${{ github.ref }}-${{ github.event.inputs.stack || 'grafana' }} - cancel-in-progress: false - -jobs: - opentofu: - name: OpenTofu (${{ github.event.inputs.stack || 'grafana' }}) - runs-on: [self-hosted, linux, x86_64, burrow-forge] - timeout-minutes: 30 - env: - MODE: ${{ github.event.inputs.mode || 'validate' }} - STACK: ${{ github.event.inputs.stack || 'grafana' }} - APPLY_CONFIRMATION: ${{ github.event.inputs.apply_confirmation || '' }} - IMPORT_ADDRESS: ${{ github.event.inputs.import_address || '' }} - IMPORT_ID: ${{ github.event.inputs.import_id || '' }} - TF_INPUT: "false" - TF_IN_AUTOMATION: "true" - TF_VAR_grafana_url: ${{ vars.GRAFANA_URL || 'https://graphs.burrow.net' }} - TF_VAR_google_project_id: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - TF_VAR_google_project_number: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - TF_VAR_google_runner_service_account_email: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - TF_VAR_google_wif_runner_client_id: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - TF_VAR_google_release_bucket_name: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }} - TF_VAR_google_package_bucket_name: ${{ vars.BURROW_PACKAGE_GCS_BUCKET || 'burrow-net-packages' }} - TF_VAR_google_nix_cache_bucket_name: ${{ vars.BURROW_NIX_CACHE_GCS_BUCKET || 'burrow-net-nix-cache' }} - TF_VAR_google_nix_cache_public_read: ${{ vars.BURROW_NIX_CACHE_GCS_PUBLIC_READ || 'false' }} - TF_VAR_google_storage_location: ${{ vars.BURROW_GCS_LOCATION || 'US' }} - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - OPENTOFU_STATE_BUCKET: ${{ vars.OPENTOFU_STATE_BUCKET || '' }} - OPENTOFU_STATE_REGION: ${{ vars.OPENTOFU_STATE_REGION || vars.AWS_REGION || 'us-west-2' }} - OPENTOFU_STATE_DYNAMODB_TABLE: ${{ vars.OPENTOFU_STATE_DYNAMODB_TABLE || '' }} - steps: - - name: Checkout - shell: bash - run: | - set -euo pipefail - repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - if [ ! -d .git ]; then - git init . - fi - if git remote get-url origin >/dev/null 2>&1; then - git remote set-url origin "${repo_url}" - else - git remote add origin "${repo_url}" - fi - git fetch --force --tags origin "${GITHUB_SHA}" - git checkout --force --detach FETCH_HEAD - git clean -ffdqx - - - name: Validate Request - shell: bash - run: | - set -euo pipefail - - case "$STACK" in - grafana|identity|openbao|releases) ;; - *) - echo "::error ::Unsupported OpenTofu stack: $STACK" >&2 - exit 2 - ;; - esac - - case "$MODE" in - fmt|validate|plan|apply|import) ;; - *) - echo "::error ::Unsupported OpenTofu mode: $MODE" >&2 - exit 2 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" && "$MODE" != "validate" ]]; then - echo "::error ::Non-manual runs may only validate." >&2 - exit 2 - fi - - if [[ "$MODE" == "apply" && "$APPLY_CONFIRMATION" != "apply-burrow-${STACK}-tofu" ]]; then - echo "::error ::apply_confirmation must be exactly apply-burrow-${STACK}-tofu." >&2 - exit 1 - fi - - if [[ "$MODE" == "import" && ( -z "$IMPORT_ADDRESS" || -z "$IMPORT_ID" ) ]]; then - echo "::error ::import mode requires import_address and import_id." >&2 - exit 2 - fi - - - name: Configure Age - if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'import') }} - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Configure Grafana Provider Auth - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.stack == 'grafana' && (github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'import') }} - shell: bash - run: | - set -euo pipefail - - : "${AGE_IDENTITY_PATH:?AGE_IDENTITY_PATH is required}" - agenix="$(nix build --no-link .#agenix --print-out-paths | tail -n1)/bin/agenix" - password="$("$agenix" --identity "$AGE_IDENTITY_PATH" --decrypt secrets/infra/grafana-admin-password.age | tr -d '\r')" - - echo "::add-mask::${password}" - { - echo "TF_VAR_grafana_auth<> "$GITHUB_ENV" - - - name: Configure OpenTofu Backend - shell: bash - run: | - set -euo pipefail - - stack_dir="infra/${STACK}" - rm -f "${stack_dir}/backend.hcl" - if [[ -n "$OPENTOFU_STATE_BUCKET" ]]; then - { - printf 'bucket = "%s"\n' "$OPENTOFU_STATE_BUCKET" - printf 'key = "%s"\n' "${STACK}/${STACK}.tfstate" - printf 'region = "%s"\n' "$OPENTOFU_STATE_REGION" - printf 'encrypt = true\n' - if [[ -n "$OPENTOFU_STATE_DYNAMODB_TABLE" ]]; then - printf 'dynamodb_table = "%s"\n' "$OPENTOFU_STATE_DYNAMODB_TABLE" - fi - } > "${stack_dir}/backend.hcl" - elif [[ "$MODE" == "apply" || "$MODE" == "import" ]]; then - echo "::error ::OPENTOFU_STATE_BUCKET is required for apply/import." >&2 - exit 1 - fi - - - name: Authenticate Google WIF - if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.stack == 'identity' || github.event.inputs.stack == 'releases') && (github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'import') }} - shell: bash - run: | - set -euo pipefail - nix develop .#ci -c Scripts/ci/google-wif-auth.sh - - - name: Run OpenTofu - shell: bash - run: | - set -euo pipefail - - stack_script="Scripts/${STACK}-tofu.sh" - case "$MODE" in - fmt) - nix develop .#ci -c tofu -chdir="infra/${STACK}" fmt -check - ;; - validate) - "$stack_script" init -backend=false - "$stack_script" validate - ;; - plan) - if [[ -f "infra/${STACK}/backend.hcl" ]]; then - "$stack_script" init -backend-config=backend.hcl - else - "$stack_script" init -backend=false - fi - "$stack_script" plan - ;; - apply) - "$stack_script" init -backend-config=backend.hcl - "$stack_script" apply -auto-approve - ;; - import) - "$stack_script" init -backend-config=backend.hcl - "$stack_script" import "$IMPORT_ADDRESS" "$IMPORT_ID" - ;; - esac diff --git a/.forgejo/workflows/lint-governance.yml b/.forgejo/workflows/lint-governance.yml deleted file mode 100644 index 2db94cc..0000000 --- a/.forgejo/workflows/lint-governance.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Lint Governance - -on: - push: - branches: - - main - pull_request: - branches: - - "**" - workflow_dispatch: - -jobs: - governance: - name: BEP Metadata - runs-on: [self-hosted, linux, x86_64, burrow-forge] - steps: - - name: Checkout - shell: bash - run: | - set -euo pipefail - repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - if [ ! -d .git ]; then - git init . - fi - if git remote get-url origin >/dev/null 2>&1; then - git remote set-url origin "${repo_url}" - else - git remote add origin "${repo_url}" - fi - git fetch --force --tags origin "${GITHUB_SHA}" - git checkout --force --detach FETCH_HEAD - git clean -ffdqx - - - name: Validate BEP metadata - shell: bash - run: | - set -euo pipefail - python3 Scripts/check-bep-metadata.py diff --git a/.forgejo/workflows/publish-nix-cache.yml b/.forgejo/workflows/publish-nix-cache.yml deleted file mode 100644 index 5ff5d27..0000000 --- a/.forgejo/workflows/publish-nix-cache.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: "Cache: Publish Nix" -run-name: "Publish Nix Cache (${{ github.ref_name }})" - -on: - push: - branches: - - main - paths: - - ".forgejo/workflows/publish-nix-cache.yml" - - "Cargo.lock" - - "Cargo.toml" - - "flake.lock" - - "flake.nix" - - "crates/**" - - "services/forgejo-nsc/**" - - "nixos/**" - - "Scripts/ci/ensure-nix.sh" - - "Scripts/ci/publish-nix-cache.sh" - workflow_dispatch: - inputs: - attrs: - description: Space-separated flake attrs to build and push - required: false - default: ".#burrow .#forgejo-nsc-dispatcher .#forgejo-nsc-autoscaler" - required: - description: Fail when the cache push token is missing - required: false - default: "false" - -permissions: - contents: read - -concurrency: - group: publish-nix-cache-${{ github.ref }} - cancel-in-progress: true - -jobs: - publish: - name: Publish Nix Cache - runs-on: namespace-profile-linux-medium - timeout-minutes: 45 - env: - BURROW_NIX_CACHE_SERVER_NAME: ${{ vars.BURROW_NIX_CACHE_SERVER_NAME || 'burrow' }} - BURROW_NIX_CACHE_SERVER_URL: ${{ vars.BURROW_NIX_CACHE_SERVER_URL || 'https://nix.burrow.net' }} - BURROW_NIX_CACHE_NAME: ${{ vars.BURROW_NIX_CACHE_NAME || 'burrow' }} - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - BURROW_NIX_CACHE_PUSH_TOKEN: ${{ secrets.BURROW_NIX_CACHE_PUSH_TOKEN }} - BURROW_NIX_CACHE_ATTRS: ${{ github.event.inputs.attrs || '.#burrow .#forgejo-nsc-dispatcher .#forgejo-nsc-autoscaler' }} - BURROW_NIX_CACHE_REQUIRED: ${{ github.event.inputs.required || 'false' }} - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Publish Cache Paths - shell: bash - run: nix develop .#ci -c Scripts/ci/publish-nix-cache.sh diff --git a/.forgejo/workflows/publish-package-repositories.yml b/.forgejo/workflows/publish-package-repositories.yml deleted file mode 100644 index 315e43b..0000000 --- a/.forgejo/workflows/publish-package-repositories.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: "Publish: Package Repositories" -run-name: "Publish Package Repositories (${{ github.event.inputs.channel || 'stable' }})" - -on: - workflow_dispatch: - inputs: - build_number: - description: Build number or release label for package artifact paths - required: false - default: "" - channel: - description: Native package repository channel - required: true - default: stable - type: choice - options: - - stable - - beta - - nightly - publish_gcs: - description: Publish repository tree to configured package storage targets - required: false - default: "true" - -permissions: - contents: read - -concurrency: - group: publish-package-repositories-${{ github.ref }}-${{ github.event.inputs.channel || 'stable' }} - cancel-in-progress: false - -jobs: - publish: - name: Build Native Repositories - runs-on: [self-hosted, linux, x86_64, burrow-forge] - timeout-minutes: 45 - env: - BUILD_NUMBER: ${{ github.event.inputs.build_number || github.sha }} - BURROW_PACKAGE_CHANNEL: ${{ github.event.inputs.channel || 'stable' }} - BURROW_KMS_LOCATION: ${{ vars.BURROW_KMS_LOCATION || 'global' }} - BURROW_KMS_KEYRING: ${{ vars.BURROW_KMS_KEYRING || 'burrow-identity' }} - BURROW_KMS_VERSION: ${{ vars.BURROW_KMS_VERSION || '1' }} - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_PACKAGE_GCS_BUCKET: ${{ vars.BURROW_PACKAGE_GCS_BUCKET || 'burrow-net-packages' }} - BURROW_PACKAGE_GCS_PREFIX: ${{ vars.BURROW_PACKAGE_GCS_PREFIX || '' }} - BURROW_PACKAGE_GCS_BACKUP: ${{ vars.BURROW_PACKAGE_GCS_BACKUP || 'true' }} - BURROW_PACKAGE_GARAGE_BUCKET: ${{ vars.BURROW_PACKAGE_GARAGE_BUCKET || 'burrow-packages' }} - BURROW_PACKAGE_GARAGE_PREFIX: ${{ vars.BURROW_PACKAGE_GARAGE_PREFIX || vars.BURROW_PACKAGE_GCS_PREFIX || '' }} - BURROW_PACKAGE_REQUIRE_GARAGE: ${{ vars.BURROW_PACKAGE_REQUIRE_GARAGE || 'true' }} - BURROW_GARAGE_ENDPOINT: ${{ vars.BURROW_GARAGE_ENDPOINT || 'https://objects.burrow.net' }} - BURROW_GARAGE_REGION: ${{ vars.BURROW_GARAGE_REGION || 'garage' }} - AWS_ACCESS_KEY_ID: ${{ secrets.BURROW_GARAGE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.BURROW_GARAGE_SECRET_ACCESS_KEY }} - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - steps: - - name: Checkout - shell: bash - run: | - set -euo pipefail - repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - if [ ! -d .git ]; then - git init . - fi - if git remote get-url origin >/dev/null 2>&1; then - git remote set-url origin "${repo_url}" - else - git remote add origin "${repo_url}" - fi - git fetch --force --tags origin "${GITHUB_SHA}" - git checkout --force --detach FETCH_HEAD - git clean -ffdqx - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Build Linux Packages - shell: bash - run: | - set -euo pipefail - export BURROW_RELEASE_OUT="$PWD/dist/builds/$BUILD_NUMBER" - nix develop .#ci -c Scripts/ci/package-release-artifacts.sh linux - - - name: Authenticate Google WIF - shell: bash - run: | - set -euo pipefail - nix develop .#ci -c Scripts/ci/google-wif-auth.sh - - - name: Export KMS Public Keys - shell: bash - run: | - set -euo pipefail - mkdir -p "$PWD/.kms" - - export BURROW_APT_REPO_KMS_KEY="${BURROW_APT_REPO_KMS_KEY:-package-apt-repository}" - export BURROW_RPM_REPO_KMS_KEY="${BURROW_RPM_REPO_KMS_KEY:-package-rpm-repository}" - export BURROW_PACMAN_REPO_KMS_KEY="${BURROW_PACMAN_REPO_KMS_KEY:-package-pacman-repository}" - - for item in \ - "APT:${BURROW_APT_REPO_KMS_KEY}:apt" \ - "RPM:${BURROW_RPM_REPO_KMS_KEY}:rpm" \ - "PACMAN:${BURROW_PACMAN_REPO_KMS_KEY}:pacman" - do - IFS=: read -r env_prefix key_name file_prefix <<< "$item" - pem="$PWD/.kms/${file_prefix}.pem" - nix develop .#ci -c gcloud kms keys versions get-public-key "$BURROW_KMS_VERSION" \ - --location "$BURROW_KMS_LOCATION" \ - --keyring "$BURROW_KMS_KEYRING" \ - --key "$key_name" \ - --project "$GOOGLE_CLOUD_PROJECT" \ - --output-file "$pem" - echo "BURROW_${env_prefix}_REPO_KMS_PUBLIC_KEY_PEM=$pem" >> "$GITHUB_ENV" - echo "BURROW_${env_prefix}_REPO_KMS_KEY=$key_name" >> "$GITHUB_ENV" - done - - - name: Build Repository Metadata - shell: bash - run: | - set -euo pipefail - export BURROW_PACKAGE_INPUT_DIR="$PWD/dist/builds/$BUILD_NUMBER/linux" - export BURROW_PACKAGE_REPOSITORY_DIR="$PWD/dist/builds/$BUILD_NUMBER/repositories" - nix develop .#ci -c Scripts/package/build-repositories.sh all - - - name: Upload Repository Artifact - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-package-repositories-${{ github.event.inputs.channel || 'stable' }}-${{ github.event.inputs.build_number || github.sha }} - path: dist/builds/${{ github.event.inputs.build_number || github.sha }}/repositories/** - if-no-files-found: error - - - name: Publish Package Repository To Storage - if: ${{ github.event.inputs.publish_gcs == 'true' }} - shell: bash - run: | - set -euo pipefail - export BURROW_PACKAGE_REPOSITORY_DIR="$PWD/dist/builds/$BUILD_NUMBER/repositories" - nix develop .#ci -c Scripts/ci/upload-package-storage.sh diff --git a/.forgejo/workflows/publish-store-uploads.yml b/.forgejo/workflows/publish-store-uploads.yml deleted file mode 100644 index 904abce..0000000 --- a/.forgejo/workflows/publish-store-uploads.yml +++ /dev/null @@ -1,385 +0,0 @@ -name: "Publish: Release Uploads" -run-name: "Publish: Release Uploads (Build ${{ inputs.build_number }})" - -on: - workflow_dispatch: - inputs: - build_number: - description: Build number to publish - required: true - upload_app_store: - description: Upload iOS/macOS/visionOS artifacts to App Store Connect - required: false - default: "true" - distribute_testflight: - description: Distribute an already-uploaded iOS build to TestFlight - required: false - default: "false" - upload_sparkle: - description: Publish Sparkle artifacts - required: false - default: "true" - upload_microsoft_store: - description: Upload Windows package to Microsoft Partner Center beta lane - required: false - default: "false" - sparkle_channel: - description: Sparkle channel, defaults to build- - required: false - default: "" - sparkle_point_main: - description: Point Sparkle main appcast/default channel to selected channel - required: false - default: "true" - testflight_distribute_external: - description: Distribute to external TestFlight groups - required: false - default: "false" - testflight_groups: - description: Optional comma-separated TestFlight groups for external distribution - required: false - default: "" - ios_uses_non_exempt_encryption: - description: Set true only when the iOS build uses non-exempt encryption - required: false - default: "false" - -permissions: - contents: read - -concurrency: - group: burrow-publish-store-uploads-${{ inputs.build_number }} - cancel-in-progress: true - -env: - BURROW_RELEASE_GCS_BUCKET: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }} - BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }} - BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }} - BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }} - BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }} - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }} - BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }} - BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }} - BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }} - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }} - BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID: ${{ vars.BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID || 'net.burrow.app.network' }} - BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID: ${{ vars.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID || '3G42677598' }} - BURROW_DEVELOPER_ID_CERTIFICATE_ID: ${{ vars.BURROW_DEVELOPER_ID_CERTIFICATE_ID || '9JKN6HXBHC' }} - BURROW_APPLE_SIGNING_MODE: ${{ vars.BURROW_APPLE_SIGNING_MODE || 'google-kms-staged' }} - BURROW_NOTARIZE_MACOS: ${{ vars.BURROW_NOTARIZE_MACOS || 'true' }} - BURROW_SPARKLE_SIGN_WITH_KMS: ${{ vars.BURROW_SPARKLE_SIGN_WITH_KMS || 'true' }} - BURROW_SPARKLE_KMS_KEY: ${{ vars.BURROW_SPARKLE_KMS_KEY || 'sparkle-ed25519' }} - BURROW_SPARKLE_KMS_VERSION: ${{ vars.BURROW_SPARKLE_KMS_VERSION || '1' }} - BURROW_KMS_LOCATION: ${{ vars.BURROW_KMS_LOCATION || 'global' }} - BURROW_KMS_KEYRING: ${{ vars.BURROW_KMS_KEYRING || 'burrow-identity' }} - TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS: ${{ vars.TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS || '7200' }} - -jobs: - sign-apple-kms: - name: Sign (Apple KMS) - if: ${{ inputs.upload_app_store == 'true' || inputs.upload_sparkle == 'true' }} - runs-on: namespace-profile-linux-medium - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Configure Age - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Validate App Store Credentials - shell: bash - run: Scripts/ci/check-release-config.sh app-store - - - name: Download Staged Apple Artifacts - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - run: | - set -euo pipefail - nix develop .#ci -c Scripts/ci/google-wif-auth.sh - mkdir -p "dist/builds/${BUILD_NUMBER}/apple" - nix develop .#ci -c gcloud storage rsync --recursive "gs://${BURROW_RELEASE_GCS_BUCKET}/builds/${BUILD_NUMBER}/apple" "dist/builds/${BUILD_NUMBER}/apple" - find "dist/builds/${BUILD_NUMBER}/apple" -type f -print | sort - - - name: Sync Apple Provisioning Profiles - shell: bash - run: nix develop .#ci -c Scripts/ci/sync-apple-provisioning-profiles.sh all - - - name: Sign Apple Artifacts With KMS - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - SPARKLE_CHANNEL_INPUT: ${{ inputs.sparkle_channel }} - run: | - set -euo pipefail - channel="${SPARKLE_CHANNEL_INPUT:-}" - if [[ -z "$channel" ]]; then - channel="build-${BUILD_NUMBER}" - fi - SPARKLE_CHANNEL="$channel" nix develop .#ci -c Scripts/apple/sign-release-artifacts-kms.sh all - - - name: Upload Signed Apple Artifacts - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - run: nix develop .#ci -c Scripts/ci/upload-release-storage.sh - - upload-app-store: - name: Upload (App Store Connect) - needs: sign-apple-kms - if: ${{ inputs.upload_app_store == 'true' }} - runs-on: namespace-profile-macos-large - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Configure Age - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Validate App Store Credentials - shell: bash - run: Scripts/ci/check-release-config.sh app-store - - - name: Download Apple Artifacts From GCS - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - run: | - set -euo pipefail - nix develop .#ci -c Scripts/ci/google-wif-auth.sh - mkdir -p publish/apple - nix develop .#ci -c gcloud storage rsync --recursive "gs://${BURROW_RELEASE_GCS_BUCKET}/builds/${BUILD_NUMBER}/apple" publish/apple - find publish/apple -type f -print | sort - - - name: Upload Apple Packages - shell: bash - run: | - set -euo pipefail - Scripts/ci/check-release-config.sh app-store - key_dir="${RUNNER_TEMP:-/tmp}/burrow-asc" - altool_key_dir="${HOME}/.appstoreconnect/private_keys" - mkdir -p "$key_dir" "$altool_key_dir" - key_path="$key_dir/AuthKey_${ASC_API_KEY_ID}.p8" - if [[ -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then - cp "$ASC_API_KEY_PATH" "$key_path" - else - printf '%s' "$ASC_API_KEY_BASE64" | base64 --decode > "$key_path" - fi - cp "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8" - chmod 600 "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8" - shopt -s nullglob - artifacts=() - for artifact in publish/apple/*.ipa publish/apple/*.pkg; do - [[ "$artifact" == *.unsigned.ipa ]] && continue - artifacts+=("$artifact") - done - if (( ${#artifacts[@]} == 0 )); then - echo "::error ::No Apple upload artifacts found for build ${{ inputs.build_number }}." - exit 1 - fi - for artifact in "${artifacts[@]}"; do - upload_type="ios" - if [[ "$artifact" == *.pkg ]]; then - upload_type="macos" - fi - upload_log="${RUNNER_TEMP:-/tmp}/burrow-altool-${upload_type}-$(basename "$artifact").log" - set +e - xcrun altool --upload-app \ - --type "$upload_type" \ - --file "$artifact" \ - --apiKey "$ASC_API_KEY_ID" \ - --apiIssuer "$ASC_API_ISSUER_ID" 2>&1 | tee "$upload_log" - altool_status=${PIPESTATUS[0]} - set -e - if (( altool_status != 0 )) || grep -Eq 'UPLOAD FAILED|Validation failed|ERROR:' "$upload_log"; then - echo "::error ::altool reported upload validation failure for $artifact" >&2 - exit 1 - fi - done - - release-ios-testflight: - name: Release (TestFlight iOS Testers) - needs: upload-app-store - if: ${{ inputs.upload_app_store == 'true' }} - runs-on: namespace-profile-linux-testflight - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Configure Age - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Validate App Store Credentials - shell: bash - run: Scripts/ci/check-release-config.sh app-store - - - name: Distribute iOS Build To TestFlight - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ inputs.testflight_distribute_external || 'false' }} - TESTFLIGHT_GROUPS: ${{ inputs.testflight_groups || '' }} - IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ inputs.ios_uses_non_exempt_encryption || 'false' }} - run: Scripts/ci/distribute-testflight.sh - - distribute-ios-testflight: - name: Distribute (TestFlight iOS Testers) - if: ${{ inputs.upload_app_store != 'true' && inputs.distribute_testflight == 'true' }} - runs-on: namespace-profile-linux-testflight - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Configure Age - shell: bash - env: - AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} - run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV" - - - name: Decrypt Release Secrets - uses: ./.forgejo/actions/decrypt-release-secrets - - - name: Validate App Store Credentials - shell: bash - run: Scripts/ci/check-release-config.sh app-store - - - name: Distribute iOS Build To TestFlight - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ inputs.testflight_distribute_external || 'false' }} - TESTFLIGHT_GROUPS: ${{ inputs.testflight_groups || '' }} - IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ inputs.ios_uses_non_exempt_encryption || 'false' }} - run: Scripts/ci/distribute-testflight.sh - - upload-sparkle: - name: Upload (Sparkle) - needs: sign-apple-kms - if: ${{ inputs.upload_sparkle == 'true' }} - runs-on: namespace-profile-linux-medium - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Resolve Sparkle Channel - id: channel - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - run: | - set -euo pipefail - channel="${{ inputs.sparkle_channel }}" - if [[ -z "$channel" ]]; then - channel="build-${BUILD_NUMBER}" - fi - echo "channel=$channel" >> "$GITHUB_OUTPUT" - - - name: Ensure Nix - shell: bash - run: bash Scripts/ci/ensure-nix.sh - - - name: Publish Sparkle Channel - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - CHANNEL: ${{ steps.channel.outputs.channel }} - SPARKLE_POINT_MAIN: ${{ inputs.sparkle_point_main }} - run: | - set -euo pipefail - nix develop .#ci -c Scripts/ci/google-wif-auth.sh - mkdir -p "publish/sparkle/${CHANNEL}" - nix develop .#ci -c gcloud storage rsync --recursive "gs://${BURROW_RELEASE_GCS_BUCKET}/builds/${BUILD_NUMBER}/sparkle/${CHANNEL}" "publish/sparkle/${CHANNEL}" || true - if [[ ! -f "publish/sparkle/${CHANNEL}/appcast.xml" ]]; then - echo "::warning ::No Sparkle appcast staged for build ${BUILD_NUMBER}; skipping Sparkle publish." - find "publish/sparkle/${CHANNEL}" -maxdepth 2 -type f -print || true - exit 0 - fi - nix develop .#ci -c gcloud storage rsync --recursive "publish/sparkle/${CHANNEL}" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/${CHANNEL}" - if [[ "$SPARKLE_POINT_MAIN" == "true" ]]; then - printf '%s\n' "$CHANNEL" > main-channel.txt - nix develop .#ci -c gcloud storage cp main-channel.txt "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/main-channel.txt" - nix develop .#ci -c gcloud storage cp "publish/sparkle/${CHANNEL}/appcast.xml" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/appcast.xml" - nix develop .#ci -c gcloud storage rsync --recursive "publish/sparkle/${CHANNEL}" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/default" - fi - - upload-microsoft-store: - name: Upload (Microsoft Store Beta) - if: ${{ inputs.upload_microsoft_store == 'true' }} - runs-on: namespace-profile-windows-large - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Validate Microsoft Store Credentials - shell: pwsh - env: - MICROSOFT_TENANT_ID: ${{ secrets.MICROSOFT_TENANT_ID }} - MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }} - MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }} - run: | - foreach ($name in "MICROSOFT_TENANT_ID","MICROSOFT_CLIENT_ID","MICROSOFT_CLIENT_SECRET") { - if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { - throw "missing required environment variable: $name" - } - } - - - name: Stop Until Store Package Exists - shell: pwsh - run: | - Write-Host "::warning ::Burrow only builds a Windows Rust stub today; Microsoft Store package upload is wired for credentials but waits on MSIX packaging." diff --git a/.forgejo/workflows/release-if-needed.yml b/.forgejo/workflows/release-if-needed.yml deleted file mode 100644 index cc39214..0000000 --- a/.forgejo/workflows/release-if-needed.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: "Release: If Needed" -run-name: "Release: If Needed (${{ github.ref_name }})" - -on: - push: - branches: - - main - schedule: - - cron: "*/30 * * * *" - workflow_dispatch: - inputs: - upload_app_store: - description: Trigger downstream App Store Connect upload workflow - required: false - default: "true" - upload_sparkle: - description: Trigger downstream Sparkle publish workflow - required: false - default: "true" - upload_microsoft_store: - description: Trigger downstream Microsoft Store beta upload workflow - required: false - default: "false" - sparkle_point_main: - description: Update Sparkle main channel pointers - required: false - default: "true" - testflight_distribute_external: - description: Distribute to external TestFlight groups - required: false - default: "false" - testflight_groups: - description: Optional comma-separated TestFlight groups for external distribution - required: false - default: "" - ios_uses_non_exempt_encryption: - description: Set true only when the iOS build uses non-exempt encryption - required: false - default: "false" - -permissions: - contents: read - actions: write - -concurrency: - group: burrow-release-if-needed-main - cancel-in-progress: true - -env: - GITHUB_TOKEN: ${{ github.token }} - BURROW_VERSION_REQUIRE_REMOTE: "1" - -jobs: - check: - name: Check (Release Needed) - runs-on: namespace-profile-linux-medium - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Fetch Build Tags - shell: bash - run: git fetch --force --prune origin "+refs/tags/builds/*:refs/tags/builds/*" - - - name: Dispatch Release Build - shell: bash - env: - UPLOAD_APP_STORE: ${{ github.event.inputs.upload_app_store || 'true' }} - UPLOAD_SPARKLE: ${{ github.event.inputs.upload_sparkle || 'true' }} - UPLOAD_MICROSOFT_STORE: ${{ github.event.inputs.upload_microsoft_store || 'false' }} - SPARKLE_POINT_MAIN: ${{ github.event.inputs.sparkle_point_main || 'true' }} - TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ github.event.inputs.testflight_distribute_external || 'false' }} - TESTFLIGHT_GROUPS: ${{ github.event.inputs.testflight_groups || '' }} - IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ github.event.inputs.ios_uses_non_exempt_encryption || 'false' }} - run: | - set -euo pipefail - status="$(Scripts/version.sh status)" - if [[ "$status" == "clean" ]]; then - echo "No unreleased changes." - exit 0 - fi - inputs="$(python3 -c 'import json, os; print(json.dumps({"upload_app_store":os.environ["UPLOAD_APP_STORE"],"upload_sparkle":os.environ["UPLOAD_SPARKLE"],"upload_microsoft_store":os.environ["UPLOAD_MICROSOFT_STORE"],"sparkle_point_main":os.environ["SPARKLE_POINT_MAIN"],"testflight_distribute_external":os.environ["TESTFLIGHT_DISTRIBUTE_EXTERNAL"],"testflight_groups":os.environ["TESTFLIGHT_GROUPS"],"ios_uses_non_exempt_encryption":os.environ["IOS_USES_NON_EXEMPT_ENCRYPTION"]}))')" - curl -fsS \ - -X POST \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"ref\":\"main\",\"inputs\":${inputs}}" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/build-release.yml/dispatches" - echo "Dispatched build-release.yml (status=${status})." diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index cf05fd3..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -env: - BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }} - BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }} - -jobs: - release: - name: Release Build - runs-on: namespace-profile-linux-medium - steps: - - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 - - - name: Bootstrap Nix - shell: bash - run: | - set -euo pipefail - chmod +x Scripts/ci/ensure-nix.sh - Scripts/ci/ensure-nix.sh - - - name: Build release artifacts - shell: bash - env: - RELEASE_REF: ${{ github.ref_name }} - run: | - set -euo pipefail - ref="${RELEASE_REF:-manual-${GITHUB_SHA::7}}" - export RELEASE_REF="${ref}" - chmod +x Scripts/ci/build-release-artifacts.sh - nix develop .#ci -c Scripts/ci/build-release-artifacts.sh - - - name: Flatten release assets - shell: bash - env: - RELEASE_REF: ${{ github.ref_name }} - run: | - set -euo pipefail - ref="${RELEASE_REF:-manual-${GITHUB_SHA::7}}" - mkdir -p dist/release-assets - find "dist/builds/${ref}" -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.sha256' -o -name 'README.txt' \) -exec cp {} dist/release-assets/ \; - rm -rf dist/builds - cp dist/release-assets/* dist/ - find dist -maxdepth 1 -type f -print | sort - - - name: Upload release artifacts - uses: https://code.forgejo.org/actions/upload-artifact@v3 - with: - name: burrow-release-${{ github.ref_name }} - path: dist/* - if-no-files-found: error - - - name: Publish Forgejo release - if: startsWith(github.ref, 'refs/tags/') - shell: bash - env: - RELEASE_TAG: ${{ github.ref_name }} - API_URL: ${{ github.api_url }} - REPOSITORY: ${{ github.repository }} - TOKEN: ${{ github.token }} - run: | - set -euo pipefail - chmod +x Scripts/ci/publish-forgejo-release.sh - nix develop .#ci -c Scripts/ci/publish-forgejo-release.sh diff --git a/.github/workflows/build-apple.yml b/.github/workflows/build-apple.yml index 5a135b4..7ae8c4c 100644 --- a/.github/workflows/build-apple.yml +++ b/.github/workflows/build-apple.yml @@ -54,7 +54,6 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.85.0 targets: ${{ join(matrix.rust-targets, ', ') }} - name: Install Protobuf shell: bash @@ -87,4 +86,4 @@ jobs: destination: ${{ matrix.destination }} test-plan: ${{ matrix.xcode-ui-test }} artifact-prefix: ui-tests-${{ matrix.sdk-name }} - check-name: Xcode UI Tests (${{ matrix.platform }}) + check-name: Xcode UI Tests (${{ matrix.platform }}) \ No newline at end of file diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index cbbdd81..95fc628 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -6,9 +6,6 @@ on: pull_request: branches: - "*" -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true jobs: build: name: Build Crate (${{ matrix.platform }}) @@ -75,14 +72,14 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.85.0 + toolchain: stable components: rustfmt targets: ${{ join(matrix.targets, ', ') }} - name: Setup Rust Cache uses: Swatinem/rust-cache@v2 - name: Build shell: bash - run: cargo build --locked --verbose --workspace --all-features --target ${{ join(matrix.targets, ' --target ') }} --target ${{ join(matrix.test-targets, ' --target ') }} + run: cargo build --verbose --workspace --all-features --target ${{ join(matrix.targets, ' --target ') }} --target ${{ join(matrix.test-targets, ' --target ') }} - name: Test shell: bash - run: cargo test --locked --verbose --workspace --all-features --target ${{ join(matrix.test-targets, ' --target ') }} + run: cargo test --verbose --workspace --all-features --target ${{ join(matrix.test-targets, ' --target ') }} \ No newline at end of file diff --git a/.github/workflows/lint-governance.yml b/.github/workflows/lint-governance.yml deleted file mode 100644 index 08b665c..0000000 --- a/.github/workflows/lint-governance.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Governance Lint - -on: - pull_request: - branches: - - "*" - -jobs: - governance: - name: BEP Metadata - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - - name: Validate BEP metadata - shell: bash - run: | - set -euo pipefail - python3 Scripts/check-bep-metadata.py diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index b36ed73..c869d6a 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -47,7 +47,6 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.85.0 targets: ${{ join(matrix.rust-targets, ', ') }} - name: Install Protobuf shell: bash diff --git a/.gitignore b/.gitignore index 8237d1a..3ce64aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,5 @@ # Xcode xcuserdata -Apple/build/ -.derived-data/ -AuthKey_*.p8 -burrow-certs-pass.txt -*.p12 # Swift Apple/Package/.swiftpm/ @@ -13,19 +8,11 @@ Apple/Package/.swiftpm/ target/ .env -# Bazel -bazel-* - .DS_STORE .idea/ tmp/ -.cache/ intake/ -dist/ -publish/ -release/ -ci-artifacts/ *.db *.sqlite3 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 0ca7ced..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# instructions for agents - -1. Spell the project name as `Burrow` in user-facing copy and `burrow` in code, package, and protocol identifiers unless an existing integration requires a different literal. -2. Read [CONSTITUTION.md](CONSTITUTION.md) before changing Apple clients, the daemon, the control plane, forge infrastructure, identity, or security-sensitive code. -3. Anchor non-trivial changes in a Burrow Evolution Proposal (BEP) under [evolution/](evolution/README.md) so future contributors can inherit the rationale, safeguards, and rollout shape. -4. Before touching the Apple app, daemon IPC, or Tailnet flows, review: - - [evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md](evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md) - - [evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md](evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md) - - [evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md](evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md) - - [evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md](evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md) -5. Apple clients must talk only to the daemon over gRPC. Do not add direct HTTP, control-plane, or helper-process calls from Swift UI code. -6. Treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a separate user-facing protocol surface. -7. Maintain canonical identity and operator metadata in [contributors.nix](contributors.nix). If Burrow forge, Authentik, Headscale, or admin/group mappings need to change, edit that registry first and derive runtime configuration from it. -8. When process or architecture is unclear, stop and draft or update a BEP instead of improvising durable behavior in code. diff --git a/Android/README.md b/Android/README.md deleted file mode 100644 index 755b722..0000000 --- a/Android/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Burrow Android - -The Android app starts as a Kotlin shell linked to `crates/burrow-mobile-ffi`, -which exposes the cross-platform `burrow-core` crate through JNI. - -The current stub proves the package shape and Rust core boundary. Full VPN -support must use Android `VpnService` and keep platform consent, Play Store -policy, and disclosure requirements in the release lane. - -The Bazel entrypoint is: - -```sh -bazel build //bazel/android:check_stub_stamp -``` diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts deleted file mode 100644 index cb1a8ac..0000000 --- a/Android/app/build.gradle.kts +++ /dev/null @@ -1,21 +0,0 @@ -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") -} - -android { - namespace = "net.burrow.android" - compileSdk = 36 - - defaultConfig { - applicationId = "net.burrow.android" - minSdk = 26 - targetSdk = 36 - versionCode = 1 - versionName = "0.1.0" - } -} - -kotlin { - jvmToolchain(17) -} diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 2bb3a13..0000000 --- a/Android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - diff --git a/Android/app/src/main/java/net/burrow/android/BurrowCore.kt b/Android/app/src/main/java/net/burrow/android/BurrowCore.kt deleted file mode 100644 index cf286ba..0000000 --- a/Android/app/src/main/java/net/burrow/android/BurrowCore.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.burrow.android - -object BurrowCore { - init { - System.loadLibrary("burrow_mobile_ffi") - } - - external fun version(): String -} diff --git a/Android/app/src/main/java/net/burrow/android/MainActivity.kt b/Android/app/src/main/java/net/burrow/android/MainActivity.kt deleted file mode 100644 index 810a403..0000000 --- a/Android/app/src/main/java/net/burrow/android/MainActivity.kt +++ /dev/null @@ -1,32 +0,0 @@ -package net.burrow.android - -import android.app.Activity -import android.os.Bundle -import android.widget.LinearLayout -import android.widget.TextView - -class MainActivity : Activity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val versionText = runCatching { BurrowCore.version() } - .getOrElse { "burrow-core unavailable" } - - val root = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(40, 40, 40, 40) - } - - root.addView(TextView(this).apply { - text = getString(R.string.app_name) - textSize = 24f - }) - - root.addView(TextView(this).apply { - text = versionText - textSize = 14f - }) - - setContentView(root) - } -} diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 5d973f2..0000000 Binary files a/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 5d973f2..0000000 Binary files a/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 68cf88b..0000000 Binary files a/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 68cf88b..0000000 Binary files a/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 2f341d8..0000000 Binary files a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 2f341d8..0000000 Binary files a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index ed3c1ba..0000000 Binary files a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index ed3c1ba..0000000 Binary files a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 3e052d2..0000000 Binary files a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 3e052d2..0000000 Binary files a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml deleted file mode 100644 index 2f79d4b..0000000 --- a/Android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Burrow - diff --git a/Android/app/src/main/res/values/styles.xml b/Android/app/src/main/res/values/styles.xml deleted file mode 100644 index 3bc0947..0000000 --- a/Android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/Android/build.gradle.kts b/Android/build.gradle.kts deleted file mode 100644 index 60678e4..0000000 --- a/Android/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -plugins { - id("com.android.application") version "8.10.1" apply false - id("org.jetbrains.kotlin.android") version "2.1.21" apply false -} diff --git a/Android/settings.gradle.kts b/Android/settings.gradle.kts deleted file mode 100644 index a4925f9..0000000 --- a/Android/settings.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "BurrowAndroid" -include(":app") diff --git a/Apple/App/App.xcconfig b/Apple/App/App.xcconfig index 05fbc27..4e42ddc 100644 --- a/Apple/App/App.xcconfig +++ b/Apple/App/App.xcconfig @@ -10,8 +10,6 @@ INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*] = YES INFOPLIST_KEY_UIStatusBarStyle[sdk=iphone*] = UIStatusBarStyleDefault INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad[sdk=iphone*] = UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone[sdk=iphone*] = UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight -ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES[sdk=iphone*] = BurrowPanel02 BurrowPanel03 BurrowPanel04 BurrowPanel05 BurrowPanel06 -ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS[sdk=iphone*] = YES TARGETED_DEVICE_FAMILY[sdk=iphone*] = 1,2 EXCLUDED_SOURCE_FILE_NAMES = MainMenu.xib @@ -20,10 +18,6 @@ INFOPLIST_KEY_LSUIElement[sdk=macosx*] = YES INFOPLIST_KEY_NSMainNibFile[sdk=macosx*] = MainMenu INFOPLIST_KEY_NSPrincipalClass[sdk=macosx*] = NSApplication INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*] = public.app-category.utilities -BURROW_SPARKLE_FEED_URL = https:/$()/releases.burrow.net/sparkle/appcast.xml -BURROW_SPARKLE_PUBLIC_ED_KEY = uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30= -INFOPLIST_KEY_SUFeedURL[sdk=macosx*] = $(BURROW_SPARKLE_FEED_URL) -INFOPLIST_KEY_SUPublicEDKey[sdk=macosx*] = $(BURROW_SPARKLE_PUBLIC_ED_KEY) CODE_SIGN_ENTITLEMENTS = App/App-iOS.entitlements CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = App/App-macOS.entitlements diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index 8eccc07..0ea93f4 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -1,19 +1,11 @@ #if os(macOS) import AppKit import BurrowUI -import Sparkle import SwiftUI @main @MainActor class AppDelegate: NSObject, NSApplicationDelegate { - private var windowController: NSWindowController? - private let updaterController = SPUStandardUpdaterController( - startingUpdater: true, - updaterDelegate: nil, - userDriverDelegate: nil - ) - private let quitItem: NSMenuItem = { let quitItem = NSMenuItem( title: "Quit Burrow", @@ -25,27 +17,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { return quitItem }() - private lazy var openItem: NSMenuItem = { - let item = NSMenuItem( - title: "Open Burrow", - action: #selector(openWindow), - keyEquivalent: "o" - ) - item.target = self - item.keyEquivalentModifierMask = .command - return item - }() - - private lazy var checkForUpdatesItem: NSMenuItem = { - let item = NSMenuItem( - title: "Check for Updates...", - action: #selector(checkForUpdates(_:)), - keyEquivalent: "" - ) - item.target = self - return item - }() - private let toggleItem: NSMenuItem = { let toggleView = NSHostingView(rootView: MenuItemToggleView()) toggleView.frame.size = CGSize(width: 300, height: 32) @@ -60,8 +31,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let menu = NSMenu() menu.items = [ toggleItem, - openItem, - checkForUpdatesItem, .separator(), quitItem ] @@ -72,7 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let statusBar = NSStatusBar.system let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "pipe.and.drop.fill", accessibilityDescription: nil) + button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: nil) } return statusItem }() @@ -80,33 +49,5 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { statusItem.menu = menu } - - @objc - private func openWindow() { - if let window = windowController?.window { - window.makeKeyAndOrderFront(nil) - NSApplication.shared.activate(ignoringOtherApps: true) - return - } - - let contentView = BurrowView() - let hostingController = NSHostingController(rootView: contentView) - let window = NSWindow(contentViewController: hostingController) - window.title = "Burrow" - window.setContentSize(NSSize(width: 820, height: 720)) - window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable]) - window.center() - - let controller = NSWindowController(window: window) - controller.shouldCascadeWindows = true - controller.showWindow(nil) - windowController = controller - NSApplication.shared.activate(ignoringOtherApps: true) - } - - @objc - private func checkForUpdates(_ sender: Any?) { - updaterController.checkForUpdates(sender) - } } #endif diff --git a/Apple/App/BurrowApp.swift b/Apple/App/BurrowApp.swift index 45529dc..838ef54 100644 --- a/Apple/App/BurrowApp.swift +++ b/Apple/App/BurrowApp.swift @@ -1,46 +1,14 @@ #if !os(macOS) import BurrowUI import SwiftUI -#if os(iOS) -import UIKit -#endif @MainActor @main struct BurrowApp: App { var body: some Scene { WindowGroup { - #if os(iOS) - BurrowView(appIconManager: UIKitAppIconManager()) - #else BurrowView() - #endif - } - } -} - -#if os(iOS) -@MainActor -private struct UIKitAppIconManager: AppIconManaging { - var supportsAlternateIcons: Bool { - UIApplication.shared.supportsAlternateIcons - } - - var alternateIconName: String? { - UIApplication.shared.alternateIconName - } - - func setAlternateIconName(_ iconName: String?) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - UIApplication.shared.setAlternateIconName(iconName) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } - } } } } #endif -#endif diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift deleted file mode 100644 index b7d8111..0000000 --- a/Apple/AppUITests/BurrowUITests.swift +++ /dev/null @@ -1,439 +0,0 @@ -import XCTest -import UIKit - -@MainActor -final class BurrowTailnetLoginUITests: XCTestCase { - private enum TailnetLoginMode: String, Decodable { - case tailscale - case discovered - } - - private struct TestConfig: Decodable { - let email: String - let username: String - let password: String - let mode: TailnetLoginMode? - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testTailnetLoginThroughAuthentikWebSession() throws { - let config = try loadTestConfig() - let email = config.email - let username = config.username - let password = config.password - let mode = config.mode ?? .tailscale - let browserIdentity = mode == .tailscale ? email : username - - let app = XCUIApplication() - app.launch() - - let tailnetButton = app.buttons["quick-add-tailnet"] - XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear") - tailnetButton.tap() - - configureTailnetIfNeeded(in: app, mode: mode) - - let discoveryField = app.textFields["tailnet-discovery-email"] - XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear") - replaceText(in: discoveryField, with: email) - - let serverCard = app.descendants(matching: .any) - .matching(identifier: "tailnet-server-card") - .firstMatch - XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear") - - let signInButton = app.buttons["tailnet-start-sign-in"] - XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear") - signInButton.tap() - - acceptAuthenticationPromptIfNeeded(in: app, timeout: 20) - - let webSession = webAuthenticationSession() - XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear") - - signIntoAuthentik(in: webSession, username: browserIdentity, password: password) - - app.activate() - XCTAssertTrue( - waitForTailnetSignedIn(in: app, timeout: 60), - "Tailnet sign-in never reached the running state" - ) - } - - private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) { - guard mode == .discovered else { return } - - openTailnetMenu(in: app) - tapMenuButton(named: "Edit Custom Server", in: app) - - openTailnetMenu(in: app) - tapMenuButton(named: "Show Advanced Settings", in: app) - - let authorityField = app.textFields["tailnet-authority"] - XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") - replaceText(in: authorityField, with: "") - } - - private func openTailnetMenu(in app: XCUIApplication) { - let moreButton = app.buttons["More"] - XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear") - moreButton.tap() - } - - private func tapMenuButton(named title: String, in app: XCUIApplication) { - let menuButton = firstExistingElement( - from: [ - app.buttons[title], - app.descendants(matching: .button)[title], - ], - timeout: 5 - ) - XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear") - menuButton.tap() - } - - private func acceptAuthenticationPromptIfNeeded( - in app: XCUIApplication, - timeout: TimeInterval - ) { - let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") - let deadline = Date().addingTimeInterval(timeout) - - repeat { - let promptCandidates = [ - springboard.buttons["Continue"], - springboard.buttons["Allow"], - app.buttons["Continue"], - app.buttons["Allow"], - ] - - for button in promptCandidates where button.exists && button.isHittable { - button.tap() - return - } - - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) - } while Date() < deadline - - let promptCandidates = [ - springboard.buttons["Continue"], - springboard.buttons["Allow"], - app.buttons["Continue"], - app.buttons["Allow"], - ] - - for button in promptCandidates where button.exists { - button.tap() - return - } - } - - private func webAuthenticationSession() -> XCUIApplication { - let safariViewService = XCUIApplication(bundleIdentifier: "com.apple.SafariViewService") - if safariViewService.waitForExistence(timeout: 5) { - return safariViewService - } - - let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") - _ = safari.waitForExistence(timeout: 5) - return safari - } - - private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) { - followTailnetRedirectIfNeeded(in: webSession) - - if !webSession.exists { - return - } - - let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) - if immediatePasswordField.exists { - replaceSecureText(in: immediatePasswordField, within: webSession, with: password) - submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField) - return - } - - let usernameField = firstExistingElement( - in: webSession, - queries: [ - { $0.textFields["Username"] }, - { $0.textFields["Email or Username"] }, - { $0.textFields["Email address"] }, - { $0.textFields["Email"] }, - { $0.webViews.textFields["Username"] }, - { $0.webViews.textFields["Email or Username"] }, - { $0.descendants(matching: .textField).firstMatch }, - ], - timeout: 12 - ) - if !usernameField.exists { - return - } - replaceText(in: usernameField, with: username) - - tapFirstExistingButton( - in: webSession, - titles: ["Continue", "Next", "Sign In", "Log in", "Login"], - timeout: 5 - ) - - let passwordField = firstExistingSecureField(in: webSession, timeout: 20) - XCTAssertTrue(passwordField.exists, "Authentik password field did not appear") - replaceSecureText(in: passwordField, within: webSession, with: password) - submitAuthenticationForm(in: webSession, focusedField: passwordField) - } - - private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) { - let redirectCandidates = [ - webSession.links["Found"], - webSession.webViews.links["Found"], - webSession.buttons["Found"], - webSession.webViews.buttons["Found"], - ] - - let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8) - if redirectLink.exists { - redirectLink.tap() - } - } - - private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement { - let candidates = [ - app.descendants(matching: .secureTextField).firstMatch, - app.secureTextFields["Password"], - app.secureTextFields["Password or Token"], - app.webViews.secureTextFields["Password"], - app.webViews.secureTextFields["Password or Token"], - ] - - return firstExistingElement(from: candidates, timeout: timeout) - } - - private func tapFirstExistingButton( - in app: XCUIApplication, - titles: [String], - timeout: TimeInterval - ) { - let candidates = titles.flatMap { title in - [ - app.buttons[title], - app.webViews.buttons[title], - ] - } + [app.descendants(matching: .button).firstMatch] - - let button = firstExistingElement(from: candidates, timeout: timeout) - XCTAssertTrue(button.exists, "Expected one of \(titles.joined(separator: ", ")) to appear") - button.tap() - } - - private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) { - focus(focusedField) - focusedField.typeText("\n") - if waitForAny( - [ - { !focusedField.exists }, - { !app.staticTexts["Burrow Tailnet Authentication"].exists }, - ], - timeout: 1.5 - ) { - return - } - - let keyboard = app.keyboards.firstMatch - if keyboard.waitForExistence(timeout: 2) { - let keyboardCandidates = [ - "Return", - "return", - "Go", - "go", - "Continue", - "continue", - "Done", - "done", - "Join", - "join", - "Sign In", - "Log In", - "Login", - ] - for title in keyboardCandidates { - let key = keyboard.buttons[title] - if key.exists && key.isHittable { - key.tap() - return - } - } - - if let lastKey = keyboard.buttons.allElementsBoundByIndex.last, - lastKey.exists, - lastKey.isHittable - { - lastKey.tap() - return - } - } - - tapFirstExistingButton( - in: app, - titles: ["Continue", "Sign In", "Log in", "Login"], - timeout: 5 - ) - } - - private func loadTestConfig() throws -> TestConfig { - let environment = ProcessInfo.processInfo.environment - if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"), - let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD") - { - return TestConfig( - email: email, - username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email, - password: password, - mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE") - .flatMap(TailnetLoginMode.init(rawValue:)) - ) - } - - let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json" - let configURL = URL(fileURLWithPath: configPath) - guard FileManager.default.fileExists(atPath: configURL.path) else { - throw XCTSkip( - "Missing UI test configuration. Expected env vars or config file at \(configURL.path)" - ) - } - - let data = try Data(contentsOf: configURL) - return try JSONDecoder().decode(TestConfig.self, from: data) - } - - private func nonEmptyEnvironment(_ key: String) -> String? { - guard let value = ProcessInfo.processInfo.environment[key]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty - else { - return nil - } - return value - } - - private func waitForFieldValue( - _ field: XCUIElement, - containing substring: String, - timeout: TimeInterval - ) -> Bool { - let predicate = NSPredicate(format: "value CONTAINS %@", substring) - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: field) - return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed - } - - private func waitForButtonLabel( - _ button: XCUIElement, - equals expected: String, - timeout: TimeInterval - ) -> Bool { - let predicate = NSPredicate(format: "label == %@", expected) - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button) - return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed - } - - private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool { - let button = app.buttons["tailnet-start-sign-in"] - let deadline = Date().addingTimeInterval(timeout) - - repeat { - acceptAuthenticationPromptIfNeeded(in: app, timeout: 1) - if button.exists, button.label == "Signed In" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.3)) - } while Date() < deadline - - return button.exists && button.label == "Signed In" - } - - private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - repeat { - if conditions.contains(where: { $0() }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - } while Date() < deadline - return conditions.contains(where: { $0() }) - } - - private func firstExistingElement( - in app: XCUIApplication, - queries: [(XCUIApplication) -> XCUIElement], - timeout: TimeInterval - ) -> XCUIElement { - firstExistingElement(from: queries.map { $0(app) }, timeout: timeout) - } - - private func firstExistingElement(from candidates: [XCUIElement], timeout: TimeInterval) -> XCUIElement { - let deadline = Date().addingTimeInterval(timeout) - repeat { - for candidate in candidates where candidate.exists { - return candidate - } - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - } while Date() < deadline - - return candidates[0] - } - - private func replaceText(in element: XCUIElement, with value: String) { - focus(element) - clearText(in: element) - element.typeText(value) - } - - private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) { - UIPasteboard.general.string = value - focus(element) - for revealMenu in [ - { element.doubleTap() }, - { element.press(forDuration: 1.2) }, - ] { - revealMenu() - let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3) - if pasteButton.exists { - pasteButton.tap() - return - } - } - - focus(element) - element.typeText(value) - } - - private func clearText(in element: XCUIElement) { - guard let currentValue = element.value as? String, !currentValue.isEmpty else { - return - } - - let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) - element.typeText(deleteSequence) - } - - private func focus(_ element: XCUIElement) { - element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - RunLoop.current.run(until: Date().addingTimeInterval(0.3)) - } - - private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] { - let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"] - return pasteLabels.flatMap { label in - [ - app.menuItems[label], - app.buttons[label], - app.webViews.buttons[label], - app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch, - app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch, - ] - } - } -} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 71de427..617b88f 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; - D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.swift */; }; D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; }; @@ -17,7 +16,6 @@ D03383B02C8E67E300F7C44E /* NIOTransportServices in Frameworks */ = {isa = PBXBuildFile; productRef = D044EE952C8DAB2800778185 /* NIOTransportServices */; }; D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */; }; D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; platformFilters = (macos, ); }; - D0A11C102F90000100112233 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = D0A11C0F2F90000100112233 /* DequeModule */; }; D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D0B1D10F2C436152004B7823 /* AsyncAlgorithms */; }; D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; @@ -25,6 +23,7 @@ D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49A2C8D921A007F820A /* Logging.swift */; }; D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; + D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49D2C8D921A007F820A /* HackClub.swift */; }; D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49E2C8D921A007F820A /* Network.swift */; }; D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; }; D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A22C8D921A007F820A /* BurrowView.swift */; }; @@ -34,10 +33,10 @@ D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */; }; D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; }; D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A82C8D921A007F820A /* NetworkView.swift */; }; + D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A92C8D921A007F820A /* OAuth2.swift */; }; D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; }; D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; }; D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */; }; - D0D4E5812C8D9C94007F820A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0D4E4A12C8D921A007F820A /* Assets.xcassets */; }; D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; }; D0D4E58A2C8D9C9E007F820A /* BurrowUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D0D4E58B2C8D9CA4007F820A /* BurrowConfiguration.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -45,21 +44,13 @@ D0D4E5A62C8D9E65007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; D0F7594E2C8DAB6B00126CF3 /* GRPC in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E02C8DA375008A8CEC /* GRPC */; }; - D0FA10012D10200100112233 /* burrow.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10032D10200100112233 /* burrow.pb.swift */; }; - D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10042D10200100112233 /* burrow.grpc.swift */; }; + D0F759612C8DB24B00126CF3 /* grpc-swift-config.json in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4962C8D921A007F820A /* grpc-swift-config.json */; }; + D0F759622C8DB24B00126CF3 /* swift-protobuf-config.json in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */; }; D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; }; D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; }; - D0C0DE102FA0000100112233 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; platformFilters = (macos, ); productRef = D0C0DE122FA0000100112233 /* Sparkle */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - D11000022F70000100112233 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; - remoteInfo = App; - }; D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; @@ -141,9 +132,6 @@ /* Begin PBXFileReference section */ D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - D11000032F70000100112233 /* BurrowUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BurrowUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - D11000042F70000100112233 /* BurrowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowUITests.swift; sourceTree = ""; }; - D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -168,8 +156,11 @@ D0BCC6032A09535900AD070D /* libburrow.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libburrow.a; sourceTree = BUILT_PRODUCTS_DIR; }; D0BF09582C8E6789000D8DEC /* UI.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UI.xcconfig; sourceTree = ""; }; D0D4E4952C8D921A007F820A /* burrow.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = burrow.proto; sourceTree = ""; }; + D0D4E4962C8D921A007F820A /* grpc-swift-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "grpc-swift-config.json"; sourceTree = ""; }; + D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "swift-protobuf-config.json"; sourceTree = ""; }; D0D4E4992C8D921A007F820A /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; D0D4E49A2C8D921A007F820A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; + D0D4E49D2C8D921A007F820A /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = ""; }; D0D4E49E2C8D921A007F820A /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; D0D4E49F2C8D921A007F820A /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = ""; }; D0D4E4A12C8D921A007F820A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -180,6 +171,7 @@ D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = ""; }; D0D4E4A82C8D921A007F820A /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = ""; }; + D0D4E4A92C8D921A007F820A /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = ""; }; D0D4E4AA2C8D921A007F820A /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = ""; }; D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = ""; }; @@ -191,18 +183,9 @@ D0D4E58E2C8D9D0A007F820A /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; D0D4E58F2C8D9D0A007F820A /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D0D4E5902C8D9D0A007F820A /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; - D0FA10032D10200100112233 /* burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = ""; }; - D0FA10042D10200100112233 /* burrow.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.grpc.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - D11000062F70000100112233 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F65029E4A697002790F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -221,7 +204,6 @@ D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */, D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */, D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */, - D0C0DE102FA0000100112233 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,7 +224,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D0A11C102F90000100112233 /* DequeModule in Frameworks */, D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -266,7 +247,6 @@ D0D4E4F72C8D941D007F820A /* Framework.xcconfig */, D020F64029E4A1FF002790F6 /* Compiler.xcconfig */, D0D4E4F62C8D932D007F820A /* Debug.xcconfig */, - D11000052F70000100112233 /* UITests.xcconfig */, D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */, D020F64229E4A1FF002790F6 /* Info.plist */, D0D4E5912C8D9D0A007F820A /* Constants */, @@ -292,7 +272,6 @@ isa = PBXGroup; children = ( D05B9F7429E39EEC008CB1F9 /* App */, - D11000072F70000100112233 /* AppUITests */, D020F65629E4A697002790F6 /* NetworkExtension */, D0D4E49C2C8D921A007F820A /* Core */, D0D4E4AD2C8D921A007F820A /* UI */, @@ -306,7 +285,6 @@ isa = PBXGroup; children = ( D05B9F7229E39EEC008CB1F9 /* Burrow.app */, - D11000032F70000100112233 /* BurrowUITests.xctest */, D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */, D0BCC6032A09535900AD070D /* libburrow.a */, D0D4E5312C8D996F007F820A /* BurrowCore.framework */, @@ -329,14 +307,6 @@ path = App; sourceTree = ""; }; - D11000072F70000100112233 /* AppUITests */ = { - isa = PBXGroup; - children = ( - D11000042F70000100112233 /* BurrowUITests.swift */, - ); - path = AppUITests; - sourceTree = ""; - }; D0B98FD729FDDB57004E7149 /* libburrow */ = { isa = PBXGroup; children = ( @@ -351,8 +321,8 @@ isa = PBXGroup; children = ( D0D4E4952C8D921A007F820A /* burrow.proto */, - D0FA10032D10200100112233 /* burrow.pb.swift */, - D0FA10042D10200100112233 /* burrow.grpc.swift */, + D0D4E4962C8D921A007F820A /* grpc-swift-config.json */, + D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */, ); path = Client; sourceTree = ""; @@ -370,6 +340,7 @@ D0D4E4A02C8D921A007F820A /* Networks */ = { isa = PBXGroup; children = ( + D0D4E49D2C8D921A007F820A /* HackClub.swift */, D0D4E49E2C8D921A007F820A /* Network.swift */, D0D4E49F2C8D921A007F820A /* WireGuard.swift */, ); @@ -387,6 +358,7 @@ D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */, D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */, D0D4E4A82C8D921A007F820A /* NetworkView.swift */, + D0D4E4A92C8D921A007F820A /* OAuth2.swift */, D0D4E4AA2C8D921A007F820A /* Tunnel.swift */, D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */, D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */, @@ -409,24 +381,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - D11000082F70000100112233 /* BurrowUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */; - buildPhases = ( - D110000A2F70000100112233 /* Sources */, - D11000062F70000100112233 /* Frameworks */, - D11000092F70000100112233 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - D110000B2F70000100112233 /* PBXTargetDependency */, - ); - name = BurrowUITests; - productName = BurrowUITests; - productReference = D11000032F70000100112233 /* BurrowUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; D020F65229E4A697002790F6 /* NetworkExtension */ = { isa = PBXNativeTarget; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; @@ -465,9 +419,6 @@ D020F65C29E4A697002790F6 /* PBXTargetDependency */, ); name = App; - packageProductDependencies = ( - D0C0DE122FA0000100112233 /* Sparkle */, - ); productName = Burrow; productReference = D05B9F7229E39EEC008CB1F9 /* Burrow.app */; productType = "com.apple.product-type.application"; @@ -483,6 +434,8 @@ ); dependencies = ( D0F7598A2C8DB34200126CF3 /* PBXTargetDependency */, + D0F7595E2C8DB24400126CF3 /* PBXTargetDependency */, + D0F759602C8DB24400126CF3 /* PBXTargetDependency */, ); name = Core; packageProductDependencies = ( @@ -512,7 +465,6 @@ ); name = UI; packageProductDependencies = ( - D0A11C0F2F90000100112233 /* DequeModule */, ); productName = Core; productReference = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; @@ -546,10 +498,6 @@ LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { - D11000082F70000100112233 = { - CreatedOnToolsVersion = 16.0; - TestTargetID = D05B9F7129E39EEC008CB1F9; - }; D020F65229E4A697002790F6 = { CreatedOnToolsVersion = 14.3; }; @@ -576,15 +524,12 @@ D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */, D044EE8F2C8DAB2000778185 /* XCRemoteSwiftPackageReference "swift-nio" */, D044EE942C8DAB2800778185 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */, - D0A11C0D2F90000100112233 /* XCRemoteSwiftPackageReference "swift-collections" */, - D0C0DE112FA0000100112233 /* XCRemoteSwiftPackageReference "Sparkle" */, ); productRefGroup = D05B9F7329E39EEC008CB1F9 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( D05B9F7129E39EEC008CB1F9 /* App */, - D11000082F70000100112233 /* BurrowUITests */, D020F65229E4A697002790F6 /* NetworkExtension */, D0D4E5502C8D9BF2007F820A /* UI */, D0D4E5302C8D996F007F820A /* Core */, @@ -594,19 +539,11 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - D11000092F70000100112233 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D05B9F7029E39EEC008CB1F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */, - D0D4E5812C8D9C94007F820A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -665,14 +602,6 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - D110000A2F70000100112233 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D11000012F70000100112233 /* BurrowUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F64F29E4A697002790F6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -694,8 +623,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0FA10012D10200100112233 /* burrow.pb.swift in Sources */, - D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */, + D0F759612C8DB24B00126CF3 /* grpc-swift-config.json in Sources */, + D0F759622C8DB24B00126CF3 /* swift-protobuf-config.json in Sources */, D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */, D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */, ); @@ -705,6 +634,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */, D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */, D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */, D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */, @@ -714,6 +644,7 @@ D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */, D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */, D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */, + D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */, D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */, D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */, D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */, @@ -731,11 +662,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - D110000B2F70000100112233 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D05B9F7129E39EEC008CB1F9 /* App */; - targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; - }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; @@ -771,6 +697,14 @@ target = D0D4E5302C8D996F007F820A /* Core */; targetProxy = D0F4FAD12C8DC7960068730A /* PBXContainerItemProxy */; }; + D0F7595E2C8DB24400126CF3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = D0F7595D2C8DB24400126CF3 /* GRPCSwiftPlugin */; + }; + D0F759602C8DB24400126CF3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = D0F7595F2C8DB24400126CF3 /* SwiftProtobufPlugin */; + }; D0F7598A2C8DB34200126CF3 /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = D0F759892C8DB34200126CF3 /* GRPC */; @@ -778,20 +712,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - D110000C2F70000100112233 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; - buildSettings = { - }; - name = Debug; - }; - D110000D2F70000100112233 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; - buildSettings = { - }; - name = Release; - }; D020F65F29E4A697002790F6 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; @@ -879,15 +799,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D110000C2F70000100112233 /* Debug */, - D110000D2F70000100112233 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -961,14 +872,6 @@ minimumVersion = 1.21.0; }; }; - D0A11C0D2F90000100112233 /* XCRemoteSwiftPackageReference "swift-collections" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-collections.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.3; - }; - }; D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; @@ -977,14 +880,6 @@ minimumVersion = 1.0.1; }; }; - D0C0DE112FA0000100112233 /* XCRemoteSwiftPackageReference "Sparkle" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.9.2; - }; - }; D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/grpc/grpc-swift.git"; @@ -1029,20 +924,20 @@ package = D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */; productName = SwiftProtobuf; }; - D0A11C0F2F90000100112233 /* DequeModule */ = { - isa = XCSwiftPackageProductDependency; - package = D0A11C0D2F90000100112233 /* XCRemoteSwiftPackageReference "swift-collections" */; - productName = DequeModule; - }; D0B1D10F2C436152004B7823 /* AsyncAlgorithms */ = { isa = XCSwiftPackageProductDependency; package = D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; productName = AsyncAlgorithms; }; - D0C0DE122FA0000100112233 /* Sparkle */ = { + D0F7595D2C8DB24400126CF3 /* GRPCSwiftPlugin */ = { isa = XCSwiftPackageProductDependency; - package = D0C0DE112FA0000100112233 /* XCRemoteSwiftPackageReference "Sparkle" */; - productName = Sparkle; + package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; + productName = "plugin:GRPCSwiftPlugin"; + }; + D0F7595F2C8DB24400126CF3 /* SwiftProtobufPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */; + productName = "plugin:SwiftProtobufPlugin"; }; D0F7597D2C8DB30500126CF3 /* CGRPCZlib */ = { isa = XCSwiftPackageProductDependency; diff --git a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a633fc0..739b77c 100644 --- a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5d81b59cbecacd7aae6aa598b32e3513c636250dadab9280a55b905bb0e0ee60", + "originHash" : "fa512b990383b7e309c5854a5279817052294a8191a6d3c55c49cfb38e88c0c3", "pins" : [ { "identity" : "grpc-swift", @@ -10,15 +10,6 @@ "version" : "1.23.0" } }, - { - "identity" : "sparkle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle.git", - "state" : { - "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", - "version" : "2.9.2" - } - }, { "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", diff --git a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme index f580ea7..a524e87 100644 --- a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -28,20 +28,7 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "NO"> - - - - - - + shouldAutocreateTestPlan = "YES"> = { switch FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) { case .some(let url): .success(url) - case .none: - fallbackContainerURL().mapError { _ in .invalidAppGroupIdentifier } + case .none: .failure(.invalidAppGroupIdentifier) } }() - - private static func fallbackContainerURL() -> Result { -#if targetEnvironment(simulator) - Result { - // The simulator app's Application Support path lives inside its sandbox container, - // so the host daemon cannot reach it. Use a shared host temp location instead. - let url = URL(filePath: "/tmp", directoryHint: .isDirectory) - .appending(component: bundleIdentifier, directoryHint: .isDirectory) - .appending(component: "SimulatorFallback", directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } -#else - .failure(Error.invalidAppGroupIdentifier) -#endif - } } extension Logger { diff --git a/Apple/Configuration/Info.plist b/Apple/Configuration/Info.plist index 519d374..7632dad 100644 --- a/Apple/Configuration/Info.plist +++ b/Apple/Configuration/Info.plist @@ -4,9 +4,5 @@ CFBundleName $(INFOPLIST_KEY_CFBundleDisplayName) - SUFeedURL - $(BURROW_SPARKLE_FEED_URL) - SUPublicEDKey - $(BURROW_SPARKLE_PUBLIC_ED_KEY) diff --git a/Apple/Configuration/UITests.xcconfig b/Apple/Configuration/UITests.xcconfig deleted file mode 100644 index a97e290..0000000 --- a/Apple/Configuration/UITests.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -#include "Compiler.xcconfig" - -SUPPORTED_PLATFORMS = iphonesimulator iphoneos -TARGETED_DEVICE_FAMILY[sdk=iphone*] = 1,2 - -PRODUCT_NAME = $(TARGET_NAME) -PRODUCT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).uitests - -STRING_CATALOG_GENERATE_SYMBOLS = NO -SWIFT_EMIT_LOC_STRINGS = NO - -ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES -LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks -TEST_TARGET_NAME = App diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index 7d4cfc7..8874e3b 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -1,7 +1,5 @@ -import Foundation import GRPC import NIOTransportServices -import SwiftProtobuf public typealias TunnelClient = Burrow_TunnelAsyncClient public typealias NetworksClient = Burrow_NetworksAsyncClient @@ -32,477 +30,3 @@ extension NetworksClient: Client { self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none) } } - -public struct Burrow_TailnetDiscoverRequest: Sendable { - public var email: String = "" - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TailnetDiscoverResponse: Sendable { - public var domain: String = "" - public var authority: String = "" - public var oidcIssuer: String = "" - public var managed: Bool = false - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TailnetProbeRequest: Sendable { - public var authority: String = "" - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TailnetProbeResponse: Sendable { - public var authority: String = "" - public var statusCode: Int32 = 0 - public var summary: String = "" - public var detail: String = "" - public var reachable: Bool = false - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TailnetLoginStartRequest: Sendable { - public var accountName: String = "" - public var identityName: String = "" - public var hostname: String = "" - public var authority: String = "" - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TailnetLoginStatusRequest: Sendable { - public var sessionID: String = "" - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TailnetLoginCancelRequest: Sendable { - public var sessionID: String = "" - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TailnetLoginStatusResponse: Sendable { - public var sessionID: String = "" - public var backendState: String = "" - public var authURL: String = "" - public var running: Bool = false - public var needsLogin: Bool = false - public var tailnetName: String = "" - public var magicDNSSuffix: String = "" - public var selfDNSName: String = "" - public var tailnetIPs: [String] = [] - public var health: [String] = [] - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TunnelPacket: Sendable { - public var payload = Data() - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "email") - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.email) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.email.isEmpty { - try visitor.visitSingularStringField(value: self.email, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TailnetDiscoverResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetDiscoverResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "domain"), - 2: .same(proto: "authority"), - 3: .same(proto: "oidc_issuer"), - 4: .same(proto: "managed"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.domain) - case 2: try decoder.decodeSingularStringField(value: &self.authority) - case 3: try decoder.decodeSingularStringField(value: &self.oidcIssuer) - case 4: try decoder.decodeSingularBoolField(value: &self.managed) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.domain.isEmpty { - try visitor.visitSingularStringField(value: self.domain, fieldNumber: 1) - } - if !self.authority.isEmpty { - try visitor.visitSingularStringField(value: self.authority, fieldNumber: 2) - } - if !self.oidcIssuer.isEmpty { - try visitor.visitSingularStringField(value: self.oidcIssuer, fieldNumber: 3) - } - if self.managed { - try visitor.visitSingularBoolField(value: self.managed, fieldNumber: 4) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TailnetProbeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetProbeRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "authority") - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.authority) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.authority.isEmpty { - try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetProbeResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "authority"), - 2: .same(proto: "status_code"), - 3: .same(proto: "summary"), - 4: .same(proto: "detail"), - 5: .same(proto: "reachable"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.authority) - case 2: try decoder.decodeSingularInt32Field(value: &self.statusCode) - case 3: try decoder.decodeSingularStringField(value: &self.summary) - case 4: try decoder.decodeSingularStringField(value: &self.detail) - case 5: try decoder.decodeSingularBoolField(value: &self.reachable) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.authority.isEmpty { - try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1) - } - if self.statusCode != 0 { - try visitor.visitSingularInt32Field(value: self.statusCode, fieldNumber: 2) - } - if !self.summary.isEmpty { - try visitor.visitSingularStringField(value: self.summary, fieldNumber: 3) - } - if !self.detail.isEmpty { - try visitor.visitSingularStringField(value: self.detail, fieldNumber: 4) - } - if self.reachable { - try visitor.visitSingularBoolField(value: self.reachable, fieldNumber: 5) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TailnetLoginStartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetLoginStartRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .standard(proto: "account_name"), - 2: .standard(proto: "identity_name"), - 3: .same(proto: "hostname"), - 4: .same(proto: "authority"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.accountName) - case 2: try decoder.decodeSingularStringField(value: &self.identityName) - case 3: try decoder.decodeSingularStringField(value: &self.hostname) - case 4: try decoder.decodeSingularStringField(value: &self.authority) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.accountName.isEmpty { - try visitor.visitSingularStringField(value: self.accountName, fieldNumber: 1) - } - if !self.identityName.isEmpty { - try visitor.visitSingularStringField(value: self.identityName, fieldNumber: 2) - } - if !self.hostname.isEmpty { - try visitor.visitSingularStringField(value: self.hostname, fieldNumber: 3) - } - if !self.authority.isEmpty { - try visitor.visitSingularStringField(value: self.authority, fieldNumber: 4) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TailnetLoginStatusRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetLoginStatusRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .standard(proto: "session_id") - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.sessionID) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.sessionID.isEmpty { - try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TailnetLoginCancelRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetLoginCancelRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .standard(proto: "session_id") - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.sessionID) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.sessionID.isEmpty { - try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TailnetLoginStatusResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .standard(proto: "session_id"), - 2: .standard(proto: "backend_state"), - 3: .standard(proto: "auth_url"), - 4: .same(proto: "running"), - 5: .standard(proto: "needs_login"), - 6: .standard(proto: "tailnet_name"), - 7: .standard(proto: "magic_dns_suffix"), - 8: .standard(proto: "self_dns_name"), - 9: .standard(proto: "tailnet_ips"), - 10: .same(proto: "health"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.sessionID) - case 2: try decoder.decodeSingularStringField(value: &self.backendState) - case 3: try decoder.decodeSingularStringField(value: &self.authURL) - case 4: try decoder.decodeSingularBoolField(value: &self.running) - case 5: try decoder.decodeSingularBoolField(value: &self.needsLogin) - case 6: try decoder.decodeSingularStringField(value: &self.tailnetName) - case 7: try decoder.decodeSingularStringField(value: &self.magicDNSSuffix) - case 8: try decoder.decodeSingularStringField(value: &self.selfDNSName) - case 9: try decoder.decodeRepeatedStringField(value: &self.tailnetIPs) - case 10: try decoder.decodeRepeatedStringField(value: &self.health) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.sessionID.isEmpty { - try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) - } - if !self.backendState.isEmpty { - try visitor.visitSingularStringField(value: self.backendState, fieldNumber: 2) - } - if !self.authURL.isEmpty { - try visitor.visitSingularStringField(value: self.authURL, fieldNumber: 3) - } - if self.running { - try visitor.visitSingularBoolField(value: self.running, fieldNumber: 4) - } - if self.needsLogin { - try visitor.visitSingularBoolField(value: self.needsLogin, fieldNumber: 5) - } - if !self.tailnetName.isEmpty { - try visitor.visitSingularStringField(value: self.tailnetName, fieldNumber: 6) - } - if !self.magicDNSSuffix.isEmpty { - try visitor.visitSingularStringField(value: self.magicDNSSuffix, fieldNumber: 7) - } - if !self.selfDNSName.isEmpty { - try visitor.visitSingularStringField(value: self.selfDNSName, fieldNumber: 8) - } - if !self.tailnetIPs.isEmpty { - try visitor.visitRepeatedStringField(value: self.tailnetIPs, fieldNumber: 9) - } - if !self.health.isEmpty { - try visitor.visitRepeatedStringField(value: self.health, fieldNumber: 10) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.TunnelPacket" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "payload") - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularBytesField(value: &self.payload) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.payload.isEmpty { - try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -public struct TailnetClient: Client, GRPCClient { - public let channel: GRPCChannel - public var defaultCallOptions: CallOptions - - public init(channel: any GRPCChannel) { - self.channel = channel - self.defaultCallOptions = .init() - } - - public func discover( - _ request: Burrow_TailnetDiscoverRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_TailnetDiscoverResponse { - try await self.performAsyncUnaryCall( - path: "/burrow.TailnetControl/Discover", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } - - public func probe( - _ request: Burrow_TailnetProbeRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_TailnetProbeResponse { - try await self.performAsyncUnaryCall( - path: "/burrow.TailnetControl/Probe", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } - - public func loginStart( - _ request: Burrow_TailnetLoginStartRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_TailnetLoginStatusResponse { - try await self.performAsyncUnaryCall( - path: "/burrow.TailnetControl/LoginStart", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } - - public func loginStatus( - _ request: Burrow_TailnetLoginStatusRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_TailnetLoginStatusResponse { - try await self.performAsyncUnaryCall( - path: "/burrow.TailnetControl/LoginStatus", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } - - public func loginCancel( - _ request: Burrow_TailnetLoginCancelRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_Empty { - try await self.performAsyncUnaryCall( - path: "/burrow.TailnetControl/LoginCancel", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } -} - -public struct TunnelPacketClient: Client, GRPCClient { - public let channel: GRPCChannel - public var defaultCallOptions: CallOptions - - public init(channel: any GRPCChannel) { - self.channel = channel - self.defaultCallOptions = .init() - } - - public func makeTunnelPacketsCall( - callOptions: CallOptions? = nil - ) -> GRPCAsyncBidirectionalStreamingCall { - self.makeAsyncBidirectionalStreamingCall( - path: "/burrow.Tunnel/TunnelPackets", - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } -} diff --git a/Apple/Core/Client/Generated/burrow.grpc.swift b/Apple/Core/Client/Generated/burrow.grpc.swift deleted file mode 100644 index d1f848c..0000000 --- a/Apple/Core/Client/Generated/burrow.grpc.swift +++ /dev/null @@ -1,761 +0,0 @@ -// -// DO NOT EDIT. -// swift-format-ignore-file -// -// Generated by the protocol buffer compiler. -// Source: burrow.proto -// -import GRPC -import NIO -import NIOConcurrencyHelpers -import SwiftProtobuf - - -/// Usage: instantiate `Burrow_TunnelClient`, then call methods of this protocol to make API calls. -public protocol Burrow_TunnelClientProtocol: GRPCClient { - var serviceName: String { get } - var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { get } - - func tunnelConfiguration( - _ request: Burrow_Empty, - callOptions: CallOptions?, - handler: @escaping (Burrow_TunnelConfigurationResponse) -> Void - ) -> ServerStreamingCall - - func tunnelStart( - _ request: Burrow_Empty, - callOptions: CallOptions? - ) -> UnaryCall - - func tunnelStop( - _ request: Burrow_Empty, - callOptions: CallOptions? - ) -> UnaryCall - - func tunnelStatus( - _ request: Burrow_Empty, - callOptions: CallOptions?, - handler: @escaping (Burrow_TunnelStatusResponse) -> Void - ) -> ServerStreamingCall -} - -extension Burrow_TunnelClientProtocol { - public var serviceName: String { - return "burrow.Tunnel" - } - - /// Server streaming call to TunnelConfiguration - /// - /// - Parameters: - /// - request: Request to send to TunnelConfiguration. - /// - callOptions: Call options. - /// - handler: A closure called when each response is received from the server. - /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. - public func tunnelConfiguration( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil, - handler: @escaping (Burrow_TunnelConfigurationResponse) -> Void - ) -> ServerStreamingCall { - return self.makeServerStreamingCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelConfiguration.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelConfigurationInterceptors() ?? [], - handler: handler - ) - } - - /// Unary call to TunnelStart - /// - /// - Parameters: - /// - request: Request to send to TunnelStart. - /// - callOptions: Call options. - /// - Returns: A `UnaryCall` with futures for the metadata, status and response. - public func tunnelStart( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> UnaryCall { - return self.makeUnaryCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStart.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStartInterceptors() ?? [] - ) - } - - /// Unary call to TunnelStop - /// - /// - Parameters: - /// - request: Request to send to TunnelStop. - /// - callOptions: Call options. - /// - Returns: A `UnaryCall` with futures for the metadata, status and response. - public func tunnelStop( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> UnaryCall { - return self.makeUnaryCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStop.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStopInterceptors() ?? [] - ) - } - - /// Server streaming call to TunnelStatus - /// - /// - Parameters: - /// - request: Request to send to TunnelStatus. - /// - callOptions: Call options. - /// - handler: A closure called when each response is received from the server. - /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. - public func tunnelStatus( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil, - handler: @escaping (Burrow_TunnelStatusResponse) -> Void - ) -> ServerStreamingCall { - return self.makeServerStreamingCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStatus.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStatusInterceptors() ?? [], - handler: handler - ) - } -} - -@available(*, deprecated) -extension Burrow_TunnelClient: @unchecked Sendable {} - -@available(*, deprecated, renamed: "Burrow_TunnelNIOClient") -public final class Burrow_TunnelClient: Burrow_TunnelClientProtocol { - private let lock = Lock() - private var _defaultCallOptions: CallOptions - private var _interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? - public let channel: GRPCChannel - public var defaultCallOptions: CallOptions { - get { self.lock.withLock { return self._defaultCallOptions } } - set { self.lock.withLockVoid { self._defaultCallOptions = newValue } } - } - public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { - get { self.lock.withLock { return self._interceptors } } - set { self.lock.withLockVoid { self._interceptors = newValue } } - } - - /// Creates a client for the burrow.Tunnel service. - /// - /// - Parameters: - /// - channel: `GRPCChannel` to the service host. - /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. - /// - interceptors: A factory providing interceptors for each RPC. - public init( - channel: GRPCChannel, - defaultCallOptions: CallOptions = CallOptions(), - interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? = nil - ) { - self.channel = channel - self._defaultCallOptions = defaultCallOptions - self._interceptors = interceptors - } -} - -public struct Burrow_TunnelNIOClient: Burrow_TunnelClientProtocol { - public var channel: GRPCChannel - public var defaultCallOptions: CallOptions - public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? - - /// Creates a client for the burrow.Tunnel service. - /// - /// - Parameters: - /// - channel: `GRPCChannel` to the service host. - /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. - /// - interceptors: A factory providing interceptors for each RPC. - public init( - channel: GRPCChannel, - defaultCallOptions: CallOptions = CallOptions(), - interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? = nil - ) { - self.channel = channel - self.defaultCallOptions = defaultCallOptions - self.interceptors = interceptors - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -public protocol Burrow_TunnelAsyncClientProtocol: GRPCClient { - static var serviceDescriptor: GRPCServiceDescriptor { get } - var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { get } - - func makeTunnelConfigurationCall( - _ request: Burrow_Empty, - callOptions: CallOptions? - ) -> GRPCAsyncServerStreamingCall - - func makeTunnelStartCall( - _ request: Burrow_Empty, - callOptions: CallOptions? - ) -> GRPCAsyncUnaryCall - - func makeTunnelStopCall( - _ request: Burrow_Empty, - callOptions: CallOptions? - ) -> GRPCAsyncUnaryCall - - func makeTunnelStatusCall( - _ request: Burrow_Empty, - callOptions: CallOptions? - ) -> GRPCAsyncServerStreamingCall -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension Burrow_TunnelAsyncClientProtocol { - public static var serviceDescriptor: GRPCServiceDescriptor { - return Burrow_TunnelClientMetadata.serviceDescriptor - } - - public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { - return nil - } - - public func makeTunnelConfigurationCall( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncServerStreamingCall { - return self.makeAsyncServerStreamingCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelConfiguration.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelConfigurationInterceptors() ?? [] - ) - } - - public func makeTunnelStartCall( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncUnaryCall { - return self.makeAsyncUnaryCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStart.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStartInterceptors() ?? [] - ) - } - - public func makeTunnelStopCall( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncUnaryCall { - return self.makeAsyncUnaryCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStop.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStopInterceptors() ?? [] - ) - } - - public func makeTunnelStatusCall( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncServerStreamingCall { - return self.makeAsyncServerStreamingCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStatus.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStatusInterceptors() ?? [] - ) - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension Burrow_TunnelAsyncClientProtocol { - public func tunnelConfiguration( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncResponseStream { - return self.performAsyncServerStreamingCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelConfiguration.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelConfigurationInterceptors() ?? [] - ) - } - - public func tunnelStart( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) async throws -> Burrow_Empty { - return try await self.performAsyncUnaryCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStart.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStartInterceptors() ?? [] - ) - } - - public func tunnelStop( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) async throws -> Burrow_Empty { - return try await self.performAsyncUnaryCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStop.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStopInterceptors() ?? [] - ) - } - - public func tunnelStatus( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncResponseStream { - return self.performAsyncServerStreamingCall( - path: Burrow_TunnelClientMetadata.Methods.tunnelStatus.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeTunnelStatusInterceptors() ?? [] - ) - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -public struct Burrow_TunnelAsyncClient: Burrow_TunnelAsyncClientProtocol { - public var channel: GRPCChannel - public var defaultCallOptions: CallOptions - public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? - - public init( - channel: GRPCChannel, - defaultCallOptions: CallOptions = CallOptions(), - interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? = nil - ) { - self.channel = channel - self.defaultCallOptions = defaultCallOptions - self.interceptors = interceptors - } -} - -public protocol Burrow_TunnelClientInterceptorFactoryProtocol: Sendable { - - /// - Returns: Interceptors to use when invoking 'tunnelConfiguration'. - func makeTunnelConfigurationInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'tunnelStart'. - func makeTunnelStartInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'tunnelStop'. - func makeTunnelStopInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'tunnelStatus'. - func makeTunnelStatusInterceptors() -> [ClientInterceptor] -} - -public enum Burrow_TunnelClientMetadata { - public static let serviceDescriptor = GRPCServiceDescriptor( - name: "Tunnel", - fullName: "burrow.Tunnel", - methods: [ - Burrow_TunnelClientMetadata.Methods.tunnelConfiguration, - Burrow_TunnelClientMetadata.Methods.tunnelStart, - Burrow_TunnelClientMetadata.Methods.tunnelStop, - Burrow_TunnelClientMetadata.Methods.tunnelStatus, - ] - ) - - public enum Methods { - public static let tunnelConfiguration = GRPCMethodDescriptor( - name: "TunnelConfiguration", - path: "/burrow.Tunnel/TunnelConfiguration", - type: GRPCCallType.serverStreaming - ) - - public static let tunnelStart = GRPCMethodDescriptor( - name: "TunnelStart", - path: "/burrow.Tunnel/TunnelStart", - type: GRPCCallType.unary - ) - - public static let tunnelStop = GRPCMethodDescriptor( - name: "TunnelStop", - path: "/burrow.Tunnel/TunnelStop", - type: GRPCCallType.unary - ) - - public static let tunnelStatus = GRPCMethodDescriptor( - name: "TunnelStatus", - path: "/burrow.Tunnel/TunnelStatus", - type: GRPCCallType.serverStreaming - ) - } -} - -/// Usage: instantiate `Burrow_NetworksClient`, then call methods of this protocol to make API calls. -public protocol Burrow_NetworksClientProtocol: GRPCClient { - var serviceName: String { get } - var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { get } - - func networkAdd( - _ request: Burrow_Network, - callOptions: CallOptions? - ) -> UnaryCall - - func networkList( - _ request: Burrow_Empty, - callOptions: CallOptions?, - handler: @escaping (Burrow_NetworkListResponse) -> Void - ) -> ServerStreamingCall - - func networkReorder( - _ request: Burrow_NetworkReorderRequest, - callOptions: CallOptions? - ) -> UnaryCall - - func networkDelete( - _ request: Burrow_NetworkDeleteRequest, - callOptions: CallOptions? - ) -> UnaryCall -} - -extension Burrow_NetworksClientProtocol { - public var serviceName: String { - return "burrow.Networks" - } - - /// Unary call to NetworkAdd - /// - /// - Parameters: - /// - request: Request to send to NetworkAdd. - /// - callOptions: Call options. - /// - Returns: A `UnaryCall` with futures for the metadata, status and response. - public func networkAdd( - _ request: Burrow_Network, - callOptions: CallOptions? = nil - ) -> UnaryCall { - return self.makeUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkAdd.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkAddInterceptors() ?? [] - ) - } - - /// Server streaming call to NetworkList - /// - /// - Parameters: - /// - request: Request to send to NetworkList. - /// - callOptions: Call options. - /// - handler: A closure called when each response is received from the server. - /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. - public func networkList( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil, - handler: @escaping (Burrow_NetworkListResponse) -> Void - ) -> ServerStreamingCall { - return self.makeServerStreamingCall( - path: Burrow_NetworksClientMetadata.Methods.networkList.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkListInterceptors() ?? [], - handler: handler - ) - } - - /// Unary call to NetworkReorder - /// - /// - Parameters: - /// - request: Request to send to NetworkReorder. - /// - callOptions: Call options. - /// - Returns: A `UnaryCall` with futures for the metadata, status and response. - public func networkReorder( - _ request: Burrow_NetworkReorderRequest, - callOptions: CallOptions? = nil - ) -> UnaryCall { - return self.makeUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkReorder.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkReorderInterceptors() ?? [] - ) - } - - /// Unary call to NetworkDelete - /// - /// - Parameters: - /// - request: Request to send to NetworkDelete. - /// - callOptions: Call options. - /// - Returns: A `UnaryCall` with futures for the metadata, status and response. - public func networkDelete( - _ request: Burrow_NetworkDeleteRequest, - callOptions: CallOptions? = nil - ) -> UnaryCall { - return self.makeUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkDelete.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkDeleteInterceptors() ?? [] - ) - } -} - -@available(*, deprecated) -extension Burrow_NetworksClient: @unchecked Sendable {} - -@available(*, deprecated, renamed: "Burrow_NetworksNIOClient") -public final class Burrow_NetworksClient: Burrow_NetworksClientProtocol { - private let lock = Lock() - private var _defaultCallOptions: CallOptions - private var _interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? - public let channel: GRPCChannel - public var defaultCallOptions: CallOptions { - get { self.lock.withLock { return self._defaultCallOptions } } - set { self.lock.withLockVoid { self._defaultCallOptions = newValue } } - } - public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { - get { self.lock.withLock { return self._interceptors } } - set { self.lock.withLockVoid { self._interceptors = newValue } } - } - - /// Creates a client for the burrow.Networks service. - /// - /// - Parameters: - /// - channel: `GRPCChannel` to the service host. - /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. - /// - interceptors: A factory providing interceptors for each RPC. - public init( - channel: GRPCChannel, - defaultCallOptions: CallOptions = CallOptions(), - interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? = nil - ) { - self.channel = channel - self._defaultCallOptions = defaultCallOptions - self._interceptors = interceptors - } -} - -public struct Burrow_NetworksNIOClient: Burrow_NetworksClientProtocol { - public var channel: GRPCChannel - public var defaultCallOptions: CallOptions - public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? - - /// Creates a client for the burrow.Networks service. - /// - /// - Parameters: - /// - channel: `GRPCChannel` to the service host. - /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. - /// - interceptors: A factory providing interceptors for each RPC. - public init( - channel: GRPCChannel, - defaultCallOptions: CallOptions = CallOptions(), - interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? = nil - ) { - self.channel = channel - self.defaultCallOptions = defaultCallOptions - self.interceptors = interceptors - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -public protocol Burrow_NetworksAsyncClientProtocol: GRPCClient { - static var serviceDescriptor: GRPCServiceDescriptor { get } - var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { get } - - func makeNetworkAddCall( - _ request: Burrow_Network, - callOptions: CallOptions? - ) -> GRPCAsyncUnaryCall - - func makeNetworkListCall( - _ request: Burrow_Empty, - callOptions: CallOptions? - ) -> GRPCAsyncServerStreamingCall - - func makeNetworkReorderCall( - _ request: Burrow_NetworkReorderRequest, - callOptions: CallOptions? - ) -> GRPCAsyncUnaryCall - - func makeNetworkDeleteCall( - _ request: Burrow_NetworkDeleteRequest, - callOptions: CallOptions? - ) -> GRPCAsyncUnaryCall -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension Burrow_NetworksAsyncClientProtocol { - public static var serviceDescriptor: GRPCServiceDescriptor { - return Burrow_NetworksClientMetadata.serviceDescriptor - } - - public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { - return nil - } - - public func makeNetworkAddCall( - _ request: Burrow_Network, - callOptions: CallOptions? = nil - ) -> GRPCAsyncUnaryCall { - return self.makeAsyncUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkAdd.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkAddInterceptors() ?? [] - ) - } - - public func makeNetworkListCall( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncServerStreamingCall { - return self.makeAsyncServerStreamingCall( - path: Burrow_NetworksClientMetadata.Methods.networkList.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkListInterceptors() ?? [] - ) - } - - public func makeNetworkReorderCall( - _ request: Burrow_NetworkReorderRequest, - callOptions: CallOptions? = nil - ) -> GRPCAsyncUnaryCall { - return self.makeAsyncUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkReorder.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkReorderInterceptors() ?? [] - ) - } - - public func makeNetworkDeleteCall( - _ request: Burrow_NetworkDeleteRequest, - callOptions: CallOptions? = nil - ) -> GRPCAsyncUnaryCall { - return self.makeAsyncUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkDelete.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkDeleteInterceptors() ?? [] - ) - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension Burrow_NetworksAsyncClientProtocol { - public func networkAdd( - _ request: Burrow_Network, - callOptions: CallOptions? = nil - ) async throws -> Burrow_Empty { - return try await self.performAsyncUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkAdd.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkAddInterceptors() ?? [] - ) - } - - public func networkList( - _ request: Burrow_Empty, - callOptions: CallOptions? = nil - ) -> GRPCAsyncResponseStream { - return self.performAsyncServerStreamingCall( - path: Burrow_NetworksClientMetadata.Methods.networkList.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkListInterceptors() ?? [] - ) - } - - public func networkReorder( - _ request: Burrow_NetworkReorderRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_Empty { - return try await self.performAsyncUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkReorder.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkReorderInterceptors() ?? [] - ) - } - - public func networkDelete( - _ request: Burrow_NetworkDeleteRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_Empty { - return try await self.performAsyncUnaryCall( - path: Burrow_NetworksClientMetadata.Methods.networkDelete.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeNetworkDeleteInterceptors() ?? [] - ) - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -public struct Burrow_NetworksAsyncClient: Burrow_NetworksAsyncClientProtocol { - public var channel: GRPCChannel - public var defaultCallOptions: CallOptions - public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? - - public init( - channel: GRPCChannel, - defaultCallOptions: CallOptions = CallOptions(), - interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? = nil - ) { - self.channel = channel - self.defaultCallOptions = defaultCallOptions - self.interceptors = interceptors - } -} - -public protocol Burrow_NetworksClientInterceptorFactoryProtocol: Sendable { - - /// - Returns: Interceptors to use when invoking 'networkAdd'. - func makeNetworkAddInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'networkList'. - func makeNetworkListInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'networkReorder'. - func makeNetworkReorderInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'networkDelete'. - func makeNetworkDeleteInterceptors() -> [ClientInterceptor] -} - -public enum Burrow_NetworksClientMetadata { - public static let serviceDescriptor = GRPCServiceDescriptor( - name: "Networks", - fullName: "burrow.Networks", - methods: [ - Burrow_NetworksClientMetadata.Methods.networkAdd, - Burrow_NetworksClientMetadata.Methods.networkList, - Burrow_NetworksClientMetadata.Methods.networkReorder, - Burrow_NetworksClientMetadata.Methods.networkDelete, - ] - ) - - public enum Methods { - public static let networkAdd = GRPCMethodDescriptor( - name: "NetworkAdd", - path: "/burrow.Networks/NetworkAdd", - type: GRPCCallType.unary - ) - - public static let networkList = GRPCMethodDescriptor( - name: "NetworkList", - path: "/burrow.Networks/NetworkList", - type: GRPCCallType.serverStreaming - ) - - public static let networkReorder = GRPCMethodDescriptor( - name: "NetworkReorder", - path: "/burrow.Networks/NetworkReorder", - type: GRPCCallType.unary - ) - - public static let networkDelete = GRPCMethodDescriptor( - name: "NetworkDelete", - path: "/burrow.Networks/NetworkDelete", - type: GRPCCallType.unary - ) - } -} - diff --git a/Apple/Core/Client/Generated/burrow.pb.swift b/Apple/Core/Client/Generated/burrow.pb.swift deleted file mode 100644 index fccd769..0000000 --- a/Apple/Core/Client/Generated/burrow.pb.swift +++ /dev/null @@ -1,598 +0,0 @@ -// DO NOT EDIT. -// swift-format-ignore-file -// swiftlint:disable all -// -// Generated by the Swift generator plugin for the protocol buffer compiler. -// Source: burrow.proto -// -// For information on using the generated types, please see the documentation: -// https://github.com/apple/swift-protobuf/ - -import Foundation -import SwiftProtobuf - -// If the compiler emits an error on this type, it is because this file -// was generated by a version of the `protoc` Swift plug-in that is -// incompatible with the version of SwiftProtobuf to which you are linking. -// Please ensure that you are building against the same version of the API -// that was used to generate this file. -fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { - struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} - typealias Version = _2 -} - -public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { - public typealias RawValue = Int - case wireGuard // = 0 - case tailnet // = 1 - case UNRECOGNIZED(Int) - - public init() { - self = .wireGuard - } - - public init?(rawValue: Int) { - switch rawValue { - case 0: self = .wireGuard - case 1: self = .tailnet - default: self = .UNRECOGNIZED(rawValue) - } - } - - public var rawValue: Int { - switch self { - case .wireGuard: return 0 - case .tailnet: return 1 - case .UNRECOGNIZED(let i): return i - } - } - - // The compiler won't synthesize support with the UNRECOGNIZED case. - public static let allCases: [Burrow_NetworkType] = [ - .wireGuard, - .tailnet, - ] - -} - -public enum Burrow_State: SwiftProtobuf.Enum, Swift.CaseIterable { - public typealias RawValue = Int - case stopped // = 0 - case running // = 1 - case UNRECOGNIZED(Int) - - public init() { - self = .stopped - } - - public init?(rawValue: Int) { - switch rawValue { - case 0: self = .stopped - case 1: self = .running - default: self = .UNRECOGNIZED(rawValue) - } - } - - public var rawValue: Int { - switch self { - case .stopped: return 0 - case .running: return 1 - case .UNRECOGNIZED(let i): return i - } - } - - // The compiler won't synthesize support with the UNRECOGNIZED case. - public static let allCases: [Burrow_State] = [ - .stopped, - .running, - ] - -} - -public struct Burrow_NetworkReorderRequest: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var id: Int32 = 0 - - public var index: Int32 = 0 - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_WireGuardPeer: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var endpoint: String = String() - - public var subnet: [String] = [] - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_WireGuardNetwork: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var address: String = String() - - public var dns: String = String() - - public var peer: [Burrow_WireGuardPeer] = [] - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_NetworkDeleteRequest: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var id: Int32 = 0 - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_Network: @unchecked Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var id: Int32 = 0 - - public var type: Burrow_NetworkType = .wireGuard - - public var payload: Data = Data() - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_NetworkListResponse: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var network: [Burrow_Network] = [] - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_Empty: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_TunnelStatusResponse: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var state: Burrow_State = .stopped - - public var start: SwiftProtobuf.Google_Protobuf_Timestamp { - get {return _start ?? SwiftProtobuf.Google_Protobuf_Timestamp()} - set {_start = newValue} - } - /// Returns true if `start` has been explicitly set. - public var hasStart: Bool {return self._start != nil} - /// Clears the value of `start`. Subsequent reads from it will return its default value. - public mutating func clearStart() {self._start = nil} - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} - - fileprivate var _start: SwiftProtobuf.Google_Protobuf_Timestamp? = nil -} - -public struct Burrow_TunnelConfigurationResponse: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var addresses: [String] = [] - - public var mtu: Int32 = 0 - - public var routes: [String] = [] - - public var dnsServers: [String] = [] - - public var searchDomains: [String] = [] - - public var includeDefaultRoute: Bool = false - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -// MARK: - Code below here is support for the SwiftProtobuf runtime. - -fileprivate let _protobuf_package = "burrow" - -extension Burrow_NetworkType: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 0: .same(proto: "WireGuard"), - 1: .same(proto: "Tailnet"), - ] -} - -extension Burrow_State: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 0: .same(proto: "Stopped"), - 1: .same(proto: "Running"), - ] -} - -extension Burrow_NetworkReorderRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".NetworkReorderRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - 2: .same(proto: "index"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularInt32Field(value: &self.id) }() - case 2: try { try decoder.decodeSingularInt32Field(value: &self.index) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.id != 0 { - try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) - } - if self.index != 0 { - try visitor.visitSingularInt32Field(value: self.index, fieldNumber: 2) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_NetworkReorderRequest, rhs: Burrow_NetworkReorderRequest) -> Bool { - if lhs.id != rhs.id {return false} - if lhs.index != rhs.index {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_WireGuardPeer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".WireGuardPeer" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "endpoint"), - 2: .same(proto: "subnet"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularStringField(value: &self.endpoint) }() - case 2: try { try decoder.decodeRepeatedStringField(value: &self.subnet) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.endpoint.isEmpty { - try visitor.visitSingularStringField(value: self.endpoint, fieldNumber: 1) - } - if !self.subnet.isEmpty { - try visitor.visitRepeatedStringField(value: self.subnet, fieldNumber: 2) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_WireGuardPeer, rhs: Burrow_WireGuardPeer) -> Bool { - if lhs.endpoint != rhs.endpoint {return false} - if lhs.subnet != rhs.subnet {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_WireGuardNetwork: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".WireGuardNetwork" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "address"), - 2: .same(proto: "dns"), - 3: .same(proto: "peer"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularStringField(value: &self.address) }() - case 2: try { try decoder.decodeSingularStringField(value: &self.dns) }() - case 3: try { try decoder.decodeRepeatedMessageField(value: &self.peer) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.address.isEmpty { - try visitor.visitSingularStringField(value: self.address, fieldNumber: 1) - } - if !self.dns.isEmpty { - try visitor.visitSingularStringField(value: self.dns, fieldNumber: 2) - } - if !self.peer.isEmpty { - try visitor.visitRepeatedMessageField(value: self.peer, fieldNumber: 3) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_WireGuardNetwork, rhs: Burrow_WireGuardNetwork) -> Bool { - if lhs.address != rhs.address {return false} - if lhs.dns != rhs.dns {return false} - if lhs.peer != rhs.peer {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_NetworkDeleteRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".NetworkDeleteRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularInt32Field(value: &self.id) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.id != 0 { - try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_NetworkDeleteRequest, rhs: Burrow_NetworkDeleteRequest) -> Bool { - if lhs.id != rhs.id {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_Network: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".Network" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - 2: .same(proto: "type"), - 3: .same(proto: "payload"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularInt32Field(value: &self.id) }() - case 2: try { try decoder.decodeSingularEnumField(value: &self.type) }() - case 3: try { try decoder.decodeSingularBytesField(value: &self.payload) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.id != 0 { - try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) - } - if self.type != .wireGuard { - try visitor.visitSingularEnumField(value: self.type, fieldNumber: 2) - } - if !self.payload.isEmpty { - try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 3) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_Network, rhs: Burrow_Network) -> Bool { - if lhs.id != rhs.id {return false} - if lhs.type != rhs.type {return false} - if lhs.payload != rhs.payload {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_NetworkListResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".NetworkListResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "network"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeRepeatedMessageField(value: &self.network) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.network.isEmpty { - try visitor.visitRepeatedMessageField(value: self.network, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_NetworkListResponse, rhs: Burrow_NetworkListResponse) -> Bool { - if lhs.network != rhs.network {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_Empty: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".Empty" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap() - - public mutating func decodeMessage(decoder: inout D) throws { - // Load everything into unknown fields - while try decoder.nextFieldNumber() != nil {} - } - - public func traverse(visitor: inout V) throws { - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_Empty, rhs: Burrow_Empty) -> Bool { - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_TunnelStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".TunnelStatusResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "state"), - 2: .same(proto: "start"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularEnumField(value: &self.state) }() - case 2: try { try decoder.decodeSingularMessageField(value: &self._start) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every if/case branch local when no optimizations - // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and - // https://github.com/apple/swift-protobuf/issues/1182 - if self.state != .stopped { - try visitor.visitSingularEnumField(value: self.state, fieldNumber: 1) - } - try { if let v = self._start { - try visitor.visitSingularMessageField(value: v, fieldNumber: 2) - } }() - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_TunnelStatusResponse, rhs: Burrow_TunnelStatusResponse) -> Bool { - if lhs.state != rhs.state {return false} - if lhs._start != rhs._start {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".TunnelConfigurationResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "addresses"), - 2: .same(proto: "mtu"), - 3: .same(proto: "routes"), - 4: .standard(proto: "dns_servers"), - 5: .standard(proto: "search_domains"), - 6: .standard(proto: "include_default_route"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }() - case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }() - case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }() - case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }() - case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }() - case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.addresses.isEmpty { - try visitor.visitRepeatedStringField(value: self.addresses, fieldNumber: 1) - } - if self.mtu != 0 { - try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2) - } - if !self.routes.isEmpty { - try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3) - } - if !self.dnsServers.isEmpty { - try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4) - } - if !self.searchDomains.isEmpty { - try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5) - } - if self.includeDefaultRoute { - try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool { - if lhs.addresses != rhs.addresses {return false} - if lhs.mtu != rhs.mtu {return false} - if lhs.routes != rhs.routes {return false} - if lhs.dnsServers != rhs.dnsServers {return false} - if lhs.searchDomains != rhs.searchDomains {return false} - if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} diff --git a/Apple/Core/Client/google/protobuf/timestamp.proto b/Apple/Core/Client/google/protobuf/timestamp.proto deleted file mode 100644 index 7db2f6a..0000000 --- a/Apple/Core/Client/google/protobuf/timestamp.proto +++ /dev/null @@ -1,64 +0,0 @@ -// Protocol Buffers - Google's data interchange format -// Copyright 2008 Google Inc. All rights reserved. -// https://developers.google.com/protocol-buffers/ -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are -// met: -// -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above -// copyright notice, this list of conditions and the following disclaimer -// in the documentation and/or other materials provided with the -// distribution. -// * Neither the name of Google Inc. nor the names of its -// contributors may be used to endorse or promote products derived from -// this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -syntax = "proto3"; - -package google.protobuf; - -option cc_enable_arenas = true; -option go_package = "google.golang.org/protobuf/types/known/timestamppb"; -option java_package = "com.google.protobuf"; -option java_outer_classname = "TimestampProto"; -option java_multiple_files = true; -option objc_class_prefix = "GPB"; -option csharp_namespace = "Google.Protobuf.WellKnownTypes"; - -// A Timestamp represents a point in time independent of any time zone or local -// calendar, encoded as a count of seconds and fractions of seconds at -// nanosecond resolution. The count is relative to an epoch at UTC midnight on -// January 1, 1970, in the proleptic Gregorian calendar which extends the -// Gregorian calendar backwards to year one. -// -// All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap -// second table is needed for interpretation, using a 24-hour linear smear. -// -// The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By -// restricting to that range, we ensure that we can convert to and from RFC -// 3339 date strings. -message Timestamp { - // Represents seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. - // Must be from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59Z inclusive. - int64 seconds = 1; - - // Non-negative fractions of a second at nanosecond resolution. Negative - // second values with fractions must still have non-negative nanos values - // that count forward in time. Must be from 0 to 999,999,999 inclusive. - int32 nanos = 2; -} diff --git a/Apple/Core/Client/grpc-swift-config.json b/Apple/Core/Client/grpc-swift-config.json new file mode 100644 index 0000000..2d89698 --- /dev/null +++ b/Apple/Core/Client/grpc-swift-config.json @@ -0,0 +1,11 @@ +{ + "invocations": [ + { + "protoFiles": [ + "burrow.proto", + ], + "server": false, + "visibility": "public" + } + ] +} diff --git a/Apple/Core/Client/swift-protobuf-config.json b/Apple/Core/Client/swift-protobuf-config.json new file mode 100644 index 0000000..87aaec3 --- /dev/null +++ b/Apple/Core/Client/swift-protobuf-config.json @@ -0,0 +1,10 @@ +{ + "invocations": [ + { + "protoFiles": [ + "burrow.proto", + ], + "visibility": "public" + } + ] +} diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 3f3d8b4..54b813c 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -1,35 +1,21 @@ import AsyncAlgorithms import BurrowConfiguration import BurrowCore -import GRPC import libburrow -import NetworkExtension +@preconcurrency import NetworkExtension import os -private final class SendableCallbackBox: @unchecked Sendable { - let callback: Callback +// Xcode 26 imports `startTunnel(options:)` as `[String: NSObject]?` and treats the +// override as crossing a nonisolated boundary. The extension target does not +// mutate or forward these Cocoa objects, so treat them as an unchecked escape hatch. +extension NSObject: @retroactive @unchecked Sendable {} - init(_ callback: Callback) { - self.callback = callback - } -} - -final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { +class PacketTunnelProvider: NEPacketTunnelProvider { enum Error: Swift.Error { case missingTunnelConfiguration } - private let logger = Logger.logger(for: PacketTunnelProvider.self) - private var packetCall: GRPCAsyncBidirectionalStreamingCall? - private var inboundPacketTask: Task? - private var outboundPacketTask: Task? - - private var client: TunnelClient { - get throws { try _client.get() } - } - private let _client: Result = Result { - try TunnelClient.unix(socketURL: Constants.socketURL) - } + private static let logger = Logger.logger(for: PacketTunnelProvider.self) override init() { do { @@ -38,289 +24,51 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { databasePath: try Constants.databaseURL.path(percentEncoded: false) ) } catch { - logger.error("Failed to spawn networking thread: \(error)") + Self.logger.error("Failed to spawn networking thread: \(error)") } } - override func startTunnel( - options: [String: NSObject]?, - completionHandler: @escaping (Swift.Error?) -> Void - ) { - let completion = SendableCallbackBox(completionHandler) - Task { - do { - _ = try await client.tunnelStart(.init()) - let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first - guard let settings = configuration?.settings else { - throw Error.missingTunnelConfiguration - } - try await setTunnelNetworkSettings(settings) - try startPacketBridge() - logger.log("Started tunnel with network settings: \(settings)") - completion.callback(nil) - } catch { - logger.error("Failed to start tunnel: \(error)") - stopPacketBridge() - completion.callback(error) + nonisolated override func startTunnel(options: [String: NSObject]? = nil) async throws { + do { + let client = try TunnelClient.unix(socketURL: Constants.socketURL) + let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first + guard let settings = configuration?.settings else { + throw Error.missingTunnelConfiguration } + try await setTunnelNetworkSettings(settings) + _ = try await client.tunnelStart(.init()) + Self.logger.log("Started tunnel with network settings: \(settings)") + } catch { + Self.logger.error("Failed to start tunnel: \(error)") + throw error } } - override func stopTunnel( - with reason: NEProviderStopReason, - completionHandler: @escaping () -> Void - ) { - let completion = SendableCallbackBox(completionHandler) - Task { - stopPacketBridge() - do { - _ = try await client.tunnelStop(.init()) - logger.log("Stopped client") - } catch { - logger.error("Failed to stop tunnel: \(error)") - } - completion.callback() - } - } -} - -extension PacketTunnelProvider { - private func startPacketBridge() throws { - stopPacketBridge() - - let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL) - let call = packetClient.makeTunnelPacketsCall() - self.packetCall = call - - inboundPacketTask = Task { [weak self] in - guard let self else { return } - do { - for try await packet in call.responseStream { - let payload = packet.payload - self.packetFlow.writePackets( - [payload], - withProtocols: [Self.protocolNumber(for: payload)] - ) - } - } catch { - guard !Task.isCancelled else { return } - self.logger.error("Tunnel packet receive loop failed: \(error)") - } - } - - outboundPacketTask = Task { [weak self] in - guard let self else { return } - defer { call.requestStream.finish() } - do { - while !Task.isCancelled { - let packets = await self.readPacketsBatch() - for (payload, _) in packets { - var packet = Burrow_TunnelPacket() - packet.payload = payload - try await call.requestStream.send(packet) - } - } - } catch { - guard !Task.isCancelled else { return } - self.logger.error("Tunnel packet send loop failed: \(error)") - } - } - } - - private func stopPacketBridge() { - inboundPacketTask?.cancel() - inboundPacketTask = nil - outboundPacketTask?.cancel() - outboundPacketTask = nil - packetCall?.cancel() - packetCall = nil - } - - private func readPacketsBatch() async -> [(Data, NSNumber)] { - await withCheckedContinuation { continuation in - packetFlow.readPackets { packets, protocols in - continuation.resume(returning: Array(zip(packets, protocols))) - } - } - } - - private static func protocolNumber(for payload: Data) -> NSNumber { - guard let version = payload.first.map({ $0 >> 4 }) else { - return NSNumber(value: AF_INET) - } - switch version { - case 6: - return NSNumber(value: AF_INET6) - default: - return NSNumber(value: AF_INET) + nonisolated override func stopTunnel(with reason: NEProviderStopReason) async { + do { + let client = try TunnelClient.unix(socketURL: Constants.socketURL) + _ = try await client.tunnelStop(.init()) + Self.logger.log("Stopped client") + } catch { + Self.logger.error("Failed to stop tunnel: \(error)") } } } extension Burrow_TunnelConfigurationResponse { fileprivate var settings: NEPacketTunnelNetworkSettings { - let parsedAddresses = addresses.compactMap(ParsedTunnelAddress.init(rawValue:)) - let ipv4Addresses = parsedAddresses.compactMap(\.ipv4Address) - let ipv6Addresses = parsedAddresses.compactMap(\.ipv6Address) - let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:)) - var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route) - var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route) - if includeDefaultRoute { - ipv4Routes.append(.default()) - ipv6Routes.append(.default()) - } + let ipv6Addresses = addresses.filter { IPv6Address($0) != nil } let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") settings.mtu = NSNumber(value: mtu) - if !ipv4Addresses.isEmpty { - let ipv4Settings = NEIPv4Settings( - addresses: ipv4Addresses.map(\.address), - subnetMasks: ipv4Addresses.map(\.subnetMask) - ) - if !ipv4Routes.isEmpty { - ipv4Settings.includedRoutes = ipv4Routes - } - settings.ipv4Settings = ipv4Settings - } - if !ipv6Addresses.isEmpty { - let ipv6Settings = NEIPv6Settings( - addresses: ipv6Addresses.map(\.address), - networkPrefixLengths: ipv6Addresses.map(\.prefixLength) - ) - if !ipv6Routes.isEmpty { - ipv6Settings.includedRoutes = ipv6Routes - } - settings.ipv6Settings = ipv6Settings - } - if !dnsServers.isEmpty { - let dnsSettings = NEDNSSettings(servers: dnsServers) - if !searchDomains.isEmpty { - dnsSettings.matchDomains = searchDomains - } - settings.dnsSettings = dnsSettings - } + settings.ipv4Settings = NEIPv4Settings( + addresses: addresses.filter { IPv4Address($0) != nil }, + subnetMasks: ["255.255.255.0"] + ) + settings.ipv6Settings = NEIPv6Settings( + addresses: ipv6Addresses, + networkPrefixLengths: ipv6Addresses.map { _ in 64 } + ) return settings } } - -private struct ParsedTunnelAddress { - struct IPv4AddressSetting { - let address: String - let subnetMask: String - } - - struct IPv6AddressSetting { - let address: String - let prefixLength: NSNumber - } - - let ipv4Address: IPv4AddressSetting? - let ipv6Address: IPv6AddressSetting? - - init?(rawValue: String) { - let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init) - let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !address.isEmpty else { - return nil - } - - let prefix = components.count == 2 ? Int(components[1]) : nil - if IPv4Address(address) != nil { - let prefixLength = prefix ?? 32 - guard (0 ... 32).contains(prefixLength) else { - return nil - } - ipv4Address = IPv4AddressSetting( - address: address, - subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength) - ) - ipv6Address = nil - return - } - - if IPv6Address(address) != nil { - let prefixLength = prefix ?? 128 - guard (0 ... 128).contains(prefixLength) else { - return nil - } - ipv4Address = nil - ipv6Address = IPv6AddressSetting( - address: address, - prefixLength: NSNumber(value: prefixLength) - ) - return - } - - return nil - } - - private static func ipv4SubnetMask(prefixLength: Int) -> String { - guard prefixLength > 0 else { - return "0.0.0.0" - } - let mask = UInt32.max << (32 - prefixLength) - let octets = [ - (mask >> 24) & 0xff, - (mask >> 16) & 0xff, - (mask >> 8) & 0xff, - mask & 0xff, - ] - return octets.map(String.init).joined(separator: ".") - } -} - -private struct ParsedTunnelRoute { - let ipv4Route: NEIPv4Route? - let ipv6Route: NEIPv6Route? - - init?(rawValue: String) { - let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init) - let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !address.isEmpty else { - return nil - } - - let prefix = components.count == 2 ? Int(components[1]) : nil - if IPv4Address(address) != nil { - let prefixLength = prefix ?? 32 - guard (0 ... 32).contains(prefixLength) else { - return nil - } - ipv4Route = NEIPv4Route( - destinationAddress: address, - subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength) - ) - ipv6Route = nil - return - } - - if IPv6Address(address) != nil { - let prefixLength = prefix ?? 128 - guard (0 ... 128).contains(prefixLength) else { - return nil - } - ipv4Route = nil - ipv6Route = NEIPv6Route( - destinationAddress: address, - networkPrefixLength: NSNumber(value: prefixLength) - ) - return - } - - return nil - } - - private static func ipv4SubnetMask(prefixLength: Int) -> String { - var mask = UInt32.max << (32 - prefixLength) - if prefixLength == 0 { - mask = 0 - } - let octets = [ - String((mask >> 24) & 0xff), - String((mask >> 16) & 0xff), - String((mask >> 8) & 0xff), - String(mask & 0xff), - ] - return octets.joined(separator: ".") - } -} diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index 81cb901..3da8fae 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -45,27 +45,6 @@ for ARCH in "${BURROW_ARCHS[@]}"; do esac done -if [[ -x "$(command -v rustup)" ]]; then - INSTALLED_TARGETS="$(rustup target list --installed)" - MISSING_TARGETS=() - for TARGET in "${RUST_TARGETS[@]}"; do - if ! grep -qx "$TARGET" <<< "$INSTALLED_TARGETS"; then - MISSING_TARGETS+=("$TARGET") - fi - done - if (( ${#MISSING_TARGETS[@]} > 0 )); then - rustup target add "${MISSING_TARGETS[@]}" - fi -else - SYSROOT="$(rustc --print sysroot)" - for TARGET in "${RUST_TARGETS[@]}"; do - if [[ ! -d "${SYSROOT}/lib/rustlib/${TARGET}" ]]; then - echo "error: Rust target ${TARGET} is not installed and rustup is unavailable" >&2 - exit 1 - fi - done -fi - # Pass all RUST_TARGETS in a single invocation CARGO_ARGS=() for TARGET in "${RUST_TARGETS[@]}"; do @@ -83,44 +62,79 @@ else CARGO_TARGET_SUBDIR="release" fi +RUSTUP_TOOLCHAIN="" if [[ -x "$(command -v rustup)" ]]; then - CARGO_PATH="$(dirname "$(rustup which cargo)"):/usr/bin" + RUSTUP_TOOLCHAIN="$(rustup show active-toolchain | awk '{print $1}')" + if [[ -z "${RUSTUP_TOOLCHAIN}" ]]; then + echo 'error: Unable to determine active rustup toolchain' + exit 1 + fi + CARGO_BIN="$(rustup which --toolchain "${RUSTUP_TOOLCHAIN}" cargo)" + RUSTC_BIN="$(rustup which --toolchain "${RUSTUP_TOOLCHAIN}" rustc)" + CARGO_PATH="$(dirname "${CARGO_BIN}"):$(dirname "${RUSTC_BIN}"):/usr/bin" else - CARGO_PATH="$(dirname "$(command -v cargo)"):/usr/bin" + CARGO_BIN="$(command -v cargo)" + CARGO_PATH="$(dirname "${CARGO_BIN}"):/usr/bin" fi -if [[ -n "${PROTOC:-}" ]]; then - if [[ ! -x "$PROTOC" ]]; then - echo "error: PROTOC is set but is not executable: $PROTOC" >&2 - exit 127 - fi -elif ! PROTOC="$(command -v protoc 2>/dev/null)"; then - echo 'error: Unable to find protoc; pass PROTOC or include it in PATH for the Xcode Rust build phase' >&2 - exit 127 -fi +PROTOC=$(readlink -f $(which protoc)) CARGO_PATH="$(dirname $PROTOC):$CARGO_PATH" +if [[ -n "${RUSTC_WRAPPER:-}" && "${RUSTC_WRAPPER}" != /* ]]; then + WRAPPER_PATH="$(command -v "${RUSTC_WRAPPER}" || true)" + if [[ -n "${WRAPPER_PATH}" ]]; then + RUSTC_WRAPPER="${WRAPPER_PATH}" + fi +fi + +if [[ -x "$(command -v rustup)" ]]; then + for TARGET in "${RUST_TARGETS[@]}"; do + if ! rustup target list --installed | grep -qx "${TARGET}"; then + rustup target add --toolchain "${RUSTUP_TOOLCHAIN}" "${TARGET}" + fi + done +fi + # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. -CARGO_ENV=( +EXTRA_ENV=() +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 +EFFECTIVE_CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${CONFIGURATION_TEMP_DIR}/target}" +BUILD_ENV=( "PATH=$CARGO_PATH" "PROTOC=$PROTOC" - "CARGO_TARGET_DIR=${CONFIGURATION_TEMP_DIR}/target" + "CARGO_TARGET_DIR=${EFFECTIVE_CARGO_TARGET_DIR}" + "${EXTRA_ENV[@]}" ) - -if [[ -n "$IPHONEOS_DEPLOYMENT_TARGET" ]]; then - CARGO_ENV+=("IPHONEOS_DEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET") +if [[ -n "${RUSTUP_TOOLCHAIN}" ]]; then + BUILD_ENV+=("RUSTUP_TOOLCHAIN=${RUSTUP_TOOLCHAIN}") fi - -if [[ -n "$MACOSX_DEPLOYMENT_TARGET" ]]; then - CARGO_ENV+=("MACOSX_DEPLOYMENT_TARGET=$MACOSX_DEPLOYMENT_TARGET") +if [[ -n "${RUSTC_BIN:-}" ]]; then + BUILD_ENV+=("RUSTC=${RUSTC_BIN}") fi - -env -i "${CARGO_ENV[@]}" cargo build "${CARGO_ARGS[@]}" +if [[ -n "${IPHONEOS_DEPLOYMENT_TARGET:-}" ]]; then + BUILD_ENV+=("IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET}") +fi +if [[ -n "${MACOSX_DEPLOYMENT_TARGET:-}" ]]; then + BUILD_ENV+=("MACOSX_DEPLOYMENT_TARGET=${MACOSX_DEPLOYMENT_TARGET}") +fi +echo "Using Rust toolchain: ${RUSTUP_TOOLCHAIN:-system}" +echo "Using cargo: ${CARGO_BIN}" +if [[ -n "${RUSTC_BIN:-}" ]]; then + echo "Using rustc: ${RUSTC_BIN}" +fi +if [[ -n "${RUSTC_WRAPPER:-}" ]]; then + echo "Using rustc wrapper: ${RUSTC_WRAPPER}" +fi +env -i "${BUILD_ENV[@]}" "${CARGO_BIN}" 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/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json b/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json index 127b747..eb87897 100644 --- a/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,15 +1,6 @@ { "colors" : [ { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x31", - "green" : "0x7A", - "red" : "0xB7" - } - }, "idiom" : "universal" } ], diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/100.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..f86c139 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..872c9ce Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..3bb278d Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..185615e Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png new file mode 100644 index 0000000..51bd97c Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..b05e371 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..c95ea8a Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 0000000..3cb15a5 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..a3ad6a2 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 0000000..9f3bdb4 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..53c1237 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 0000000..ea95961 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..aec8236 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png new file mode 100644 index 0000000..9f0e3ce Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 0000000..a82ce93 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..8dc25c1 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 0000000..655a424 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..1f7f5e9 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 0000000..4a67ebf Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..88985d8 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 0000000..e5cbf6a Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 0000000..dc079ea Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..de4fddc Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..961adad Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..2a9e939 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 0000000..c67e407 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..d09aebe Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..3e649b6 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..6dad29f Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..a8ccb38 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png new file mode 100644 index 0000000..b1a478a Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json b/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json index 092f7cd..f78687a 100644 --- a/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,218 +1,344 @@ { - "images": [ + "images" : [ { - "filename": "ios-panel-01-40.png", - "idiom": "iphone", - "scale": "2x", - "size": "20x20" + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" }, { - "filename": "ios-panel-01-60.png", - "idiom": "iphone", - "scale": "3x", - "size": "20x20" + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" }, { - "filename": "ios-panel-01-29.png", - "idiom": "iphone", - "scale": "1x", - "size": "29x29" + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" }, { - "filename": "ios-panel-01-58.png", - "idiom": "iphone", - "scale": "2x", - "size": "29x29" + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" }, { - "filename": "ios-panel-01-87.png", - "idiom": "iphone", - "scale": "3x", - "size": "29x29" + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" }, { - "filename": "ios-panel-01-80.png", - "idiom": "iphone", - "scale": "2x", - "size": "40x40" + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" }, { - "filename": "ios-panel-01-120.png", - "idiom": "iphone", - "scale": "3x", - "size": "40x40" + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" }, { - "filename": "ios-panel-01-57.png", - "idiom": "iphone", - "scale": "1x", - "size": "57x57" + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" }, { - "filename": "ios-panel-01-114.png", - "idiom": "iphone", - "scale": "2x", - "size": "57x57" + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" }, { - "filename": "ios-panel-01-120.png", - "idiom": "iphone", - "scale": "2x", - "size": "60x60" + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" }, { - "filename": "ios-panel-01-180.png", - "idiom": "iphone", - "scale": "3x", - "size": "60x60" + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" }, { - "filename": "ios-panel-01-20.png", - "idiom": "ipad", - "scale": "1x", - "size": "20x20" + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" }, { - "filename": "ios-panel-01-40.png", - "idiom": "ipad", - "scale": "2x", - "size": "20x20" + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" }, { - "filename": "ios-panel-01-29.png", - "idiom": "ipad", - "scale": "1x", - "size": "29x29" + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" }, { - "filename": "ios-panel-01-58.png", - "idiom": "ipad", - "scale": "2x", - "size": "29x29" + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" }, { - "filename": "ios-panel-01-40.png", - "idiom": "ipad", - "scale": "1x", - "size": "40x40" + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" }, { - "filename": "ios-panel-01-80.png", - "idiom": "ipad", - "scale": "2x", - "size": "40x40" + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" }, { - "filename": "ios-panel-01-50.png", - "idiom": "ipad", - "scale": "1x", - "size": "50x50" + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" }, { - "filename": "ios-panel-01-100.png", - "idiom": "ipad", - "scale": "2x", - "size": "50x50" + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" }, { - "filename": "ios-panel-01-72.png", - "idiom": "ipad", - "scale": "1x", - "size": "72x72" + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" }, { - "filename": "ios-panel-01-144.png", - "idiom": "ipad", - "scale": "2x", - "size": "72x72" + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" }, { - "filename": "ios-panel-01-76.png", - "idiom": "ipad", - "scale": "1x", - "size": "76x76" + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" }, { - "filename": "ios-panel-01-152.png", - "idiom": "ipad", - "scale": "2x", - "size": "76x76" + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" }, { - "filename": "ios-panel-01-167.png", - "idiom": "ipad", - "scale": "2x", - "size": "83.5x83.5" + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" }, { - "filename": "ios-panel-01-1024.png", - "idiom": "ios-marketing", - "scale": "1x", - "size": "1024x1024" + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" }, { - "filename": "mac-panel-05-16.png", - "idiom": "mac", - "scale": "1x", - "size": "16x16" + "filename" : "16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" }, { - "filename": "mac-panel-05-32.png", - "idiom": "mac", - "scale": "2x", - "size": "16x16" + "filename" : "32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" }, { - "filename": "mac-panel-05-32.png", - "idiom": "mac", - "scale": "1x", - "size": "32x32" + "filename" : "32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" }, { - "filename": "mac-panel-05-64.png", - "idiom": "mac", - "scale": "2x", - "size": "32x32" + "filename" : "64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" }, { - "filename": "mac-panel-05-128.png", - "idiom": "mac", - "scale": "1x", - "size": "128x128" + "filename" : "128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" }, { - "filename": "mac-panel-05-256.png", - "idiom": "mac", - "scale": "2x", - "size": "128x128" + "filename" : "256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" }, { - "filename": "mac-panel-05-256.png", - "idiom": "mac", - "scale": "1x", - "size": "256x256" + "filename" : "256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" }, { - "filename": "mac-panel-05-512.png", - "idiom": "mac", - "scale": "2x", - "size": "256x256" + "filename" : "512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" }, { - "filename": "mac-panel-05-512.png", - "idiom": "mac", - "scale": "1x", - "size": "512x512" + "filename" : "512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" }, { - "filename": "mac-panel-05-1024.png", - "idiom": "mac", - "scale": "2x", - "size": "512x512" + "filename" : "1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + }, + { + "filename" : "48.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "55.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "58.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "80.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "88.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "100.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "172.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "196.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "216.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" } ], - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-100.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-100.png deleted file mode 100644 index dbe8e53..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-100.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-1024.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-1024.png deleted file mode 100644 index e335771..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-114.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-114.png deleted file mode 100644 index af8f95a..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-114.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-120.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-120.png deleted file mode 100644 index eea76eb..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-120.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-144.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-144.png deleted file mode 100644 index ed3c1ba..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-144.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-152.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-152.png deleted file mode 100644 index 2c7a7cd..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-152.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-167.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-167.png deleted file mode 100644 index 7ce3660..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-167.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-180.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-180.png deleted file mode 100644 index 488ac42..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-180.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-20.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-20.png deleted file mode 100644 index bd3cc95..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-20.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-29.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-29.png deleted file mode 100644 index 227cb6c..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-29.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-40.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-40.png deleted file mode 100644 index c0ef687..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-40.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-50.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-50.png deleted file mode 100644 index 377281b..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-50.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-57.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-57.png deleted file mode 100644 index b9b88e1..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-57.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-58.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-58.png deleted file mode 100644 index 0847ee9..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-58.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-60.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-60.png deleted file mode 100644 index cd8358b..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-60.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-72.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-72.png deleted file mode 100644 index 5d973f2..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-72.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-76.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-76.png deleted file mode 100644 index 1cde009..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-76.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-80.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-80.png deleted file mode 100644 index ecf97e9..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-80.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-87.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-87.png deleted file mode 100644 index e1f3193..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-87.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-1024.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-1024.png deleted file mode 100644 index 065e8b6..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-128.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-128.png deleted file mode 100644 index f4bd4bc..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-128.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-16.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-16.png deleted file mode 100644 index 30e2ad9..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-16.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-256.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-256.png deleted file mode 100644 index d919e9c..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-256.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-32.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-32.png deleted file mode 100644 index 3c1fd9f..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-32.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-512.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-512.png deleted file mode 100644 index 441a139..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-512.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-64.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-64.png deleted file mode 100644 index e6a72db..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-64.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/Contents.json deleted file mode 100644 index d6ee58c..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images": [ - { - "filename": "preview.png", - "idiom": "universal", - "scale": "1x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/preview.png b/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/preview.png deleted file mode 100644 index 561abcb..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/preview.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-100.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-100.png deleted file mode 100644 index 48c06a4..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-100.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-1024.png deleted file mode 100644 index 278575e..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-114.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-114.png deleted file mode 100644 index 411802b..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-114.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-120.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-120.png deleted file mode 100644 index 95473ee..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-120.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-144.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-144.png deleted file mode 100644 index 2b2afb9..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-144.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-152.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-152.png deleted file mode 100644 index 0668af2..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-152.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-167.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-167.png deleted file mode 100644 index 5486437..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-167.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-180.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-180.png deleted file mode 100644 index 18111cf..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-180.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-20.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-20.png deleted file mode 100644 index b0df643..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-20.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-29.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-29.png deleted file mode 100644 index 49d677b..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-29.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-40.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-40.png deleted file mode 100644 index 1fb9e0f..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-40.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-50.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-50.png deleted file mode 100644 index c73d033..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-50.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-57.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-57.png deleted file mode 100644 index c55bc86..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-57.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-58.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-58.png deleted file mode 100644 index 470ab9c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-58.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-60.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-60.png deleted file mode 100644 index 914fe25..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-60.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-72.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-72.png deleted file mode 100644 index 463a7cc..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-72.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-76.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-76.png deleted file mode 100644 index 7080ec3..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-76.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-80.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-80.png deleted file mode 100644 index 90be22c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-80.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-87.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-87.png deleted file mode 100644 index f494783..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-87.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/Contents.json deleted file mode 100644 index a6ab553..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/Contents.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "images": [ - { - "filename": "BurrowPanel02-40.png", - "idiom": "iphone", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel02-60.png", - "idiom": "iphone", - "scale": "3x", - "size": "20x20" - }, - { - "filename": "BurrowPanel02-29.png", - "idiom": "iphone", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel02-58.png", - "idiom": "iphone", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel02-87.png", - "idiom": "iphone", - "scale": "3x", - "size": "29x29" - }, - { - "filename": "BurrowPanel02-80.png", - "idiom": "iphone", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel02-120.png", - "idiom": "iphone", - "scale": "3x", - "size": "40x40" - }, - { - "filename": "BurrowPanel02-57.png", - "idiom": "iphone", - "scale": "1x", - "size": "57x57" - }, - { - "filename": "BurrowPanel02-114.png", - "idiom": "iphone", - "scale": "2x", - "size": "57x57" - }, - { - "filename": "BurrowPanel02-120.png", - "idiom": "iphone", - "scale": "2x", - "size": "60x60" - }, - { - "filename": "BurrowPanel02-180.png", - "idiom": "iphone", - "scale": "3x", - "size": "60x60" - }, - { - "filename": "BurrowPanel02-20.png", - "idiom": "ipad", - "scale": "1x", - "size": "20x20" - }, - { - "filename": "BurrowPanel02-40.png", - "idiom": "ipad", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel02-29.png", - "idiom": "ipad", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel02-58.png", - "idiom": "ipad", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel02-40.png", - "idiom": "ipad", - "scale": "1x", - "size": "40x40" - }, - { - "filename": "BurrowPanel02-80.png", - "idiom": "ipad", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel02-50.png", - "idiom": "ipad", - "scale": "1x", - "size": "50x50" - }, - { - "filename": "BurrowPanel02-100.png", - "idiom": "ipad", - "scale": "2x", - "size": "50x50" - }, - { - "filename": "BurrowPanel02-72.png", - "idiom": "ipad", - "scale": "1x", - "size": "72x72" - }, - { - "filename": "BurrowPanel02-144.png", - "idiom": "ipad", - "scale": "2x", - "size": "72x72" - }, - { - "filename": "BurrowPanel02-76.png", - "idiom": "ipad", - "scale": "1x", - "size": "76x76" - }, - { - "filename": "BurrowPanel02-152.png", - "idiom": "ipad", - "scale": "2x", - "size": "76x76" - }, - { - "filename": "BurrowPanel02-167.png", - "idiom": "ipad", - "scale": "2x", - "size": "83.5x83.5" - }, - { - "filename": "BurrowPanel02-1024.png", - "idiom": "ios-marketing", - "scale": "1x", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/Contents.json deleted file mode 100644 index d6ee58c..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images": [ - { - "filename": "preview.png", - "idiom": "universal", - "scale": "1x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/preview.png b/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/preview.png deleted file mode 100644 index f6d9feb..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/preview.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-100.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-100.png deleted file mode 100644 index 8d72c1b..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-100.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-1024.png deleted file mode 100644 index 6f40b0a..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-114.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-114.png deleted file mode 100644 index 9d44be4..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-114.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-120.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-120.png deleted file mode 100644 index 434c1a8..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-120.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-144.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-144.png deleted file mode 100644 index d837b45..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-144.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-152.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-152.png deleted file mode 100644 index c507534..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-152.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-167.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-167.png deleted file mode 100644 index e79fc1e..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-167.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-180.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-180.png deleted file mode 100644 index 7022128..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-180.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-20.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-20.png deleted file mode 100644 index 28468f0..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-20.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-29.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-29.png deleted file mode 100644 index b4dc026..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-29.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-40.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-40.png deleted file mode 100644 index 2ed0ee4..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-40.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-50.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-50.png deleted file mode 100644 index 7015cf1..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-50.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-57.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-57.png deleted file mode 100644 index 6cfb78d..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-57.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-58.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-58.png deleted file mode 100644 index 1efe951..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-58.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-60.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-60.png deleted file mode 100644 index e007091..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-60.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-72.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-72.png deleted file mode 100644 index f82f554..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-72.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-76.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-76.png deleted file mode 100644 index 5f92d14..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-76.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-80.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-80.png deleted file mode 100644 index 37337e7..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-80.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-87.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-87.png deleted file mode 100644 index e37171c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-87.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/Contents.json deleted file mode 100644 index 923e927..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/Contents.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "images": [ - { - "filename": "BurrowPanel03-40.png", - "idiom": "iphone", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel03-60.png", - "idiom": "iphone", - "scale": "3x", - "size": "20x20" - }, - { - "filename": "BurrowPanel03-29.png", - "idiom": "iphone", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel03-58.png", - "idiom": "iphone", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel03-87.png", - "idiom": "iphone", - "scale": "3x", - "size": "29x29" - }, - { - "filename": "BurrowPanel03-80.png", - "idiom": "iphone", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel03-120.png", - "idiom": "iphone", - "scale": "3x", - "size": "40x40" - }, - { - "filename": "BurrowPanel03-57.png", - "idiom": "iphone", - "scale": "1x", - "size": "57x57" - }, - { - "filename": "BurrowPanel03-114.png", - "idiom": "iphone", - "scale": "2x", - "size": "57x57" - }, - { - "filename": "BurrowPanel03-120.png", - "idiom": "iphone", - "scale": "2x", - "size": "60x60" - }, - { - "filename": "BurrowPanel03-180.png", - "idiom": "iphone", - "scale": "3x", - "size": "60x60" - }, - { - "filename": "BurrowPanel03-20.png", - "idiom": "ipad", - "scale": "1x", - "size": "20x20" - }, - { - "filename": "BurrowPanel03-40.png", - "idiom": "ipad", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel03-29.png", - "idiom": "ipad", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel03-58.png", - "idiom": "ipad", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel03-40.png", - "idiom": "ipad", - "scale": "1x", - "size": "40x40" - }, - { - "filename": "BurrowPanel03-80.png", - "idiom": "ipad", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel03-50.png", - "idiom": "ipad", - "scale": "1x", - "size": "50x50" - }, - { - "filename": "BurrowPanel03-100.png", - "idiom": "ipad", - "scale": "2x", - "size": "50x50" - }, - { - "filename": "BurrowPanel03-72.png", - "idiom": "ipad", - "scale": "1x", - "size": "72x72" - }, - { - "filename": "BurrowPanel03-144.png", - "idiom": "ipad", - "scale": "2x", - "size": "72x72" - }, - { - "filename": "BurrowPanel03-76.png", - "idiom": "ipad", - "scale": "1x", - "size": "76x76" - }, - { - "filename": "BurrowPanel03-152.png", - "idiom": "ipad", - "scale": "2x", - "size": "76x76" - }, - { - "filename": "BurrowPanel03-167.png", - "idiom": "ipad", - "scale": "2x", - "size": "83.5x83.5" - }, - { - "filename": "BurrowPanel03-1024.png", - "idiom": "ios-marketing", - "scale": "1x", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/Contents.json deleted file mode 100644 index d6ee58c..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images": [ - { - "filename": "preview.png", - "idiom": "universal", - "scale": "1x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/preview.png b/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/preview.png deleted file mode 100644 index 76d432e..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/preview.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-100.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-100.png deleted file mode 100644 index 3c70fad..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-100.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-1024.png deleted file mode 100644 index 4925f9f..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-114.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-114.png deleted file mode 100644 index 6ea1195..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-114.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-120.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-120.png deleted file mode 100644 index b07aa10..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-120.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-144.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-144.png deleted file mode 100644 index 783367c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-144.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-152.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-152.png deleted file mode 100644 index 5a13fd3..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-152.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-167.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-167.png deleted file mode 100644 index 694d1f1..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-167.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-180.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-180.png deleted file mode 100644 index af7e0fb..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-180.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-20.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-20.png deleted file mode 100644 index 6889afc..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-20.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-29.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-29.png deleted file mode 100644 index 81f027c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-29.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-40.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-40.png deleted file mode 100644 index 6fc8b68..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-40.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-50.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-50.png deleted file mode 100644 index cf8d516..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-50.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-57.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-57.png deleted file mode 100644 index 6bddc05..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-57.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-58.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-58.png deleted file mode 100644 index c80a63f..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-58.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-60.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-60.png deleted file mode 100644 index 22bf565..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-60.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-72.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-72.png deleted file mode 100644 index 1ce020b..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-72.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-76.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-76.png deleted file mode 100644 index d0f2173..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-76.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-80.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-80.png deleted file mode 100644 index 3d4be54..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-80.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-87.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-87.png deleted file mode 100644 index 0de4b64..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-87.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/Contents.json deleted file mode 100644 index ee5c59a..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/Contents.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "images": [ - { - "filename": "BurrowPanel04-40.png", - "idiom": "iphone", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel04-60.png", - "idiom": "iphone", - "scale": "3x", - "size": "20x20" - }, - { - "filename": "BurrowPanel04-29.png", - "idiom": "iphone", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel04-58.png", - "idiom": "iphone", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel04-87.png", - "idiom": "iphone", - "scale": "3x", - "size": "29x29" - }, - { - "filename": "BurrowPanel04-80.png", - "idiom": "iphone", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel04-120.png", - "idiom": "iphone", - "scale": "3x", - "size": "40x40" - }, - { - "filename": "BurrowPanel04-57.png", - "idiom": "iphone", - "scale": "1x", - "size": "57x57" - }, - { - "filename": "BurrowPanel04-114.png", - "idiom": "iphone", - "scale": "2x", - "size": "57x57" - }, - { - "filename": "BurrowPanel04-120.png", - "idiom": "iphone", - "scale": "2x", - "size": "60x60" - }, - { - "filename": "BurrowPanel04-180.png", - "idiom": "iphone", - "scale": "3x", - "size": "60x60" - }, - { - "filename": "BurrowPanel04-20.png", - "idiom": "ipad", - "scale": "1x", - "size": "20x20" - }, - { - "filename": "BurrowPanel04-40.png", - "idiom": "ipad", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel04-29.png", - "idiom": "ipad", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel04-58.png", - "idiom": "ipad", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel04-40.png", - "idiom": "ipad", - "scale": "1x", - "size": "40x40" - }, - { - "filename": "BurrowPanel04-80.png", - "idiom": "ipad", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel04-50.png", - "idiom": "ipad", - "scale": "1x", - "size": "50x50" - }, - { - "filename": "BurrowPanel04-100.png", - "idiom": "ipad", - "scale": "2x", - "size": "50x50" - }, - { - "filename": "BurrowPanel04-72.png", - "idiom": "ipad", - "scale": "1x", - "size": "72x72" - }, - { - "filename": "BurrowPanel04-144.png", - "idiom": "ipad", - "scale": "2x", - "size": "72x72" - }, - { - "filename": "BurrowPanel04-76.png", - "idiom": "ipad", - "scale": "1x", - "size": "76x76" - }, - { - "filename": "BurrowPanel04-152.png", - "idiom": "ipad", - "scale": "2x", - "size": "76x76" - }, - { - "filename": "BurrowPanel04-167.png", - "idiom": "ipad", - "scale": "2x", - "size": "83.5x83.5" - }, - { - "filename": "BurrowPanel04-1024.png", - "idiom": "ios-marketing", - "scale": "1x", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/Contents.json deleted file mode 100644 index d6ee58c..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images": [ - { - "filename": "preview.png", - "idiom": "universal", - "scale": "1x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/preview.png b/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/preview.png deleted file mode 100644 index 36b526c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/preview.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-100.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-100.png deleted file mode 100644 index caed322..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-100.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-1024.png deleted file mode 100644 index 065e8b6..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-114.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-114.png deleted file mode 100644 index a6a5dfa..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-114.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-120.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-120.png deleted file mode 100644 index 184604d..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-120.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-144.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-144.png deleted file mode 100644 index 89a64a9..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-144.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-152.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-152.png deleted file mode 100644 index 339670e..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-152.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-167.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-167.png deleted file mode 100644 index ac23f7c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-167.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-180.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-180.png deleted file mode 100644 index d2c8089..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-180.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-20.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-20.png deleted file mode 100644 index 821044f..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-20.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-29.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-29.png deleted file mode 100644 index b20c630..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-29.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-40.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-40.png deleted file mode 100644 index 9fa18b5..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-40.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-50.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-50.png deleted file mode 100644 index 619429d..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-50.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-57.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-57.png deleted file mode 100644 index bde5672..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-57.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-58.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-58.png deleted file mode 100644 index 8d8311d..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-58.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-60.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-60.png deleted file mode 100644 index 5d37b74..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-60.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-72.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-72.png deleted file mode 100644 index a3624bf..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-72.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-76.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-76.png deleted file mode 100644 index 35730d1..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-76.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-80.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-80.png deleted file mode 100644 index 43ad408..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-80.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-87.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-87.png deleted file mode 100644 index 1e7bbdf..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-87.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/Contents.json deleted file mode 100644 index a1e0e4b..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/Contents.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "images": [ - { - "filename": "BurrowPanel05-40.png", - "idiom": "iphone", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel05-60.png", - "idiom": "iphone", - "scale": "3x", - "size": "20x20" - }, - { - "filename": "BurrowPanel05-29.png", - "idiom": "iphone", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel05-58.png", - "idiom": "iphone", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel05-87.png", - "idiom": "iphone", - "scale": "3x", - "size": "29x29" - }, - { - "filename": "BurrowPanel05-80.png", - "idiom": "iphone", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel05-120.png", - "idiom": "iphone", - "scale": "3x", - "size": "40x40" - }, - { - "filename": "BurrowPanel05-57.png", - "idiom": "iphone", - "scale": "1x", - "size": "57x57" - }, - { - "filename": "BurrowPanel05-114.png", - "idiom": "iphone", - "scale": "2x", - "size": "57x57" - }, - { - "filename": "BurrowPanel05-120.png", - "idiom": "iphone", - "scale": "2x", - "size": "60x60" - }, - { - "filename": "BurrowPanel05-180.png", - "idiom": "iphone", - "scale": "3x", - "size": "60x60" - }, - { - "filename": "BurrowPanel05-20.png", - "idiom": "ipad", - "scale": "1x", - "size": "20x20" - }, - { - "filename": "BurrowPanel05-40.png", - "idiom": "ipad", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel05-29.png", - "idiom": "ipad", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel05-58.png", - "idiom": "ipad", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel05-40.png", - "idiom": "ipad", - "scale": "1x", - "size": "40x40" - }, - { - "filename": "BurrowPanel05-80.png", - "idiom": "ipad", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel05-50.png", - "idiom": "ipad", - "scale": "1x", - "size": "50x50" - }, - { - "filename": "BurrowPanel05-100.png", - "idiom": "ipad", - "scale": "2x", - "size": "50x50" - }, - { - "filename": "BurrowPanel05-72.png", - "idiom": "ipad", - "scale": "1x", - "size": "72x72" - }, - { - "filename": "BurrowPanel05-144.png", - "idiom": "ipad", - "scale": "2x", - "size": "72x72" - }, - { - "filename": "BurrowPanel05-76.png", - "idiom": "ipad", - "scale": "1x", - "size": "76x76" - }, - { - "filename": "BurrowPanel05-152.png", - "idiom": "ipad", - "scale": "2x", - "size": "76x76" - }, - { - "filename": "BurrowPanel05-167.png", - "idiom": "ipad", - "scale": "2x", - "size": "83.5x83.5" - }, - { - "filename": "BurrowPanel05-1024.png", - "idiom": "ios-marketing", - "scale": "1x", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/Contents.json deleted file mode 100644 index d6ee58c..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images": [ - { - "filename": "preview.png", - "idiom": "universal", - "scale": "1x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/preview.png b/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/preview.png deleted file mode 100644 index d919e9c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/preview.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-100.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-100.png deleted file mode 100644 index c723bc5..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-100.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-1024.png deleted file mode 100644 index 54336b2..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-114.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-114.png deleted file mode 100644 index f0d4a55..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-114.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-120.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-120.png deleted file mode 100644 index cf95959..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-120.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-144.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-144.png deleted file mode 100644 index b1bb422..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-144.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-152.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-152.png deleted file mode 100644 index a44333c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-152.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-167.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-167.png deleted file mode 100644 index adb7652..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-167.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-180.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-180.png deleted file mode 100644 index 0e64715..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-180.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-20.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-20.png deleted file mode 100644 index d9d652b..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-20.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-29.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-29.png deleted file mode 100644 index e200e3c..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-29.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-40.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-40.png deleted file mode 100644 index 3db6db7..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-40.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-50.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-50.png deleted file mode 100644 index 7f3b15e..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-50.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-57.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-57.png deleted file mode 100644 index 49fd3f6..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-57.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-58.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-58.png deleted file mode 100644 index 7f7c2ff..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-58.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-60.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-60.png deleted file mode 100644 index da1f3ca..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-60.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-72.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-72.png deleted file mode 100644 index 1a9e3dc..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-72.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-76.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-76.png deleted file mode 100644 index e4407db..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-76.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-80.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-80.png deleted file mode 100644 index 0c0b7f4..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-80.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-87.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-87.png deleted file mode 100644 index 3740748..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-87.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/Contents.json deleted file mode 100644 index 91d9323..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/Contents.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "images": [ - { - "filename": "BurrowPanel06-40.png", - "idiom": "iphone", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel06-60.png", - "idiom": "iphone", - "scale": "3x", - "size": "20x20" - }, - { - "filename": "BurrowPanel06-29.png", - "idiom": "iphone", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel06-58.png", - "idiom": "iphone", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel06-87.png", - "idiom": "iphone", - "scale": "3x", - "size": "29x29" - }, - { - "filename": "BurrowPanel06-80.png", - "idiom": "iphone", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel06-120.png", - "idiom": "iphone", - "scale": "3x", - "size": "40x40" - }, - { - "filename": "BurrowPanel06-57.png", - "idiom": "iphone", - "scale": "1x", - "size": "57x57" - }, - { - "filename": "BurrowPanel06-114.png", - "idiom": "iphone", - "scale": "2x", - "size": "57x57" - }, - { - "filename": "BurrowPanel06-120.png", - "idiom": "iphone", - "scale": "2x", - "size": "60x60" - }, - { - "filename": "BurrowPanel06-180.png", - "idiom": "iphone", - "scale": "3x", - "size": "60x60" - }, - { - "filename": "BurrowPanel06-20.png", - "idiom": "ipad", - "scale": "1x", - "size": "20x20" - }, - { - "filename": "BurrowPanel06-40.png", - "idiom": "ipad", - "scale": "2x", - "size": "20x20" - }, - { - "filename": "BurrowPanel06-29.png", - "idiom": "ipad", - "scale": "1x", - "size": "29x29" - }, - { - "filename": "BurrowPanel06-58.png", - "idiom": "ipad", - "scale": "2x", - "size": "29x29" - }, - { - "filename": "BurrowPanel06-40.png", - "idiom": "ipad", - "scale": "1x", - "size": "40x40" - }, - { - "filename": "BurrowPanel06-80.png", - "idiom": "ipad", - "scale": "2x", - "size": "40x40" - }, - { - "filename": "BurrowPanel06-50.png", - "idiom": "ipad", - "scale": "1x", - "size": "50x50" - }, - { - "filename": "BurrowPanel06-100.png", - "idiom": "ipad", - "scale": "2x", - "size": "50x50" - }, - { - "filename": "BurrowPanel06-72.png", - "idiom": "ipad", - "scale": "1x", - "size": "72x72" - }, - { - "filename": "BurrowPanel06-144.png", - "idiom": "ipad", - "scale": "2x", - "size": "72x72" - }, - { - "filename": "BurrowPanel06-76.png", - "idiom": "ipad", - "scale": "1x", - "size": "76x76" - }, - { - "filename": "BurrowPanel06-152.png", - "idiom": "ipad", - "scale": "2x", - "size": "76x76" - }, - { - "filename": "BurrowPanel06-167.png", - "idiom": "ipad", - "scale": "2x", - "size": "83.5x83.5" - }, - { - "filename": "BurrowPanel06-1024.png", - "idiom": "ios-marketing", - "scale": "1x", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/Contents.json deleted file mode 100644 index d6ee58c..0000000 --- a/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images": [ - { - "filename": "preview.png", - "idiom": "universal", - "scale": "1x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/preview.png b/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/preview.png deleted file mode 100644 index 7c63df0..0000000 Binary files a/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/preview.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json new file mode 100644 index 0000000..911b4b1 --- /dev/null +++ b/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x50", + "green" : "0x37", + "red" : "0xEC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json new file mode 100644 index 0000000..ddd0664 --- /dev/null +++ b/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "flag-standalone-wtransparent.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf b/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf new file mode 100644 index 0000000..1506fe9 Binary files /dev/null and b/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf differ diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 8145928..96467c7 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,1879 +1,67 @@ -import BurrowConfiguration -import Foundation -import SwiftUI -#if canImport(AuthenticationServices) import AuthenticationServices -#endif - -@MainActor -public protocol AppIconManaging { - var supportsAlternateIcons: Bool { get } - var alternateIconName: String? { get } - func setAlternateIconName(_ iconName: String?) async throws -} +import SwiftUI +#if !os(macOS) public struct BurrowView: View { - private let appIconManager: (any AppIconManaging)? - - @State private var networkViewModel: NetworkViewModel - @State private var accountStore = NetworkAccountStore() - @State private var activeSheet: ConfigurationSheet? - @State private var didRunAutomation = false - #if os(iOS) - @State private var selectedAppIconName: String? - @State private var appIconError: String? - @State private var isSettingAppIcon = false - #endif + @Environment(\.webAuthenticationSession) + private var webAuthenticationSession public var body: some View { NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 6) { - Text("Burrow") - .font(.largeTitle) - .fontWeight(.bold) - if showsHeaderSubtitle { - Text("Networks and accounts") - .font(.headline) - .foregroundStyle(.secondary) - } - } - if showsToolbarAddMenu { - Spacer() - Menu { - Button("Add WireGuard Network") { - activeSheet = .wireGuard - } - Button("Save Tor Account") { - activeSheet = .tor - } - Button("Add Tailnet Account") { - activeSheet = .tailnet - } - } label: { - Image(systemName: "plus.circle.fill") - .font(.title) - .accessibilityLabel("Add") - } - } - } - .padding(.top) - - if showsInlineQuickActions { - quickAddSection - } - - if showsAppIconSection { - appIconSection - } - - VStack(alignment: .leading, spacing: 12) { - sectionHeader( - title: "Networks", - detail: showsInlineQuickActions - ? nil - : "Stored daemon networks and their active account selectors" - ) - if let connectionError = networkViewModel.connectionError { - Text(connectionError) - .font(.footnote) - .foregroundStyle(.secondary) - } - NetworkCarouselView(networks: networkViewModel.cards) - } - - if showsAccountsSection { - VStack(alignment: .leading, spacing: 12) { - sectionHeader( - title: "Accounts", - detail: showsInlineQuickActions - ? nil - : "Per-network identities and sign-in state" - ) - if accountStore.accounts.isEmpty { - ContentUnavailableView( - "No Accounts Yet", - systemImage: "person.crop.circle.badge.plus", - description: Text("Save a Tor account or sign in to Tailnet to keep network identities ready on this device.") - ) - .frame(maxWidth: .infinity, minHeight: 180) - } else { - LazyVStack(spacing: 12) { - ForEach(accountStore.accounts) { account in - AccountRowView( - account: account, - hasSecret: accountStore.hasStoredSecret(for: account) - ) - } - } - } - } - } - - VStack(alignment: .leading, spacing: 8) { - sectionHeader( - title: "Tunnel", - detail: showsInlineQuickActions ? nil : "Current system extension state" - ) - TunnelStatusView() - TunnelButton() - .padding(.bottom) - } - } - .padding() - } - } - .sheet(item: $activeSheet) { sheet in - ConfigurationSheetView( - sheet: sheet, - networkViewModel: networkViewModel, - accountStore: accountStore - ) - } - .onAppear { - runAutomationIfNeeded() - refreshAppIconSelection() - } - } - - public init(appIconManager: (any AppIconManaging)? = nil) { - self.appIconManager = appIconManager - _networkViewModel = State( - initialValue: NetworkViewModel( - socketURLResult: Result { try Constants.socketURL } - ) - ) - } - - private func runAutomationIfNeeded() { - guard !didRunAutomation, - let automation = BurrowAutomationConfig.current, - automation.action == .tailnetLogin || automation.action == .tailnetProbe - else { - return - } - didRunAutomation = true - activeSheet = .tailnet - } - - private func refreshAppIconSelection() { - #if os(iOS) - selectedAppIconName = appIconManager?.alternateIconName - #endif - } - - @ViewBuilder - private var quickAddSection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader(title: "Add", detail: nil) - VStack(spacing: 12) { - ForEach(ConfigurationSheet.allCases) { sheet in - QuickAddButton(sheet: sheet) { - activeSheet = sheet - } - } - } - } - } - - @ViewBuilder - private var appIconSection: some View { - #if os(iOS) - VStack(alignment: .leading, spacing: 12) { - sectionHeader(title: "App Icon", detail: nil) - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 86), spacing: 12)], - alignment: .leading, - spacing: 12 - ) { - ForEach(AppIconChoice.allCases) { choice in - let isSelected = selectedAppIconName == choice.alternateIconName - Button { - setAppIcon(choice) - } label: { - VStack(spacing: 8) { - Image(choice.previewAssetName) - .resizable() - .aspectRatio(1, contentMode: .fit) - .frame(width: 72, height: 72) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 3) - } - .shadow(color: .black.opacity(0.18), radius: 5, y: 2) - Text(choice.title) - .font(.caption2.weight(.semibold)) - .foregroundStyle(isSelected ? Color.accentColor : Color.primary) - .lineLimit(1) - .minimumScaleFactor(0.75) - } - .frame(width: 86, height: 104) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isSettingAppIcon) - .accessibilityLabel(choice.accessibilityLabel) - } - } - if let appIconError { - Text(appIconError) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - #else - EmptyView() - #endif - } - - #if os(iOS) - private func setAppIcon(_ choice: AppIconChoice) { - guard let appIconManager, appIconManager.supportsAlternateIcons else { - return - } - guard selectedAppIconName != choice.alternateIconName else { - return - } - - isSettingAppIcon = true - appIconError = nil - Task { @MainActor in - do { - try await appIconManager.setAlternateIconName(choice.alternateIconName) - selectedAppIconName = appIconManager.alternateIconName - } catch { - appIconError = error.localizedDescription - selectedAppIconName = appIconManager.alternateIconName - } - isSettingAppIcon = false - } - } - #endif - - @ViewBuilder - private func sectionHeader(title: String, detail: String?) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.title2.weight(.semibold)) - if let detail, !detail.isEmpty { - Text(detail) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - - private var showsInlineQuickActions: Bool { - #if os(iOS) - true - #else - false - #endif - } - - private var showsToolbarAddMenu: Bool { - !showsInlineQuickActions - } - - private var showsHeaderSubtitle: Bool { - !showsInlineQuickActions - } - - private var showsAccountsSection: Bool { - #if os(iOS) - !accountStore.accounts.isEmpty - #else - true - #endif - } - - private var showsAppIconSection: Bool { - #if os(iOS) - appIconManager?.supportsAlternateIcons == true - #else - false - #endif - } -} - -private enum AppIconChoice: CaseIterable, Identifiable { - case guardian - case field - case burrow - case sentinel - case stride - case post - - var id: String { previewAssetName } - - var title: String { - switch self { - case .guardian: - "Guardian" - case .field: - "Field" - case .burrow: - "Burrow" - case .sentinel: - "Sentinel" - case .stride: - "Stride" - case .post: - "Post" - } - } - - var previewAssetName: String { - switch self { - case .guardian: - "BurrowPanel01Preview" - case .field: - "BurrowPanel02Preview" - case .burrow: - "BurrowPanel03Preview" - case .sentinel: - "BurrowPanel04Preview" - case .stride: - "BurrowPanel05Preview" - case .post: - "BurrowPanel06Preview" - } - } - - var alternateIconName: String? { - switch self { - case .guardian: - nil - case .field: - "BurrowPanel02" - case .burrow: - "BurrowPanel03" - case .sentinel: - "BurrowPanel04" - case .stride: - "BurrowPanel05" - case .post: - "BurrowPanel06" - } - } - - var accessibilityLabel: String { - "\(title) app icon" - } -} - -private enum ConfigurationSheet: String, CaseIterable, Identifiable { - case wireGuard - case tor - case tailnet - - var id: String { rawValue } - - var kind: AccountNetworkKind { - switch self { - case .wireGuard: .wireGuard - case .tor: .tor - case .tailnet: .tailnet - } - } - - var iconName: String { - switch self { - case .wireGuard: - "wave.3.right" - case .tor: - "shield.lefthalf.filled.badge.checkmark" - case .tailnet: - "network.badge.shield.half.filled" - } - } - - var quickActionTitle: String { - switch self { - case .wireGuard: - "WireGuard" - case .tor: - "Tor" - case .tailnet: - "Tailnet" - } - } - - var quickActionSubtitle: String { - switch self { - case .wireGuard: - "Import a tunnel" - case .tor: - "Save an Arti profile" - case .tailnet: - "Sign in or save a control plane" - } - } - - var quickActionColor: Color { - switch self { - case .wireGuard: - .blue - case .tor, .tailnet: - kind.accentColor - } - } -} - -private struct QuickAddButton: View { - let sheet: ConfigurationSheet - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 14) { - Image(systemName: sheet.iconName) - .font(.title3.weight(.semibold)) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 4) { - Text(sheet.quickActionTitle) - .font(.headline) - Text(sheet.quickActionSubtitle) - .font(.caption) - .opacity(0.88) - } - - Spacer() - } - .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) - } - .accessibilityIdentifier("quick-add-\(sheet.rawValue)") - .buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18)) - } -} - -private struct AccountDraft { - var title = "" - var accountName = "" - var identityName = "" - var wireGuardConfig = "" - - var discoveryEmail = "" - var authority = "" - var tailnet = "" - var hostname = ProcessInfo.processInfo.hostName - var username = "" - var secret = "" - var authMode: AccountAuthMode = .none - - var torAddresses = "100.64.0.2/32" - var torDNS = "1.1.1.1, 1.0.0.1" - var torMTU = "1400" - var torListen = "127.0.0.1:9040" - - init(sheet: ConfigurationSheet) { - switch sheet { - case .wireGuard: - break - case .tor: - title = "Default Tor" - accountName = "default" - identityName = "apple" - case .tailnet: - title = "Tailnet" - accountName = "default" - identityName = "apple" - authority = TailnetProvider.tailscale.defaultAuthority ?? "" - authMode = .web - } - } -} - -private struct ConfigurationSheetView: View { - @Environment(\.dismiss) private var dismiss - - let sheet: ConfigurationSheet - let networkViewModel: NetworkViewModel - let accountStore: NetworkAccountStore - - @State private var draft: AccountDraft - @State private var isSubmitting = false - @State private var errorMessage: String? - @State private var discoveryStatus: TailnetDiscoveryResponse? - @State private var discoveryError: String? - @State private var isDiscoveringTailnet = false - @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? - @State private var authorityProbeError: String? - @State private var isProbingAuthority = false - @State private var tailnetLoginStatus: TailnetLoginStatus? - @State private var tailnetLoginError: String? - @State private var tailnetLoginSessionID: String? - @State private var isStartingTailnetLogin = false - @State private var tailnetPresentedAuthURL: URL? - @State private var preserveTailnetLoginSession = false - @State private var usesCustomTailnetAuthority = false - @State private var showsAdvancedTailnetSettings = false - @State private var browserAuthenticator = TailnetBrowserAuthenticator() - @State private var tailnetLoginPollTask: Task? - @State private var tailnetDiscoveryTask: Task? - @State private var tailnetProbeTask: Task? - @State private var didRunAutomation = false - - init( - sheet: ConfigurationSheet, - networkViewModel: NetworkViewModel, - accountStore: NetworkAccountStore - ) { - self.sheet = sheet - self.networkViewModel = networkViewModel - self.accountStore = accountStore - _draft = State(initialValue: AccountDraft(sheet: sheet)) - } - - var body: some View { - NavigationStack { - Form { - Section { - sheetSummaryCard - } - .listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0)) - .listRowBackground(Color.clear) - - if showsIdentitySection { - Section("Identity") { - identityFields - } - } - - switch sheet { - case .wireGuard: - Section("WireGuard Configuration") { - TextEditor(text: $draft.wireGuardConfig) - .font(.body.monospaced()) - .frame(minHeight: wireGuardEditorHeight) - .contextMenu { - wireGuardContextActions - } - } - case .tor: - Section("Tor Preferences") { - TextField("Virtual Addresses", text: $draft.torAddresses) - TextField("DNS Resolvers", text: $draft.torDNS) - TextField("MTU", text: $draft.torMTU) - TextField("Transparent Listener", text: $draft.torListen) - } - case .tailnet: - tailnetSections - } - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - } - } - .navigationTitle(sheet.kind.title) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - Task { @MainActor in - await cancelTailnetLoginIfNeeded() - dismiss() - } - } - } - #if os(iOS) - ToolbarItem(placement: .topBarTrailing) { + VStack { + HStack { + Text("Networks") + .font(.largeTitle) + .fontWeight(.bold) + Spacer() Menu { - sheetMenuActions + Button("Hack Club", action: addHackClubNetwork) + Button("WireGuard", action: addWireGuardNetwork) } label: { - Image(systemName: "ellipsis.circle") - } - .accessibilityLabel("More") - } - #else - ToolbarItem(placement: .primaryAction) { - Menu { - sheetMenuActions - } label: { - Image(systemName: "ellipsis.circle") - } - .accessibilityLabel("More") - } - #endif - if !showsBottomActionButton { - ToolbarItem(placement: .confirmationAction) { - Button(confirmationTitle) { - submit() - } - .disabled(isSubmitting || submissionDisabled) + Image(systemName: "plus.circle.fill") + .font(.title) + .accessibilityLabel("Add") } } - } - } - #if os(macOS) - .frame(minWidth: 520, minHeight: 620) - #endif - .safeAreaInset(edge: .bottom) { - if showsBottomActionButton { - bottomActionBar - } - } - .onAppear { - runAutomationIfNeeded() - } - .onChange(of: draft.authority) { _, _ in - resetAuthorityProbe() - if sheet == .tailnet, usesCustomTailnetAuthority { - scheduleTailnetAuthorityProbe() - } - } - .onChange(of: draft.discoveryEmail) { _, _ in - resetTailnetDiscoveryFeedback() - if sheet == .tailnet, !usesCustomTailnetAuthority { - scheduleTailnetDiscovery() - } - } - .onChange(of: draft.authMode) { _, newMode in - guard newMode != .web else { return } - Task { @MainActor in - await cancelTailnetLoginIfNeeded() - } - } - .onDisappear { - tailnetLoginPollTask?.cancel() - tailnetDiscoveryTask?.cancel() - tailnetProbeTask?.cancel() - browserAuthenticator.cancel() - if !preserveTailnetLoginSession { - Task { @MainActor in - await cancelTailnetLoginIfNeeded() - } - } - } - } - - @ViewBuilder - private var identityFields: some View { - TextField("Title", text: $draft.title) - TextField("Account", text: $draft.accountName) - TextField("Identity", text: $draft.identityName) - if sheet == .tailnet { - TextField("Hostname", text: $draft.hostname) - .burrowLoginField() - .autocorrectionDisabled() - } - } - - @ViewBuilder - private var tailnetSections: some View { - Section("Connection") { - TextField("Email address", text: $draft.discoveryEmail) - .burrowEmailField() - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-discovery-email") - .submitLabel(.continue) - .onSubmit { - if !usesCustomTailnetAuthority { - scheduleTailnetDiscovery(immediate: true) - } - } - - tailnetServerCard - - if showsAdvancedTailnetSettings { - if usesCustomTailnetAuthority { - TextField("Server URL", text: $draft.authority) - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-authority") - } else { - TextField("Tailnet", text: $draft.tailnet) - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-name") - } - } - } - - Section("Authentication") { - if showsAdvancedTailnetSettings { - Picker("Authentication", selection: $draft.authMode) { - ForEach(availableTailnetAuthModes) { mode in - Text(mode.title).tag(mode) - } - } - .pickerStyle(.menu) - } - - if draft.authMode == .web { - Button { - startTailnetLogin() - } label: { - Label { - Text(isStartingTailnetLogin ? "Starting Sign-In" : tailnetSignInActionTitle) - } icon: { - Image(systemName: isStartingTailnetLogin ? "hourglass" : "person.badge.key") - } - } - .buttonStyle(.borderless) - .disabled(isStartingTailnetLogin || tailnetLoginActionDisabled) - .accessibilityIdentifier("tailnet-start-sign-in") - - if let tailnetLoginStatus { - tailnetLoginCard(status: tailnetLoginStatus, failure: nil) - } else if let tailnetLoginError { - tailnetLoginCard(status: nil, failure: tailnetLoginError) - } - } else { - TextField("Username", text: $draft.username) - .burrowLoginField() - .autocorrectionDisabled() - if draft.authMode != .none { - SecureField( - draft.authMode == .password ? "Password" : "Preauth Key", - text: $draft.secret - ) - } - } - - Text(tailnetAuthenticationFootnote) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - private var sheetSummaryCard: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 12) { - Image(systemName: sheet.iconName) - .font(.title3.weight(.semibold)) - .foregroundStyle(sheetAccentColor) - .frame(width: 28, height: 28) - .background( - Circle() - .fill(sheetAccentColor.opacity(0.14)) - ) - - VStack(alignment: .leading, spacing: 3) { - Text(summaryTitle) - .font(.headline) - Text(sheet.kind.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - + .padding(.top) + NetworkCarouselView() Spacer() + TunnelStatusView() + TunnelButton() + .padding(.bottom) } - - if let availabilityNote = sheet.kind.availabilityNote { - Text(availabilityNote) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if sheet == .tailnet { - labeledValue("Server", tailnetServerDisplayLabel) - if let connectionSummary = tailnetConnectionSummary { - Text(connectionSummary) - .font(.footnote.weight(.medium)) - .foregroundStyle(tailnetConnectionSummaryColor) - } - if tailnetLoginStatus?.running == true { - HStack(spacing: 8) { - summaryBadge("Signed In") - } - } - } + .padding() + .handleOAuth2Callback() } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 18) - .fill(.thinMaterial) + } + + public init() { + } + + private func addHackClubNetwork() { + Task { + try await authenticateWithSlack() + } + } + + private func addWireGuardNetwork() { + } + + private func authenticateWithSlack() async throws { + guard + let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"), + let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"), + let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return } + let session = OAuth2.Session( + authorizationEndpoint: authorizationEndpoint, + tokenEndpoint: tokenEndpoint, + redirectURI: redirectURI, + scopes: ["openid", "profile"], + clientID: "2210535565.6884042183125", + clientSecret: "2793c8a5255cae38830934c664eeb62d" ) + let response = try await session.authorize(webAuthenticationSession) } - - private var tailnetServerCard: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(usesCustomTailnetAuthority ? "Custom Server" : "Server") - .font(.subheadline.weight(.medium)) - Text(tailnetServerDisplayLabel) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - Spacer() - - if isDiscoveringTailnet || isProbingAuthority { - ProgressView() - .controlSize(.small) - } else if let summary = tailnetConnectionSummary { - Text(summary) - .font(.caption.weight(.medium)) - .foregroundStyle(tailnetConnectionSummaryColor) - } - } - - if let detail = tailnetServerDetail { - Text(detail) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - .accessibilityIdentifier("tailnet-server-card") - } - - private func tailnetAuthorityProbeCard( - status: TailnetAuthorityProbeStatus?, - failure: String? - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - if let status { - Text(status.summary) - .font(.subheadline.weight(.medium)) - Text(status.detail ?? "HTTP \(status.statusCode) from \(status.authority)") - .font(.footnote) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } else if let failure { - Text("Connection failed") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.red) - Text(failure) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - .accessibilityIdentifier("tailnet-authority-probe-card") - } - - private func tailnetDiscoveryCard( - status: TailnetDiscoveryResponse?, - failure: String? - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - if let status { - Text("Discovered Tailnet Server") - .font(.subheadline.weight(.medium)) - Text(status.authority) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - Text(status.provider == .tailscale ? "Managed authority" : "Custom authority") - .font(.footnote) - .foregroundStyle(.secondary) - if let oidcIssuer = status.oidcIssuer { - Text("OIDC: \(oidcIssuer)") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(3) - .textSelection(.enabled) - } - } else if let failure { - Text("Discovery failed") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.red) - Text(failure) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - .accessibilityIdentifier("tailnet-discovery-card") - } - - private func tailnetLoginCard( - status: TailnetLoginStatus?, - failure: String? - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - if let status { - Text(status.running ? "Signed In" : status.needsLogin ? "Browser Sign-In Required" : "Checking Sign-In") - .font(.subheadline.weight(.medium)) - if let tailnetName = status.tailnetName, !tailnetName.isEmpty { - Text("Tailnet: \(tailnetName)") - .font(.footnote) - .foregroundStyle(.secondary) - } - if let selfDNSName = status.selfDNSName, !selfDNSName.isEmpty { - Text(selfDNSName) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - if !status.tailnetIPs.isEmpty { - Text(status.tailnetIPs.joined(separator: ", ")) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - if !status.health.isEmpty { - Text(status.health.joined(separator: " • ")) - .font(.footnote) - .foregroundStyle(.secondary) - } - } else if let failure { - Text("Sign-In failed") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.red) - Text(failure) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - .accessibilityIdentifier("tailnet-login-card") - } - - private func summaryBadge(_ label: String) -> some View { - Text(label) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - Capsule() - .fill(.white.opacity(0.5)) - ) - } - - @ViewBuilder - private var bottomActionBar: some View { - VStack(spacing: 0) { - Divider() - .overlay(.white.opacity(0.3)) - Button(confirmationTitle) { - submit() - } - .buttonStyle(.floating(color: sheetAccentColor, cornerRadius: 18)) - .disabled(isSubmitting || submissionDisabled) - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 8) - } - .background(.ultraThinMaterial) - } - - @ViewBuilder - private var sheetMenuActions: some View { - Button("Use Suggested Identity") { - applySuggestedIdentity() - } - - switch sheet { - case .wireGuard: - Button("Paste Configuration") { - pasteWireGuardConfiguration() - } - .disabled(clipboardString?.isEmpty ?? true) - - Button("Clear Configuration", role: .destructive) { - draft.wireGuardConfig = "" - } - .disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - case .tor: - Menu("Presets") { - Button("Recommended Tor Defaults") { - applyTorDefaults() - } - Button("Restore Suggested Identity") { - applySuggestedIdentity() - } - } - - case .tailnet: - Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") { - toggleTailnetAuthorityMode() - } - - Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") { - showsAdvancedTailnetSettings.toggle() - } - - if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 { - Menu("Authentication") { - ForEach(availableTailnetAuthModes) { mode in - Button(mode.title) { - draft.authMode = mode - if mode == .none { - draft.secret = "" - } - } - } - } - } - - Button("Refresh Server Lookup") { - scheduleTailnetDiscovery(immediate: true) - } - .disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil) - } - } - - @ViewBuilder - private var wireGuardContextActions: some View { - Button("Paste Configuration") { - pasteWireGuardConfiguration() - } - .disabled(clipboardString?.isEmpty ?? true) - - Button("Clear", role: .destructive) { - draft.wireGuardConfig = "" - } - .disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - - private var sheetAccentColor: Color { - switch sheet { - case .wireGuard: - .blue - case .tor, .tailnet: - sheet.kind.accentColor - } - } - - private var summaryTitle: String { - switch sheet { - case .wireGuard: - "Import WireGuard" - case .tor: - "Configure Tor" - case .tailnet: - "Connect Tailnet" - } - } - - private var showsBottomActionButton: Bool { - #if os(iOS) - return true - #else - return false - #endif - } - - private var showsIdentitySection: Bool { - switch sheet { - case .wireGuard, .tor: - return true - case .tailnet: - return showsAdvancedTailnetSettings - } - } - - private var wireGuardEditorHeight: CGFloat { - #if os(iOS) - 180 - #else - 220 - #endif - } - - private var confirmationTitle: String { - switch sheet { - case .wireGuard: - return "Add Network" - case .tor: - return "Save Account" - case .tailnet: - return "Save Account" - } - } - - private var tailnetLoginActionDisabled: Bool { - switch sheet { - case .tailnet: - if usesCustomTailnetAuthority { - return normalizedOptional(draft.authority) == nil - } - return false - case .wireGuard, .tor: - return true - } - } - - private var submissionDisabled: Bool { - switch sheet { - case .wireGuard: - return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - case .tor: - return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil - case .tailnet: - if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { - return true - } - if normalizedOptional(draft.authority) == nil { - return true - } - if draft.authMode == .web { - return tailnetLoginStatus?.running != true - } - if draft.authMode != .none && normalizedOptional(draft.secret) == nil { - return true - } - return false - } - } - - private var tailnetServerDisplayLabel: String { - if usesCustomTailnetAuthority { - return normalizedOptional(draft.authority) - ?? "Enter a custom Tailnet server" - } - return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed" - } - - private var tailnetServerDetail: String? { - if usesCustomTailnetAuthority { - if let discovery = discoveryStatus { - return "Discovered from \(discovery.domain)." - } - if let discoveryError { - return discoveryError - } - return "Use a custom Tailnet authority when your domain does not advertise one." - } - return "Continue with Tailscale, or open advanced settings to use a custom server." - } - - private var tailnetConnectionSummary: String? { - if isDiscoveringTailnet { - return "Finding server" - } - if isProbingAuthority { - return "Checking" - } - if let authorityProbeStatus { - return authorityProbeStatus.summary - } - if authorityProbeError != nil { - return "Unavailable" - } - return nil - } - - private var tailnetConnectionSummaryColor: Color { - if authorityProbeError != nil { - return .red - } - return .secondary - } - - private func submit() { - isSubmitting = true - errorMessage = nil - - Task { @MainActor in - defer { isSubmitting = false } - do { - switch sheet { - case .wireGuard: - try await submitWireGuard() - dismiss() - case .tor: - try submitTor() - dismiss() - case .tailnet: - try await submitTailnet() - } - } catch { - errorMessage = error.localizedDescription - } - } - } - - private func submitWireGuard() async throws { - let networkID = try await networkViewModel.addWireGuardNetwork( - configText: draft.wireGuardConfig - ) - - let title = titleOrFallback("WireGuard \(networkID)") - let record = NetworkAccountRecord( - id: UUID(), - kind: .wireGuard, - title: title, - authority: nil, - provider: nil, - accountName: normalized(draft.accountName, fallback: "default"), - identityName: normalized(draft.identityName, fallback: "network-\(networkID)"), - hostname: nil, - username: nil, - tailnet: nil, - authMode: .none, - note: "Linked to daemon network #\(networkID).", - createdAt: .now, - updatedAt: .now - ) - try accountStore.upsert(record, secret: nil) - } - - private func submitTor() throws { - let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))") - let note = [ - "Addresses: \(csvSummary(draft.torAddresses))", - "DNS: \(csvSummary(draft.torDNS))", - "MTU: \(normalized(draft.torMTU, fallback: "1400"))", - "Listen: \(normalized(draft.torListen, fallback: "127.0.0.1:9040"))", - ].joined(separator: " • ") - - let record = NetworkAccountRecord( - id: UUID(), - kind: .tor, - title: title, - authority: "arti://local", - provider: nil, - accountName: normalized(draft.accountName, fallback: "default"), - identityName: normalized(draft.identityName, fallback: "apple"), - hostname: nil, - username: nil, - tailnet: nil, - authMode: .none, - note: note, - createdAt: .now, - updatedAt: .now - ) - try accountStore.upsert(record, secret: nil) - } - - private func submitTailnet() async throws { - let secret = (draft.authMode == .none || draft.authMode == .web) ? nil : draft.secret - let username = normalizedOptional(draft.username) - preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true - try await saveTailnetAccount(secret: secret, username: username) - dismiss() - } - - private func runAutomationIfNeeded() { - guard !didRunAutomation, - sheet == .tailnet, - let automation = BurrowAutomationConfig.current, - automation.action == .tailnetLogin || automation.action == .tailnetProbe - else { - return - } - - didRunAutomation = true - draft.title = automation.title ?? draft.title - draft.accountName = automation.accountName ?? draft.accountName - draft.identityName = automation.identityName ?? draft.identityName - draft.hostname = automation.hostname ?? draft.hostname - - Task { @MainActor in - switch automation.action { - case .tailnetLogin: - applyTailnetDefaults(for: .tailscale) - startTailnetLogin() - case .tailnetProbe: - usesCustomTailnetAuthority = true - showsAdvancedTailnetSettings = true - draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority - probeTailnetAuthority() - } - } - } - - private func saveTailnetAccount(secret: String?, username: String?) async throws { - let provider = inferredTailnetProvider - let title = titleOrFallback( - hostnameFallback(from: draft.authority, fallback: "Tailnet") - ) - - let payload = TailnetNetworkPayload( - provider: provider, - authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""), - account: normalized(draft.accountName, fallback: "default"), - identity: normalized(draft.identityName, fallback: "apple"), - tailnet: normalizedOptional(draft.tailnet), - hostname: normalizedOptional(draft.hostname) - ) - - var noteParts: [String] = [ - "Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))", - ] - - if showsAdvancedTailnetSettings || draft.authMode != .web { - noteParts.append("Auth: \(draft.authMode.title)") - } - - if draft.authMode == .web, tailnetLoginStatus?.running == true { - noteParts.append("Browser sign-in complete") - } - - do { - let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) - noteParts.append("Linked to daemon network #\(networkID)") - } catch { - noteParts.append("Daemon network add pending") - } - - let record = NetworkAccountRecord( - id: UUID(), - kind: .tailnet, - title: title, - authority: payload.authority, - provider: provider, - accountName: payload.account, - identityName: payload.identity, - hostname: payload.hostname, - username: username, - tailnet: payload.tailnet, - authMode: draft.authMode, - note: noteParts.joined(separator: " • "), - createdAt: .now, - updatedAt: .now - ) - try accountStore.upsert(record, secret: secret) - } - - private func applySuggestedIdentity() { - let defaults = AccountDraft(sheet: sheet) - if draft.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - draft.title = defaults.title - } - draft.accountName = defaults.accountName - draft.identityName = defaults.identityName - if sheet == .tailnet { - draft.hostname = defaults.hostname - } - } - - private func applyTorDefaults() { - let defaults = AccountDraft(sheet: .tor) - draft.title = defaults.title - draft.accountName = defaults.accountName - draft.identityName = defaults.identityName - draft.torAddresses = defaults.torAddresses - draft.torDNS = defaults.torDNS - draft.torMTU = defaults.torMTU - draft.torListen = defaults.torListen - } - - private func applyTailnetDefaults(for provider: TailnetProvider) { - resetTailnetDiscoveryFeedback() - usesCustomTailnetAuthority = provider != .tailscale - draft.authority = provider.defaultAuthority ?? "" - if !availableTailnetAuthModes.contains(draft.authMode) { - draft.authMode = .web - } - } - - private func startTailnetLogin() { - isStartingTailnetLogin = true - tailnetLoginError = nil - preserveTailnetLoginSession = false - - Task { @MainActor in - defer { isStartingTailnetLogin = false } - do { - let authority = try await resolveTailnetAuthorityForLogin() - let status = try await networkViewModel.startTailnetLogin( - accountName: normalized(draft.accountName, fallback: "default"), - identityName: normalized(draft.identityName, fallback: "apple"), - hostname: normalizedOptional(draft.hostname), - authority: authority - ) - tailnetLoginSessionID = status.sessionID - updateTailnetLoginStatus(status) - beginTailnetLoginPolling(sessionID: status.sessionID) - } catch { - tailnetLoginError = error.localizedDescription - } - } - } - - private func probeTailnetAuthority() { - guard let authority = normalizedOptional(draft.authority) else { - authorityProbeStatus = nil - authorityProbeError = "Enter a server URL first." - return - } - - isProbingAuthority = true - authorityProbeStatus = nil - authorityProbeError = nil - - Task { @MainActor in - defer { isProbingAuthority = false } - do { - authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority) - } catch { - authorityProbeError = error.localizedDescription - } - } - } - - private func resetAuthorityProbe() { - tailnetProbeTask?.cancel() - authorityProbeStatus = nil - authorityProbeError = nil - tailnetLoginError = nil - } - - private func resetTailnetDiscoveryFeedback() { - tailnetDiscoveryTask?.cancel() - discoveryStatus = nil - discoveryError = nil - } - - private func discoverTailnetAuthority() { - guard let email = normalizedOptional(draft.discoveryEmail) else { - discoveryStatus = nil - discoveryError = "Enter an email address first." - return - } - - isDiscoveringTailnet = true - discoveryStatus = nil - discoveryError = nil - - Task { @MainActor in - defer { isDiscoveringTailnet = false } - do { - let discovery = try await networkViewModel.discoverTailnet(email: email) - discoveryStatus = discovery - draft.authority = discovery.authority - probeTailnetAuthority() - } catch { - discoveryError = error.localizedDescription - } - } - } - - private func scheduleTailnetDiscovery(immediate: Bool = false) { - guard sheet == .tailnet else { return } - tailnetDiscoveryTask?.cancel() - - guard !usesCustomTailnetAuthority else { - discoveryStatus = nil - discoveryError = nil - return - } - - guard normalizedOptional(draft.discoveryEmail) != nil else { - discoveryStatus = nil - discoveryError = nil - draft.authority = TailnetProvider.tailscale.defaultAuthority ?? "" - return - } - - tailnetDiscoveryTask = Task { @MainActor in - if !immediate { - try? await Task.sleep(for: .milliseconds(450)) - } - guard !Task.isCancelled else { return } - discoverTailnetAuthority() - } - } - - private func scheduleTailnetAuthorityProbe() { - guard sheet == .tailnet else { return } - tailnetProbeTask?.cancel() - guard normalizedOptional(draft.authority) != nil else { return } - - tailnetProbeTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - probeTailnetAuthority() - } - } - - private func toggleTailnetAuthorityMode() { - let discoveredAuthority = discoveryStatus?.authority - usesCustomTailnetAuthority.toggle() - resetTailnetDiscoveryFeedback() - resetAuthorityProbe() - if usesCustomTailnetAuthority { - draft.authority = discoveredAuthority ?? draft.authority - } else { - draft.authority = TailnetProvider.tailscale.defaultAuthority ?? "" - scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil) - } - } - - private func resolveTailnetAuthorityForLogin() async throws -> String { - if !usesCustomTailnetAuthority { - let authority = TailnetProvider.tailscale.defaultAuthority ?? "" - draft.authority = authority - scheduleTailnetAuthorityProbe() - return authority - } - - if let authority = normalizedOptional(draft.authority) { - return authority - } - - if let email = normalizedOptional(draft.discoveryEmail) { - let discovery = try await networkViewModel.discoverTailnet(email: email) - discoveryStatus = discovery - discoveryError = nil - draft.authority = discovery.authority - scheduleTailnetAuthorityProbe() - return discovery.authority - } - - throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first." - ]) - } - - private func beginTailnetLoginPolling(sessionID: String) { - tailnetLoginPollTask?.cancel() - tailnetLoginPollTask = Task { @MainActor in - while !Task.isCancelled { - do { - let status = try await networkViewModel.tailnetLoginStatus(sessionID: sessionID) - updateTailnetLoginStatus(status) - if status.running { - tailnetLoginPollTask = nil - return - } - } catch { - tailnetLoginError = error.localizedDescription - tailnetLoginPollTask = nil - return - } - try? await Task.sleep(for: .seconds(1)) - } - } - } - - private func updateTailnetLoginStatus(_ status: TailnetLoginStatus) { - tailnetLoginStatus = status - tailnetLoginError = nil - tailnetLoginSessionID = status.sessionID - - if status.running { - browserAuthenticator.cancel() - tailnetPresentedAuthURL = nil - return - } - - guard let authURL = status.authURL else { - return - } - - if tailnetPresentedAuthURL != authURL { - tailnetPresentedAuthURL = authURL - browserAuthenticator.start(url: authURL) { [sessionID = status.sessionID] in - Task { @MainActor in - if tailnetLoginStatus?.running != true { - tailnetLoginSessionID = sessionID - } - } - } - } - } - - private func cancelTailnetLoginIfNeeded() async { - tailnetLoginPollTask?.cancel() - tailnetLoginPollTask = nil - browserAuthenticator.cancel() - tailnetPresentedAuthURL = nil - - guard tailnetLoginStatus?.running != true, - let sessionID = tailnetLoginSessionID - else { - return - } - - do { - try await networkViewModel.cancelTailnetLogin(sessionID: sessionID) - } catch { - tailnetLoginError = error.localizedDescription - } - - tailnetLoginStatus = nil - tailnetLoginSessionID = nil - } - - private func pasteWireGuardConfiguration() { - guard let clipboardString else { return } - draft.wireGuardConfig = clipboardString - } - - private var clipboardString: String? { - #if canImport(UIKit) - UIPasteboard.general.string - #elseif canImport(AppKit) - NSPasteboard.general.string(forType: .string) - #else - nil - #endif - } - - private func normalized(_ value: String, fallback: String) -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? fallback : trimmed - } - - private func normalizedOptional(_ value: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func titleOrFallback(_ fallback: String) -> String { - normalized(draft.title, fallback: fallback) - } - - private func csvSummary(_ value: String) -> String { - value - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: ", ") - } - - private func hostnameFallback(from value: String, fallback: String) -> String { - guard let url = URL(string: value), let host = url.host, !host.isEmpty else { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? fallback : trimmed - } - return host - } - - private var availableTailnetAuthModes: [AccountAuthMode] { - [.web, .none, .password, .preauthKey] - } - - private var tailnetSignInActionTitle: String { - if tailnetLoginStatus?.running == true { - return "Signed In" - } - if tailnetLoginSessionID != nil { - return "Resume Sign-In" - } - return "Continue with Tailscale" - } - - private var tailnetAuthenticationFootnote: String { - switch draft.authMode { - case .web: - if usesCustomTailnetAuthority { - return "Burrow signs in through the daemon using your custom Tailnet server." - } - return "Burrow signs in through the daemon using Tailscale's managed browser flow." - case .none: - return "Save the authority only. Useful when the control plane handles authentication elsewhere." - case .password, .preauthKey: - return "Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh." - } - } - - private var inferredTailnetProvider: TailnetProvider { - TailnetProvider.inferred( - authority: normalizedOptional(draft.authority), - explicit: discoveryStatus?.provider - ) - } - - @ViewBuilder - private func labeledValue(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.body.monospaced()) - } - } -} - -private struct AccountRowView: View { - let account: NetworkAccountRecord - let hasSecret: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text(account.title) - .font(.headline) - Text(account.kind.title) - .font(.subheadline) - .foregroundStyle(account.kind.accentColor) - } - Spacer() - if hasSecret { - Label("Credential stored", systemImage: "key.fill") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if let authority = account.authority { - labeledValue("Authority", authority) - } - - labeledValue("Account", account.accountName) - labeledValue("Identity", account.identityName) - - if let hostname = account.hostname { - labeledValue("Hostname", hostname) - } - - if let username = account.username { - labeledValue("Username", username) - } - - if let tailnet = account.tailnet { - labeledValue("Tailnet", tailnet) - } - - if let note = account.note { - Text(note) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - } - - @ViewBuilder - private func labeledValue(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.body.monospaced()) - } - } -} - -private extension View { - @ViewBuilder - func burrowLoginField() -> some View { - #if os(iOS) - textInputAutocapitalization(.never) - #else - self - #endif - } - - @ViewBuilder - func burrowEmailField() -> some View { - #if os(iOS) - textInputAutocapitalization(.never) - .keyboardType(.emailAddress) - #else - self - #endif - } -} - -#if canImport(AuthenticationServices) -@MainActor -private final class TailnetBrowserAuthenticator: NSObject { - private var session: ASWebAuthenticationSession? - private static var prefersEphemeralSessionForCurrentProcess: Bool { - let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - return rawValue == "1" || rawValue == "true" || rawValue == "yes" - } - - func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { - cancel() - let session = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { _, _ in - onDismiss() - } - session.presentationContextProvider = self - session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess - self.session = session - _ = session.start() - } - - func cancel() { - session?.cancel() - session = nil - } -} - -extension TailnetBrowserAuthenticator: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - #if canImport(AppKit) - return NSApplication.shared.keyWindow - ?? NSApplication.shared.windows.first - ?? ASPresentationAnchor() - #elseif canImport(UIKit) - return ASPresentationAnchor() - #else - return ASPresentationAnchor() - #endif - } -} -#else -@MainActor -private final class TailnetBrowserAuthenticator { - func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { - _ = url - onDismiss() - } - - func cancel() {} -} -#endif - -private struct BurrowAutomationConfig { - enum Action: String { - case tailnetLogin = "tailnet-login" - case tailnetProbe = "tailnet-probe" - } - - let action: Action - let title: String? - let accountName: String? - let identityName: String? - let hostname: String? - let authority: String? - - static let current: BurrowAutomationConfig? = { - let environment = ProcessInfo.processInfo.environment - guard let rawAction = environment["BURROW_UI_AUTOMATION"], - let action = Action(rawValue: rawAction) - else { - return nil - } - - return BurrowAutomationConfig( - action: action, - title: environment["BURROW_UI_AUTOMATION_TITLE"], - accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"], - identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"], - hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"], - authority: environment["BURROW_UI_AUTOMATION_AUTHORITY"] - ) - }() } #if DEBUG @@ -1884,3 +72,4 @@ struct NetworkView_Previews: PreviewProvider { } } #endif +#endif diff --git a/Apple/UI/NetworkCarouselView.swift b/Apple/UI/NetworkCarouselView.swift index e7368db..f969356 100644 --- a/Apple/UI/NetworkCarouselView.swift +++ b/Apple/UI/NetworkCarouselView.swift @@ -1,61 +1,39 @@ import SwiftUI struct NetworkCarouselView: View { - var networks: [NetworkCardModel] + var networks: [any Network] = [ + HackClub(id: 1), + HackClub(id: 2), + WireGuard(id: 4), + HackClub(id: 5) + ] var body: some View { - Group { - if networks.isEmpty { - #if os(iOS) - VStack(alignment: .leading, spacing: 6) { - Text("No stored networks yet") - .font(.headline) - Text("WireGuard and Tailnet networks show up here as soon as you add one.") - .font(.footnote) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background( - RoundedRectangle(cornerRadius: 18) - .fill(.thinMaterial) - ) - #else - ContentUnavailableView( - "No Networks Yet", - systemImage: "network.slash", - description: Text("Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.") - ) - .frame(maxWidth: .infinity, minHeight: 175) - #endif - } else { - ScrollView(.horizontal) { - LazyHStack { - ForEach(networks) { network in - NetworkView(network: network) - .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) - .scrollTransition(.interactive, axis: .horizontal) { content, phase in - content - .scaleEffect(1.0 - abs(phase.value) * 0.1) - } + ScrollView(.horizontal) { + LazyHStack { + ForEach(networks, id: \.id) { network in + NetworkView(network: network) + .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) + .scrollTransition(.interactive, axis: .horizontal) { content, phase in + content + .scaleEffect(1.0 - abs(phase.value) * 0.1) } - } } - .scrollTargetLayout() - .scrollClipDisabled() - .scrollIndicators(.hidden) - .defaultScrollAnchor(.center) - .scrollTargetBehavior(.viewAligned) - .containerRelativeFrame(.horizontal) } } + .scrollTargetLayout() + .scrollClipDisabled() + .scrollIndicators(.hidden) + .defaultScrollAnchor(.center) + .scrollTargetBehavior(.viewAligned) + .containerRelativeFrame(.horizontal) } } #if DEBUG struct NetworkCarouselView_Previews: PreviewProvider { static var previews: some View { - NetworkCarouselView(networks: [WireGuardCard(id: 1, detail: "10.13.13.2/24 · wg.burrow.rs:51820").card]) + NetworkCarouselView() } } #endif diff --git a/Apple/UI/NetworkExtensionTunnel.swift b/Apple/UI/NetworkExtensionTunnel.swift index 23559f3..7aaa3b1 100644 --- a/Apple/UI/NetworkExtensionTunnel.swift +++ b/Apple/UI/NetworkExtensionTunnel.swift @@ -105,7 +105,7 @@ public final class NetworkExtensionTunnel: Tunnel { let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = bundleIdentifier - proto.serverAddress = "burrow.rs" + proto.serverAddress = "hackclub.com" manager.protocolConfiguration = proto try await manager.save() diff --git a/Apple/UI/NetworkView.swift b/Apple/UI/NetworkView.swift index 437adce..b839d65 100644 --- a/Apple/UI/NetworkView.swift +++ b/Apple/UI/NetworkView.swift @@ -31,8 +31,8 @@ struct NetworkView: View { } extension NetworkView where Content == AnyView { - init(network: NetworkCardModel) { + init(network: any Network) { color = network.backgroundColor - content = { network.label } + content = { AnyView(network.label) } } } diff --git a/Apple/UI/Networks/HackClub.swift b/Apple/UI/Networks/HackClub.swift new file mode 100644 index 0000000..b1c2023 --- /dev/null +++ b/Apple/UI/Networks/HackClub.swift @@ -0,0 +1,27 @@ +import BurrowCore +import SwiftUI + +struct HackClub: Network { + typealias NetworkType = Burrow_WireGuardNetwork + static let type: Burrow_NetworkType = .hackClub + + var id: Int32 + var backgroundColor: Color { .init("HackClub") } + + @MainActor var label: some View { + GeometryReader { reader in + VStack(alignment: .leading) { + Image("HackClub") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: reader.size.height / 4) + Spacer() + Text("@conradev") + .foregroundStyle(.white) + .font(.body.monospaced()) + } + .padding() + .frame(maxWidth: .infinity) + } + } +} diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 35bd0e1..c6d5fba 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -1,623 +1,36 @@ -import BurrowConfiguration +import Atomics import BurrowCore -import Foundation -import Security import SwiftProtobuf import SwiftUI -struct NetworkCardModel: Identifiable { - let id: Int32 - let backgroundColor: Color - let label: AnyView -} +protocol Network { + associatedtype NetworkType: Message + associatedtype Label: View -struct TailnetNetworkPayload: Codable, Sendable { - var provider: TailnetProvider - var authority: String? - var account: String - var identity: String - var tailnet: String? - var hostname: String? + static var type: Burrow_NetworkType { get } - func encoded() throws -> Data { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - return try encoder.encode(self) - } -} + var id: Int32 { get } + var backgroundColor: Color { get } -struct TailnetDiscoveryResponse: Codable, Sendable { - var domain: String - var provider: TailnetProvider - var authority: String - var oidcIssuer: String? -} - -struct TailnetAuthorityProbeStatus: Sendable { - var authority: String - var statusCode: Int - var summary: String - var detail: String? -} - -struct TailnetLoginStatus: Sendable { - var sessionID: String - var backendState: String - var authURL: URL? - var running: Bool - var needsLogin: Bool - var tailnetName: String? - var magicDNSSuffix: String? - var selfDNSName: String? - var tailnetIPs: [String] - var health: [String] -} - -enum TailnetDiscoveryClient { - static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { - var request = Burrow_TailnetDiscoverRequest() - request.email = email - - let response = try await TailnetClient.unix(socketURL: socketURL).discover(request) - return TailnetDiscoveryResponse( - domain: response.domain, - provider: response.managed ? .tailscale : .headscale, - authority: response.authority, - oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil - : response.oidcIssuer - ) - } -} - -enum TailnetAuthorityProbeClient { - static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus { - var request = Burrow_TailnetProbeRequest() - request.authority = authority - - let response = try await TailnetClient.unix(socketURL: socketURL).probe(request) - return TailnetAuthorityProbeStatus( - authority: response.authority, - statusCode: Int(response.statusCode), - summary: response.summary, - detail: response.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil - : response.detail - ) - } -} - -enum TailnetLoginClient { - static func start( - accountName: String, - identityName: String, - hostname: String?, - authority: String, - socketURL: URL - ) async throws -> TailnetLoginStatus { - var request = Burrow_TailnetLoginStartRequest() - request.accountName = accountName - request.identityName = identityName - request.hostname = hostname ?? "" - request.authority = authority - let response = try await TailnetClient.unix(socketURL: socketURL).loginStart(request) - return decode(response) - } - - static func status(sessionID: String, socketURL: URL) async throws -> TailnetLoginStatus { - var request = Burrow_TailnetLoginStatusRequest() - request.sessionID = sessionID - let response = try await TailnetClient.unix(socketURL: socketURL).loginStatus(request) - return decode(response) - } - - static func cancel(sessionID: String, socketURL: URL) async throws { - var request = Burrow_TailnetLoginCancelRequest() - request.sessionID = sessionID - _ = try await TailnetClient.unix(socketURL: socketURL).loginCancel(request) - } - - private static func decode(_ response: Burrow_TailnetLoginStatusResponse) -> TailnetLoginStatus { - TailnetLoginStatus( - sessionID: response.sessionID, - backendState: response.backendState, - authURL: URL(string: response.authURL.trimmingCharacters(in: .whitespacesAndNewlines)), - running: response.running, - needsLogin: response.needsLogin, - tailnetName: response.tailnetName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil - : response.tailnetName, - magicDNSSuffix: response.magicDNSSuffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil - : response.magicDNSSuffix, - selfDNSName: response.selfDNSName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil - : response.selfDNSName, - tailnetIPs: response.tailnetIPs, - health: response.health - ) - } + @MainActor var label: Label { get } } @Observable @MainActor final class NetworkViewModel: Sendable { private(set) var networks: [Burrow_Network] = [] - private(set) var connectionError: String? - private let socketURLResult: Result - @ObservationIgnored private var task: Task? + private var task: Task! - init(socketURLResult: Result) { - self.socketURLResult = socketURLResult - startStreaming() - } - - deinit { - task?.cancel() - } - - var cards: [NetworkCardModel] { - networks.map(Self.makeCard(for:)) - } - - var nextNetworkID: Int32 { - (networks.map(\.id).max() ?? 0) + 1 - } - - func addWireGuardNetwork(configText: String) async throws -> Int32 { - try await addNetwork(type: .wireGuard, payload: Data(configText.utf8)) - } - - func addTailnetNetwork(payload: TailnetNetworkPayload) async throws -> Int32 { - try await addNetwork(type: .tailnet, payload: payload.encoded()) - } - - func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse { - let socketURL = try socketURLResult.get() - return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL) - } - - func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus { - let socketURL = try socketURLResult.get() - return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) - } - - func startTailnetLogin( - accountName: String, - identityName: String, - hostname: String?, - authority: String - ) async throws -> TailnetLoginStatus { - let socketURL = try socketURLResult.get() - return try await TailnetLoginClient.start( - accountName: accountName, - identityName: identityName, - hostname: hostname, - authority: authority, - socketURL: socketURL - ) - } - - func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus { - let socketURL = try socketURLResult.get() - return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL) - } - - func cancelTailnetLogin(sessionID: String) async throws { - let socketURL = try socketURLResult.get() - try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL) - } - - private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { - let socketURL = try socketURLResult.get() - let networkID = nextNetworkID - let request = Burrow_Network.with { - $0.id = networkID - $0.type = type - $0.payload = payload - } - - let client = NetworksClient.unix(socketURL: socketURL) - _ = try await client.networkAdd(request) - return networkID - } - - private func startStreaming() { - task?.cancel() - let socketURLResult = self.socketURLResult + init(socketURL: URL) { task = Task { [weak self] in - do { - let socketURL = try socketURLResult.get() - let client = NetworksClient.unix(socketURL: socketURL) - for try await response in client.networkList(.init()) { - guard !Task.isCancelled else { return } - await MainActor.run { - guard let self else { return } - self.networks = response.network - self.connectionError = nil - } - } - } catch { - guard !Task.isCancelled else { return } - await MainActor.run { - guard let self else { return } - self.connectionError = error.localizedDescription + let client = NetworksClient.unix(socketURL: socketURL) + for try await networks in client.networkList(.init()) { + guard let viewModel = self else { continue } + Task { @MainActor in + viewModel.networks = networks.network } } } } - - private static func makeCard(for network: Burrow_Network) -> NetworkCardModel { - switch network.type { - case .wireGuard: - WireGuardCard(network: network).card - case .tailnet: - TailnetCard(network: network).card - case .UNRECOGNIZED(let rawValue): - unsupportedCard( - id: network.id, - title: "Unknown Network", - detail: "Type \(rawValue) is not recognized by this build." - ) - @unknown default: - unsupportedCard( - id: network.id, - title: "Unsupported Network", - detail: "Update Burrow to view this network." - ) - } - } - - private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel { - NetworkCardModel( - id: id, - backgroundColor: .gray.opacity(0.85), - label: AnyView( - VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.title3.weight(.semibold)) - .foregroundStyle(.white) - Text(detail) - .font(.body) - .foregroundStyle(.white.opacity(0.9)) - Spacer() - Text("Network #\(id)") - .font(.footnote.monospaced()) - .foregroundStyle(.white.opacity(0.8)) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - ) - ) - } -} - -enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { - case tailscale - case headscale - case burrow - - var id: String { rawValue } - - var title: String { - switch self { - case .tailscale: "Tailscale" - case .headscale: "Custom Tailnet" - case .burrow: "Burrow" - } - } - - var defaultAuthority: String? { - switch self { - case .tailscale: - "https://controlplane.tailscale.com" - case .headscale: - "https://ts.burrow.net" - case .burrow: - nil - } - } - - var subtitle: String { - switch self { - case .tailscale: - "Managed Tailnet authority." - case .headscale: - "Custom Tailnet control server." - case .burrow: - "Burrow-native Tailnet authority." - } - } - - static func inferred(authority: String?, explicit: TailnetProvider?) -> TailnetProvider { - if explicit == .burrow { - return .burrow - } - if isManagedTailscaleAuthority(authority) { - return .tailscale - } - return .headscale - } - - static func isManagedTailscaleAuthority(_ authority: String?) -> Bool { - guard let normalized = authority? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .trimmingCharacters(in: CharacterSet(charactersIn: "/")), - !normalized.isEmpty - else { - return false - } - - return normalized == "https://controlplane.tailscale.com" - || normalized == "http://controlplane.tailscale.com" - || normalized == "controlplane.tailscale.com" - } -} - -enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { - case wireGuard - case tor - case tailnet - - var id: String { rawValue } - - var title: String { - switch self { - case .wireGuard: "WireGuard" - case .tor: "Tor" - case .tailnet: "Tailnet" - } - } - - var subtitle: String { - switch self { - case .wireGuard: "Import a tunnel and optional account metadata." - case .tor: "Store Arti account and identity preferences." - case .tailnet: "Save Tailnet authority, identity defaults, and login material." - } - } - - var accentColor: Color { - switch self { - case .wireGuard: .init("WireGuard") - case .tor: .orange - case .tailnet: .mint - } - } - - var actionTitle: String { - switch self { - case .wireGuard: "Add Network" - case .tor: "Save Account" - case .tailnet: "Save Account" - } - } - - var availabilityNote: String? { - switch self { - case .wireGuard: - nil - case .tor: - "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." - case .tailnet: - "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can already be stored in the daemon." - } - } -} - -enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { - case web - case none - case password - case preauthKey - - var id: String { rawValue } - - var title: String { - switch self { - case .web: "Browser Sign-In" - case .none: "None" - case .password: "Password" - case .preauthKey: "Preauth Key" - } - } -} - -struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable { - let id: UUID - var kind: AccountNetworkKind - var title: String - var authority: String? - var provider: TailnetProvider? - var accountName: String - var identityName: String - var hostname: String? - var username: String? - var tailnet: String? - var authMode: AccountAuthMode - var note: String? - var createdAt: Date - var updatedAt: Date -} - -struct TailnetCard { - var id: Int32 - var title: String - var detail: String - - init(network: Burrow_Network) { - let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) - id = network.id - title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" - detail = [ - payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority, - payload?.authority, - payload.map { "Account: \($0.account)" }, - ] - .compactMap { $0 } - .joined(separator: " · ") - .ifEmpty("Stored Tailnet configuration") - } - - var card: NetworkCardModel { - NetworkCardModel( - id: id, - backgroundColor: .mint, - label: AnyView( - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Tailnet") - .font(.headline) - .foregroundStyle(.white.opacity(0.85)) - Text(title) - .font(.title3.weight(.semibold)) - .foregroundStyle(.white) - } - Spacer() - } - Spacer() - Text(detail) - .font(.body.monospaced()) - .foregroundStyle(.white.opacity(0.92)) - .lineLimit(4) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - ) - ) - } -} - -@Observable -@MainActor -final class NetworkAccountStore { - private static let storageKey = "burrow.network-accounts" - - private let defaults: UserDefaults - private(set) var accounts: [NetworkAccountRecord] = [] - - init(defaults: UserDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) ?? .standard) { - self.defaults = defaults - load() - } - - func upsert(_ record: NetworkAccountRecord, secret: String?) throws { - if let index = accounts.firstIndex(where: { $0.id == record.id }) { - accounts[index] = record - } else { - accounts.append(record) - } - accounts.sort { - if $0.kind == $1.kind { - return $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending - } - return $0.kind.rawValue < $1.kind.rawValue - } - try persist() - if let secret, !secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - try AccountSecretStore.store(secret, for: record.id) - } else { - try AccountSecretStore.removeSecret(for: record.id) - } - } - - func delete(_ record: NetworkAccountRecord) throws { - accounts.removeAll { $0.id == record.id } - try persist() - try AccountSecretStore.removeSecret(for: record.id) - } - - func hasStoredSecret(for record: NetworkAccountRecord) -> Bool { - AccountSecretStore.hasSecret(for: record.id) - } - - private func load() { - guard let data = defaults.data(forKey: Self.storageKey) else { - accounts = [] - return - } - - do { - accounts = try JSONDecoder().decode([NetworkAccountRecord].self, from: data) - } catch { - accounts = [] - } - } - - private func persist() throws { - let data = try JSONEncoder().encode(accounts) - defaults.set(data, forKey: Self.storageKey) - } -} - -private enum AccountSecretStore { - private static let service = "\(Constants.bundleIdentifier).accounts" - - static func hasSecret(for accountID: UUID) -> Bool { - let query = baseQuery(for: accountID) - return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess - } - - static func store(_ secret: String, for accountID: UUID) throws { - let data = Data(secret.utf8) - let query = baseQuery(for: accountID) - let status = SecItemCopyMatching(query as CFDictionary, nil) - - if status == errSecSuccess { - let updateStatus = SecItemUpdate( - query as CFDictionary, - [kSecValueData as String: data] as CFDictionary - ) - guard updateStatus == errSecSuccess else { - throw AccountSecretStoreError.osStatus(updateStatus) - } - return - } - - var item = query - item[kSecValueData as String] = data - item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - let addStatus = SecItemAdd(item as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw AccountSecretStoreError.osStatus(addStatus) - } - } - - static func removeSecret(for accountID: UUID) throws { - let status = SecItemDelete(baseQuery(for: accountID) as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw AccountSecretStoreError.osStatus(status) - } - } - - private static func baseQuery(for accountID: UUID) -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: accountID.uuidString, - ] - } -} - -private enum AccountSecretStoreError: LocalizedError { - case osStatus(OSStatus) - - var errorDescription: String? { - switch self { - case .osStatus(let status): - if let message = SecCopyErrorMessageString(status, nil) as String? { - return message - } - return "Keychain error \(status)" - } - } -} - -private extension String { - func ifEmpty(_ fallback: @autoclosure () -> String) -> String { - isEmpty ? fallback() : self - } } diff --git a/Apple/UI/Networks/WireGuard.swift b/Apple/UI/Networks/WireGuard.swift index c0426cd..cba67ef 100644 --- a/Apple/UI/Networks/WireGuard.swift +++ b/Apple/UI/Networks/WireGuard.swift @@ -1,40 +1,14 @@ import BurrowCore -import Foundation import SwiftUI -struct WireGuardCard { +struct WireGuard: Network { + typealias NetworkType = Burrow_WireGuardNetwork + static let type: BurrowCore.Burrow_NetworkType = .wireGuard + var id: Int32 - var title: String - var detail: String + var backgroundColor: Color { .init("WireGuard") } - init(id: Int32, title: String = "WireGuard", detail: String = "Stored configuration") { - self.id = id - self.title = title - self.detail = detail - } - - init(network: Burrow_Network) { - let payload = String(data: network.payload, encoding: .utf8) ?? "" - let address = Self.firstValue(for: "Address", in: payload) - let endpoint = Self.firstValue(for: "Endpoint", in: payload) - self.id = network.id - self.title = "WireGuard" - self.detail = [address, endpoint] - .compactMap { $0 } - .filter { !$0.isEmpty } - .joined(separator: " · ") - .ifEmpty("Stored configuration") - } - - var card: NetworkCardModel { - NetworkCardModel( - id: id, - backgroundColor: .init("WireGuard"), - label: AnyView(label) - ) - } - - private var label: some View { + @MainActor var label: some View { GeometryReader { reader in VStack(alignment: .leading) { HStack { @@ -49,29 +23,12 @@ struct WireGuardCard { } .frame(maxWidth: .infinity, maxHeight: reader.size.height / 4) Spacer() - Text(detail) + Text("@conradev") .foregroundStyle(.white) .font(.body.monospaced()) - .lineLimit(3) } .padding() .frame(maxWidth: .infinity) } } - - private static func firstValue(for key: String, in config: String) -> String? { - config - .split(whereSeparator: \.isNewline) - .map(String.init) - .first(where: { $0.hasPrefix("\(key) = ") })? - .split(separator: "=", maxSplits: 1) - .last - .map { $0.trimmingCharacters(in: .whitespaces) } - } -} - -private extension String { - func ifEmpty(_ fallback: @autoclosure () -> String) -> String { - isEmpty ? fallback() : self - } } diff --git a/Apple/UI/OAuth2.swift b/Apple/UI/OAuth2.swift new file mode 100644 index 0000000..0fafc8d --- /dev/null +++ b/Apple/UI/OAuth2.swift @@ -0,0 +1,293 @@ +import AuthenticationServices +import Foundation +import os +import SwiftUI + +enum OAuth2 { + enum Error: Swift.Error { + case unknown + case invalidAuthorizationURL + case invalidCallbackURL + case invalidRedirectURI + } + + struct Credential { + var accessToken: String + var refreshToken: String? + var expirationDate: Date? + } + + struct Session { + var authorizationEndpoint: URL + var tokenEndpoint: URL + var redirectURI: URL + var responseType = OAuth2.ResponseType.code + var scopes: Set + var clientID: String + var clientSecret: String + + fileprivate static let queue: OSAllocatedUnfairLock<[Int: CheckedContinuation]> = { + .init(initialState: [:]) + }() + + fileprivate static func handle(url: URL) { + let continuations = queue.withLock { continuations in + let copy = continuations + continuations.removeAll() + return copy + } + for (_, continuation) in continuations { + continuation.resume(returning: url) + } + } + + init( + authorizationEndpoint: URL, + tokenEndpoint: URL, + redirectURI: URL, + scopes: Set, + clientID: String, + clientSecret: String + ) { + self.authorizationEndpoint = authorizationEndpoint + self.tokenEndpoint = tokenEndpoint + self.redirectURI = redirectURI + self.scopes = scopes + self.clientID = clientID + self.clientSecret = clientSecret + } + + private var authorizationURL: URL { + get throws { + var queryItems: [URLQueryItem] = [ + .init(name: "client_id", value: clientID), + .init(name: "response_type", value: responseType.rawValue), + .init(name: "redirect_uri", value: redirectURI.absoluteString) + ] + if !scopes.isEmpty { + queryItems.append(.init(name: "scope", value: scopes.joined(separator: ","))) + } + guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else { + throw OAuth2.Error.invalidAuthorizationURL + } + components.queryItems = queryItems + guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL } + return authorizationURL + } + } + + private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse { + switch responseType { + case .code: + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { + throw OAuth2.Error.invalidCallbackURL + } + return try await handle(response: try components.decode(OAuth2.CodeResponse.self)) + default: + throw OAuth2.Error.invalidCallbackURL + } + } + + private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse { + var components = URLComponents() + components.queryItems = [ + .init(name: "client_id", value: clientID), + .init(name: "client_secret", value: clientSecret), + .init(name: "grant_type", value: GrantType.authorizationCode.rawValue), + .init(name: "code", value: response.code), + .init(name: "redirect_uri", value: redirectURI.absoluteString) + ] + let httpBody = Data(components.percentEncodedQuery!.utf8) + + var request = URLRequest(url: tokenEndpoint) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + request.httpBody = httpBody + + let session = URLSession(configuration: .ephemeral) + let (data, _) = try await session.data(for: request) + return try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data) + } + + func authorize(_ session: WebAuthenticationSession) async throws -> Credential { + let authorizationURL = try authorizationURL + let callbackURL = try await session.start( + url: authorizationURL, + redirectURI: redirectURI + ) + return try await handle(callbackURL: callbackURL).credential + } + } + + private struct CodeResponse: Codable { + var code: String + var state: String? + } + + private struct AccessTokenResponse: Codable { + var accessToken: String + var tokenType: TokenType + var expiresIn: Double? + var refreshToken: String? + + var credential: Credential { + .init( + accessToken: accessToken, + refreshToken: refreshToken, + expirationDate: expiresIn.map { Date(timeIntervalSinceNow: $0) } + ) + } + } + + enum TokenType: Codable, RawRepresentable { + case bearer + case unknown(String) + + init(rawValue: String) { + self = switch rawValue.lowercased() { + case "bearer": .bearer + default: .unknown(rawValue) + } + } + + var rawValue: String { + switch self { + case .bearer: "bearer" + case .unknown(let type): type + } + } + } + + enum GrantType: Codable, RawRepresentable { + case authorizationCode + case unknown(String) + + init(rawValue: String) { + self = switch rawValue.lowercased() { + case "authorization_code": .authorizationCode + default: .unknown(rawValue) + } + } + + var rawValue: String { + switch self { + case .authorizationCode: "authorization_code" + case .unknown(let type): type + } + } + } + + enum ResponseType: Codable, RawRepresentable { + case code + case idToken + case unknown(String) + + init(rawValue: String) { + self = switch rawValue.lowercased() { + case "code": .code + case "id_token": .idToken + default: .unknown(rawValue) + } + } + + var rawValue: String { + switch self { + case .code: "code" + case .idToken: "id_token" + case .unknown(let type): type + } + } + } + + fileprivate static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + fileprivate static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + } +} + +extension WebAuthenticationSession: @unchecked @retroactive Sendable { +} + +extension WebAuthenticationSession { +#if canImport(BrowserEngineKit) + @available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) + fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback { + switch redirectURI.scheme { + case "https": + guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI } + return .https(host: host, path: redirectURI.path) + case "http": + throw OAuth2.Error.invalidRedirectURI + case .some(let scheme): + return .customScheme(scheme) + case .none: + throw OAuth2.Error.invalidRedirectURI + } + } +#endif + + fileprivate func start(url: URL, redirectURI: URL) async throws -> URL { + #if canImport(BrowserEngineKit) + if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) { + return try await authenticate( + using: url, + callback: try Self.callback(for: redirectURI), + additionalHeaderFields: [:] + ) + } + #endif + + return try await withThrowingTaskGroup(of: URL.self) { group in + group.addTask { + return try await authenticate(using: url, callbackURLScheme: redirectURI.scheme ?? "") + } + + let id = Int.random(in: 0.. some View { + onOpenURL { url in OAuth2.Session.handle(url: url) } + } +} + +extension URLComponents { + fileprivate func decode(_ type: T.Type) throws -> T { + guard let queryItems else { + throw DecodingError.valueNotFound( + T.self, + .init(codingPath: [], debugDescription: "Missing query items") + ) + } + let data = try OAuth2.encoder.encode(try queryItems.values) + return try OAuth2.decoder.decode(T.self, from: data) + } +} + +extension Sequence where Element == URLQueryItem { + fileprivate var values: [String: String?] { + get throws { + try Dictionary(map { ($0.name, $0.value) }) { _, _ in + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items")) + } + } + } +} diff --git a/BUILD.bazel b/BUILD.bazel deleted file mode 100644 index d67b251..0000000 --- a/BUILD.bazel +++ /dev/null @@ -1,69 +0,0 @@ -filegroup( - name = "apple_project_files", - srcs = glob( - [ - "Apple/**", - "Scripts/ci/install-apple-signing-assets.sh", - "Scripts/ci/package-apple-artifacts.sh", - "Scripts/version.sh", - ], - exclude = [ - "Apple/build/**", - "Apple/**/xcuserdata/**", - ], - ), - visibility = ["//visibility:public"], -) - -filegroup( - name = "android_project_files", - srcs = glob( - [ - "Android/**", - ], - exclude = [ - "Android/.gradle/**", - "Android/**/build/**", - ], - ), - visibility = ["//visibility:public"], -) - -filegroup( - name = "mobile_rust_core_files", - srcs = glob( - [ - "crates/burrow-core/**", - "crates/burrow-mobile-ffi/**", - ], - ) + [ - "Cargo.lock", - "Cargo.toml", - "rust-toolchain.toml", - ], - visibility = ["//visibility:public"], -) - -alias( - name = "apple_release_ios", - actual = "//bazel/apple:release_ios_stamp", - visibility = ["//visibility:public"], -) - -alias( - name = "apple_release_macos", - actual = "//bazel/apple:release_macos_stamp", - visibility = ["//visibility:public"], -) - -alias( - name = "apple_release_all", - actual = "//bazel/apple:release_all_stamp", - visibility = ["//visibility:public"], -) - -alias( - name = "android_check_stub", - actual = "//bazel/android:check_stub_stamp", - visibility = ["//visibility:public"], -) diff --git a/Cargo.lock b/Cargo.lock index 020c072..a7833c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -174,18 +165,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash 0.5.0", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -437,6 +416,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.6.20" @@ -452,7 +453,7 @@ dependencies = [ "http-body 0.4.6", "hyper 0.14.32", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -467,33 +468,32 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "async-trait", - "axum-core 0.4.5", + "axum-core 0.5.6", "bytes", + "form_urlencoded", "futures-util", "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.7.0", + "hyper 1.8.1", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tower 0.5.2", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -518,40 +518,23 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http 1.3.1", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -707,14 +690,12 @@ version = "0.1.0" dependencies = [ "aead", "anyhow", - "argon2", "arti-client", "async-channel", "async-stream 0.2.1", - "axum 0.7.9", + "axum 0.8.8", "base64 0.21.7", "blake2", - "bytes", "caps", "chacha20poly1305", "clap", @@ -723,41 +704,36 @@ dependencies = [ "dotenv", "fehler", "futures", - "hickory-proto", "hmac", "hyper-util", "insta", "ip_network", "ip_network_table", - "ipnetwork", "libc", "libsystemd", "log", - "netstack-smoltcp", "nix 0.27.1", "once_cell", "parking_lot", - "prost 0.13.5", - "prost-types 0.13.5", + "prost 0.14.3", + "prost-types 0.14.3", "rand 0.8.5", "rand_core 0.6.4", - "reqwest 0.12.23", + "reqwest", "ring", "rusqlite", "rust-ini", "schemars 0.8.22", "serde", "serde_json", - "subtle", - "tempfile", "tokio", "tokio-stream", "tokio-util", "toml 0.8.23", - "tonic 0.12.3", - "tonic-build", - "tor-rtcompat", - "tower 0.4.13", + "tonic 0.14.5", + "tonic-prost", + "tonic-prost-build", + "tower 0.5.3", "tracing", "tracing-journald", "tracing-log 0.1.4", @@ -767,22 +743,6 @@ dependencies = [ "x25519-dalek", ] -[[package]] -name = "burrow-core" -version = "0.1.0" - -[[package]] -name = "burrow-mobile-ffi" -version = "0.1.0" -dependencies = [ - "burrow-core", - "jni", -] - -[[package]] -name = "burrow-windows-stub" -version = "0.1.0" - [[package]] name = "by_address" version = "1.2.1" @@ -851,9 +811,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.38" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -914,14 +874,14 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link 0.2.1", + "windows-link 0.2.0", ] [[package]] @@ -1013,6 +973,15 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "coarsetime" version = "0.1.37" @@ -1152,9 +1121,9 @@ checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -1178,6 +1147,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1247,12 +1226,6 @@ dependencies = [ "itertools 0.13.0", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1466,50 +1439,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" - -[[package]] -name = "defmt" -version = "0.3.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" -dependencies = [ - "defmt 1.0.1", -] - -[[package]] -name = "defmt" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" -dependencies = [ - "defmt-parser", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.16", -] +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der" @@ -1607,22 +1539,23 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", + "rustc_version", "syn 2.0.106", "unicode-xid", ] @@ -1701,6 +1634,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1799,18 +1738,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "enum-ordinalize" version = "3.1.15" @@ -1873,15 +1800,6 @@ dependencies = [ "windows-sys 0.61.0", ] -[[package]] -name = "etherparse" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8d8a704b617484e9d867a0423cd45f7577f008c4068e2e33378f8d3860a6d73" -dependencies = [ - "arrayvec", -] - [[package]] name = "event-listener" version = "5.4.1" @@ -1983,9 +1901,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -2053,9 +1971,9 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.14.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2" +checksum = "189ebb6d350de8d03181999fa9ebe8a021c5ab041004388f29e4dd2c52dc88a2" dependencies = [ "derive_builder_fork_arti", "dirs", @@ -2066,6 +1984,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fslock" version = "0.2.1" @@ -2234,12 +2158,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" version = "0.3.3" @@ -2312,15 +2230,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -2373,16 +2282,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - [[package]] name = "heck" version = "0.5.0" @@ -2395,31 +2294,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.2", - "ring", - "thiserror 2.0.16", - "tinyvec", - "tokio", - "tracing", - "url", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -2563,9 +2437,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -2591,14 +2465,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.3.1", - "hyper 1.7.0", + "hyper 1.8.1", "hyper-util", "rustls", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2619,55 +2492,43 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.7.0", + "hyper 1.8.1", "hyper-util", "pin-project-lite", "tokio", "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", - "hyper 1.7.0", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2 0.6.3", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2675,7 +2536,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.62.1", ] [[package]] @@ -2872,24 +2733,13 @@ dependencies = [ [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" dependencies = [ "rustversion", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ip_network" version = "0.4.1" @@ -2918,15 +2768,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" -[[package]] -name = "ipnetwork" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" -dependencies = [ - "serde", -] - [[package]] name = "iri-string" version = "0.7.8" @@ -2985,7 +2826,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-sys", "log", "thiserror 1.0.69", "walkdir", @@ -2994,31 +2835,9 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.106", -] +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" @@ -3032,12 +2851,10 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.93" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -3115,7 +2932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link 0.2.0", ] [[package]] @@ -3146,9 +2963,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags 2.9.4", "libc", @@ -3225,12 +3042,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "managed" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" - [[package]] name = "matchers" version = "0.2.0" @@ -3246,6 +3057,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.5" @@ -3362,30 +3179,14 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] -[[package]] -name = "netstack-smoltcp" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8eb143b5f4a5907f5ac72a929edf6c9d9454485cf5a3a35ce8fd3c62165adf" -dependencies = [ - "etherparse", - "futures", - "rand 0.8.5", - "smoltcp", - "spin", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "nix" version = "0.26.4" @@ -3408,7 +3209,6 @@ dependencies = [ "bitflags 2.9.4", "cfg-if", "libc", - "memoffset 0.9.1", ] [[package]] @@ -3521,9 +3321,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -3557,9 +3357,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -3567,9 +3367,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3596,24 +3396,11 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -dependencies = [ - "critical-section", - "portable-atomic", -] [[package]] name = "once_cell_polyfill" @@ -3674,6 +3461,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.109" @@ -3808,17 +3601,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" @@ -3833,7 +3615,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash 0.4.2", + "password-hash", "sha2", ] @@ -3860,11 +3642,12 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" -version = "0.7.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", + "hashbrown 0.15.5", "indexmap 2.11.4", ] @@ -4015,12 +3798,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - [[package]] name = "postage" version = "0.5.0" @@ -4092,11 +3869,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.25.4+spec-1.1.0", ] [[package]] @@ -4142,29 +3919,30 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive 0.13.5", + "prost-derive 0.14.3", ] [[package]] name = "prost-build" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", "itertools 0.14.0", "log", "multimap", - "once_cell", "petgraph", "prettyplease", - "prost 0.13.5", - "prost-types 0.13.5", + "prost 0.14.3", + "prost-types 0.14.3", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", "syn 2.0.106", "tempfile", @@ -4185,9 +3963,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", @@ -4207,11 +3985,31 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost 0.13.5", + "prost 0.14.3", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" +dependencies = [ + "bitflags 2.9.4", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", ] [[package]] @@ -4252,6 +4050,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.3", "lru-slab", @@ -4487,80 +4286,42 @@ checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" -version = "0.11.27" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - -[[package]] -name = "reqwest" -version = "0.12.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.7.0", + "hyper 1.8.1", "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", "tokio-rustls", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -4653,12 +4414,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -4717,12 +4472,12 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ + "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4730,12 +4485,15 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls-native-certs" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "base64 0.21.7", + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", ] [[package]] @@ -4749,11 +4507,39 @@ dependencies = [ ] [[package]] -name = "rustls-webpki" -version = "0.103.8" +name = "rustls-platform-verifier" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4773,9 +4559,9 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "safelog" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee9f10dd250956c65d58a19507dd06ff976f898560fe843580d05134541f0898" +checksum = "8949ab2810bf603caef654634e5b4cedcbc05c120342a177cf8aaa122ef4bb76" dependencies = [ "derive_more", "educe", @@ -4892,7 +4678,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5010,9 +4809,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -5195,21 +4994,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smoltcp" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" -dependencies = [ - "bitflags 1.3.2", - "byteorder", - "cfg-if", - "defmt 0.3.100", - "heapless", - "log", - "managed", -] - [[package]] name = "socket2" version = "0.5.10" @@ -5235,9 +5019,6 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] [[package]] name = "spki" @@ -5434,20 +5215,20 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 1.3.2", - "core-foundation", + "bitflags 2.9.4", + "core-foundation 0.9.4", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", @@ -5599,22 +5380,19 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "slab", "socket2 0.6.3", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -5629,25 +5407,15 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", "syn 2.0.106", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.3" @@ -5660,9 +5428,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -5703,11 +5471,11 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.11.4", "serde_core", - "serde_spanned 1.1.0", + "serde_spanned 1.0.4", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.13", + "winnow", ] [[package]] @@ -5728,6 +5496,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -5739,28 +5516,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow 0.7.13", + "winnow", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap 2.11.4", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", - "winnow 0.7.13", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] @@ -5771,9 +5548,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tonic" @@ -5804,29 +5581,28 @@ dependencies = [ [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ - "async-stream 0.3.6", "async-trait", - "axum 0.7.9", + "axum 0.8.8", "base64 0.22.1", "bytes", "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.7.0", + "hyper 1.8.1", "hyper-timeout 0.5.2", "hyper-util", "percent-encoding", "pin-project", - "prost 0.13.5", - "socket2 0.5.10", + "socket2 0.6.3", + "sync_wrapper 1.0.2", "tokio", "tokio-stream", - "tower 0.4.13", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -5834,16 +5610,41 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.12.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost 0.14.3", + "tonic 0.14.5", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" dependencies = [ "prettyplease", "proc-macro2", "prost-build", - "prost-types 0.13.5", + "prost-types 0.14.3", "quote", "syn 2.0.106", + "tempfile", + "tonic-build", ] [[package]] @@ -6821,15 +6622,18 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.11.4", "pin-project-lite", + "slab", "sync_wrapper 1.0.2", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -6837,9 +6641,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.9.4", "bytes", @@ -6848,7 +6652,7 @@ dependencies = [ "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower 0.5.2", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -7005,7 +6809,7 @@ dependencies = [ "libloading 0.7.4", "log", "nix 0.26.4", - "reqwest 0.11.27", + "reqwest", "schemars 0.8.22", "serde", "socket2 0.5.10", @@ -7043,6 +6847,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -7051,9 +6861,9 @@ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" @@ -7228,9 +7038,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.116" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -7241,19 +7051,23 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.66" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ + "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.116" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7261,9 +7075,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.116" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -7274,9 +7088,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.116" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -7323,9 +7137,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.93" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -7342,10 +7156,10 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.3" +name = "webpki-root-certs" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -7445,15 +7259,15 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.62.2" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", ] [[package]] @@ -7497,9 +7311,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-numerics" @@ -7511,6 +7325,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f91f87ce112ffb7275000ea98eb1940912c21c1567c9312fde20261f3eadd29" +dependencies = [ + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7522,11 +7347,11 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link 0.2.1", + "windows-link 0.2.0", ] [[package]] @@ -7540,11 +7365,11 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.5.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link 0.2.1", + "windows-link 0.2.0", ] [[package]] @@ -7556,15 +7381,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -7598,7 +7414,7 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" dependencies = [ - "windows-link 0.2.1", + "windows-link 0.2.0", ] [[package]] @@ -7862,22 +7678,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "wit-bindgen" version = "0.46.0" @@ -8072,9 +7872,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" dependencies = [ "zeroize_derive", ] diff --git a/Cargo.toml b/Cargo.toml index 13b3e08..362ba2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,5 @@ [workspace] -members = [ - "burrow", - "tun", - "crates/burrow-core", - "crates/burrow-mobile-ffi", - "crates/burrow-windows-stub", -] +members = ["burrow", "tun"] resolver = "2" exclude = ["burrow-gtk"] diff --git a/Dockerfile b/Dockerfile index 2c4415e..404179b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.85-slim-bookworm AS builder +FROM docker.io/library/rust:1.79-slim-bookworm AS builder ARG TARGETPLATFORM ARG LLVM_VERSION=16 @@ -85,8 +85,8 @@ LABEL \ # https://github.com/opencontainers/image-spec/blob/master/annotations.md org.opencontainers.image.title="burrow" \ org.opencontainers.image.description="Burrow is an open source tool for burrowing through firewalls, built by teenagers at Hack Club." \ - org.opencontainers.image.url="https://git.burrow.net/hackclub/burrow" \ - org.opencontainers.image.source="https://git.burrow.net/hackclub/burrow" \ + org.opencontainers.image.url="https://github.com/hackclub/burrow" \ + org.opencontainers.image.source="https://github.com/hackclub/burrow" \ org.opencontainers.image.vendor="hackclub" \ org.opencontainers.image.licenses="GPL-3.0" diff --git a/MODULE.bazel b/MODULE.bazel deleted file mode 100644 index 03e942e..0000000 --- a/MODULE.bazel +++ /dev/null @@ -1,7 +0,0 @@ -module( - name = "burrow", - version = "0.1.0", -) - -bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "rules_shell", version = "0.6.1") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock deleted file mode 100644 index 28f5c18..0000000 --- a/MODULE.bazel.lock +++ /dev/null @@ -1,448 +0,0 @@ -{ - "lockFileVersion": 26, - "registryFileHashes": { - "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", - "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", - "https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589", - "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0", - "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb", - "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", - "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", - "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", - "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", - "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", - "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", - "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", - "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", - "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", - "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", - "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", - "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", - "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", - "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", - "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", - "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", - "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", - "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", - "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", - "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", - "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", - "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", - "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", - "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", - "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", - "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", - "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", - "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080", - "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309", - "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", - "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", - "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", - "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", - "https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a", - "https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5", - "https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d", - "https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651", - "https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138", - "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", - "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", - "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", - "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f", - "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", - "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", - "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", - "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", - "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", - "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", - "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", - "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", - "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", - "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", - "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", - "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", - "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", - "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", - "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", - "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", - "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", - "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", - "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", - "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", - "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", - "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", - "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", - "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", - "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", - "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", - "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", - "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", - "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", - "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", - "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", - "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", - "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", - "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", - "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", - "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", - "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", - "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", - "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", - "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", - "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", - "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", - "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", - "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", - "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", - "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", - "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", - "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", - "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", - "https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c", - "https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f", - "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", - "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", - "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", - "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", - "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", - "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", - "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", - "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", - "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", - "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", - "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", - "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", - "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", - "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", - "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", - "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", - "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", - "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", - "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", - "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d", - "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6", - "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", - "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", - "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", - "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", - "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", - "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", - "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", - "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", - "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", - "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", - "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", - "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", - "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", - "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", - "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", - "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", - "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", - "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", - "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", - "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", - "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", - "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", - "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", - "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", - "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", - "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", - "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", - "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", - "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", - "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", - "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", - "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", - "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", - "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", - "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", - "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", - "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", - "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", - "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", - "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", - "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", - "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", - "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", - "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", - "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", - "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", - "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", - "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", - "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", - "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", - "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", - "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", - "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" - }, - "selectedYankedVersions": {}, - "moduleExtensions": { - "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { - "general": { - "bzlTransitiveDigest": "Ga4z8lQy1YQ5rAMy+dOl0dqcCEBnYNCXku8x3YQmDZI=", - "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedInputs": [ - "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" - ], - "generatedRepoSpecs": { - "com_github_jetbrains_kotlin_git": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", - "attributes": { - "urls": [ - "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" - ], - "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" - } - }, - "com_github_jetbrains_kotlin": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", - "attributes": { - "git_repository_name": "com_github_jetbrains_kotlin_git", - "compiler_version": "1.9.23" - } - }, - "com_github_google_ksp": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", - "attributes": { - "urls": [ - "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" - ], - "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", - "strip_version": "1.9.23-1.0.20" - } - }, - "com_github_pinterest_ktlint": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", - "attributes": { - "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", - "urls": [ - "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" - ], - "executable": true - } - }, - "rules_android": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", - "strip_prefix": "rules_android-0.1.1", - "urls": [ - "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" - ] - } - } - } - } - }, - "@@rules_python+//python/extensions:config.bzl%config": { - "general": { - "bzlTransitiveDigest": "iibnRYgg8LpcfmH7EAnVwYePC3jsVaJ6Id8XxUjSZps=", - "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", - "recordedInputs": [ - "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", - "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", - "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", - "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", - "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", - "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", - "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", - "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", - "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", - "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", - "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", - "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", - "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", - "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", - "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", - "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" - ], - "generatedRepoSpecs": { - "rules_python_internal": { - "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", - "attributes": { - "transition_setting_generators": {}, - "transition_settings": [] - } - }, - "pypi__build": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl", - "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__click": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", - "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__colorama": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", - "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__importlib_metadata": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", - "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__installer": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", - "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__more_itertools": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", - "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__packaging": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", - "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pep517": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl", - "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pip": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl", - "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pip_tools": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", - "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pyproject_hooks": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl", - "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__setuptools": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", - "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__tomli": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", - "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__wheel": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", - "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__zipp": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl", - "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - } - } - } - }, - "@@rules_python+//python/uv:uv.bzl%uv": { - "general": { - "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", - "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", - "recordedInputs": [ - "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", - "REPO_MAPPING:rules_python+,platforms platforms" - ], - "generatedRepoSpecs": { - "uv": { - "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", - "attributes": { - "toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'", - "toolchain_names": [ - "none" - ], - "toolchain_implementations": { - "none": "'@@rules_python+//python:none'" - }, - "toolchain_compatible_with": { - "none": [ - "@platforms//:incompatible" - ] - }, - "toolchain_target_settings": {} - } - } - } - } - } - }, - "facts": {} -} diff --git a/Makefile b/Makefile index 1a0488c..6738052 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,56 @@ +FLAKE ?= . +AGENIX ?= nix run ${FLAKE}\#agenix -- + +SECRETS := forgejo/admin-password \ + forgejo/agent-ssh-key \ + forgejo/nsc-token \ + forgejo/nsc-dispatcher-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 -- cargo_norm := env RUST_BACKTRACE=1 RUST_LOG=debug cargo run -- sudo_cargo_console := sudo -E env RUST_BACKTRACE=1 RUST_LOG=debug RUSTFLAGS='--cfg tokio_unstable' cargo run --all-features -- sudo_cargo_norm := sudo -E env RUST_BACKTRACE=1 RUST_LOG=debug cargo run -- +.PHONY: secret secret-file secrets-list + +secret: + @if [ -z "${name}" ]; then \ + printf 'Usage: make secret name=\nAvailable secrets:\n %s\n' "${SECRETS}"; \ + exit 1; \ + fi + ${AGENIX} -e secrets/${name}.age + +secret-file: + @if [ -z "${name}" ]; then \ + printf 'Usage: make secret-file name= file=\nAvailable secrets:\n %s\n' "${SECRETS}"; \ + exit 1; \ + fi + @if [ -z "${file}" ]; then \ + printf 'Usage: make secret-file name= file=\n'; \ + exit 1; \ + fi + @if [ ! -f "${file}" ]; then \ + printf 'Source file "%s" not found.\n' "${file}"; \ + exit 1; \ + fi + SECRET_SOURCE_FILE="${file}" EDITOR="${PWD}/Scripts/agenix-load-file.sh" ${AGENIX} -e secrets/${name}.age + + diff --git a/Scripts/_burrow-flake.sh b/Scripts/_burrow-flake.sh index 25fb6e1..ba4e372 100755 --- a/Scripts/_burrow-flake.sh +++ b/Scripts/_burrow-flake.sh @@ -27,7 +27,7 @@ burrow_prepare_flake_ref() { local resolved resolved="$(cd "${input}" && pwd)" - local cache_root="${BURROW_FLAKE_CACHE_ROOT:-${HOME}/.cache/burrow}" + local cache_root="${HOME}/.cache/burrow" mkdir -p "${cache_root}" local copy_root @@ -37,8 +37,6 @@ burrow_prepare_flake_ref() { rsync -a \ --delete \ --exclude '.git' \ - --exclude '.cache' \ - --exclude '.derived-data' \ --exclude '.direnv' \ --exclude 'result' \ --exclude 'burrow.sock' \ diff --git a/Scripts/_burrow-secrets.sh b/Scripts/_burrow-secrets.sh new file mode 100644 index 0000000..6f1bc28 --- /dev/null +++ b/Scripts/_burrow-secrets.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +BURROW_SECRET_TMPFILES=() + +burrow_secret_repo_path() { + local repo_root="$1" + local secret_path="$2" + + case "${secret_path}" in + "${repo_root}"/*) + printf '%s\n' "${secret_path#${repo_root}/}" + ;; + *) + printf '%s\n' "${secret_path}" + ;; + esac +} + +burrow_agenix_identity_path() { + local repo_root="$1" + local candidate + + for candidate in \ + "${BURROW_AGE_IDENTITY:-}" \ + "${BURROW_FORGE_SSH_KEY:-}" \ + "${repo_root}/intake/agent_at_burrow_net_ed25519" \ + "${HOME}/.ssh/agent_at_burrow_net_ed25519" \ + "${HOME}/.ssh/id_ed25519" + do + if [[ -n "${candidate}" && -f "${candidate}" ]]; then + printf '%s\n' "${candidate}" + return 0 + fi + done +} + +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 agenix_path + local identity_path + local tmp_file + + if [[ ! -f "${secret_path}" ]]; then + echo "age secret not found: ${secret_path}" >&2 + return 1 + fi + agenix_path="$(burrow_secret_repo_path "${repo_root}" "${secret_path}")" + identity_path="$(burrow_agenix_identity_path "${repo_root}")" + + tmp_file="$(mktemp "${TMPDIR:-/tmp}/burrow-secret.XXXXXX")" + if [[ -n "${identity_path}" ]]; then + nix --extra-experimental-features "nix-command flakes" run "${repo_root}#agenix" -- -d "${agenix_path}" -i "${identity_path}" > "${tmp_file}" + else + nix --extra-experimental-features "nix-command flakes" run "${repo_root}#agenix" -- -d "${agenix_path}" > "${tmp_file}" + fi + 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 "${age_path}" && -f "${age_path}" ]]; then + burrow_decrypt_age_secret_to_temp "${repo_root}" "${age_path}" + return 0 + fi + + if [[ -n "${intake_path}" && -s "${intake_path}" ]]; then + printf '%s\n' "${intake_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" + local agenix_path + local backup_file="" + + if [[ ! -s "${source_path}" ]]; then + echo "secret source missing or empty: ${source_path}" >&2 + return 1 + fi + agenix_path="$(burrow_secret_repo_path "${repo_root}" "${secret_path}")" + if [[ -f "${secret_path}" ]]; then + backup_file="$(mktemp "${TMPDIR:-/tmp}/burrow-secret-backup.XXXXXX")" + cp "${secret_path}" "${backup_file}" + fi + rm -f "${secret_path}" + + if ! nix --extra-experimental-features "nix-command flakes" run "${repo_root}#agenix" -- -e "${agenix_path}" < "${source_path}"; then + if [[ -n "${backup_file}" && -f "${backup_file}" ]]; then + mv "${backup_file}" "${secret_path}" + fi + return 1 + fi + + [[ -n "${backup_file}" ]] && rm -f "${backup_file}" +} diff --git a/Scripts/agenix-load-file.sh b/Scripts/agenix-load-file.sh new file mode 100755 index 0000000..b91108b --- /dev/null +++ b/Scripts/agenix-load-file.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: agenix-load-file.sh " >&2 + exit 1 +fi + +dest="${!#}" +source_path="${SECRET_SOURCE_FILE:-}" + +if [[ -z "$source_path" ]]; then + echo "SECRET_SOURCE_FILE is not set; point it at the source file to encrypt." >&2 + exit 1 +fi + +if [[ ! -f "$source_path" ]]; then + echo "Source file '$source_path' does not exist." >&2 + exit 1 +fi + +cp "$source_path" "$dest" diff --git a/Scripts/apple/create-asc-certificate.mjs b/Scripts/apple/create-asc-certificate.mjs deleted file mode 100755 index 9c530d9..0000000 --- a/Scripts/apple/create-asc-certificate.mjs +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import { webcrypto } from "node:crypto"; - -const crypto = globalThis.crypto ?? webcrypto; - -function b64url(input) { - const buf = Buffer.isBuffer(input) ? input : Buffer.from(input); - return buf - .toString("base64") - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); -} - -function parseArgs(argv) { - const out = { _: [] }; - for (let i = 2; i < argv.length; i++) { - const arg = argv[i]; - if (arg === "--help" || arg === "-h") out.help = true; - else if (arg.startsWith("--")) { - const key = arg.slice(2); - const value = argv[i + 1]; - if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`); - out[key] = value; - i++; - } else { - out._.push(arg); - } - } - return out; -} - -async function makeJwt({ keyId, issuerId, keyPem }) { - const header = { alg: "ES256", kid: keyId, typ: "JWT" }; - const now = Math.floor(Date.now() / 1000); - const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" }; - const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`; - const pkcs8Der = Buffer.from( - keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""), - "base64", - ); - const key = await crypto.subtle.importKey( - "pkcs8", - pkcs8Der, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - key, - new TextEncoder().encode(input), - ); - return `${input}.${b64url(Buffer.from(sig))}`; -} - -async function ascFetch(jwt, url, opts = {}) { - const res = await fetch(url, { - ...opts, - headers: { - ...(opts.headers ?? {}), - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - }); - const text = await res.text(); - let body; - try { - body = text ? JSON.parse(text) : {}; - } catch { - body = { raw: text }; - } - if (!res.ok) { - throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`); - } - return body; -} - -function certificateFileName(type, id) { - return `${type.toLowerCase().replace(/_/g, "-")}-${id}.cer`; -} - -function csrContentForApi(csrFile) { - const bytes = fs.readFileSync(csrFile); - const text = bytes.toString("ascii"); - const pemMatch = text.match(/-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/); - if (pemMatch) { - return pemMatch[1].replace(/\s+/g, ""); - } - return bytes.toString("base64"); -} - -async function main() { - const args = parseArgs(process.argv); - if (args.help) { - console.log("Usage: create-asc-certificate.mjs --key-id ID --issuer-id ID --key-file AuthKey.p8 --certificate-type IOS_DISTRIBUTION --csr-file file.csr --out-dir DIR"); - process.exit(0); - } - - const keyId = args["key-id"] ?? process.env.APP_STORE_CONNECT_KEY_ID; - const issuerId = args["issuer-id"] ?? process.env.APP_STORE_CONNECT_ISSUER_ID; - const keyFile = args["key-file"] ?? process.env.APP_STORE_CONNECT_KEY_FILE; - const certificateType = args["certificate-type"] ?? "IOS_DISTRIBUTION"; - const csrFile = args["csr-file"]; - const outDir = args["out-dir"] ?? "."; - const outFile = args["out-file"]; - const metadataFile = args["metadata-file"]; - - if (!keyId || !issuerId || !keyFile || !csrFile) { - throw new Error("required: --key-id --issuer-id --key-file --csr-file"); - } - - const jwt = await makeJwt({ - keyId, - issuerId, - keyPem: fs.readFileSync(keyFile, "utf8"), - }); - const csrContent = csrContentForApi(csrFile); - const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/certificates", { - method: "POST", - body: JSON.stringify({ - data: { - type: "certificates", - attributes: { - certificateType, - csrContent, - }, - }, - }), - }); - - const cert = created?.data; - const id = cert?.id; - const content = cert?.attributes?.certificateContent; - if (!id || !content) { - throw new Error(`ASC response did not include certificate id/content: ${JSON.stringify(created).slice(0, 2000)}`); - } - - fs.mkdirSync(outDir, { recursive: true }); - const certificatePath = outFile ?? path.join(outDir, certificateFileName(certificateType, id)); - fs.writeFileSync(certificatePath, Buffer.from(content, "base64")); - - const metadata = { - id, - certificateType: cert?.attributes?.certificateType ?? certificateType, - displayName: cert?.attributes?.displayName, - name: cert?.attributes?.name, - platform: cert?.attributes?.platform, - expirationDate: cert?.attributes?.expirationDate, - serialNumber: cert?.attributes?.serialNumber, - certificatePath, - }; - if (metadataFile) { - fs.writeFileSync(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`); - } - process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`); -} - -main().catch((err) => { - console.error(err?.stack || String(err)); - process.exit(1); -}); diff --git a/Scripts/apple/google-kms-csr.py b/Scripts/apple/google-kms-csr.py deleted file mode 100755 index 15a7d89..0000000 --- a/Scripts/apple/google-kms-csr.py +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env python3 -"""Build an Apple-compatible PKCS#10 CSR backed by Google Cloud KMS. - -The script intentionally implements only the RSA/SHA-256 subset Burrow needs for -Apple Developer ID and iOS Distribution certificate requests. It fetches the KMS -public key, builds CertificationRequestInfo locally, asks KMS to sign that DER -payload, and writes a standard PEM CSR. -""" - -from __future__ import annotations - -import argparse -import base64 -import os -import subprocess -import sys -import tempfile -from pathlib import Path - - -DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33" -DEFAULT_LOCATION = "global" -DEFAULT_KEYRING = "burrow-identity" -DEFAULT_KEY = "apple-developer-id-application" -DEFAULT_VERSION = "1" -DEFAULT_COMMON_NAME = "Burrow Developer ID Application" -DEFAULT_EMAIL = "release@burrow.net" - - -def der_length(length: int) -> bytes: - if length < 0: - raise ValueError("negative DER length") - if length < 0x80: - return bytes([length]) - encoded = length.to_bytes((length.bit_length() + 7) // 8, "big") - return bytes([0x80 | len(encoded)]) + encoded - - -def der(tag: int, body: bytes) -> bytes: - return bytes([tag]) + der_length(len(body)) + body - - -def der_sequence(*items: bytes) -> bytes: - return der(0x30, b"".join(items)) - - -def der_set(*items: bytes) -> bytes: - return der(0x31, b"".join(items)) - - -def der_integer(value: int) -> bytes: - if value < 0: - raise ValueError("negative INTEGER is not supported") - raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big") - if raw[0] & 0x80: - raw = b"\x00" + raw - return der(0x02, raw) - - -def der_null() -> bytes: - return der(0x05, b"") - - -def der_bit_string(value: bytes) -> bytes: - return der(0x03, b"\x00" + value) - - -def der_utf8(value: str) -> bytes: - return der(0x0C, value.encode("utf-8")) - - -def der_printable(value: str) -> bytes: - return der(0x13, value.encode("ascii")) - - -def der_ia5(value: str) -> bytes: - return der(0x16, value.encode("ascii")) - - -def der_oid(dotted: str) -> bytes: - parts = [int(part) for part in dotted.split(".")] - if len(parts) < 2 or parts[0] > 2 or parts[1] > 39: - raise ValueError(f"unsupported OID: {dotted}") - body = bytes([40 * parts[0] + parts[1]]) - for value in parts[2:]: - if value < 0: - raise ValueError(f"negative OID component: {dotted}") - encoded = [value & 0x7F] - value >>= 7 - while value: - encoded.append(0x80 | (value & 0x7F)) - value >>= 7 - body += bytes(reversed(encoded)) - return der(0x06, body) - - -def name_attribute(oid: str, value_der: bytes) -> bytes: - return der_set(der_sequence(der_oid(oid), value_der)) - - -def subject_name(common_name: str, email_address: str, country: str | None) -> bytes: - attrs: list[bytes] = [] - if country: - if len(country) != 2 or not country.isalpha(): - raise ValueError("--country must be a two-letter ISO country code") - attrs.append(name_attribute("2.5.4.6", der_printable(country.upper()))) - if email_address: - attrs.append(name_attribute("1.2.840.113549.1.9.1", der_ia5(email_address))) - attrs.append(name_attribute("2.5.4.3", der_utf8(common_name))) - return der_sequence(*attrs) - - -def pem_to_der(pem: bytes) -> bytes: - lines = [ - line.strip() - for line in pem.splitlines() - if line and not line.startswith(b"-----") - ] - if not lines: - raise ValueError("PEM input did not contain base64 data") - return base64.b64decode(b"".join(lines), validate=True) - - -def pem_wrap(label: str, body: bytes) -> str: - encoded = base64.b64encode(body).decode("ascii") - lines = [f"-----BEGIN {label}-----"] - lines.extend(encoded[i : i + 64] for i in range(0, len(encoded), 64)) - lines.append(f"-----END {label}-----") - return "\n".join(lines) + "\n" - - -def run(command: list[str]) -> None: - subprocess.run(command, check=True) - - -def fetch_public_key(args: argparse.Namespace, output: Path) -> None: - command = [ - args.gcloud, - "kms", - "keys", - "versions", - "get-public-key", - args.kms_version, - "--location", - args.kms_location, - "--keyring", - args.kms_keyring, - "--key", - args.kms_key, - "--output-file", - str(output), - ] - if args.gcloud_project: - command.extend(["--project", args.gcloud_project]) - run(command) - - -def kms_sign(args: argparse.Namespace, payload: bytes) -> bytes: - with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp: - payload_path = Path(tmp) / "certification-request-info.der" - signature_path = Path(tmp) / "signature.b64" - payload_path.write_bytes(payload) - command = [ - args.gcloud, - "kms", - "asymmetric-sign", - "--location", - args.kms_location, - "--keyring", - args.kms_keyring, - "--key", - args.kms_key, - "--version", - args.kms_version, - "--digest-algorithm", - "sha256", - "--input-file", - str(payload_path), - "--signature-file", - str(signature_path), - ] - if args.gcloud_project: - command.extend(["--project", args.gcloud_project]) - run(command) - signature = signature_path.read_bytes() - try: - return base64.b64decode(signature.strip(), validate=True) - except ValueError: - return signature - - -def build_csr(args: argparse.Namespace, spki_der: bytes) -> bytes: - cri = der_sequence( - der_integer(0), - subject_name(args.common_name, args.email_address, args.country), - spki_der, - b"\xA0\x00", - ) - signature = kms_sign(args, cri) - signature_algorithm = der_sequence( - der_oid("1.2.840.113549.1.1.11"), - der_null(), - ) - return der_sequence(cri, signature_algorithm, der_bit_string(signature)) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Generate an Apple-compatible CSR backed by a Google Cloud KMS RSA signing key.", - ) - parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT) - parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION)) - parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING)) - parser.add_argument("--kms-key", default=os.environ.get("BURROW_APPLE_KMS_KEY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_KMS_KEY", DEFAULT_KEY)) - parser.add_argument("--kms-version", default=os.environ.get("BURROW_KMS_VERSION", DEFAULT_VERSION)) - parser.add_argument("--common-name", default=os.environ.get("BURROW_APPLE_CSR_COMMON_NAME") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COMMON_NAME", DEFAULT_COMMON_NAME)) - parser.add_argument("--email-address", default=os.environ.get("BURROW_APPLE_CSR_EMAIL") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_EMAIL", DEFAULT_EMAIL)) - parser.add_argument("--country", default=os.environ.get("BURROW_APPLE_CSR_COUNTRY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COUNTRY", "US")) - parser.add_argument("--public-key-pem", help="Existing KMS public-key PEM path. If omitted, gcloud fetches it.") - parser.add_argument("--public-key-output", help="Write the fetched/used public key PEM to this path.") - parser.add_argument("--output", default="developer-id-application.certSigningRequest") - parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud")) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - output = Path(args.output) - - with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp: - public_key_path = Path(args.public_key_pem) if args.public_key_pem else Path(tmp) / "kms-public.pem" - if not args.public_key_pem: - fetch_public_key(args, public_key_path) - public_key_pem = public_key_path.read_bytes() - spki_der = pem_to_der(public_key_pem) - csr = build_csr(args, spki_der) - - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(pem_wrap("CERTIFICATE REQUEST", csr), encoding="ascii") - if args.public_key_output: - public_output = Path(args.public_key_output) - public_output.parent.mkdir(parents=True, exist_ok=True) - public_output.write_bytes(public_key_pem) - print(f"wrote {output}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/Scripts/apple/profile-entitlements.py b/Scripts/apple/profile-entitlements.py deleted file mode 100755 index 26bd10d..0000000 --- a/Scripts/apple/profile-entitlements.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""Extract signing entitlements from an Apple provisioning profile.""" - -from __future__ import annotations - -import argparse -import plistlib -import subprocess -import sys -from pathlib import Path - -PROFILE_REQUIRED_KEYS = { - "application-identifier", - "beta-reports-active", - "com.apple.developer.team-identifier", - "com.apple.developer.default-data-protection", - "keychain-access-groups", -} - - -def decode_profile(path: Path) -> dict: - commands = [ - ["security", "cms", "-D", "-i", str(path)], - ["openssl", "smime", "-inform", "DER", "-verify", "-noverify", "-in", str(path)], - ["openssl", "cms", "-inform", "DER", "-verify", "-noverify", "-in", str(path)], - ] - errors: list[str] = [] - for command in commands: - try: - proc = subprocess.run( - command, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except FileNotFoundError: - continue - if proc.returncode == 0 and proc.stdout: - return plistlib.loads(proc.stdout) - errors.append(f"{command[0]} exited {proc.returncode}: {proc.stderr.decode(errors='replace').strip()}") - raise RuntimeError(f"unable to decode provisioning profile {path}: {'; '.join(errors)}") - - -def load_entitlements_template(path: Path) -> dict: - value = plistlib.loads(path.read_bytes()) - if not isinstance(value, dict): - raise RuntimeError(f"entitlements template is not a dictionary: {path}") - return value - - -def _has_build_setting(value: object) -> bool: - if isinstance(value, str): - return "$(" in value - if isinstance(value, list): - return any(_has_build_setting(item) for item in value) - if isinstance(value, dict): - return any(_has_build_setting(item) for item in value.values()) - return False - - -def _filter_list(template_value: list, profile_value: object) -> list: - if not isinstance(profile_value, list): - return template_value - profile_set = set(profile_value) - filtered = [item for item in template_value if item in profile_set] - return filtered - - -def filter_entitlements(profile_entitlements: dict, template_entitlements: dict) -> dict: - filtered = { - key: profile_entitlements[key] - for key in PROFILE_REQUIRED_KEYS - if key in profile_entitlements - } - - for key, template_value in template_entitlements.items(): - if key not in profile_entitlements: - print( - f"warning: entitlement {key} requested by template but absent from provisioning profile; omitting", - file=sys.stderr, - ) - continue - - profile_value = profile_entitlements[key] - if _has_build_setting(template_value): - filtered[key] = profile_value - elif isinstance(template_value, list): - filtered[key] = _filter_list(template_value, profile_value) - else: - filtered[key] = template_value - - return dict(sorted(filtered.items())) - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("profile", type=Path) - parser.add_argument("--output", "-o", type=Path, required=True) - parser.add_argument( - "--template", - type=Path, - help="Optional entitlements template used to filter profile entitlements for a target.", - ) - args = parser.parse_args(argv) - - profile = decode_profile(args.profile) - entitlements = profile.get("Entitlements") - if not isinstance(entitlements, dict): - raise RuntimeError(f"profile has no Entitlements dictionary: {args.profile}") - - if args.template: - entitlements = filter_entitlements(entitlements, load_entitlements_template(args.template)) - - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_bytes(plistlib.dumps(entitlements, fmt=plistlib.FMT_XML)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/Scripts/apple/sign-release-artifacts-kms.sh b/Scripts/apple/sign-release-artifacts-kms.sh deleted file mode 100755 index 526c6d6..0000000 --- a/Scripts/apple/sign-release-artifacts-kms.sh +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$repo_root" - -platform="${1:-all}" -build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}" -out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}" -apple_root="${out_root}/apple" -bundle_id="${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}" -network_extension_bundle_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${bundle_id}.network}" -notarize_macos="${BURROW_NOTARIZE_MACOS:-false}" -sparkle_sign_with_kms="${BURROW_SPARKLE_SIGN_WITH_KMS:-true}" - -truthy() { - case "${1:-}" in - 1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;; - *) return 1 ;; - esac -} - -sha256_file() { - local file="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" > "${file}.sha256" - else - sha256sum "$file" > "${file}.sha256" - fi -} - -safe_profile_slug() { - python3 - "$1" <<'PY' -import re -import sys - -print(re.sub(r"[^A-Za-z0-9]", "", sys.argv[1]).lower()) -PY -} - -profile_for() { - local identifier="$1" - local suffix="$2" - local slug path - slug="$(safe_profile_slug "$identifier")" - for path in \ - ".forgejo/actions/export/${slug}${suffix}.provisionprofile" \ - ".forgejo/actions/export/${slug}${suffix}.mobileprovision" \ - "Apple/Profiles/${slug}${suffix}.provisionprofile" \ - "Apple/Profiles/${slug}${suffix}.mobileprovision"; do - if [[ -s "$path" ]]; then - printf '%s\n' "$path" - return 0 - fi - done - return 1 -} - -require_tool() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "error: missing required tool: $1" >&2 - exit 1 - fi -} - -extract_profile_entitlements() { - local profile="$1" - local output="$2" - local template="${3:-}" - local args=("$profile" --output "$output") - if [[ -n "$template" ]]; then - args+=(--template "$template") - fi - Scripts/apple/profile-entitlements.py "${args[@]}" -} - -sign_bundle() { - local family="$1" - local bundle_path="$2" - local entitlements="$3" - local runtime_flag=() - if [[ "$family" == "developer-id" ]]; then - runtime_flag=(--runtime) - fi - Scripts/apple/sign-with-google-kms.sh \ - --family "$family" \ - --path "$bundle_path" \ - --entitlements "$entitlements" \ - "${runtime_flag[@]}" -} - -write_notary_api_key_json() { - local out="$1" - if [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" || -z "${ASC_API_KEY_PATH:-}" ]]; then - echo "macOS notarization requires ASC_API_KEY_ID, ASC_API_ISSUER_ID, and ASC_API_KEY_PATH" >&2 - exit 1 - fi - rcodesign encode-app-store-connect-api-key \ - --output-path "$out" \ - "$ASC_API_ISSUER_ID" \ - "$ASC_API_KEY_ID" \ - "$ASC_API_KEY_PATH" -} - -sign_ios() { - local unsigned_ipa app_profile ext_profile work_dir app ext app_entitlements ext_entitlements output_ipa - unsigned_ipa="${apple_root}/Burrow-iOS-${build_number}.unsigned.ipa" - if [[ ! -s "$unsigned_ipa" ]]; then - echo "error: missing staged unsigned iOS IPA: $unsigned_ipa" >&2 - exit 1 - fi - - app_profile="$(profile_for "$bundle_id" "appstore")" || { - echo "error: missing iOS App Store provisioning profile for ${bundle_id}" >&2 - exit 1 - } - ext_profile="$(profile_for "$network_extension_bundle_id" "appstore")" || { - echo "error: missing iOS App Store provisioning profile for ${network_extension_bundle_id}" >&2 - exit 1 - } - - work_dir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-ios-kms-sign.XXXXXX")" - trap 'rm -rf "${work_dir:-}"' RETURN - unzip -q "$unsigned_ipa" -d "$work_dir" - app="${work_dir}/Payload/Burrow.app" - ext="${app}/PlugIns/BurrowNetworkExtension.appex" - if [[ ! -d "$app" || ! -d "$ext" ]]; then - echo "error: unsigned IPA is missing Burrow.app or BurrowNetworkExtension.appex" >&2 - exit 1 - fi - - cp "$app_profile" "${app}/embedded.mobileprovision" - cp "$ext_profile" "${ext}/embedded.mobileprovision" - app_entitlements="${work_dir}/app-entitlements.plist" - ext_entitlements="${work_dir}/extension-entitlements.plist" - extract_profile_entitlements "$app_profile" "$app_entitlements" Apple/App/App-iOS.entitlements - extract_profile_entitlements "$ext_profile" "$ext_entitlements" Apple/NetworkExtension/NetworkExtension-iOS.entitlements - - rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature" - sign_bundle ios "$ext" "$ext_entitlements" - sign_bundle ios "$app" "$app_entitlements" - - output_ipa="${apple_root}/Burrow-iOS-${build_number}.ipa" - rm -f "$output_ipa" - (cd "$work_dir" && zip -qry "$output_ipa" Payload) - sha256_file "$output_ipa" -} - -write_sparkle_appcast() { - local artifact="$1" - local channel sparkle_dir length pubdate url - channel="${SPARKLE_CHANNEL:-build-${build_number}}" - sparkle_dir="${out_root}/sparkle/${channel}" - mkdir -p "$sparkle_dir" - cp "$artifact" "$sparkle_dir/" - length="$(wc -c < "$artifact" | tr -d ' ')" - pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" - url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$artifact")" - cat > "${sparkle_dir}/appcast.xml" < - - - Burrow ${channel} - - Burrow ${build_number} - ${pubdate} - ${build_number} - 0.1 - - - - -EOF - if truthy "$sparkle_sign_with_kms"; then - Scripts/sparkle/sign-appcast-kms.py \ - --appcast "${sparkle_dir}/appcast.xml" \ - --artifact-dir "$sparkle_dir" - fi -} - -sign_macos() { - local unsigned_zip app_profile ext_profile work_dir app ext app_entitlements ext_entitlements output_zip notary_zip key_json - unsigned_zip="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip" - if [[ ! -s "$unsigned_zip" ]]; then - echo "error: missing staged unsigned macOS ZIP: $unsigned_zip" >&2 - exit 1 - fi - - app_profile="$(profile_for "$bundle_id" "developerid")" || { - echo "error: missing macOS Developer ID provisioning profile for ${bundle_id}" >&2 - exit 1 - } - ext_profile="$(profile_for "$network_extension_bundle_id" "developerid")" || { - echo "error: missing macOS Developer ID provisioning profile for ${network_extension_bundle_id}" >&2 - exit 1 - } - - work_dir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-macos-kms-sign.XXXXXX")" - trap 'rm -rf "${work_dir:-}"' RETURN - unzip -q "$unsigned_zip" -d "$work_dir" - app="$(find "$work_dir" -maxdepth 2 -type d -name 'Burrow.app' -print | head -n 1)" - if [[ -z "$app" || ! -d "$app" ]]; then - echo "error: unsigned macOS ZIP does not contain Burrow.app" >&2 - exit 1 - fi - - ext="${app}/Contents/PlugIns/BurrowNetworkExtension.appex" - cp "$app_profile" "${app}/Contents/embedded.provisionprofile" - app_entitlements="${work_dir}/app-entitlements.plist" - extract_profile_entitlements "$app_profile" "$app_entitlements" - - if [[ -d "$ext" ]]; then - cp "$ext_profile" "${ext}/Contents/embedded.provisionprofile" - ext_entitlements="${work_dir}/extension-entitlements.plist" - extract_profile_entitlements "$ext_profile" "$ext_entitlements" - rm -rf "${ext}/Contents/_CodeSignature" - sign_bundle developer-id "$ext" "$ext_entitlements" - fi - - rm -rf "${app}/Contents/_CodeSignature" - sign_bundle developer-id "$app" "$app_entitlements" - - if truthy "$notarize_macos"; then - key_json="${work_dir}/notary-api-key.json" - notary_zip="${work_dir}/Burrow.notary.zip" - write_notary_api_key_json "$key_json" - (cd "$(dirname "$app")" && zip -qry "$notary_zip" "$(basename "$app")") - rcodesign notary-submit --api-key-file "$key_json" --wait "$notary_zip" - rcodesign staple "$app" - fi - - output_zip="${apple_root}/Burrow-macOS-${build_number}.zip" - rm -f "$output_zip" - (cd "$(dirname "$app")" && zip -qry "$output_zip" "$(basename "$app")") - sha256_file "$output_zip" - write_sparkle_appcast "$output_zip" -} - -require_tool python3 -require_tool unzip -require_tool zip -require_tool rcodesign -require_tool gcloud -mkdir -p "$apple_root" - -case "$platform" in - all) - sign_ios - sign_macos - ;; - ios) - sign_ios - ;; - macos) - sign_macos - ;; - *) - echo "unknown Apple KMS signing platform: $platform" >&2 - exit 64 - ;; -esac - -find "$out_root" -type f -print | sort diff --git a/Scripts/apple/sign-with-google-kms.sh b/Scripts/apple/sign-with-google-kms.sh deleted file mode 100755 index 33d5c7e..0000000 --- a/Scripts/apple/sign-with-google-kms.sh +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$repo_root" - -family="" -path="" -entitlements="" -certificate="" -kms_key="" -kms_version="" -runtime=false -timestamp_url="" - -usage() { - cat <<'EOF' -Usage: Scripts/apple/sign-with-google-kms.sh --family ios|developer-id --path [options] - -Options: - --entitlements XML entitlements for bundle signing - --certificate Apple public certificate matching the KMS key - --kms-key Google KMS crypto key name/resource - --kms-version Google KMS crypto key version - --runtime Set hardened-runtime code-signature flags -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --family) - family="${2:?missing value for --family}" - shift 2 - ;; - --path) - path="${2:?missing value for --path}" - shift 2 - ;; - --entitlements) - entitlements="${2:?missing value for --entitlements}" - shift 2 - ;; - --certificate) - certificate="${2:?missing value for --certificate}" - shift 2 - ;; - --kms-key) - kms_key="${2:?missing value for --kms-key}" - shift 2 - ;; - --kms-version) - kms_version="${2:?missing value for --kms-version}" - shift 2 - ;; - --runtime) - runtime=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument $1" >&2 - usage >&2 - exit 64 - ;; - esac -done - -case "$family" in - ios) - certificate="${certificate:-Apple/Certificates/ios-distribution-3G42677598.cer}" - kms_key="${kms_key:-${BURROW_IOS_DISTRIBUTION_KMS_KEY:-apple-ios-distribution}}" - kms_version="${kms_version:-${BURROW_IOS_DISTRIBUTION_KMS_VERSION:-1}}" - timestamp_url="${BURROW_IOS_CODESIGN_TIMESTAMP_URL:-none}" - ;; - developer-id) - certificate="${certificate:-Apple/Certificates/developer-id-application-9JKN6HXBHC.cer}" - kms_key="${kms_key:-${BURROW_DEVELOPER_ID_KMS_KEY:-apple-developer-id-application}}" - kms_version="${kms_version:-${BURROW_DEVELOPER_ID_KMS_VERSION:-1}}" - timestamp_url="${BURROW_DEVELOPER_ID_TIMESTAMP_URL:-${BURROW_APPLE_TIMESTAMP_URL:-}}" - ;; - *) - echo "error: --family must be ios or developer-id" >&2 - exit 64 - ;; -esac - -if [[ -z "$path" ]]; then - echo "error: --path is required" >&2 - exit 64 -fi -if [[ "$path" != /* ]]; then - path="$repo_root/$path" -fi -if [[ "$certificate" != /* ]]; then - certificate="$repo_root/$certificate" -fi -if [[ -n "$entitlements" && "$entitlements" != /* ]]; then - entitlements="$repo_root/$entitlements" -fi -if [[ ! -e "$path" ]]; then - echo "error: signing target does not exist: $path" >&2 - exit 1 -fi -if [[ ! -s "$certificate" ]]; then - echo "error: Apple certificate is missing or empty: $certificate" >&2 - exit 1 -fi -if [[ -n "$entitlements" && ! -s "$entitlements" ]]; then - echo "error: entitlements file is missing or empty: $entitlements" >&2 - exit 1 -fi - -require_tool() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "error: missing required tool: $1" >&2 - exit 1 - fi -} - -resolve_module() { - local candidate - for candidate in \ - "${BURROW_APPLE_PKCS11_MODULE:-}" \ - "${BURROW_GCP_KMS_PKCS11_MODULE:-}" \ - "${BURROW_PKCS11_MODULE:-}" \ - "$(command -v google-kms-pkcs11-module >/dev/null 2>&1 && google-kms-pkcs11-module || true)" \ - /nix/store/*-google-kms-pkcs11-*/lib/libkmsp11.so \ - /nix/store/*-google-kms-pkcs11-*/lib/libkmsp11.dylib; do - if [[ -n "$candidate" && -f "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - return 1 -} - -cert_public_key() { - local cert_file="$1" - local out="$2" - if ! openssl x509 -inform DER -in "$cert_file" -pubkey -noout >"$out" 2>/dev/null; then - openssl x509 -in "$cert_file" -pubkey -noout >"$out" - fi -} - -public_fingerprint() { - openssl pkey -pubin -in "$1" -outform DER \ - | openssl dgst -sha256 -binary \ - | od -An -tx1 \ - | tr -d ' \n' -} - -resolve_kms_parts() { - local resource="$1" - local key_ring="${BURROW_GCP_KMS_KEY_RING:-${GOOGLE_KMS_KEY_RING:-projects/${GOOGLE_CLOUD_PROJECT:-project-88c23ce9-918a-470a-b33}/locations/${BURROW_KMS_LOCATION:-global}/keyRings/${BURROW_KMS_KEYRING:-burrow-identity}}}" - - if [[ "$resource" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)/cryptoKeyVersions/([^/]+)$ ]]; then - printf '%s\n%s\n%s\n%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}" - return 0 - fi - if [[ "$resource" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)$ ]]; then - printf '%s\n%s\n%s\n%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" "$kms_version" - return 0 - fi - if [[ ! "$key_ring" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)$ ]]; then - echo "error: invalid KMS key ring resource: $key_ring" >&2 - exit 1 - fi - printf '%s\n%s\n%s\n%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "$resource" "$kms_version" -} - -verify_certificate_matches_kms() { - local work_dir="$1" - local cert_public="$work_dir/apple-public.pem" - local kms_public="$work_dir/kms-public.pem" - local project location keyring key version expected actual - kms_info="$(resolve_kms_parts "$kms_key")" - project="$(printf '%s\n' "$kms_info" | sed -n '1p')" - location="$(printf '%s\n' "$kms_info" | sed -n '2p')" - keyring="$(printf '%s\n' "$kms_info" | sed -n '3p')" - key="$(printf '%s\n' "$kms_info" | sed -n '4p')" - version="$(printf '%s\n' "$kms_info" | sed -n '5p')" - if [[ -z "$version" ]]; then - echo "error: KMS key version is required for Apple release signing" >&2 - exit 1 - fi - - cert_public_key "$certificate" "$cert_public" - expected="$(public_fingerprint "$cert_public")" - gcloud kms keys versions get-public-key "$version" \ - --project="$project" \ - --location="$location" \ - --keyring="$keyring" \ - --key="$key" \ - --output-file="$kms_public" >/dev/null - actual="$(public_fingerprint "$kms_public")" - if [[ "$actual" != "$expected" ]]; then - echo "error: Apple certificate public key does not match Google KMS key" >&2 - echo "family=$family" >&2 - echo "kms_key=$key" >&2 - echo "kms_version=$version" >&2 - echo "certificate_spki_sha256=$expected" >&2 - echo "kms_spki_sha256=$actual" >&2 - exit 1 - fi -} - -require_tool rcodesign -require_tool openssl -require_tool od -require_tool gcloud - -if ! rcodesign sign --help 2>&1 | grep -Fq -- "--pkcs11-library"; then - echo "error: rcodesign does not include PKCS#11 signing support" >&2 - exit 1 -fi - -module="$(resolve_module || true)" -if [[ -z "$module" ]]; then - echo "error: unable to locate Google KMS PKCS#11 module" >&2 - exit 1 -fi - -work_dir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-apple-kms-sign.XXXXXX")" -trap 'rm -rf "$work_dir"' EXIT - -eval "$(Scripts/release/google-kms-pkcs11-materialize.sh --emit-env)" -verify_certificate_matches_kms "$work_dir" - -real_openssl="$(command -v openssl)" -export BURROW_REAL_OPENSSL="${BURROW_REAL_OPENSSL:-$real_openssl}" -export BURROW_OPENSSL="$repo_root/Scripts/release/google-kms-openssl-dgst-shim.sh" -export BURROW_APPLE_PKCS11_BACKEND=gcp-kms -export BURROW_APPLE_PKCS11_SIGN_WITH_OPENSSL_PROVIDER=true -export BURROW_APPLE_PKCS11_SIGN_WITH_GCLOUD=true -export BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY="$kms_key" -export BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY_VERSION="$kms_version" - -pin="${BURROW_APPLE_PKCS11_PIN:-${BURROW_GCP_KMS_PKCS11_PIN:-${BURROW_PKCS11_PIN:-ignored}}}" -token_label="${BURROW_APPLE_PKCS11_TOKEN_LABEL:-${BURROW_GCP_KMS_PKCS11_TOKEN_LABEL:-${BURROW_PKCS11_TOKEN_LABEL:-burrow-release}}}" - -args=( - sign - --pkcs11-library "$module" - --pkcs11-certificate-file "$certificate" - --pkcs11-token-label "$token_label" - --pkcs11-key-label "$kms_key" - --pkcs11-pin "$pin" -) -if [[ -n "$timestamp_url" ]]; then - args+=(--timestamp-url "$timestamp_url") -fi -if [[ -n "$entitlements" ]]; then - args+=(--entitlements-xml-file "$entitlements") -fi -if [[ "$runtime" == "true" ]]; then - args+=(--code-signature-flags runtime) -fi -args+=("$path") - -rcodesign "${args[@]}" diff --git a/Scripts/apple/sync-provisioning-profiles.mjs b/Scripts/apple/sync-provisioning-profiles.mjs deleted file mode 100755 index 1a592d6..0000000 --- a/Scripts/apple/sync-provisioning-profiles.mjs +++ /dev/null @@ -1,331 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import { webcrypto } from "node:crypto"; - -const crypto = globalThis.crypto ?? webcrypto; - -function b64url(input) { - const buf = Buffer.isBuffer(input) ? input : Buffer.from(input); - return buf - .toString("base64") - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); -} - -function parseArgs(argv) { - const out = { _: [] }; - for (let i = 2; i < argv.length; i++) { - const arg = argv[i]; - if (arg === "--help" || arg === "-h") out.help = true; - else if (arg.startsWith("--")) { - const key = arg.slice(2); - const value = argv[i + 1]; - if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`); - out[key] = value; - i++; - } else { - out._.push(arg); - } - } - return out; -} - -async function makeJwt({ keyId, issuerId, keyPem }) { - const header = { alg: "ES256", kid: keyId, typ: "JWT" }; - const now = Math.floor(Date.now() / 1000); - const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" }; - const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`; - const pkcs8Der = Buffer.from( - keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""), - "base64", - ); - const key = await crypto.subtle.importKey( - "pkcs8", - pkcs8Der, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - key, - new TextEncoder().encode(input), - ); - return `${input}.${b64url(Buffer.from(sig))}`; -} - -async function ascFetch(jwt, url, opts = {}) { - const res = await fetch(url, { - ...opts, - headers: { - ...(opts.headers ?? {}), - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - }); - const text = await res.text(); - let body; - try { - body = text ? JSON.parse(text) : {}; - } catch { - body = { raw: text }; - } - if (!res.ok) { - throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`); - } - return body; -} - -function truthy(value) { - return ["1", "true", "yes", "on"].includes(String(value ?? "").toLowerCase()); -} - -function safeFileName(identifier, suffix) { - return `${identifier.replace(/[^A-Za-z0-9]/g, "").toLowerCase()}${suffix}`; -} - -function bundleDisplayName(identifier, appId) { - return identifier === appId ? "Burrow App" : "Burrow Network Extension"; -} - -async function findCertificate(jwt, preferredTypes, preferredId) { - const certs = await ascFetch( - jwt, - "https://api.appstoreconnect.apple.com/v1/certificates?limit=200", - ); - const data = certs.data ?? []; - if (preferredId) { - const cert = data.find((item) => item?.id === preferredId); - if (!cert) throw new Error(`unable to locate certificate id ${preferredId} via ASC API`); - const type = cert?.attributes?.certificateType; - if (preferredTypes.length && !preferredTypes.includes(type)) { - throw new Error(`certificate ${preferredId} has type ${type}, expected ${preferredTypes.join(" or ")}`); - } - return cert; - } - for (const type of preferredTypes) { - const cert = data.find((item) => item?.attributes?.certificateType === type); - if (cert) return cert; - } - throw new Error(`unable to locate certificate type ${preferredTypes.join(" or ")} via ASC API`); -} - -async function resolveBundle(jwt, target, { createMissing, platform }) { - const bundleResp = await ascFetch( - jwt, - `https://api.appstoreconnect.apple.com/v1/bundleIds?limit=5&filter[identifier]=${encodeURIComponent(target.identifier)}`, - ); - let bundle = (bundleResp.data ?? []).find( - (item) => item?.attributes?.identifier === target.identifier, - ); - if (bundle || !createMissing) return bundle; - - const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIds", { - method: "POST", - body: JSON.stringify({ - data: { - type: "bundleIds", - attributes: { - identifier: target.identifier, - name: target.bundleName ?? target.name, - platform, - }, - }, - }), - }); - bundle = created?.data; - if (bundle?.id) { - process.stdout.write(`created bundle id ${target.identifier} (${platform})\n`); - } - return bundle; -} - -async function listCapabilityTypes(jwt, bundleId) { - const resp = await ascFetch( - jwt, - `https://api.appstoreconnect.apple.com/v1/bundleIds/${bundleId}/bundleIdCapabilities`, - ); - return new Set((resp.data ?? []).map((item) => item?.attributes?.capabilityType).filter(Boolean)); -} - -async function enableCapability(jwt, bundleId, capabilityType, settings) { - await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIdCapabilities", { - method: "POST", - body: JSON.stringify({ - data: { - type: "bundleIdCapabilities", - attributes: { - capabilityType, - ...(settings ? { settings } : {}), - }, - relationships: { - bundleId: { - data: { - id: bundleId, - type: "bundleIds", - }, - }, - }, - }, - }), - }); -} - -async function profileIncludesCertificate(jwt, profileId, certificateId) { - const resp = await ascFetch( - jwt, - `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}/certificates?limit=200`, - ); - return (resp.data ?? []).some((item) => item?.id === certificateId); -} - -async function ensureCapabilities(jwt, bundle, capabilities) { - const existing = await listCapabilityTypes(jwt, bundle.id); - for (const capability of capabilities) { - if (existing.has(capability.type)) continue; - try { - await enableCapability(jwt, bundle.id, capability.type, capability.settings); - process.stdout.write(`enabled ${capability.type} for ${bundle.attributes.identifier}\n`); - } catch (err) { - process.stderr.write(`warning: could not enable ${capability.type} for ${bundle.attributes.identifier}: ${err.message}\n`); - } - } -} - -async function syncTarget(jwt, cert, profileType, target, outDir, options) { - const bundle = await resolveBundle(jwt, target, options); - if (!bundle) throw new Error(`bundleId not found in ASC: ${target.identifier}`); - - if (options.enableCapabilities && target.capabilities?.length) { - await ensureCapabilities(jwt, bundle, target.capabilities); - } - - const existing = await ascFetch( - jwt, - `https://api.appstoreconnect.apple.com/v1/profiles?limit=10&filter[profileType]=${encodeURIComponent(profileType)}&filter[name]=${encodeURIComponent(target.name)}`, - ); - let profileId = (existing.data ?? []).find((item) => item?.attributes?.name === target.name)?.id; - if (profileId && options.replaceCertificateMismatch) { - const includesCertificate = await profileIncludesCertificate(jwt, profileId, cert.id); - if (!includesCertificate) { - await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`, { - method: "DELETE", - }); - process.stdout.write(`deleted stale profile ${target.name}; it did not include certificate ${cert.id}\n`); - profileId = undefined; - } - } - if (!profileId) { - const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/profiles", { - method: "POST", - body: JSON.stringify({ - data: { - type: "profiles", - attributes: { name: target.name, profileType }, - relationships: { - bundleId: { data: { type: "bundleIds", id: bundle.id } }, - certificates: { data: [{ type: "certificates", id: cert.id }] }, - }, - }, - }), - }); - profileId = created?.data?.id; - } - if (!profileId) throw new Error(`failed to resolve/create profile for ${target.identifier}`); - - const profile = await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`); - const content = profile?.data?.attributes?.profileContent; - if (!content) throw new Error(`profileContent missing for ${target.name} (${profileId})`); - - fs.mkdirSync(outDir, { recursive: true }); - const profileBytes = Buffer.from(content, "base64"); - const profilePath = path.join(outDir, target.file); - fs.writeFileSync(profilePath, profileBytes); - if (target.mobileFile) { - fs.writeFileSync(path.join(outDir, target.mobileFile), profileBytes); - } - process.stdout.write(`synced ${target.identifier} -> ${profilePath}\n`); -} - -async function main() { - const args = parseArgs(process.argv); - if (args.help) { - console.log("Usage: sync-provisioning-profiles.mjs --platform ios|macos|all --key-id ID --issuer-id ID --key-file AuthKey.p8 --out-dir DIR [--ios-certificate-id ID] [--macos-certificate-id ID] [--replace-certificate-mismatch true]"); - process.exit(0); - } - - const platform = args.platform ?? "all"; - const keyId = args["key-id"]; - const issuerId = args["issuer-id"]; - const keyFile = args["key-file"]; - const outDir = args["out-dir"] ?? ".forgejo/actions/export"; - const appId = args["app-id"] ?? "net.burrow.app"; - const networkId = args["network-id"] ?? `${appId}.network`; - const iosCertificateId = args["ios-certificate-id"] ?? process.env.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID; - const macosCertificateId = args["macos-certificate-id"] ?? process.env.BURROW_DEVELOPER_ID_CERTIFICATE_ID; - const createMissing = truthy(args["create-missing-bundle-ids"]); - const enableCapabilities = createMissing || truthy(args["enable-capabilities"]); - const replaceCertificateMismatch = truthy(args["replace-certificate-mismatch"]); - const bundlePlatform = args["bundle-platform"] ?? "UNIVERSAL"; - const associatedDomains = (args["associated-domains"] ?? "applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - const appCapabilities = [ - { type: "NETWORK_EXTENSIONS" }, - { type: "APP_GROUPS" }, - { - type: "ASSOCIATED_DOMAINS", - settings: [{ key: "ASSOCIATED_DOMAIN_IDS", options: associatedDomains.map((key) => ({ key, enabled: true })) }], - }, - ]; - const extensionCapabilities = [ - { type: "NETWORK_EXTENSIONS" }, - { type: "APP_GROUPS" }, - ]; - const options = { createMissing, enableCapabilities, replaceCertificateMismatch, platform: bundlePlatform }; - - if (!keyId || !issuerId || !keyFile) throw new Error("required: --key-id --issuer-id --key-file"); - - const jwt = await makeJwt({ keyId, issuerId, keyPem: fs.readFileSync(keyFile, "utf8") }); - - if (platform === "ios" || platform === "all") { - const cert = await findCertificate(jwt, ["IOS_DISTRIBUTION", "DISTRIBUTION", "APPLE_DISTRIBUTION"], iosCertificateId); - const targets = [appId, networkId].map((identifier) => ({ - identifier, - name: `${identifier}.app-store`, - bundleName: bundleDisplayName(identifier, appId), - file: safeFileName(identifier, "appstore.provisionprofile"), - mobileFile: safeFileName(identifier, "appstore.mobileprovision"), - capabilities: identifier === appId ? appCapabilities : extensionCapabilities, - })); - for (const target of targets) { - await syncTarget(jwt, cert, "IOS_APP_STORE", target, outDir, options); - } - } - - if (platform === "macos" || platform === "all") { - const cert = await findCertificate( - jwt, - ["DEVELOPER_ID_APPLICATION", "DEVELOPER_ID_APPLICATION_G2"], - macosCertificateId, - ); - const targets = [appId, networkId].map((identifier) => ({ - identifier, - name: `${identifier}.developer-id`, - bundleName: bundleDisplayName(identifier, appId), - file: safeFileName(identifier, "developerid.provisionprofile"), - capabilities: identifier === appId ? appCapabilities : extensionCapabilities, - })); - for (const target of targets) { - await syncTarget(jwt, cert, "MAC_APP_DIRECT", target, outDir, options); - } - } -} - -main().catch((err) => { - console.error(err?.stack || String(err)); - process.exit(1); -}); diff --git a/Scripts/authentik-sync-1password-oidc.sh b/Scripts/authentik-sync-1password-oidc.sh deleted file mode 100755 index f523d9a..0000000 --- a/Scripts/authentik-sync-1password-oidc.sh +++ /dev/null @@ -1,243 +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_ONEPASSWORD_APPLICATION_SLUG:-onepassword}" -application_name="${AUTHENTIK_ONEPASSWORD_APPLICATION_NAME:-1Password}" -provider_name="${AUTHENTIK_ONEPASSWORD_PROVIDER_NAME:-1Password}" -template_slug="${AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG:-ts}" -client_id="${AUTHENTIK_ONEPASSWORD_CLIENT_ID:-1password.burrow.net}" -launch_url="${AUTHENTIK_ONEPASSWORD_LAUNCH_URL:-https://burrow-team.1password.com/}" -redirect_uris_json="${AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON:-[ - \"https://burrow-team.1password.com/sso/oidc/redirect/\", - \"onepassword://sso/oidc/redirect\" -]}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-1password-oidc.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_ONEPASSWORD_APPLICATION_SLUG - AUTHENTIK_ONEPASSWORD_APPLICATION_NAME - AUTHENTIK_ONEPASSWORD_PROVIDER_NAME - AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG - AUTHENTIK_ONEPASSWORD_CLIENT_ID - AUTHENTIK_ONEPASSWORD_LAUNCH_URL - AUTHENTIK_ONEPASSWORD_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_ONEPASSWORD_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 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: "public", - 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" - }' -)" - -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: 1Password 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: true, - 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: 1Password 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 1Password OIDC application ${application_slug} (${application_name})." - exit 0 - fi - sleep 2 -done - -echo "warning: 1Password OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik 1Password OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh deleted file mode 100644 index 68dbcff..0000000 --- a/Scripts/authentik-sync-burrow-directory.sh +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" -bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" -directory_json="${AUTHENTIK_BURROW_DIRECTORY_JSON:-[]}" -users_group="${AUTHENTIK_BURROW_USERS_GROUP:-burrow-users}" -admins_group="${AUTHENTIK_BURROW_ADMINS_GROUP:-burrow-admins}" -extra_groups_json="${AUTHENTIK_BURROW_EXTRA_GROUPS_JSON:-[]}" -forgejo_application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-burrow-directory.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_BURROW_DIRECTORY_JSON - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_BURROW_USERS_GROUP - AUTHENTIK_BURROW_ADMINS_GROUP - AUTHENTIK_BURROW_EXTRA_GROUPS_JSON - AUTHENTIK_FORGEJO_APPLICATION_SLUG -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' "$directory_json" | jq -e 'type == "array"' >/dev/null; then - echo "error: AUTHENTIK_BURROW_DIRECTORY_JSON must be a JSON array" >&2 - exit 1 -fi - -if ! printf '%s' "$extra_groups_json" | jq -e 'type == "array"' >/dev/null; then - echo "error: AUTHENTIK_BURROW_EXTRA_GROUPS_JSON must be a 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 -} - -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 -} - -lookup_group_pk() { - local group_name="$1" - - api GET "/api/v3/core/groups/?page_size=200&search=${group_name}" \ - | jq -r --arg name "$group_name" '.results[]? | select(.name == $name) | .pk // empty' \ - | head -n1 -} - -ensure_group() { - local group_name="$1" - local payload group_pk - - payload="$( - jq -cn \ - --arg name "$group_name" \ - '{name: $name}' - )" - - group_pk="$(lookup_group_pk "$group_name")" - if [[ -n "$group_pk" ]]; then - api PATCH "/api/v3/core/groups/${group_pk}/" "$payload" >/dev/null - else - group_pk="$( - api POST "/api/v3/core/groups/" "$payload" \ - | jq -r '.pk // empty' - )" - fi - - if [[ -z "$group_pk" ]]; then - echo "error: could not create Authentik group ${group_name}" >&2 - exit 1 - fi - - printf '%s\n' "$group_pk" -} - -lookup_user_pk() { - local username="$1" - - api GET "/api/v3/core/users/?page_size=200&search=${username}" \ - | jq -r --arg username "$username" '.results[]? | select(.username == $username) | .pk // empty' \ - | head -n1 -} - -ensure_user() { - local user_spec="$1" - local username name email is_admin groups_json password_file effective_groups_json group_name - local group_pks_json payload user_pk - - username="$(printf '%s\n' "$user_spec" | jq -r '.username')" - name="$(printf '%s\n' "$user_spec" | jq -r '.name')" - email="$(printf '%s\n' "$user_spec" | jq -r '.email')" - is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')" - groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')" - password_file="$(printf '%s\n' "$user_spec" | jq -r '.passwordFile // empty')" - - if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then - echo "error: each Burrow Authentik user requires username and email" >&2 - exit 1 - fi - - effective_groups_json="$( - printf '%s\n' "$groups_json" \ - | jq -c --arg users_group "$users_group" --arg admins_group "$admins_group" --argjson is_admin "$is_admin" ' - . + [$users_group] + (if $is_admin then [$admins_group] else [] end) | unique - ' - )" - - group_pks_json='[]' - while IFS= read -r group_name; do - group_pk="$(ensure_group "$group_name")" - group_pks_json="$( - jq -cn \ - --argjson current "$group_pks_json" \ - --arg next "$group_pk" \ - '$current + [$next]' - )" - done < <(printf '%s\n' "$effective_groups_json" | jq -r '.[]') - - payload="$( - jq -cn \ - --arg username "$username" \ - --arg name "$name" \ - --arg email "$email" \ - --argjson groups "$group_pks_json" \ - '{ - username: $username, - name: $name, - email: $email, - is_active: true, - path: "users", - groups: $groups - }' - )" - - user_pk="$(lookup_user_pk "$username")" - if [[ -n "$user_pk" ]]; then - api PATCH "/api/v3/core/users/${user_pk}/" "$payload" >/dev/null - else - user_pk="$( - api POST "/api/v3/core/users/" "$payload" \ - | jq -r '.pk // empty' - )" - fi - - if [[ -z "$user_pk" ]]; then - echo "error: could not create Authentik user ${username}" >&2 - exit 1 - fi - - if [[ -n "$password_file" ]]; then - if [[ ! -s "$password_file" ]]; then - echo "error: password file for Authentik user ${username} is missing: ${password_file}" >&2 - exit 1 - fi - - api POST "/api/v3/core/users/${user_pk}/set_password/" "$( - jq -cn \ - --arg password "$(tr -d '\r\n' < "$password_file")" \ - '{password: $password}' - )" >/dev/null - fi -} - -lookup_application_pk() { - local slug="$1" - - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ - | head -n1 -} - -ensure_application_group_binding() { - local application_slug="$1" - local group_name="$2" - local application_pk group_pk existing payload binding_pk - - application_pk="$(lookup_application_pk "$application_slug")" - if [[ -z "$application_pk" ]]; then - echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 - return 0 - fi - - group_pk="$(lookup_group_pk "$group_name")" - if [[ -z "$group_pk" ]]; then - echo "error: could not resolve Authentik group ${group_name}" >&2 - exit 1 - fi - - existing="$( - api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ - | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ - | head -n1 - )" - - payload="$( - jq -cn \ - --arg target "$application_pk" \ - --arg group "$group_pk" \ - '{ - group: $group, - target: $target, - negate: false, - enabled: true, - order: 100, - timeout: 30, - failure_result: false - }' - )" - - if [[ -n "$existing" ]]; then - binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null - else - api POST "/api/v3/policies/bindings/" "$payload" >/dev/null - fi -} - -wait_for_authentik -ensure_group "$users_group" >/dev/null -ensure_group "$admins_group" >/dev/null -while IFS= read -r group_name; do - ensure_group "$group_name" >/dev/null -done < <(printf '%s\n' "$extra_groups_json" | jq -r '.[]') - -while IFS= read -r user_spec; do - ensure_user "$user_spec" -done < <(printf '%s\n' "$directory_json" | jq -c '.[]') - -if [[ -n "$forgejo_application_slug" ]]; then - ensure_application_group_binding "$forgejo_application_slug" "$users_group" -fi - -echo "Synced Burrow Authentik directory." diff --git a/Scripts/authentik-sync-forgejo-oidc.sh b/Scripts/authentik-sync-forgejo-oidc.sh deleted file mode 100644 index b490698..0000000 --- a/Scripts/authentik-sync-forgejo-oidc.sh +++ /dev/null @@ -1,249 +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_FORGEJO_APPLICATION_SLUG:-git}" -application_name="${AUTHENTIK_FORGEJO_APPLICATION_NAME:-burrow.net}" -provider_name="${AUTHENTIK_FORGEJO_PROVIDER_NAME:-burrow.net}" -client_id="${AUTHENTIK_FORGEJO_CLIENT_ID:-git.burrow.net}" -client_secret="${AUTHENTIK_FORGEJO_CLIENT_SECRET:-}" -launch_url="${AUTHENTIK_FORGEJO_LAUNCH_URL:-https://git.burrow.net/}" -redirect_uris_json="${AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON:-[ - \"https://git.burrow.net/user/oauth2/burrow.net/callback\", - \"https://git.burrow.net/user/oauth2/authentik/callback\" -]}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-forgejo-oidc.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_FORGEJO_CLIENT_SECRET - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_FORGEJO_APPLICATION_SLUG - AUTHENTIK_FORGEJO_APPLICATION_NAME - AUTHENTIK_FORGEJO_PROVIDER_NAME - AUTHENTIK_FORGEJO_CLIENT_ID - AUTHENTIK_FORGEJO_LAUNCH_URL - AUTHENTIK_FORGEJO_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 [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then - echo "Forgejo OIDC client secret is not configured; skipping Authentik Forgejo sync." >&2 - exit 0 -fi - -if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then - echo "error: AUTHENTIK_FORGEJO_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 '.results[]? | select(.assigned_application_slug == "ts")' \ - | head -n1 -)" - -if [[ -z "$template_provider" ]]; then - echo "error: could not resolve the Burrow Tailnet OAuth provider template" >&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: "confidential", - client_id: $client_id, - client_secret: $client_secret, - 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" - }' -)" - -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: Forgejo 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: Forgejo 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 Forgejo OIDC application ${application_slug} (${application_name})." - exit 0 - fi - sleep 2 -done - -echo "warning: Forgejo OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Forgejo OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-google-source.sh b/Scripts/authentik-sync-google-source.sh deleted file mode 100755 index a4c9edb..0000000 --- a/Scripts/authentik-sync-google-source.sh +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" -bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" -google_client_id="${AUTHENTIK_GOOGLE_CLIENT_ID:-}" -google_client_secret="${AUTHENTIK_GOOGLE_CLIENT_SECRET:-}" -source_slug="${AUTHENTIK_GOOGLE_SOURCE_SLUG:-google}" -source_name="${AUTHENTIK_GOOGLE_SOURCE_NAME:-Google}" -identification_stage_name="${AUTHENTIK_GOOGLE_IDENTIFICATION_STAGE_NAME:-default-authentication-identification}" -authentication_flow_slug="${AUTHENTIK_GOOGLE_AUTHENTICATION_FLOW_SLUG:-default-source-authentication}" -enrollment_flow_slug="${AUTHENTIK_GOOGLE_ENROLLMENT_FLOW_SLUG:-default-source-enrollment}" -login_mode="${AUTHENTIK_GOOGLE_LOGIN_MODE:-redirect}" -user_matching_mode="${AUTHENTIK_GOOGLE_USER_MATCHING_MODE:-email_link}" -policy_engine_mode="${AUTHENTIK_GOOGLE_POLICY_ENGINE_MODE:-any}" -google_account_map_json="${AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON:-[]}" -property_mapping_name="${AUTHENTIK_GOOGLE_PROPERTY_MAPPING_NAME:-Burrow Google Account Map}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-google-source.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_GOOGLE_CLIENT_ID - AUTHENTIK_GOOGLE_CLIENT_SECRET - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_GOOGLE_SOURCE_SLUG - AUTHENTIK_GOOGLE_SOURCE_NAME - AUTHENTIK_GOOGLE_IDENTIFICATION_STAGE_NAME - AUTHENTIK_GOOGLE_AUTHENTICATION_FLOW_SLUG - AUTHENTIK_GOOGLE_ENROLLMENT_FLOW_SLUG - AUTHENTIK_GOOGLE_LOGIN_MODE promoted|redirect - AUTHENTIK_GOOGLE_USER_MATCHING_MODE identifier|email_link|email_deny|username_link|username_deny - AUTHENTIK_GOOGLE_POLICY_ENGINE_MODE all|any - AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON JSON array of alias mappings - AUTHENTIK_GOOGLE_PROPERTY_MAPPING_NAME -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 [[ -z "$google_client_id" || -z "$google_client_secret" || "$google_client_id" == PENDING* || "$google_client_secret" == PENDING* ]]; then - echo "Google OAuth credentials are not configured; skipping Authentik Google source sync." >&2 - echo "Set Authorized redirect URI in Google to ${authentik_url}/source/oauth/callback/${source_slug}/" >&2 - exit 0 -fi - -if ! printf '%s' "$google_account_map_json" | jq -e 'type == "array"' >/dev/null; then - echo "error: AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON must be a JSON array" >&2 - exit 1 -fi - -case "$login_mode" in - promoted|redirect) ;; - *) - echo "warning: unsupported AUTHENTIK_GOOGLE_LOGIN_MODE=$login_mode; falling back to redirect" >&2 - login_mode="redirect" - ;; -esac - -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 -} - -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 -} - -lookup_single_result() { - local path="$1" - local jq_filter="$2" - - api GET "$path" | jq -r "$jq_filter" | head -n1 -} - -wait_for_authentik - -flow_pk="$( - lookup_single_result \ - "/api/v3/flows/instances/?slug=${authentication_flow_slug}" \ - '.results[] | select(.slug != null) | .pk // empty' -)" -if [[ -z "$flow_pk" ]]; then - echo "error: could not resolve Authentik authentication flow slug ${authentication_flow_slug}" >&2 - exit 1 -fi - -enrollment_flow_pk="$( - lookup_single_result \ - "/api/v3/flows/instances/?slug=${enrollment_flow_slug}" \ - '.results[] | select(.slug != null) | .pk // empty' -)" -if [[ -z "$enrollment_flow_pk" ]]; then - echo "error: could not resolve Authentik enrollment flow slug ${enrollment_flow_slug}" >&2 - exit 1 -fi - -identification_stage="$( - api GET "/api/v3/stages/identification/" \ - | jq -c --arg name "$identification_stage_name" '.results[] | select(.name == $name)' -)" -if [[ -z "$identification_stage" ]]; then - echo "error: could not resolve Authentik identification stage ${identification_stage_name}" >&2 - exit 1 -fi - -stage_pk="$(printf '%s\n' "$identification_stage" | jq -r '.pk')" - -property_mapping_payload='[]' -if [[ "$(printf '%s' "$google_account_map_json" | jq 'length')" -gt 0 ]]; then - alias_map_python="$( - printf '%s' "$google_account_map_json" \ - | jq -c ' - map({ - key: (.source_email | ascii_downcase), - value: { - username: .username, - email: .email, - name: .name - } - }) - | from_entries - ' - )" - - oauth_property_mapping_expression="$( - cat </dev/null - else - property_mapping_pk="$( - api POST "/api/v3/propertymappings/source/oauth/" "$oauth_property_mapping_payload" \ - | jq -r '.pk // empty' - )" - fi - - if [[ -z "${property_mapping_pk:-}" ]]; then - echo "error: Google OAuth property mapping did not return a primary key" >&2 - exit 1 - fi - - property_mapping_payload="$(jq -cn --arg property_mapping_pk "$property_mapping_pk" '[$property_mapping_pk]')" -fi - -oauth_source_payload="$( - jq -n \ - --arg name "$source_name" \ - --arg slug "$source_slug" \ - --arg authentication_flow "$flow_pk" \ - --arg enrollment_flow "$enrollment_flow_pk" \ - --arg user_matching_mode "$user_matching_mode" \ - --arg policy_engine_mode "$policy_engine_mode" \ - --argjson user_property_mappings "$property_mapping_payload" \ - --arg consumer_key "$google_client_id" \ - --arg consumer_secret "$google_client_secret" \ - '{ - name: $name, - slug: $slug, - enabled: true, - promoted: true, - authentication_flow: $authentication_flow, - enrollment_flow: $enrollment_flow, - user_property_mappings: $user_property_mappings, - group_property_mappings: [], - policy_engine_mode: $policy_engine_mode, - user_matching_mode: $user_matching_mode, - provider_type: "google", - consumer_key: $consumer_key, - consumer_secret: $consumer_secret - }' -)" - -existing_source="$( - api GET "/api/v3/sources/oauth/?slug=${source_slug}" \ - | jq -c '.results[]?' -)" - -if [[ -n "$existing_source" ]]; then - source_pk="$(printf '%s\n' "$existing_source" | jq -r '.pk')" - api PATCH "/api/v3/sources/oauth/${source_slug}/" "$oauth_source_payload" >/dev/null -else - source_pk="$( - api POST "/api/v3/sources/oauth/" "$oauth_source_payload" \ - | jq -r '.pk // empty' - )" -fi - -if [[ -z "$source_pk" ]]; then - echo "error: Google OAuth source did not return a primary key" >&2 - exit 1 -fi - -stage_patch="$( - printf '%s\n' "$identification_stage" \ - | jq -c \ - --arg source_pk "$source_pk" \ - --arg login_mode "$login_mode" ' - .sources = ( - if $login_mode == "redirect" then - [$source_pk] - else - ([ $source_pk ] + ((.sources // []) | map(select(. != $source_pk)))) - end - ) - | .show_source_labels = true - | if $login_mode == "redirect" then - .user_fields = [] - else - . - end - | { - sources, - show_source_labels, - user_fields - }' -)" - -api PATCH "/api/v3/stages/identification/${stage_pk}/" "$stage_patch" >/dev/null - -echo "Synced Authentik Google source ${source_slug} (${source_pk}) in ${login_mode} mode." diff --git a/Scripts/authentik-sync-google-wif-oidc.sh b/Scripts/authentik-sync-google-wif-oidc.sh deleted file mode 100755 index c8bae10..0000000 --- a/Scripts/authentik-sync-google-wif-oidc.sh +++ /dev/null @@ -1,347 +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_GOOGLE_WIF_APPLICATION_SLUG:-google-cloud}" -application_name="${AUTHENTIK_GOOGLE_WIF_APPLICATION_NAME:-Google Cloud WIF}" -provider_name="${AUTHENTIK_GOOGLE_WIF_PROVIDER_NAME:-Google Cloud WIF}" -template_slug="${AUTHENTIK_GOOGLE_WIF_TEMPLATE_SLUG:-ts}" -client_id="${AUTHENTIK_GOOGLE_WIF_CLIENT_ID:-google-wif.burrow.net}" -client_secret="${AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET:-}" -launch_url="${AUTHENTIK_GOOGLE_WIF_LAUNCH_URL:-https://console.cloud.google.com/}" -redirect_uris_json="${AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON:-[]}" -access_group="${AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP:-}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-google-wif-oidc.sh - -Reconciles the Authentik OIDC provider used as the Google Workload Identity -Federation issuer for Burrow release runners. - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_GOOGLE_WIF_APPLICATION_SLUG - AUTHENTIK_GOOGLE_WIF_APPLICATION_NAME - AUTHENTIK_GOOGLE_WIF_PROVIDER_NAME - AUTHENTIK_GOOGLE_WIF_TEMPLATE_SLUG - AUTHENTIK_GOOGLE_WIF_CLIENT_ID - AUTHENTIK_GOOGLE_WIF_LAUNCH_URL - AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON - AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP (optional; leave empty for client credentials) - -AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET is the Authentik client-credentials secret -used by Forgejo runners. For service-account authentication, store -base64(":"). -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 [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then - echo "Google WIF OIDC client secret is not configured; skipping Authentik Google WIF sync." >&2 - exit 0 -fi - -if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array"' >/dev/null; then - echo "error: AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON must be a 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 slug "$template_slug" '.results[]? | select(.assigned_application_slug == $slug)' \ - | head -n1 -)" - -if [[ -z "$template_provider" ]]; then - echo "error: could not resolve 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: "confidential", - grant_types: ["client_credentials"], - client_id: $client_id, - client_secret: $client_secret, - 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" - }' -)" - -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: Google WIF 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 [[ -z "$existing_application" ]]; then - direct_application_result="$(api_with_status GET "/api/v3/core/applications/${application_slug}/")" - direct_application_status="$(printf '%s\n' "$direct_application_result" | sed -n '1p')" - direct_application_body="$(printf '%s\n' "$direct_application_result" | sed '1d')" - if [[ "$direct_application_status" == "200" ]]; then - existing_application="$direct_application_body" - fi -fi - -if [[ -n "$existing_application" ]]; then - application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null -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="$( - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -r --arg slug "$application_slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ - | head -n1 - )" - if [[ -z "$application_pk" ]]; then - direct_application_result="$(api_with_status GET "/api/v3/core/applications/${application_slug}/")" - direct_application_status="$(printf '%s\n' "$direct_application_result" | sed -n '1p')" - direct_application_body="$(printf '%s\n' "$direct_application_result" | sed '1d')" - if [[ "$direct_application_status" == "200" ]]; then - application_pk="$(printf '%s\n' "$direct_application_body" | jq -r '.pk // empty')" - fi - fi - 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: Google WIF OIDC application did not return a primary key" >&2 - exit 1 -fi - -delete_binding() { - local binding_pk="$1" - api DELETE "/api/v3/policies/bindings/${binding_pk}/" >/dev/null || true -} - -delete_application_group_bindings_except() { - local keep_group_pk="${1:-}" - local binding_pks - - binding_pks="$( - api GET "/api/v3/policies/bindings/?page_size=500" \ - | jq -r \ - --arg target "$application_pk" \ - --arg keep_group "$keep_group_pk" \ - '.results[]? - | select((.target | tostring) == $target and .group != null) - | select(($keep_group == "") or ((.group | tostring) != $keep_group)) - | .pk' - )" - - while IFS= read -r binding_pk; do - [[ -n "$binding_pk" ]] || continue - delete_binding "$binding_pk" - done <<< "$binding_pks" -} - -if [[ -n "$access_group" ]]; then - group_pk="$( - api GET "/api/v3/core/groups/?page_size=200" \ - | jq -r --arg name "$access_group" '.results[]? | select(.name == $name) | .pk' \ - | head -n1 - )" - if [[ -z "$group_pk" ]]; then - echo "warning: Authentik Google WIF access group ${access_group} was not found; application policy left unchanged." >&2 - else - delete_application_group_bindings_except "$group_pk" - existing_binding="$( - api GET "/api/v3/policies/bindings/?page_size=500" \ - | jq -r \ - --arg target "$application_pk" \ - --arg group "$group_pk" \ - '.results[]? - | select((.target | tostring) == $target and (.group | tostring) == $group) - | .pk' \ - | head -n1 - )" - if [[ -z "$existing_binding" ]]; then - api POST "/api/v3/policies/bindings/" "$( - jq -n \ - --arg target "$application_pk" \ - --arg group "$group_pk" \ - '{ - target: $target, - group: $group, - negate: false, - order: 0, - enabled: true, - timeout: 30, - failure_result: false - }' - )" >/dev/null || true - fi - fi -else - delete_application_group_bindings_except "" -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 Google WIF OIDC application ${application_slug} (${application_name})." - exit 0 - fi - sleep 2 -done - -echo "warning: Google WIF OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Google WIF OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-grafana-oidc.sh b/Scripts/authentik-sync-grafana-oidc.sh deleted file mode 100755 index 0d32c06..0000000 --- a/Scripts/authentik-sync-grafana-oidc.sh +++ /dev/null @@ -1,332 +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_GRAFANA_APPLICATION_SLUG:-grafana}" -application_name="${AUTHENTIK_GRAFANA_APPLICATION_NAME:-Burrow Grafana}" -provider_name="${AUTHENTIK_GRAFANA_PROVIDER_NAME:-Burrow Grafana}" -template_slug="${AUTHENTIK_GRAFANA_TEMPLATE_SLUG:-ts}" -client_id="${AUTHENTIK_GRAFANA_CLIENT_ID:-graphs.burrow.net}" -client_secret="${AUTHENTIK_GRAFANA_CLIENT_SECRET:-}" -launch_url="${AUTHENTIK_GRAFANA_LAUNCH_URL:-https://graphs.burrow.net/}" -access_group="${AUTHENTIK_GRAFANA_ACCESS_GROUP:-}" -redirect_uris_json="${AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON:-[ - \"https://graphs.burrow.net/login/generic_oauth\" -]}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-grafana-oidc.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_GRAFANA_CLIENT_SECRET - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_GRAFANA_APPLICATION_SLUG - AUTHENTIK_GRAFANA_APPLICATION_NAME - AUTHENTIK_GRAFANA_PROVIDER_NAME - AUTHENTIK_GRAFANA_TEMPLATE_SLUG - AUTHENTIK_GRAFANA_CLIENT_ID - AUTHENTIK_GRAFANA_LAUNCH_URL - AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON - AUTHENTIK_GRAFANA_ACCESS_GROUP -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 [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then - echo "Grafana OIDC client secret is not configured; skipping Authentik Grafana sync." >&2 - exit 0 -fi - -if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then - echo "error: AUTHENTIK_GRAFANA_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 -} - -lookup_group_pk() { - local group_name="$1" - - api GET "/api/v3/core/groups/?page_size=200" \ - | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ - | head -n1 -} - -lookup_application_pk() { - local slug="$1" - local application_pk lookup_result lookup_status - - application_pk="$( - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ - | head -n1 - )" - - if [[ -n "$application_pk" ]]; then - printf '%s\n' "$application_pk" - return 0 - fi - - lookup_result="$(api_with_status GET "/api/v3/core/applications/${slug}/")" - lookup_status="$(printf '%s\n' "$lookup_result" | sed -n '1p')" - if [[ "$lookup_status" =~ ^20[01]$ ]]; then - printf '%s\n' "$lookup_result" | sed '1d' | jq -r '.pk // empty' - fi -} - -ensure_application_group_binding() { - local application_slug="$1" - local group_name="$2" - local application_pk group_pk existing payload binding_pk - - application_pk="$(lookup_application_pk "$application_slug")" - if [[ -z "$application_pk" ]]; then - echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 - return 0 - fi - - group_pk="$(lookup_group_pk "$group_name")" - if [[ -z "$group_pk" ]]; then - echo "error: could not resolve Authentik group ${group_name}" >&2 - exit 1 - fi - - existing="$( - api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ - | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ - | head -n1 - )" - - payload="$( - jq -cn \ - --arg target "$application_pk" \ - --arg group "$group_pk" \ - '{ - group: $group, - target: $target, - negate: false, - enabled: true, - order: 100, - timeout: 30, - failure_result: false - }' - )" - - if [[ -n "$existing" ]]; then - binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null - else - api POST "/api/v3/policies/bindings/" "$payload" >/dev/null - fi -} - -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: "confidential", - client_id: $client_id, - client_secret: $client_secret, - 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" - }' -)" - -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: Grafana 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: Grafana OIDC application did not return a primary key" >&2 - exit 1 -fi - -if [[ -n "$access_group" ]]; then - ensure_application_group_binding "$application_slug" "$access_group" -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 Grafana OIDC application ${application_slug} (${application_name})." - exit 0 - fi - sleep 2 -done - -echo "warning: Grafana OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Grafana OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh deleted file mode 100755 index 5da64ad..0000000 --- a/Scripts/authentik-sync-linear-saml.sh +++ /dev/null @@ -1,344 +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_LINEAR_APPLICATION_SLUG:-linear}" -application_name="${AUTHENTIK_LINEAR_APPLICATION_NAME:-Linear}" -provider_name="${AUTHENTIK_LINEAR_PROVIDER_NAME:-Linear}" -launch_url="${AUTHENTIK_LINEAR_LAUNCH_URL:-https://linear.app/burrownet}" -acs_url="${AUTHENTIK_LINEAR_ACS_URL:-}" -audience="${AUTHENTIK_LINEAR_AUDIENCE:-}" -issuer="${AUTHENTIK_LINEAR_ISSUER:-${authentik_url}/application/saml/${application_slug}/metadata/}" -default_relay_state="${AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE:-}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-linear-saml.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_LINEAR_ACS_URL - AUTHENTIK_LINEAR_AUDIENCE - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_LINEAR_APPLICATION_SLUG - AUTHENTIK_LINEAR_APPLICATION_NAME - AUTHENTIK_LINEAR_PROVIDER_NAME - AUTHENTIK_LINEAR_LAUNCH_URL - AUTHENTIK_LINEAR_ISSUER - AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE -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 [[ -z "$acs_url" ]]; then - echo "error: AUTHENTIK_LINEAR_ACS_URL is required" >&2 - exit 1 -fi - -if [[ -z "$audience" ]]; then - echo "error: AUTHENTIK_LINEAR_AUDIENCE is required" >&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 -} - -lookup_oauth_template_field() { - local field="$1" - - api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -r --arg field "$field" '.results[]? | select(.assigned_application_slug == "ts") | .[$field]' \ - | head -n1 -} - -reconcile_property_mapping() { - local name="$1" - local saml_name="$2" - local friendly_name="$3" - local expression="$4" - local payload existing_pk - - payload="$( - jq -n \ - --arg name "$name" \ - --arg saml_name "$saml_name" \ - --arg friendly_name "$friendly_name" \ - --arg expression "$expression" \ - '{ - name: $name, - saml_name: $saml_name, - friendly_name: $friendly_name, - expression: $expression - }' - )" - - existing_pk="$( - api GET "/api/v3/propertymappings/provider/saml/?page_size=200" \ - | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk' \ - | head -n1 - )" - - if [[ -n "$existing_pk" ]]; then - api PATCH "/api/v3/propertymappings/provider/saml/${existing_pk}/" "$payload" >/dev/null - printf '%s\n' "$existing_pk" - else - api POST "/api/v3/propertymappings/provider/saml/" "$payload" | jq -r '.pk // empty' - fi -} - -wait_for_authentik - -authorization_flow="$(lookup_oauth_template_field authorization_flow)" -invalidation_flow="$(lookup_oauth_template_field invalidation_flow)" -signing_kp="$(lookup_oauth_template_field signing_key)" - -if [[ -z "$authorization_flow" || -z "$invalidation_flow" || -z "$signing_kp" ]]; then - echo "error: could not resolve Authentik provider defaults from Burrow Tailnet template" >&2 - exit 1 -fi - -email_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Linear SAML Email" \ - "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" \ - "email" \ - 'return request.user.email' -)" - -name_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Linear SAML Name" \ - "name" \ - "name" \ - 'return request.user.name or request.user.username' -)" - -first_name_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Linear SAML First Name" \ - "firstName" \ - "firstName" \ - $'parts = (request.user.name or "").split(" ", 1)\nif len(parts) > 0 and parts[0]:\n return parts[0]\nreturn request.user.username' -)" - -last_name_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Linear SAML Last Name" \ - "lastName" \ - "lastName" \ - $'parts = (request.user.name or "").rsplit(" ", 1)\nif len(parts) == 2 and parts[1]:\n return parts[1]\nreturn request.user.username' -)" - -if [[ -z "$email_mapping_pk" || -z "$name_mapping_pk" || -z "$first_name_mapping_pk" || -z "$last_name_mapping_pk" ]]; then - echo "error: failed to reconcile Linear SAML property mappings" >&2 - exit 1 -fi - -provider_payload="$( - jq -n \ - --arg name "$provider_name" \ - --arg authorization_flow "$authorization_flow" \ - --arg invalidation_flow "$invalidation_flow" \ - --arg acs_url "$acs_url" \ - --arg audience "$audience" \ - --arg issuer "$issuer" \ - --arg signing_kp "$signing_kp" \ - --arg default_relay_state "$default_relay_state" \ - --arg name_id_mapping "$email_mapping_pk" \ - --arg email_mapping "$email_mapping_pk" \ - --arg name_mapping "$name_mapping_pk" \ - --arg first_name_mapping "$first_name_mapping_pk" \ - --arg last_name_mapping "$last_name_mapping_pk" \ - '{ - name: $name, - authorization_flow: $authorization_flow, - invalidation_flow: $invalidation_flow, - acs_url: $acs_url, - audience: $audience, - issuer: $issuer, - signing_kp: $signing_kp, - sign_assertion: true, - sign_response: true, - sp_binding: "post", - name_id_mapping: $name_id_mapping, - property_mappings: [ - $email_mapping, - $name_mapping, - $first_name_mapping, - $last_name_mapping - ] - } - + (if $default_relay_state == "" then {} else {default_relay_state: $default_relay_state} end)' -)" - -existing_provider="$( - api GET "/api/v3/providers/saml/?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/saml/${provider_pk}/" "$provider_payload" >/dev/null -else - provider_pk="$( - api POST "/api/v3/providers/saml/" "$provider_payload" \ - | jq -r '.pk // empty' - )" -fi - -if [[ -z "${provider_pk:-}" ]]; then - echo "error: Linear SAML 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: true, - 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="existing" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null -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: Linear SAML application did not return a primary key" >&2 - exit 1 -fi - -for _ in $(seq 1 30); do - metadata_status="$( - curl -sS \ - -o /dev/null \ - -w '%{http_code}' \ - --max-redirs 0 \ - "${authentik_url}/application/saml/${application_slug}/metadata/" \ - || true - )" - case "$metadata_status" in - 200|301|302|307|308) - echo "Synced Authentik Linear SAML application ${application_slug} (${application_name})." - exit 0 - ;; - esac - sleep 2 -done - -echo "warning: Linear SAML metadata for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Linear SAML application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh deleted file mode 100644 index 4ef83e4..0000000 --- a/Scripts/authentik-sync-linear-scim.sh +++ /dev/null @@ -1,311 +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_LINEAR_APPLICATION_SLUG:-linear}" -provider_name="${AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME:-Linear SCIM}" -scim_url="${AUTHENTIK_LINEAR_SCIM_URL:-}" -scim_token_file="${AUTHENTIK_LINEAR_SCIM_TOKEN_FILE:-}" -user_identifier="${AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER:-email}" -owner_group="${AUTHENTIK_LINEAR_OWNER_GROUP:-linear-owners}" -admin_group="${AUTHENTIK_LINEAR_ADMIN_GROUP:-linear-admins}" -guest_group="${AUTHENTIK_LINEAR_GUEST_GROUP:-linear-guests}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-linear-scim.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_LINEAR_SCIM_URL - AUTHENTIK_LINEAR_SCIM_TOKEN_FILE - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_LINEAR_APPLICATION_SLUG - AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME - AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER - AUTHENTIK_LINEAR_OWNER_GROUP - AUTHENTIK_LINEAR_ADMIN_GROUP - AUTHENTIK_LINEAR_GUEST_GROUP -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 [[ -z "$scim_url" ]]; then - echo "error: AUTHENTIK_LINEAR_SCIM_URL is required" >&2 - exit 1 -fi - -if [[ -z "$scim_token_file" || ! -s "$scim_token_file" ]]; then - echo "error: AUTHENTIK_LINEAR_SCIM_TOKEN_FILE is required and must be readable" >&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 -} - -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 -} - -lookup_group_pk() { - local group_name="$1" - - api GET "/api/v3/core/groups/?page_size=200&search=${group_name}" \ - | jq -r --arg name "$group_name" '.results[]? | select(.name == $name) | .pk // empty' \ - | head -n1 -} - -ensure_group() { - local group_name="$1" - local payload group_pk - - payload="$(jq -cn --arg name "$group_name" '{name: $name}')" - group_pk="$(lookup_group_pk "$group_name")" - - if [[ -n "$group_pk" ]]; then - api PATCH "/api/v3/core/groups/${group_pk}/" "$payload" >/dev/null - else - group_pk="$( - api POST "/api/v3/core/groups/" "$payload" \ - | jq -r '.pk // empty' - )" - fi - - if [[ -z "$group_pk" ]]; then - echo "error: could not reconcile Authentik group ${group_name}" >&2 - exit 1 - fi - - printf '%s\n' "$group_pk" -} - -lookup_application() { - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ - | head -n1 -} - -lookup_scim_provider() { - api GET "/api/v3/providers/scim/?page_size=200" \ - | jq -c \ - --arg application_slug "$application_slug" \ - --arg provider_name "$provider_name" \ - '.results[]? | select(.assigned_backchannel_application_slug == $application_slug or .name == $provider_name)' \ - | head -n1 -} - -lookup_scim_mapping_pk() { - local managed_name="$1" - - api GET "/api/v3/propertymappings/provider/scim/?page_size=200" \ - | jq -r --arg managed "$managed_name" '.results[]? | select(.managed == $managed) | .pk // empty' \ - | head -n1 -} - -reconcile_property_mapping() { - local name="$1" - local expression="$2" - local payload existing_pk - - payload="$( - jq -n \ - --arg name "$name" \ - --arg expression "$expression" \ - '{ - name: $name, - expression: $expression - }' - )" - - existing_pk="$( - api GET "/api/v3/propertymappings/provider/scim/?page_size=200" \ - | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk // empty' \ - | head -n1 - )" - - if [[ -n "$existing_pk" ]]; then - api PATCH "/api/v3/propertymappings/provider/scim/${existing_pk}/" "$payload" >/dev/null - printf '%s\n' "$existing_pk" - else - api POST "/api/v3/propertymappings/provider/scim/" "$payload" \ - | jq -r '.pk // empty' - fi -} - -sync_object() { - local provider_pk="$1" - local model="$2" - local object_id="$3" - - if ! api POST "/api/v3/providers/scim/${provider_pk}/sync/object/" "$( - jq -cn \ - --arg model "$model" \ - --arg object_id "$object_id" \ - '{ - sync_object_model: $model, - sync_object_id: $object_id, - override_dry_run: false - }' - )" >/dev/null; then - echo "warning: could not trigger immediate Linear SCIM sync for ${model} ${object_id}; provider will continue with its normal sync cycle." >&2 - fi -} - -wait_for_authentik - -group_mapping_pk="$(lookup_scim_mapping_pk "goauthentik.io/providers/scim/group")" -case "$user_identifier" in - email) - user_mapping_expression=$'# Some implementations require givenName and familyName to be set\ngivenName, familyName = request.user.name, " "\nformatted = request.user.name + " "\nif " " in request.user.name:\n givenName, _, familyName = request.user.name.partition(" ")\n formatted = request.user.name\n\navatar = request.user.avatar\nphotos = None\nif "://" in avatar:\n photos = [{"value": avatar, "type": "photo"}]\n\nlocale = request.user.locale()\nif locale == "":\n locale = None\n\nemails = []\nif request.user.email != "":\n emails = [{\n "value": request.user.email,\n "type": "other",\n "primary": True,\n }]\n\nidentifier = request.user.email\nif identifier == "":\n identifier = request.user.username\n\nreturn {\n "userName": identifier,\n "name": {\n "formatted": formatted,\n "givenName": givenName,\n "familyName": familyName,\n },\n "displayName": request.user.name,\n "photos": photos,\n "locale": locale,\n "active": request.user.is_active,\n "emails": emails,\n}' - ;; - username) - user_mapping_expression=$'# Some implementations require givenName and familyName to be set\ngivenName, familyName = request.user.name, " "\nformatted = request.user.name + " "\nif " " in request.user.name:\n givenName, _, familyName = request.user.name.partition(" ")\n formatted = request.user.name\n\navatar = request.user.avatar\nphotos = None\nif "://" in avatar:\n photos = [{"value": avatar, "type": "photo"}]\n\nlocale = request.user.locale()\nif locale == "":\n locale = None\n\nemails = []\nif request.user.email != "":\n emails = [{\n "value": request.user.email,\n "type": "other",\n "primary": True,\n }]\nreturn {\n "userName": request.user.username,\n "name": {\n "formatted": formatted,\n "givenName": givenName,\n "familyName": familyName,\n },\n "displayName": request.user.name,\n "photos": photos,\n "locale": locale,\n "active": request.user.is_active,\n "emails": emails,\n}' - ;; - *) - echo "error: unsupported AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER value: ${user_identifier}" >&2 - exit 1 - ;; -esac -user_mapping_pk="$(reconcile_property_mapping "Burrow Linear SCIM User" "$user_mapping_expression")" - -if [[ -z "$user_mapping_pk" || -z "$group_mapping_pk" ]]; then - echo "error: could not resolve managed Authentik SCIM property mappings" >&2 - exit 1 -fi - -owner_group_pk="$(ensure_group "$owner_group")" -admin_group_pk="$(ensure_group "$admin_group")" -guest_group_pk="$(ensure_group "$guest_group")" - -provider_payload="$( - jq -n \ - --arg name "$provider_name" \ - --arg url "$scim_url" \ - --arg token "$(tr -d '\r\n' < "$scim_token_file")" \ - --arg user_mapping_pk "$user_mapping_pk" \ - --arg group_mapping_pk "$group_mapping_pk" \ - --arg owner_group_pk "$owner_group_pk" \ - --arg admin_group_pk "$admin_group_pk" \ - --arg guest_group_pk "$guest_group_pk" \ - '{ - name: $name, - url: $url, - token: $token, - auth_mode: "token", - verify_certificates: true, - compatibility_mode: "default", - property_mappings: [$user_mapping_pk], - property_mappings_group: [$group_mapping_pk], - group_filters: [ - $owner_group_pk, - $admin_group_pk, - $guest_group_pk - ], - dry_run: false - }' -)" - -existing_provider="$(lookup_scim_provider)" -if [[ -n "$existing_provider" ]]; then - provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" - api PATCH "/api/v3/providers/scim/${provider_pk}/" "$provider_payload" >/dev/null -else - provider_pk="$( - api POST "/api/v3/providers/scim/" "$provider_payload" \ - | jq -r '.pk // empty' - )" -fi - -if [[ -z "${provider_pk:-}" ]]; then - echo "error: Linear SCIM provider did not return a primary key" >&2 - exit 1 -fi - -application="$(lookup_application)" -if [[ -z "$application" ]]; then - echo "error: could not resolve Authentik application ${application_slug}" >&2 - exit 1 -fi - -application_payload="$( - printf '%s\n' "$application" \ - | jq \ - --arg provider_pk "$provider_pk" \ - '{ - name: .name, - slug: .slug, - provider: .provider, - backchannel_providers: ((.backchannel_providers // []) + [($provider_pk | tonumber)] | unique), - open_in_new_tab: .open_in_new_tab, - meta_launch_url: .meta_launch_url, - policy_engine_mode: .policy_engine_mode - }' -)" -api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null - -group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" -user_pks_json="$( - api GET "/api/v3/core/users/?page_size=200" \ - | jq -c \ - --argjson group_pks "$group_pks_json" \ - '[.results[]? - | select( - ([((.groups // [])[] | tostring)] as $user_groups - | ($group_pks | map(. as $wanted | ($user_groups | index($wanted)) != null) | any)) - ) - | .pk]' -)" - -while IFS= read -r group_pk; do - [[ -z "$group_pk" ]] && continue - sync_object "$provider_pk" "authentik.core.models.Group" "$group_pk" -done < <(printf '%s\n' "$group_pks_json" | jq -r '.[]') - -while IFS= read -r user_pk; do - [[ -z "$user_pk" ]] && continue - sync_object "$provider_pk" "authentik.core.models.User" "$user_pk" -done < <(printf '%s\n' "$user_pks_json" | jq -r '.[]') - -status_json="$(api GET "/api/v3/providers/scim/${provider_pk}/sync/status/" || true)" -if ! printf '%s\n' "$status_json" | jq -e 'has("last_sync_status")' >/dev/null 2>&1; then - echo "warning: could not read Linear SCIM sync status for provider ${provider_pk}; keeping reconciled configuration." >&2 -fi - -echo "Synced Authentik Linear SCIM provider ${provider_name} (${provider_pk}) with groups ${owner_group}, ${admin_group}, ${guest_group}." diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh deleted file mode 100755 index 1c715cc..0000000 --- a/Scripts/authentik-sync-tailnet-auth-flow.sh +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" -bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" -provider_slug="${AUTHENTIK_TAILNET_PROVIDER_SLUG:-ts}" -provider_slugs_json="${AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON:-}" -authentication_flow_name="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME:-Burrow Tailnet Authentication}" -authentication_flow_slug="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG:-burrow-tailnet-authentication}" -identification_stage_name="${AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME:-burrow-tailnet-identification-stage}" -password_stage_name="${AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME:-burrow-tailnet-password-stage}" -user_login_stage_name="${AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME:-burrow-tailnet-user-login-stage}" -google_source_slug="${AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG:-google}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-tailnet-auth-flow.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_TAILNET_PROVIDER_SLUG - AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON - AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME - AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG - AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME - AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME - AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME - AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG -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 [[ -n "$provider_slugs_json" ]]; then - if ! printf '%s' "$provider_slugs_json" | jq -e 'type == "array" and length > 0 and all(.[]; type == "string" and length > 0)' >/dev/null; then - echo "error: AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON must be a non-empty JSON array of strings" >&2 - exit 1 - fi -else - provider_slugs_json="$(jq -cn --arg slug "$provider_slug" '[$slug]')" -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 -} - -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 -} - -lookup_stage_by_name() { - local path="$1" - local name="$2" - - api GET "${path}?page_size=200" \ - | jq -c --arg name "$name" '.results[]? | select(.name == $name)' \ - | head -n1 -} - -lookup_flow_pk() { - local slug="$1" - - api GET "/api/v3/flows/instances/?slug=${slug}" \ - | jq -r '.results[]? | select(.slug != null) | .pk // empty' \ - | head -n1 -} - -lookup_source_pk() { - local slug="$1" - - api GET "/api/v3/sources/oauth/?page_size=200&slug=${slug}" \ - | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ - | head -n1 -} - -ensure_password_stage() { - local existing payload stage_pk - - existing="$(lookup_stage_by_name "/api/v3/stages/password/" "$password_stage_name")" - payload="$( - jq -cn \ - --arg name "$password_stage_name" \ - '{ - name: $name, - backends: [ - "authentik.core.auth.InbuiltBackend", - "authentik.core.auth.TokenBackend" - ], - allow_show_password: false, - failed_attempts_before_cancel: 5 - }' - )" - - if [[ -n "$existing" ]]; then - stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/stages/password/${stage_pk}/" "$payload" >/dev/null - else - stage_pk="$( - api POST "/api/v3/stages/password/" "$payload" \ - | jq -r '.pk // empty' - )" - fi - - printf '%s\n' "$stage_pk" -} - -ensure_identification_stage() { - local password_stage_pk="$1" - local google_source_pk="$2" - local existing payload stage_pk sources_json - - existing="$(lookup_stage_by_name "/api/v3/stages/identification/" "$identification_stage_name")" - if [[ -n "$google_source_pk" ]]; then - sources_json="$(jq -cn --arg source "$google_source_pk" '[$source]')" - else - sources_json='[]' - fi - - payload="$( - jq -cn \ - --arg name "$identification_stage_name" \ - --arg password_stage "$password_stage_pk" \ - --argjson sources "$sources_json" \ - '{ - name: $name, - user_fields: ["username", "email"], - password_stage: $password_stage, - case_insensitive_matching: true, - show_matched_user: true, - sources: $sources, - show_source_labels: true, - pretend_user_exists: false, - enable_remember_me: false - }' - )" - - if [[ -n "$existing" ]]; then - stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/stages/identification/${stage_pk}/" "$payload" >/dev/null - else - stage_pk="$( - api POST "/api/v3/stages/identification/" "$payload" \ - | jq -r '.pk // empty' - )" - fi - - printf '%s\n' "$stage_pk" -} - -ensure_user_login_stage() { - local existing payload stage_pk - - existing="$(lookup_stage_by_name "/api/v3/stages/user_login/" "$user_login_stage_name")" - payload="$( - jq -cn \ - --arg name "$user_login_stage_name" \ - '{ - name: $name, - session_duration: "hours=12", - terminate_other_sessions: false, - remember_me_offset: "seconds=0", - network_binding: "no_binding", - geoip_binding: "no_binding" - }' - )" - - if [[ -n "$existing" ]]; then - stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/stages/user_login/${stage_pk}/" "$payload" >/dev/null - else - stage_pk="$( - api POST "/api/v3/stages/user_login/" "$payload" \ - | jq -r '.pk // empty' - )" - fi - - printf '%s\n' "$stage_pk" -} - -ensure_authentication_flow() { - local existing_pk payload - - existing_pk="$(lookup_flow_pk "$authentication_flow_slug")" - payload="$( - jq -cn \ - --arg name "$authentication_flow_name" \ - --arg slug "$authentication_flow_slug" \ - '{ - name: $name, - title: $name, - slug: $slug, - designation: "authentication", - policy_engine_mode: "any", - layout: "stacked" - }' - )" - - if [[ -n "$existing_pk" ]]; then - api PATCH "/api/v3/flows/instances/${authentication_flow_slug}/" "$payload" >/dev/null - printf '%s\n' "$existing_pk" - else - api POST "/api/v3/flows/instances/" "$payload" \ - | jq -r '.pk // empty' - fi -} - -ensure_flow_binding() { - local flow_pk="$1" - local stage_pk="$2" - local order="$3" - local existing payload binding_pk - - existing="$( - api GET "/api/v3/flows/bindings/?target=${flow_pk}&stage=${stage_pk}&page_size=200" \ - | jq -c '.results[]?' \ - | head -n1 - )" - - payload="$( - jq -cn \ - --arg target "$flow_pk" \ - --arg stage "$stage_pk" \ - --argjson order "$order" \ - '{ - target: $target, - stage: $stage, - order: $order, - policy_engine_mode: "any" - }' - )" - - if [[ -n "$existing" ]]; then - binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/flows/bindings/${binding_pk}/" "$payload" >/dev/null - else - api POST "/api/v3/flows/bindings/" "$payload" >/dev/null - fi -} - -wait_for_authentik - -mapfile -t provider_pks < <( - api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -r --argjson provider_slugs "$provider_slugs_json" ' - .results[]? - | select( - ((.assigned_application_slug // empty) as $assigned | ($provider_slugs | index($assigned)) != null) - or ((.slug // empty) as $slug | ($provider_slugs | index($slug)) != null) - ) - | .pk // empty - ' -) - -if [[ "${#provider_pks[@]}" -eq 0 ]]; then - echo "error: could not resolve any Authentik Tailnet OAuth providers from ${provider_slugs_json}" >&2 - exit 1 -fi - -google_source_pk="$(lookup_source_pk "$google_source_slug" || true)" -password_stage_pk="$(ensure_password_stage)" -identification_stage_pk="$(ensure_identification_stage "$password_stage_pk" "$google_source_pk")" -user_login_stage_pk="$(ensure_user_login_stage)" -authentication_flow_pk="$(ensure_authentication_flow)" - -ensure_flow_binding "$authentication_flow_pk" "$identification_stage_pk" 10 -ensure_flow_binding "$authentication_flow_pk" "$user_login_stage_pk" 30 - -for provider_pk in "${provider_pks[@]}"; do - api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( - jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' - )" >/dev/null -done - -echo "Synced Burrow Tailnet authentication flow for providers ${provider_slugs_json}." diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh deleted file mode 100755 index 58fe7e4..0000000 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ /dev/null @@ -1,369 +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_TAILSCALE_APPLICATION_SLUG:-tailscale}" -application_name="${AUTHENTIK_TAILSCALE_APPLICATION_NAME:-Tailscale}" -provider_name="${AUTHENTIK_TAILSCALE_PROVIDER_NAME:-Tailscale}" -template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}" -client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" -client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" -launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" -access_group="${AUTHENTIK_TAILSCALE_ACCESS_GROUP:-}" -default_external_application_slug="${AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG:-}" -redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[ - \"https://login.tailscale.com/a/oauth_response\" -]}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-tailscale-oidc.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - AUTHENTIK_TAILSCALE_CLIENT_SECRET - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_TAILSCALE_APPLICATION_SLUG - AUTHENTIK_TAILSCALE_APPLICATION_NAME - AUTHENTIK_TAILSCALE_PROVIDER_NAME - AUTHENTIK_TAILSCALE_TEMPLATE_SLUG - AUTHENTIK_TAILSCALE_CLIENT_ID - AUTHENTIK_TAILSCALE_LAUNCH_URL - AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON - AUTHENTIK_TAILSCALE_ACCESS_GROUP - AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG -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 [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then - echo "Tailscale OIDC client secret is not configured; skipping Authentik Tailscale sync." >&2 - exit 0 -fi - -if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then - echo "error: AUTHENTIK_TAILSCALE_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 - -lookup_group_pk() { - local group_name="$1" - - api GET "/api/v3/core/groups/?page_size=200" \ - | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ - | head -n1 -} - -lookup_application_pk() { - local slug="$1" - local application_pk lookup_result lookup_status - - application_pk="$( - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ - | head -n1 - )" - - if [[ -n "$application_pk" ]]; then - printf '%s\n' "$application_pk" - return 0 - fi - - lookup_result="$(api_with_status GET "/api/v3/core/applications/${slug}/")" - lookup_status="$(printf '%s\n' "$lookup_result" | sed -n '1p')" - if [[ "$lookup_status" =~ ^20[01]$ ]]; then - printf '%s\n' "$lookup_result" | sed '1d' | jq -r '.pk // empty' - fi -} - -ensure_application_group_binding() { - local application_slug="$1" - local group_name="$2" - local application_pk group_pk existing payload binding_pk - - application_pk="$(lookup_application_pk "$application_slug")" - if [[ -z "$application_pk" ]]; then - echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 - return 0 - fi - - group_pk="$(lookup_group_pk "$group_name")" - if [[ -z "$group_pk" ]]; then - echo "error: could not resolve Authentik group ${group_name}" >&2 - exit 1 - fi - - existing="$( - api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ - | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ - | head -n1 - )" - - payload="$( - jq -cn \ - --arg target "$application_pk" \ - --arg group "$group_pk" \ - '{ - group: $group, - target: $target, - negate: false, - enabled: true, - order: 100, - timeout: 30, - failure_result: false - }' - )" - - if [[ -n "$existing" ]]; then - binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null - else - api POST "/api/v3/policies/bindings/" "$payload" >/dev/null - fi -} - -ensure_default_external_application() { - local application_slug="$1" - local application_pk default_brand brand_payload - - application_pk="$(lookup_application_pk "$application_slug")" - if [[ -z "$application_pk" ]]; then - echo "error: could not resolve Authentik application ${application_slug} for brand default application" >&2 - exit 1 - fi - - default_brand="$( - api GET "/api/v3/core/brands/?page_size=200" \ - | jq -c '.results[]? | select(.default == true)' \ - | head -n1 - )" - - if [[ -z "$default_brand" ]]; then - echo "warning: could not resolve the default Authentik brand; skipping external default application" >&2 - return 0 - fi - - brand_payload="$( - printf '%s\n' "$default_brand" \ - | jq --arg application_pk "$application_pk" '.default_application = $application_pk' - )" - - api PUT "/api/v3/core/brands/$(printf '%s\n' "$default_brand" | jq -r '.brand_uuid')/" "$brand_payload" >/dev/null -} - -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: "confidential", - client_id: $client_id, - client_secret: $client_secret, - 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" - }' -)" - -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: Tailscale 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: true, - 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')" - api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null -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: Tailscale OIDC application did not return a primary key" >&2 - exit 1 -fi - -if [[ -n "$access_group" ]]; then - ensure_application_group_binding "$application_slug" "$access_group" -fi - -if [[ -n "$default_external_application_slug" ]]; then - ensure_default_external_application "$default_external_application_slug" -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 Tailscale OIDC application ${application_slug} (${application_name})." - exit 0 - fi - sleep 2 -done - -echo "warning: Tailscale OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-zulip-saml.sh b/Scripts/authentik-sync-zulip-saml.sh deleted file mode 100644 index cd18752..0000000 --- a/Scripts/authentik-sync-zulip-saml.sh +++ /dev/null @@ -1,412 +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_ZULIP_APPLICATION_SLUG:-zulip}" -application_name="${AUTHENTIK_ZULIP_APPLICATION_NAME:-Zulip}" -provider_name="${AUTHENTIK_ZULIP_PROVIDER_NAME:-Zulip}" -acs_url="${AUTHENTIK_ZULIP_ACS_URL:-https://chat.burrow.net/complete/saml/}" -audience="${AUTHENTIK_ZULIP_AUDIENCE:-https://chat.burrow.net}" -launch_url="${AUTHENTIK_ZULIP_LAUNCH_URL:-https://chat.burrow.net/}" -access_group="${AUTHENTIK_ZULIP_ACCESS_GROUP:-}" -admin_group="${AUTHENTIK_ZULIP_ADMIN_GROUP:-}" -issuer="${AUTHENTIK_ZULIP_ISSUER:-$authentik_url}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-zulip-saml.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_ZULIP_APPLICATION_SLUG - AUTHENTIK_ZULIP_APPLICATION_NAME - AUTHENTIK_ZULIP_PROVIDER_NAME - AUTHENTIK_ZULIP_ACS_URL - AUTHENTIK_ZULIP_AUDIENCE - AUTHENTIK_ZULIP_LAUNCH_URL - AUTHENTIK_ZULIP_ACCESS_GROUP - AUTHENTIK_ZULIP_ADMIN_GROUP - AUTHENTIK_ZULIP_ISSUER -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 - -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 -} - -lookup_oauth_template_field() { - local field="$1" - - api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -r --arg field "$field" '.results[]? | select(.assigned_application_slug == "ts") | .[$field]' \ - | head -n1 -} - -lookup_group_pk() { - local group_name="$1" - - api GET "/api/v3/core/groups/?page_size=200" \ - | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ - | head -n1 -} - -lookup_application_pk() { - local slug="$1" - - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ - | head -n1 -} - -ensure_application_group_binding() { - local application_slug="$1" - local group_name="$2" - local application_pk group_pk existing payload binding_pk - - application_pk="$(lookup_application_pk "$application_slug")" - if [[ -z "$application_pk" ]]; then - echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 - return 0 - fi - - group_pk="$(lookup_group_pk "$group_name")" - if [[ -z "$group_pk" ]]; then - echo "error: could not resolve Authentik group ${group_name}" >&2 - exit 1 - fi - - existing="$( - api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ - | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ - | head -n1 - )" - - payload="$( - jq -cn \ - --arg target "$application_pk" \ - --arg group "$group_pk" \ - '{ - group: $group, - target: $target, - negate: false, - enabled: true, - order: 100, - timeout: 30, - failure_result: false - }' - )" - - if [[ -n "$existing" ]]; then - binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" - api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null - else - api POST "/api/v3/policies/bindings/" "$payload" >/dev/null - fi -} - -reconcile_property_mapping() { - local name="$1" - local saml_name="$2" - local friendly_name="$3" - local expression="$4" - local payload existing_pk - - payload="$( - jq -n \ - --arg name "$name" \ - --arg saml_name "$saml_name" \ - --arg friendly_name "$friendly_name" \ - --arg expression "$expression" \ - '{ - name: $name, - saml_name: $saml_name, - friendly_name: $friendly_name, - expression: $expression - }' - )" - - existing_pk="$( - api GET "/api/v3/propertymappings/provider/saml/?page_size=200" \ - | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk' \ - | head -n1 - )" - - if [[ -n "$existing_pk" ]]; then - api PATCH "/api/v3/propertymappings/provider/saml/${existing_pk}/" "$payload" >/dev/null - printf '%s\n' "$existing_pk" - else - api POST "/api/v3/propertymappings/provider/saml/" "$payload" | jq -r '.pk // empty' - fi -} - -wait_for_authentik - -authorization_flow="$(lookup_oauth_template_field authorization_flow)" -invalidation_flow="$(lookup_oauth_template_field invalidation_flow)" -signing_kp="$(lookup_oauth_template_field signing_key)" - -if [[ -z "$authorization_flow" || -z "$invalidation_flow" || -z "$signing_kp" ]]; then - echo "error: could not resolve Authentik provider defaults from Burrow Tailnet template" >&2 - exit 1 -fi - -email_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Zulip SAML Email" \ - "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" \ - "email" \ - 'return request.user.email' -)" - -name_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Zulip SAML Name" \ - "name" \ - "name" \ - 'return request.user.name or request.user.username' -)" - -first_name_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Zulip SAML First Name" \ - "firstName" \ - "firstName" \ - $'parts = (request.user.name or "").split(" ", 1)\nif len(parts) > 0 and parts[0]:\n return parts[0]\nreturn request.user.username' -)" - -last_name_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Zulip SAML Last Name" \ - "lastName" \ - "lastName" \ - $'parts = (request.user.name or "").rsplit(" ", 1)\nif len(parts) == 2 and parts[1]:\n return parts[1]\nreturn request.user.username' -)" - -role_mapping_pk="" -if [[ -n "$admin_group" ]]; then - role_mapping_pk="$( - reconcile_property_mapping \ - "Burrow Zulip SAML Role" \ - "zulip_role" \ - "zulip_role" \ - $'admin_group = "'$admin_group$'"\nif any(group.name == admin_group for group in request.user.ak_groups.all()):\n return "owner"\nreturn None' - )" -fi - -if [[ -z "$email_mapping_pk" || -z "$name_mapping_pk" || -z "$first_name_mapping_pk" || -z "$last_name_mapping_pk" ]]; then - echo "error: failed to reconcile Zulip SAML property mappings" >&2 - exit 1 -fi - -provider_payload="$( - jq -n \ - --arg name "$provider_name" \ - --arg authorization_flow "$authorization_flow" \ - --arg invalidation_flow "$invalidation_flow" \ - --arg acs_url "$acs_url" \ - --arg audience "$audience" \ - --arg issuer "$issuer" \ - --arg signing_kp "$signing_kp" \ - --arg name_id_mapping "$email_mapping_pk" \ - --arg email_mapping "$email_mapping_pk" \ - --arg name_mapping "$name_mapping_pk" \ - --arg first_name_mapping "$first_name_mapping_pk" \ - --arg last_name_mapping "$last_name_mapping_pk" \ - --arg role_mapping "$role_mapping_pk" \ - '{ - name: $name, - authorization_flow: $authorization_flow, - invalidation_flow: $invalidation_flow, - acs_url: $acs_url, - audience: $audience, - issuer: $issuer, - signing_kp: $signing_kp, - sign_assertion: true, - sign_response: true, - sp_binding: "post", - name_id_mapping: $name_id_mapping, - property_mappings: [ - $email_mapping, - $name_mapping, - $first_name_mapping, - $last_name_mapping - ] + (if $role_mapping != "" then [$role_mapping] else [] end) - }' -)" - -existing_provider="$( - api GET "/api/v3/providers/saml/?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/saml/${provider_pk}/" "$provider_payload" >/dev/null -else - provider_pk="$( - api POST "/api/v3/providers/saml/" "$provider_payload" \ - | jq -r '.pk // empty' - )" -fi - -if [[ -z "${provider_pk:-}" ]]; then - echo "error: Zulip SAML 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: true, - 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')" - api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null -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: Zulip SAML application did not return a primary key" >&2 - exit 1 -fi - -if [[ -n "$access_group" ]]; then - ensure_application_group_binding "$application_slug" "$access_group" -fi - -for _ in $(seq 1 30); do - metadata_status="$( - curl -sS \ - -o /dev/null \ - -w '%{http_code}' \ - --max-redirs 0 \ - "${authentik_url}/application/saml/${application_slug}/metadata/" \ - || true - )" - case "$metadata_status" in - 200|301|302|307|308) - echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." - exit 0 - ;; - esac - sleep 2 -done - -echo "warning: Zulip SAML metadata for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." diff --git a/Scripts/bep b/Scripts/bep deleted file mode 100755 index 1c6bd64..0000000 --- a/Scripts/bep +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(git rev-parse --show-toplevel) -proposals_dir="$repo_root/evolution/proposals" - -auto_browse() { - if command -v wisu >/dev/null 2>&1; then - exec wisu -i -g --icons "$repo_root/evolution" - fi - exec ls -la "$repo_root/evolution" -} - -usage() { - cat <<'USAGE' -Usage: bep [command] - -Commands: - list [--status ] List BEPs, optionally filtered by status. - open Open a BEP in $EDITOR. - help Show this help. - -If no command is provided, bep launches a simple browser for evolution/. -USAGE -} - -normalize_id() { - local raw="$1" - if [[ "$raw" =~ ^BEP-[0-9]+$ ]]; then - printf '%s' "$raw" - return 0 - fi - if [[ "$raw" =~ ^[0-9]+$ ]]; then - printf 'BEP-%04d' "$raw" - return 0 - fi - return 1 -} - -read_status() { - local file="$1" - awk -F ': ' '/^Status:/ {print $2; exit}' "$file" -} - -read_title() { - local file="$1" - local line - line=$(head -n 1 "$file" || true) - printf '%s' "$line" | sed -E 's/^# `[^`]+`[[:space:]]+//; s/^[^A-Za-z0-9]+//' -} - -list_bep() { - local filter="${1:-}" - local filter_lower="" - if [[ -n "$filter" ]]; then - filter_lower=$(printf '%s' "$filter" | tr '[:upper:]' '[:lower:]') - fi - - printf '%-10s %-18s %s\n' "BEP" "Status" "Title" - local file - local entries=() - for file in "$proposals_dir"/BEP-*.md; do - [[ -e "$file" ]] || continue - local base - base=$(basename "$file") - local id - id=$(printf '%s' "$base" | cut -d- -f1-2) - local status - status=$(read_status "$file") - local status_lower - status_lower=$(printf '%s' "$status" | tr '[:upper:]' '[:lower:]') - if [[ -n "$filter_lower" && "$status_lower" != "$filter_lower" ]]; then - continue - fi - local title - title=$(read_title "$file") - entries+=("$(printf '%-10s %-18s %s' "$id" "$status" "$title")") - done - if [[ ${#entries[@]} -gt 0 ]]; then - printf '%s\n' "${entries[@]}" | sort - fi -} - -open_bep() { - local raw="$1" - local id - if ! id=$(normalize_id "$raw"); then - echo "Unknown BEP id: $raw" >&2 - exit 1 - fi - local matches - matches=("$proposals_dir"/"$id"-*.md) - if [[ ${#matches[@]} -eq 0 || ! -e "${matches[0]}" ]]; then - echo "No proposal found for $id" >&2 - exit 1 - fi - if [[ ${#matches[@]} -gt 1 ]]; then - echo "Multiple proposals match $id:" >&2 - printf ' %s\n' "${matches[@]}" >&2 - exit 1 - fi - local editor="${EDITOR:-vi}" - exec "$editor" "${matches[0]}" -} - -command=${1:-} -case "$command" in - "") - auto_browse - ;; - list) - if [[ ${2:-} == "--status" && -n ${3:-} ]]; then - list_bep "$3" - else - list_bep - fi - ;; - open) - if [[ -z ${2:-} ]]; then - echo "bep open requires an id" >&2 - exit 1 - fi - open_bep "$2" - ;; - help|-h|--help) - usage - ;; - *) - echo "Unknown command: $command" >&2 - usage - exit 1 - ;; -esac 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-bep-metadata.py b/Scripts/check-bep-metadata.py deleted file mode 100755 index d054934..0000000 --- a/Scripts/check-bep-metadata.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import pathlib -import re -import sys - - -REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent -PROPOSALS_DIR = REPO_ROOT / "evolution" / "proposals" -ALLOWED_STATUSES = { - "Pitch", - "Draft", - "In Review", - "Accepted", - "Implemented", - "Rejected", - "Returned for Revision", - "Superseded", - "Archived", -} -REQUIRED_FIELDS = [ - "Status", - "Proposal", - "Authors", - "Coordinator", - "Reviewers", - "Constitution Sections", - "Implementation PRs", - "Decision Date", -] - - -def text_block_lines(path: pathlib.Path) -> list[str]: - content = path.read_text(encoding="utf-8") - match = re.search(r"```text\n(.*?)\n```", content, re.DOTALL) - if not match: - raise ValueError("missing leading ```text metadata block") - return [line.rstrip() for line in match.group(1).splitlines() if line.strip()] - - -def validate(path: pathlib.Path) -> list[str]: - errors: list[str] = [] - proposal_id = path.name.split("-", 2)[:2] - expected_id = "-".join(proposal_id).removesuffix(".md") - - try: - lines = text_block_lines(path) - except ValueError as exc: - return [f"{path}: {exc}"] - - field_names = [line.split(":", 1)[0] for line in lines] - if field_names != REQUIRED_FIELDS: - errors.append( - f"{path}: metadata fields must appear in order {', '.join(REQUIRED_FIELDS)}" - ) - return errors - - fields = dict(line.split(":", 1) for line in lines) - fields = {key.strip(): value.strip() for key, value in fields.items()} - - if fields["Status"] not in ALLOWED_STATUSES: - errors.append(f"{path}: invalid Status {fields['Status']!r}") - - if fields["Proposal"] != expected_id: - errors.append( - f"{path}: Proposal field {fields['Proposal']!r} does not match filename id {expected_id!r}" - ) - - if fields["Status"] in {"Accepted", "Implemented", "Superseded", "Rejected", "Archived"} and fields["Decision Date"] == "Pending": - errors.append( - f"{path}: Decision Date must not be Pending once status is {fields['Status']}" - ) - - return errors - - -def main() -> int: - errors: list[str] = [] - for path in sorted(PROPOSALS_DIR.glob("BEP-*.md")): - errors.extend(validate(path)) - - if errors: - for error in errors: - print(error, file=sys.stderr) - return 1 - - print(f"checked {len(list(PROPOSALS_DIR.glob('BEP-*.md')))} BEPs") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/Scripts/check-brand-icon-lowres.sh b/Scripts/check-brand-icon-lowres.sh deleted file mode 100755 index c9243a9..0000000 --- a/Scripts/check-brand-icon-lowres.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo_root" - -output_dir_arg="${1:-design/brand/low-res}" -if [[ "$output_dir_arg" = /* ]]; then - output_dir="$output_dir_arg" -else - output_dir="$repo_root/$output_dir_arg" -fi -master="$output_dir/burrow-icon-1024.png" -checked_in_master="$repo_root/design/brand/burrow-owl-icon-master.png" -source_master="$checked_in_master" -sizes=(16 20 24 29 32 40 48 64 80 128 256) - -require_tool() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "error: required tool not found: $1" >&2 - exit 127 - fi -} - -require_tool magick -require_tool awk - -mkdir -p "$output_dir" - -magick "$checked_in_master" -colorspace sRGB -strip "PNG32:$master" - -printf "size,colors,gray_stddev,status\n" >"$output_dir/report.csv" - -for size in "${sizes[@]}"; do - image="$output_dir/burrow-icon-${size}.png" - magick "$source_master" -colorspace sRGB -resize "${size}x${size}!" -strip "PNG32:$image" - - actual="$(magick "$image" -format "%wx%h" info:)" - if [[ "$actual" != "${size}x${size}" ]]; then - echo "error: $image rendered as $actual, expected ${size}x${size}" >&2 - exit 1 - fi - - colors="$(magick "$image" -format "%k" info:)" - stddev="$(magick "$image" -colorspace Gray -format "%[fx:standard_deviation]" info:)" - min_colors=$((size * 4)) - if (( min_colors < 48 )); then - min_colors=48 - fi - if (( min_colors > 900 )); then - min_colors=900 - fi - - awk -v size="$size" -v colors="$colors" -v min_colors="$min_colors" -v stddev="$stddev" ' - BEGIN { - if (colors < min_colors) { - printf "error: %dpx icon has only %d colors; expected at least %d\n", size, colors, min_colors > "/dev/stderr"; - exit 1; - } - if (stddev < 0.08) { - printf "error: %dpx icon grayscale stddev is %.6f; expected at least 0.08\n", size, stddev > "/dev/stderr"; - exit 1; - } - } - ' - - printf "%s,%s,%s,ok\n" "$size" "$colors" "$stddev" >>"$output_dir/report.csv" -done - -preview_inputs=() -pixel_preview_inputs=() -for size in 256 128 80 64 48 40 32 29 24 20 16; do - preview="$output_dir/preview-${size}.png" - if (( size <= 32 )); then - filter=Point - else - filter=Lanczos - fi - magick "$output_dir/burrow-icon-${size}.png" -filter "$filter" -resize 128x128 "$preview" - preview_inputs+=("$preview") - - if (( size <= 32 )); then - pixel_preview="$output_dir/pixel-preview-${size}.png" - magick "$output_dir/burrow-icon-${size}.png" -filter Point -resize 128x128 "$pixel_preview" - pixel_preview_inputs+=("$pixel_preview") - fi -done - -magick -background "#202020" -gravity center "${preview_inputs[@]}" +append "$output_dir/preview-strip.png" -magick -background "#202020" -gravity center "${pixel_preview_inputs[@]}" +append "$output_dir/pixel-preview-strip.png" - -echo "low-res icon exports written to $output_dir" diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index e4ba995..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,18 +14,21 @@ 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 - --expect-tailnet Fail if Authentik and Headscale 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 -EXPECT_TAILNET=0 + +cleanup() { + burrow_cleanup_secret_tmpfiles +} +trap cleanup EXIT while [[ $# -gt 0 ]]; do case "$1" in @@ -39,10 +44,6 @@ while [[ $# -gt 0 ]]; do EXPECT_NSC=1 shift ;; - --expect-tailnet) - EXPECT_TAILNET=1 - shift - ;; -h|--help) usage exit 0 @@ -57,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}" \ @@ -69,13 +77,11 @@ ssh \ -o StrictHostKeyChecking=accept-new \ "${HOST}" \ EXPECT_NSC="${EXPECT_NSC}" \ - EXPECT_TAILNET="${EXPECT_TAILNET}" \ 'bash -s' <<'EOF' set -euo pipefail base_services=( forgejo.service - grafana.service caddy.service burrow-forgejo-bootstrap.service burrow-forgejo-runner-bootstrap.service @@ -87,14 +93,6 @@ nsc_services=( forgejo-nsc-autoscaler.service ) -tailnet_services=( - burrow-authentik-runtime.service - burrow-authentik-ready.service - burrow-authentik-grafana-oidc.service - headscale.service - headscale-bootstrap.service -) - show_service() { local service="$1" systemctl show \ @@ -147,44 +145,13 @@ for service in "${nsc_services[@]}"; do fi done -for service in "${tailnet_services[@]}"; do - echo "== ${service} ==" - show_service "${service}" || true - if [[ "${EXPECT_TAILNET}" == "1" ]] && ! service_is_healthy "${service}"; then - echo "required tailnet service is not active: ${service}" >&2 - exit 1 - fi -done - echo "== intake ==" ls -l /var/lib/burrow/intake || true -if [[ "${EXPECT_TAILNET}" == "1" ]]; then - echo "== agenix ==" - ls -l /run/agenix || true - test -s /run/agenix/burrowAuthentikEnv - test -s /run/agenix/burrowGrafanaAdminPassword - test -s /run/agenix/burrowGrafanaOidcClientSecret - test -s /run/agenix/burrowHeadscaleOidcClientSecret -fi - -if [[ "${EXPECT_NSC}" == "1" ]]; then - echo "== agenix-nsc ==" - ls -l /run/agenix || true - test -s /run/agenix/burrowForgejoNscToken - test -s /run/agenix/burrowForgejoNscDispatcherConfig - test -s /run/agenix/burrowForgejoNscAutoscalerConfig -fi - if command -v curl >/dev/null 2>&1; then echo "== http-local ==" curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login curl -fsS -o /dev/null -H 'Host: burrow.net' -w 'burrow_root %{http_code}\n' http://127.0.0.1/ curl -fsS -o /dev/null -H 'Host: git.burrow.net' -w 'git_login %{http_code}\n' http://127.0.0.1/user/login - curl -fsS -o /dev/null -H 'Host: graphs.burrow.net' -w 'grafana_login %{http_code}\n' http://127.0.0.1/login - if [[ "${EXPECT_TAILNET}" == "1" ]]; then - curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ - curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true - fi fi EOF diff --git a/Scripts/ci/backup-garage-to-gcs.sh b/Scripts/ci/backup-garage-to-gcs.sh deleted file mode 100755 index 9bd95e5..0000000 --- a/Scripts/ci/backup-garage-to-gcs.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}" -: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage backup}" -: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage backup}" - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -if ! command -v aws >/dev/null 2>&1; then - echo "aws CLI is required for Garage backup" >&2 - exit 127 -fi - -if ! command -v gcloud >/dev/null 2>&1; then - echo "gcloud is required for GCS backup" >&2 - exit 127 -fi - -if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then - Scripts/ci/google-wif-auth.sh -fi - -export AWS_DEFAULT_REGION="${BURROW_GARAGE_REGION:-garage}" -export AWS_EC2_METADATA_DISABLED=true -export AWS_REQUEST_CHECKSUM_CALCULATION="${AWS_REQUEST_CHECKSUM_CALCULATION:-when_required}" -export AWS_RESPONSE_CHECKSUM_VALIDATION="${AWS_RESPONSE_CHECKSUM_VALIDATION:-when_required}" - -requested_releases=0 -requested_packages=0 -requested_nix_cache=0 -IFS=',' read -r -a requested_items <<< "${BURROW_GARAGE_BACKUP_SET:-all}" - -for raw_item in "${requested_items[@]}"; do - item="${raw_item//[[:space:]]/}" - case "$item" in - "" ) - ;; - all ) - requested_releases=1 - requested_packages=1 - requested_nix_cache=1 - ;; - release|releases ) - requested_releases=1 - ;; - package|packages ) - requested_packages=1 - ;; - attic|nix|nix-cache|nix_cache ) - requested_nix_cache=1 - ;; - * ) - echo "unknown Garage backup set item: $item" >&2 - exit 64 - ;; - esac -done - -if [[ "$requested_releases" == "0" && "$requested_packages" == "0" && "$requested_nix_cache" == "0" ]]; then - echo "no Garage backup buckets selected" >&2 - exit 1 -fi - -work_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-garage-backup" -rsync_flags=(--recursive) -if [[ "${BURROW_GARAGE_BACKUP_DELETE:-false}" == "true" ]]; then - rsync_flags+=(--delete-unmatched-destination-objects) -fi - -sync_bucket() { - local name="$1" - local garage_bucket="$2" - local gcs_bucket="$3" - local source_dir="${work_root}/${name}" - - rm -rf "$source_dir" - mkdir -p "$source_dir" - - aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "s3://${garage_bucket}" "$source_dir" --no-progress - gcloud storage rsync "${rsync_flags[@]}" "$source_dir" "gs://${gcs_bucket}" -} - -if [[ "$requested_releases" == "1" ]]; then - sync_bucket \ - releases \ - "${BURROW_RELEASE_GARAGE_BUCKET:-burrow-releases}" \ - "${BURROW_RELEASE_GCS_BUCKET:-burrow-net-releases}" -fi - -if [[ "$requested_packages" == "1" ]]; then - sync_bucket \ - packages \ - "${BURROW_PACKAGE_GARAGE_BUCKET:-burrow-packages}" \ - "${BURROW_PACKAGE_GCS_BUCKET:-burrow-net-packages}" -fi - -if [[ "$requested_nix_cache" == "1" ]]; then - sync_bucket \ - nix-cache \ - "${BURROW_NIX_CACHE_GARAGE_BUCKET:-attic}" \ - "${BURROW_NIX_CACHE_GCS_BUCKET:-burrow-net-nix-cache}" -fi diff --git a/Scripts/ci/build-release-artifacts.sh b/Scripts/ci/build-release-artifacts.sh deleted file mode 100755 index ca8bcb0..0000000 --- a/Scripts/ci/build-release-artifacts.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -build_number="${BUILD_NUMBER:-${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}}" -export BUILD_NUMBER="$build_number" -export BURROW_RELEASE_OUT="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}" - -Scripts/ci/package-release-artifacts.sh "${1:-all}" diff --git a/Scripts/ci/check-release-config.sh b/Scripts/ci/check-release-config.sh deleted file mode 100755 index 17bc3e6..0000000 --- a/Scripts/ci/check-release-config.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "missing required environment variable: ${name}" >&2 - return 1 - fi -} - -require_file_or_env() { - local file_name="$1" - local env_name="$2" - if [[ -n "${!file_name:-}" && -f "${!file_name:-}" ]]; then - return 0 - fi - if [[ -n "${!env_name:-}" ]]; then - return 0 - fi - echo "missing required ${file_name} file or ${env_name} environment value" >&2 - return 1 -} - -case "${1:-all}" in - gcs) - require_env BURROW_GOOGLE_PROJECT_ID - require_env BURROW_GOOGLE_PROJECT_NUMBER - require_env BURROW_GOOGLE_WIF_POOL_ID - require_env BURROW_GOOGLE_WIF_PROVIDER_ID - require_env BURROW_GOOGLE_WIF_SERVICE_ACCOUNT - require_env BURROW_AUTHENTIK_WIF_ISSUER - require_env BURROW_AUTHENTIK_WIF_AUDIENCE - require_env BURROW_AUTHENTIK_WIF_CLIENT_ID - if [[ -z "${BURROW_AUTHENTIK_WIF_TOKEN:-}" && -z "${BURROW_AUTHENTIK_WIF_CLIENT_SECRET:-}" ]]; then - if [[ -z "${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}" || ! -f "${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}" ]]; then - echo "missing Authentik WIF token, token file, or client secret" >&2 - exit 1 - fi - fi - ;; - app-store) - require_env ASC_API_KEY_ID - require_env ASC_API_ISSUER_ID - require_file_or_env ASC_API_KEY_PATH ASC_API_KEY_BASE64 - ;; - apple-signing) - require_file_or_env APPLE_SIGNING_CERTIFICATE_PATH APPLE_SIGNING_CERTIFICATE_BASE64 - require_env APPLE_SIGNING_CERTIFICATE_PASSWORD - ;; - sparkle) - "$0" gcs - ;; - microsoft-store) - require_env MICROSOFT_TENANT_ID - require_env MICROSOFT_CLIENT_ID - require_env MICROSOFT_CLIENT_SECRET - ;; - all) - "$0" gcs - "$0" app-store - "$0" apple-signing - ;; - *) - echo "unknown release config group: $1" >&2 - exit 64 - ;; -esac diff --git a/Scripts/ci/distribute-testflight.sh b/Scripts/ci/distribute-testflight.sh deleted file mode 100755 index 7c09ee1..0000000 --- a/Scripts/ci/distribute-testflight.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -Scripts/ci/check-release-config.sh app-store - -build_number="${BUILD_NUMBER:?BUILD_NUMBER is required}" -distribute_external="${TESTFLIGHT_DISTRIBUTE_EXTERNAL:-false}" -testflight_groups="${TESTFLIGHT_GROUPS:-}" -uses_non_exempt="${IOS_USES_NON_EXEMPT_ENCRYPTION:-false}" -wait_processing_timeout="${TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS:-7200}" - -if [[ -z "$distribute_external" ]]; then - distribute_external="false" -fi -case "$distribute_external" in - true|false) - ;; - *) - echo "::error ::TESTFLIGHT_DISTRIBUTE_EXTERNAL must be true or false." >&2 - exit 1 - ;; -esac -if [[ -z "$uses_non_exempt" ]]; then - uses_non_exempt="false" -fi -case "$uses_non_exempt" in - true|false) - ;; - *) - echo "::error ::IOS_USES_NON_EXEMPT_ENCRYPTION must be true or false." >&2 - exit 1 - ;; -esac -case "$wait_processing_timeout" in - ""|*[!0-9]*) - echo "::error ::TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS must be an integer number of seconds." >&2 - exit 1 - ;; -esac - -api_key_json="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-appstore-api-key.XXXXXX.json")" -key_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-appstore-key.XXXXXX.p8")" -cleanup() { - rm -f "$api_key_json" "$key_file" -} -trap cleanup EXIT - -if [[ -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then - cp "$ASC_API_KEY_PATH" "$key_file" -else - printf '%s' "$ASC_API_KEY_BASE64" | base64 --decode > "$key_file" -fi - -KEY_FILE="$key_file" nix develop .#ci -c python3 -c 'import json, os, pathlib; print(json.dumps({"key_id": os.environ["ASC_API_KEY_ID"], "issuer_id": os.environ["ASC_API_ISSUER_ID"], "key": pathlib.Path(os.environ["KEY_FILE"]).read_text(), "duration": 1200, "in_house": False}))' > "$api_key_json" - -args=( - run pilot - "api_key_path:${api_key_json}" - "app_identifier:${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}" - "app_platform:ios" - "distribute_only:true" - "build_number:${build_number}" - "wait_processing_interval:30" - "wait_processing_timeout_duration:${wait_processing_timeout}" - "distribute_external:${distribute_external}" - "uses_non_exempt_encryption:${uses_non_exempt}" -) - -if [[ "$distribute_external" == "true" ]]; then - if [[ -z "$testflight_groups" ]]; then - echo "::error ::TESTFLIGHT_GROUPS is required for external TestFlight distribution." >&2 - exit 1 - fi - args+=("groups:${testflight_groups}") -else - args+=( - "skip_submission:true" - "notify_external_testers:false" - ) - if [[ -n "$testflight_groups" ]]; then - echo "::warning ::Ignoring TESTFLIGHT_GROUPS for internal-only TestFlight distribution; App Store Connect manages internal tester group availability." >&2 - fi -fi - -nix run nixpkgs#fastlane -- "${args[@]}" diff --git a/Scripts/ci/ensure-nix.sh b/Scripts/ci/ensure-nix.sh deleted file mode 100755 index 05a56af..0000000 --- a/Scripts/ci/ensure-nix.sh +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -source_nix_profile() { - local candidate - for candidate in \ - "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" \ - "${HOME}/.nix-profile/etc/profile.d/nix.sh" - do - if [[ -f "${candidate}" ]]; then - # shellcheck disable=SC1090 - . "${candidate}" - return 0 - fi - done - return 1 -} - -linux_cp_supports_preserve() { - cp --help 2>&1 | grep -q -- '--preserve' -} - -ensure_root_owned_home() { - if [[ "$(id -u)" -ne 0 ]]; then - return 0 - fi - - if [[ ! -d "${HOME}" ]] || [[ ! -O "${HOME}" ]]; then - export HOME="/root" - fi - - mkdir -p "${HOME}" -} - -ensure_linux_nixbld_accounts() { - if [[ "$(id -u)" -ne 0 ]]; then - return 0 - fi - - if command -v getent >/dev/null 2>&1 && getent group nixbld >/dev/null 2>&1; then - return 0 - fi - - if command -v addgroup >/dev/null 2>&1 && ! command -v groupadd >/dev/null 2>&1; then - addgroup -S nixbld >/dev/null 2>&1 || true - for i in $(seq 1 10); do - adduser -S -D -H -h /var/empty -s /sbin/nologin -G nixbld "nixbld${i}" >/dev/null 2>&1 || true - done - return 0 - fi - - if command -v groupadd >/dev/null 2>&1; then - groupadd -r nixbld >/dev/null 2>&1 || true - for i in $(seq 1 10); do - useradd \ - --system \ - --no-create-home \ - --home-dir /var/empty \ - --shell /usr/sbin/nologin \ - --gid nixbld \ - "nixbld${i}" >/dev/null 2>&1 || true - done - return 0 - fi - - echo "linux nix bootstrap requires nixbld group creation support" >&2 - exit 1 -} - -ensure_linux_nix_bootstrap_prereqs() { - if linux_cp_supports_preserve; then - ensure_root_owned_home - ensure_linux_nixbld_accounts - return 0 - fi - - if command -v apk >/dev/null 2>&1; then - apk add --no-cache coreutils xz >/dev/null - elif command -v apt-get >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update -y >/dev/null - apt-get install -y coreutils xz-utils >/dev/null - elif command -v dnf >/dev/null 2>&1; then - dnf install -y coreutils xz >/dev/null - elif command -v yum >/dev/null 2>&1; then - yum install -y coreutils xz >/dev/null - else - echo "linux nix bootstrap requires GNU cp but no supported package manager was found" >&2 - exit 1 - fi - - linux_cp_supports_preserve || { - echo "linux nix bootstrap still lacks GNU cp after installing prerequisites" >&2 - exit 1 - } - - ensure_root_owned_home - ensure_linux_nixbld_accounts -} - -if ! command -v nix >/dev/null 2>&1; then - if ! command -v curl >/dev/null 2>&1; then - echo "curl is required to install nix" >&2 - exit 1 - fi - - case "$(uname -s)" in - Linux) - ensure_linux_nix_bootstrap_prereqs - curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon - ;; - Darwin) - installer="$(mktemp -t burrow-nix.XXXXXX)" - trap 'rm -f "${installer}"' EXIT - curl -fsSL -o "${installer}" https://install.determinate.systems/nix - chmod +x "${installer}" - if command -v sudo >/dev/null 2>&1; then - if sudo -n true 2>/dev/null; then - sudo -n sh "${installer}" install --no-confirm - else - sudo sh "${installer}" install --no-confirm - fi - else - sh "${installer}" install --no-confirm - fi - ;; - *) - echo "unsupported platform for nix bootstrap: $(uname -s)" >&2 - exit 1 - ;; - esac -fi - -source_nix_profile || true -export PATH="${HOME}/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" - -if [[ -n "${GITHUB_PATH:-}" ]]; then - { - echo "${HOME}/.nix-profile/bin" - echo "/nix/var/nix/profiles/default/bin" - echo "/nix/var/nix/profiles/default/sbin" - } >> "${GITHUB_PATH}" -fi - -config_root="${XDG_CONFIG_HOME:-$HOME/.config}" -config_file="${config_root}/nix/nix.conf" -if [[ -e "${config_file}" && ! -w "${config_file}" ]]; then - config_root="$(mktemp -d -t burrow-nix-config.XXXXXX)" - export XDG_CONFIG_HOME="${config_root}" - config_file="${XDG_CONFIG_HOME}/nix/nix.conf" -fi - -mkdir -p "$(dirname -- "${config_file}")" -{ - echo "experimental-features = nix-command flakes" - echo "sandbox = true" - echo "fallback = true" - if [[ -n "${BURROW_NIX_CACHE_PUBLIC_KEY:-}" ]]; then - echo "substituters = ${BURROW_NIX_CACHE_URL:-https://nix.burrow.net/${BURROW_NIX_CACHE_NAME:-burrow}} https://cache.nixos.org" - echo "trusted-public-keys = ${BURROW_NIX_CACHE_PUBLIC_KEY} cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - else - echo "substituters = https://cache.nixos.org" - echo "trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - fi -} > "${config_file}" - -command -v nix >/dev/null 2>&1 || { - echo "nix is still unavailable after bootstrap" >&2 - exit 1 -} diff --git a/Scripts/ci/ensure-nsc.sh b/Scripts/ci/ensure-nsc.sh deleted file mode 100755 index 87cd9d9..0000000 --- a/Scripts/ci/ensure-nsc.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if command -v nsc >/dev/null 2>&1; then - nsc version || true - exit 0 -fi - -if ! command -v curl >/dev/null 2>&1; then - echo "::error ::curl is required to install nsc" >&2 - exit 1 -fi -if ! command -v tar >/dev/null 2>&1; then - echo "::error ::tar is required to install nsc" >&2 - exit 1 -fi - -os="$(uname -s | tr '[:upper:]' '[:lower:]')" -arch="$(uname -m)" -case "$arch" in - x86_64|amd64) arch="amd64" ;; - aarch64|arm64) arch="arm64" ;; - *) - echo "::error ::Unsupported arch for nsc: ${arch}" >&2 - exit 1 - ;; -esac - -if [[ "$os" != "linux" ]]; then - echo "::warning ::nsc bootstrap is only supported on Linux; relying on the runner or Nix shell for ${os}." - exit 1 -fi - -version="${NSC_VERSION:-0.0.520}" -expected_sha256="" -case "${version}/${arch}" in - 0.0.506/amd64) expected_sha256="4a093e0269878a9514e7c00d99b88a924f307ad15fb9d1595ee5f6582f99a0f0" ;; - 0.0.506/arm64) expected_sha256="6acf074b67fcbaaf59a4437f6bd40da7e38438c427849c1b5fcf4621ce75aee2" ;; -esac - -asset="nsc_${version}_linux_${arch}.tar.gz" -url="https://get.namespace.so/packages/nsc/v${version}/${asset}" -tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-nsc.$$" -install_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nsc-bin" -mkdir -p "$tmp_dir" "$install_dir" -trap 'rm -rf "$tmp_dir"' EXIT - -curl -fsSL -o "${tmp_dir}/${asset}" "$url" -if [[ -n "$expected_sha256" ]] && command -v sha256sum >/dev/null 2>&1; then - actual_sha256="$(sha256sum "${tmp_dir}/${asset}" | awk '{print $1}')" - if [[ "$actual_sha256" != "$expected_sha256" ]]; then - echo "::error ::nsc ${asset} sha256 mismatch: expected ${expected_sha256}, got ${actual_sha256}" >&2 - exit 1 - fi -fi - -tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir" -for bin in nsc bazel-credential-nsc docker-credential-nsc; do - if [[ -x "${tmp_dir}/${bin}" ]]; then - install -m 0755 "${tmp_dir}/${bin}" "${install_dir}/${bin}" - fi -done - -if [[ ! -x "${install_dir}/nsc" || ! -x "${install_dir}/bazel-credential-nsc" ]]; then - echo "::error ::Failed to install nsc and bazel-credential-nsc from ${asset}" >&2 - exit 1 -fi - -if [[ -n "${GITHUB_PATH:-}" ]]; then - echo "${install_dir}" >> "${GITHUB_PATH}" -fi -export PATH="${install_dir}:$PATH" -nsc version || true diff --git a/Scripts/ci/ensure-spacectl.sh b/Scripts/ci/ensure-spacectl.sh deleted file mode 100755 index 8216a94..0000000 --- a/Scripts/ci/ensure-spacectl.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if command -v spacectl >/dev/null 2>&1; then - spacectl version || true - exit 0 -fi - -if ! command -v curl >/dev/null 2>&1; then - echo "::error ::curl is required to install spacectl" >&2 - exit 1 -fi -if ! command -v tar >/dev/null 2>&1; then - echo "::error ::tar is required to install spacectl" >&2 - exit 1 -fi - -os="$(uname -s | tr '[:upper:]' '[:lower:]')" -arch="$(uname -m)" -case "$arch" in - x86_64|amd64) arch="amd64" ;; - aarch64|arm64) arch="arm64" ;; - *) - echo "::error ::Unsupported arch for spacectl: ${arch}" >&2 - exit 1 - ;; -esac - -if [[ "$os" != "linux" && "$os" != "darwin" ]]; then - echo "::error ::Unsupported OS for spacectl: ${os}" >&2 - exit 1 -fi - -version="${SPACECTL_VERSION:-v0.5.0}" -version_num="${version#v}" -asset="spacectl_${version_num}_${os}_${arch}.tar.gz" -url="https://github.com/namespacelabs/spacectl/releases/download/${version}/${asset}" -tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-spacectl.$$" -install_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/spacectl-bin" -mkdir -p "$tmp_dir" "$install_dir" -trap 'rm -rf "$tmp_dir"' EXIT - -curl -fsSL -o "${tmp_dir}/${asset}" "$url" -tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir" - -bin="${tmp_dir}/spacectl" -if [[ ! -x "$bin" ]]; then - bin="$(find "$tmp_dir" -maxdepth 3 -type f -name spacectl -perm -111 | head -n1 || true)" -fi -if [[ -z "${bin}" || ! -x "${bin}" ]]; then - echo "::error ::Failed to locate spacectl binary in ${asset}" >&2 - exit 1 -fi - -install -m 0755 "$bin" "${install_dir}/spacectl" -if [[ -n "${GITHUB_PATH:-}" ]]; then - echo "${install_dir}" >> "${GITHUB_PATH}" -fi -export PATH="${install_dir}:$PATH" -spacectl version || true diff --git a/Scripts/ci/google-wif-auth.sh b/Scripts/ci/google-wif-auth.sh deleted file mode 100755 index c3eda3c..0000000 --- a/Scripts/ci/google-wif-auth.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -project_id="${GOOGLE_CLOUD_PROJECT:-${BURROW_GOOGLE_PROJECT_ID:-project-88c23ce9-918a-470a-b33}}" -project_number="${BURROW_GOOGLE_PROJECT_NUMBER:-416198671487}" -pool_id="${BURROW_GOOGLE_WIF_POOL_ID:-burrow-authentik}" -provider_id="${BURROW_GOOGLE_WIF_PROVIDER_ID:-forgejo-runners}" -service_account="${BURROW_GOOGLE_WIF_SERVICE_ACCOUNT:-burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com}" -authentik_issuer="${BURROW_AUTHENTIK_WIF_ISSUER:-https://auth.burrow.net/application/o/google-cloud/}" -authentik_token_endpoint="${BURROW_AUTHENTIK_WIF_TOKEN_ENDPOINT:-}" -authentik_audience="${BURROW_AUTHENTIK_WIF_AUDIENCE:-google-wif.burrow.net}" -authentik_client_id="${BURROW_AUTHENTIK_WIF_CLIENT_ID:-${AUTHENTIK_GOOGLE_WIF_CLIENT_ID:-google-wif.burrow.net}}" -authentik_client_secret="${BURROW_AUTHENTIK_WIF_CLIENT_SECRET:-${AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET:-}}" -authentik_token_file="${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}" -authentik_token="${BURROW_AUTHENTIK_WIF_TOKEN:-}" -work_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-google-wif" -emit_env=1 - -usage() { - cat <<'EOF' -Usage: Scripts/ci/google-wif-auth.sh [--no-emit-env] - -Authenticates gcloud to the Burrow Google project through Authentik-issued OIDC -and Google Workload Identity Federation. No Google service-account JSON key is -used or written. - -Inputs: - GOOGLE_CLOUD_PROJECT / BURROW_GOOGLE_PROJECT_ID - BURROW_GOOGLE_PROJECT_NUMBER - BURROW_GOOGLE_WIF_POOL_ID - BURROW_GOOGLE_WIF_PROVIDER_ID - BURROW_GOOGLE_WIF_SERVICE_ACCOUNT - BURROW_AUTHENTIK_WIF_ISSUER - BURROW_AUTHENTIK_WIF_TOKEN_ENDPOINT - BURROW_AUTHENTIK_WIF_AUDIENCE - BURROW_AUTHENTIK_WIF_TOKEN or BURROW_AUTHENTIK_WIF_TOKEN_FILE - -If no token is supplied, the script requests one from Authentik using -BURROW_AUTHENTIK_WIF_CLIENT_ID and BURROW_AUTHENTIK_WIF_CLIENT_SECRET. -EOF -} - -while [[ "$#" -gt 0 ]]; do - case "$1" in - --no-emit-env) - emit_env=0 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown argument: $1" >&2 - exit 64 - ;; - esac - shift -done - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "$1 is required for Google WIF authentication" >&2 - exit 127 - fi -} - -require_command gcloud -require_command curl -require_command jq - -resolve_token_endpoint() { - if [[ -n "$authentik_token_endpoint" ]]; then - printf '%s\n' "$authentik_token_endpoint" - return - fi - - local issuer_no_slash="${authentik_issuer%/}" - local discovered="" - if discovered="$( - curl -fsS "${issuer_no_slash}/.well-known/openid-configuration" 2>/dev/null \ - | jq -r '.token_endpoint // empty' 2>/dev/null - )" && [[ -n "$discovered" ]]; then - printf '%s\n' "$discovered" - return - fi - - case "$issuer_no_slash" in - */application/o/*) - printf '%s/application/o/token/\n' "${issuer_no_slash%%/application/o/*}" - ;; - *) - printf '%s/token/\n' "$issuer_no_slash" - ;; - esac -} - -mkdir -p "$work_dir" -chmod 0700 "$work_dir" - -token_path="$work_dir/authentik-oidc-token.jwt" -credential_config="$work_dir/google-wif-credentials.json" - -if [[ -n "$authentik_token_file" ]]; then - if [[ ! -s "$authentik_token_file" ]]; then - echo "Authentik WIF token file is empty or missing: $authentik_token_file" >&2 - exit 1 - fi - cp "$authentik_token_file" "$token_path" -elif [[ -n "$authentik_token" ]]; then - printf '%s' "$authentik_token" > "$token_path" -else - if [[ -z "$authentik_client_secret" ]]; then - echo "missing Authentik WIF token. Set BURROW_AUTHENTIK_WIF_TOKEN, BURROW_AUTHENTIK_WIF_TOKEN_FILE, or BURROW_AUTHENTIK_WIF_CLIENT_SECRET." >&2 - exit 1 - fi - token_endpoint="$(resolve_token_endpoint)" - curl -fsS \ - -d grant_type=client_credentials \ - -d client_id="$authentik_client_id" \ - -d client_secret="$authentik_client_secret" \ - -d scope="openid profile email groups" \ - -d audience="$authentik_audience" \ - "$token_endpoint" \ - | jq -r '.id_token // .access_token // empty' > "$token_path" -fi - -chmod 0600 "$token_path" -if [[ ! -s "$token_path" ]]; then - echo "Authentik did not return an OIDC token for Google WIF" >&2 - exit 1 -fi - -provider_resource="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${provider_id}" -gcloud iam workload-identity-pools create-cred-config "$provider_resource" \ - --service-account "$service_account" \ - --subject-token-type="urn:ietf:params:oauth:token-type:jwt" \ - --credential-source-file "$token_path" \ - --output-file "$credential_config" - -chmod 0600 "$credential_config" -export CLOUDSDK_CORE_PROJECT="$project_id" -export GOOGLE_CLOUD_PROJECT="$project_id" -gcloud auth login --cred-file="$credential_config" --brief - -{ - echo "GOOGLE_APPLICATION_CREDENTIALS=$credential_config" - echo "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=$credential_config" - echo "CLOUDSDK_CORE_PROJECT=$project_id" - echo "GOOGLE_CLOUD_PROJECT=$project_id" -} > "$work_dir/google-wif.env" - -if [[ "$emit_env" == "1" && -n "${GITHUB_ENV:-}" ]]; then - cat "$work_dir/google-wif.env" >> "$GITHUB_ENV" -fi - -echo "Authenticated gcloud through Authentik WIF as ${service_account} in ${project_id}." diff --git a/Scripts/ci/install-apple-signing-assets.sh b/Scripts/ci/install-apple-signing-assets.sh deleted file mode 100755 index c5dc0a9..0000000 --- a/Scripts/ci/install-apple-signing-assets.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$repo_root" - -signing_ready=0 -keychain_path="" - -write_env() { - local key="$1" - local value="$2" - if [[ -n "${GITHUB_ENV:-}" ]]; then - echo "${key}=${value}" >> "$GITHUB_ENV" - fi -} - -install_profile_file() { - local source="$1" - [[ -f "$source" ]] || return 0 - - local uuid - uuid="$(security cms -D -i "$source" 2>/dev/null | plutil -extract UUID raw -o - - 2>/dev/null || true)" - if [[ -z "$uuid" ]]; then - uuid="$(basename "$source")" - fi - - local profiles_dir="$HOME/Library/MobileDevice/Provisioning Profiles" - mkdir -p "$profiles_dir" - cp "$source" "${profiles_dir}/${uuid}.provisionprofile" - echo "Installed provisioning profile ${uuid}." -} - -install_profile_base64() { - local name="$1" - local value="${!name:-}" - [[ -n "$value" ]] || return 0 - - local out="${RUNNER_TEMP:-/tmp}/${name}.provisionprofile" - printf '%s' "$value" | base64 --decode > "$out" - install_profile_file "$out" -} - -install_profile_env_path() { - local name="$1" - local path="${!name:-}" - [[ -n "$path" ]] || return 0 - if [[ ! -f "$path" ]]; then - echo "::error ::${name} points to a missing provisioning profile: ${path}" >&2 - exit 1 - fi - install_profile_file "$path" -} - -for profile in \ - Apple/Profiles/*.provisionprofile \ - Apple/Profiles/*.mobileprovision \ - .forgejo/actions/export/*.provisionprofile \ - .forgejo/actions/export/*.mobileprovision; do - [[ -e "$profile" ]] || continue - install_profile_file "$profile" -done -install_profile_env_path APPLE_PROVISIONING_PROFILE_PATH -install_profile_env_path APPLE_NETWORK_EXTENSION_PROVISIONING_PROFILE_PATH -install_profile_env_path APPLE_MACOS_PROVISIONING_PROFILE_PATH -install_profile_base64 APPLE_PROVISIONING_PROFILE_BASE64 -install_profile_base64 APPLE_NETWORK_EXTENSION_PROVISIONING_PROFILE_BASE64 -install_profile_base64 APPLE_MACOS_PROVISIONING_PROFILE_BASE64 - -cert_base64="${APPLE_SIGNING_CERTIFICATE_BASE64:-${APPLE_DISTRIBUTION_CERTIFICATE_BASE64:-}}" -cert_path_env="${APPLE_SIGNING_CERTIFICATE_PATH:-${APPLE_DISTRIBUTION_CERTIFICATE_PATH:-}}" -cert_password="${APPLE_SIGNING_CERTIFICATE_PASSWORD:-${APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD:-}}" -keychain_password="${APPLE_KEYCHAIN_PASSWORD:-${cert_password}}" - -if [[ -n "$cert_base64" || -n "$cert_path_env" ]]; then - if [[ -z "$cert_password" ]]; then - echo "::error ::APPLE_SIGNING_CERTIFICATE_PASSWORD is required when Apple signing certificate material is set." >&2 - exit 1 - fi - - cert_path="${RUNNER_TEMP:-/tmp}/burrow-apple-signing.p12" - keychain_path="$HOME/Library/Keychains/BurrowRelease.keychain-db" - if [[ -n "$cert_path_env" ]]; then - if [[ ! -f "$cert_path_env" ]]; then - echo "::error ::APPLE_SIGNING_CERTIFICATE_PATH points to a missing file: ${cert_path_env}" >&2 - exit 1 - fi - cp "$cert_path_env" "$cert_path" - else - printf '%s' "$cert_base64" | base64 --decode > "$cert_path" - fi - - security create-keychain -p "$keychain_password" "$keychain_path" || true - security set-keychain-settings -lut 21600 "$keychain_path" - security unlock-keychain -p "$keychain_password" "$keychain_path" - security import "$cert_path" \ - -k "$keychain_path" \ - -P "$cert_password" \ - -T /usr/bin/codesign \ - -T /usr/bin/security \ - -T /usr/bin/productbuild \ - -T /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" >/dev/null - - existing="$(security list-keychains -d user | sed 's/[ "]//g' || true)" - if ! printf '%s\n' "$existing" | grep -Fxq "$keychain_path"; then - security list-keychains -d user -s "$keychain_path" $existing - fi - - signing_ready=1 - write_env BURROW_APPLE_KEYCHAIN_PATH "$keychain_path" -else - identities="$(security find-identity -v -p codesigning 2>/dev/null || true)" - if printf '%s\n' "$identities" | grep -Eq '"(Apple Distribution|Developer ID Application):'; then - signing_ready=1 - fi -fi - -write_env BURROW_APPLE_SIGNING_READY "$signing_ready" -if [[ "$signing_ready" == "1" ]]; then - echo "Apple signing assets are available." -else - echo "::notice ::Apple signing certificate is not configured; release lane will build unsigned validation artifacts only." -fi diff --git a/Scripts/ci/nscloud-cache.sh b/Scripts/ci/nscloud-cache.sh deleted file mode 100755 index 1f9ce09..0000000 --- a/Scripts/ci/nscloud-cache.sh +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$#" -eq 0 ]]; then - echo "No cache modes specified; skipping." - exit 0 -fi - -want_bazel_cache=0 -for mode in "$@"; do - if [[ "$mode" == "bazel" ]]; then - want_bazel_cache=1 - break - fi -done - -portable_mkdir() { - hash -r >/dev/null 2>&1 || true - command -p mkdir "$@" -} - -optional_timeout() { - local duration="$1" - shift - if command -v timeout >/dev/null 2>&1; then - timeout "$duration" "$@" - else - "$@" - fi -} - -append_bazel_storage_cache() { - local bazelrc_path="$1" - local cache_root="${BURROW_BAZEL_STORAGE_CACHE:-}" - if [[ -z "$cache_root" && -n "${NSC_CACHE_PATH:-}" ]]; then - cache_root="${NSC_CACHE_PATH}/bazel" - fi - if [[ -z "$cache_root" ]]; then - return 0 - fi - - portable_mkdir -p "${cache_root}/disk" "${cache_root}/repository" - { - echo "build --disk_cache=${cache_root}/disk" - echo "build --repository_cache=${cache_root}/repository" - } >> "$bazelrc_path" - echo "Namespace Bazel storage cache configured: ${cache_root}" -} - -configure_bazel_remote_cache() { - if [[ "$want_bazel_cache" != "1" ]]; then - return 0 - fi - - local bazelrc_path - bazelrc_path="${NSC_BAZELRC_PATH:-${TMPDIR:-/tmp}/burrow-nsc-cache.bazelrc}" - portable_mkdir -p "$(dirname "$bazelrc_path")" - - if [[ "$(uname -s 2>/dev/null || true)" == "Darwin" ]]; then - append_bazel_storage_cache "$bazelrc_path" - export BURROW_BAZEL_NSCCACHE_ACTIVE=0 - export BURROW_BAZEL_NSCCACHE_BAZELRC="$bazelrc_path" - echo "::warning ::Namespace Bazel remote cache probe skipped on macOS; using local Bazel repository/disk cache." - if [[ -n "${GITHUB_ENV:-}" ]]; then - echo "BURROW_BAZEL_NSCCACHE_ACTIVE=0" >> "$GITHUB_ENV" - echo "BURROW_BAZEL_NSCCACHE_BAZELRC=$bazelrc_path" >> "$GITHUB_ENV" - echo "NSC_BAZELRC_PATH=$bazelrc_path" >> "$GITHUB_ENV" - fi - return 0 - fi - - export BURROW_BAZEL_NSCCACHE_ACTIVE=0 - if ! command -v nsc >/dev/null 2>&1; then - if bash Scripts/ci/ensure-nsc.sh; then - nsc_bin_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nsc-bin" - if [[ -x "${nsc_bin_dir}/nsc" ]]; then - export PATH="${nsc_bin_dir}:$PATH" - fi - else - echo "::warning ::nsc not found; using local Bazel disk cache only." - if [[ -n "${GITHUB_ENV:-}" ]]; then - echo "BURROW_BAZEL_NSCCACHE_ACTIVE=0" >> "$GITHUB_ENV" - fi - return 0 - fi - fi - - local probe_timeout="${BURROW_NSC_CACHE_PROBE_TIMEOUT:-30s}" - if optional_timeout "$probe_timeout" nsc auth check-login >/dev/null 2>&1 && - optional_timeout "$probe_timeout" nsc cache bazel setup --bazelrc "$bazelrc_path" >/dev/null 2>&1; then - append_bazel_storage_cache "$bazelrc_path" - export BURROW_BAZEL_NSCCACHE_BAZELRC="$bazelrc_path" - export BURROW_BAZEL_NSCCACHE_ACTIVE=1 - echo "Namespace Bazel cache configured: $bazelrc_path" - if [[ -n "${GITHUB_ENV:-}" ]]; then - { - echo "BURROW_BAZEL_NSCCACHE_ACTIVE=1" - echo "BURROW_BAZEL_NSCCACHE_BAZELRC=$bazelrc_path" - echo "NSC_BAZELRC_PATH=$bazelrc_path" - } >> "$GITHUB_ENV" - fi - else - append_bazel_storage_cache "$bazelrc_path" - echo "::warning ::Namespace Bazel remote cache unavailable; using local Bazel repository/disk cache." - if [[ -n "${GITHUB_ENV:-}" ]]; then - echo "BURROW_BAZEL_NSCCACHE_ACTIVE=0" >> "$GITHUB_ENV" - echo "BURROW_BAZEL_NSCCACHE_BAZELRC=$bazelrc_path" >> "$GITHUB_ENV" - echo "NSC_BAZELRC_PATH=$bazelrc_path" >> "$GITHUB_ENV" - fi - fi -} - -cache_root="${NSC_CACHE_PATH:-}" -if [[ -z "$cache_root" ]]; then - ensure_dir() { - local dir="$1" - if portable_mkdir -p "$dir" 2>/dev/null; then - return 0 - fi - if [[ "$(id -u)" != "0" ]] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then - sudo mkdir -p "$dir" >/dev/null 2>&1 || return 1 - return 0 - fi - return 1 - } - - if [[ "$(uname -s 2>/dev/null || true)" == "Linux" ]]; then - tmp_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-nscloud-cache" - for candidate in "/cache/nscloud" "$tmp_root" "$PWD/.nscloud-cache"; do - if ensure_dir "$candidate"; then - cache_root="$candidate" - break - fi - done - else - tmp_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-nscloud-cache" - for candidate in "$tmp_root" "$PWD/.nscloud-cache"; do - if ensure_dir "$candidate"; then - cache_root="$candidate" - break - fi - done - fi -fi - -if [[ -z "$cache_root" ]]; then - echo "NSCloud cache volume not configured (NSC_CACHE_PATH missing); skipping." - configure_bazel_remote_cache - exit 0 -fi - -export NSC_CACHE_PATH="$cache_root" -if [[ -n "${GITHUB_ENV:-}" ]]; then - echo "NSC_CACHE_PATH=$cache_root" >> "$GITHUB_ENV" -fi - -mode_args=() -for mode in "$@"; do - [[ -z "$mode" ]] && continue - if [[ "$mode" == "bazel" ]]; then - continue - fi - if [[ "$mode" == "nix" && "${BURROW_NSC_DIRECT_NIX_VOLUME:-0}" == "1" ]]; then - echo "Namespace Nix cache volume is mounted directly at /nix; skipping spacectl nix mount." - continue - fi - if [[ "$mode" == "nix" && "$(uname -s)" == "Darwin" ]]; then - echo "Skipping late Nix cache mount on macOS; using existing Nix profile and Bazel cache." - continue - fi - mode_args+=("--mode=${mode}") -done - -if [[ "${#mode_args[@]}" -eq 0 ]]; then - echo "No filesystem cache modes require mounting; skipping spacectl cache mount." - configure_bazel_remote_cache - exit 0 -fi - -spacectl_bin_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/spacectl-bin" -if ! command -v spacectl >/dev/null 2>&1; then - if ! bash Scripts/ci/ensure-spacectl.sh; then - echo "::warning ::Unable to install spacectl; skipping filesystem cache mount." - configure_bazel_remote_cache - exit 0 - fi - if [[ -x "${spacectl_bin_dir}/spacectl" ]]; then - export PATH="${spacectl_bin_dir}:$PATH" - fi -fi - -portable_mkdir -p "$cache_root" 2>/dev/null || true - -args=(-o json cache mount "--dry_run=false" "--cache_root=${cache_root}" "${mode_args[@]}") -echo "Mounting NSCloud cache: spacectl ${args[*]//${cache_root}/}" - -spacectl_bin="$(command -v spacectl)" -run_mount=("$spacectl_bin" "${args[@]}") -if [[ "$(id -u)" != "0" ]]; then - if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then - echo "::warning ::spacectl cache mount requires passwordless sudo; skipping filesystem cache." - configure_bazel_remote_cache - exit 0 - fi - run_mount=(sudo -n env "PATH=$PATH" "$spacectl_bin" "${args[@]}") -fi - -tmp_err="$(mktemp "${TMPDIR:-/tmp}/spacectl.XXXXXX")" -cleanup() { - rm -f "$tmp_err" -} -trap cleanup EXIT - -json="" -if ! json="$("${run_mount[@]}" 2>"$tmp_err")"; then - echo "::warning ::spacectl cache mount failed; skipping filesystem cache." - if [[ -n "$json" ]]; then - printf '%s\n' "$json" || true - fi - if [[ -s "$tmp_err" ]]; then - sed -n '1,200p' "$tmp_err" || true - fi - configure_bazel_remote_cache - exit 0 -fi - -if command -v python3 >/dev/null 2>&1; then - NSC_MOUNT_JSON="$json" python3 - <<'PY' -import json -import os -import sys - -raw = os.environ.get("NSC_MOUNT_JSON", "").strip() -if not raw: - sys.exit(0) - -try: - payload = json.loads(raw) -except Exception as exc: - print(f"::warning ::Unable to parse spacectl JSON output; skipping cache env export: {exc}") - sys.exit(0) - -if payload.get("error"): - print(f"::warning ::spacectl cache mount skipped: {payload.get('message') or 'error=true'}") - sys.exit(0) - -add_envs = (payload.get("output") or {}).get("add_envs") or {} -gh_env = os.environ.get("GITHUB_ENV") -if gh_env and isinstance(add_envs, dict): - with open(gh_env, "a", encoding="utf-8") as f: - for key, value in add_envs.items(): - if key and value is not None: - f.write(f"{key}={value}\n") -PY -fi - -configure_bazel_remote_cache diff --git a/Scripts/ci/package-apple-artifacts.sh b/Scripts/ci/package-apple-artifacts.sh deleted file mode 100755 index c910f0d..0000000 --- a/Scripts/ci/package-apple-artifacts.sh +++ /dev/null @@ -1,592 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$repo_root" - -platform="${1:-all}" -build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}" -out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}" -apple_root="${out_root}/apple" -archives_root="${apple_root}/archives" -derived_data="${BURROW_DERIVED_DATA_PATH:-${repo_root}/.derived-data/apple}" -source_packages="${BURROW_SWIFTPM_CACHE_PATH:-${XDG_CACHE_HOME:-${HOME}/.cache}/burrow/swiftpm}" -project="Apple/Burrow.xcodeproj" -scheme="${BURROW_APPLE_SCHEME:-App}" -bundle_id="${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}" -network_extension_bundle_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${bundle_id}.network}" -signing_ready="${BURROW_APPLE_SIGNING_READY:-0}" -signing_mode="${BURROW_APPLE_SIGNING_MODE:-keychain}" -require_signing="${BURROW_APPLE_REQUIRE_SIGNING:-false}" -sparkle_feed_url="${BURROW_SPARKLE_FEED_URL:-https://releases.burrow.net/sparkle/appcast.xml}" -sparkle_public_ed_key="${BURROW_SPARKLE_PUBLIC_ED_KEY:-uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=}" -sparkle_sign_with_kms="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}" -notarize_macos="${BURROW_NOTARIZE_MACOS:-false}" - -mkdir -p "$apple_root" "$archives_root" "$derived_data" "$source_packages" - -truthy() { - case "${1:-}" in - 1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;; - *) return 1 ;; - esac -} - -sha256_file() { - local file="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" > "${file}.sha256" - else - sha256sum "$file" > "${file}.sha256" - fi -} - -safe_profile_slug() { - python3 - "$1" <<'PY' -import re -import sys - -print(re.sub(r"[^A-Za-z0-9]", "", sys.argv[1]).lower()) -PY -} - -profile_for() { - local identifier="$1" - local suffix="$2" - local slug path - slug="$(safe_profile_slug "$identifier")" - for path in \ - ".forgejo/actions/export/${slug}${suffix}.provisionprofile" \ - ".forgejo/actions/export/${slug}${suffix}.mobileprovision" \ - "Apple/Profiles/${slug}${suffix}.provisionprofile" \ - "Apple/Profiles/${slug}${suffix}.mobileprovision"; do - if [[ -s "$path" ]]; then - printf '%s\n' "$path" - return 0 - fi - done - return 1 -} - -generate_entitlements_from_profile() { - local profile="$1" - local output="$2" - local template="${3:-}" - local args=("$profile" --output "$output") - if [[ -n "$template" ]]; then - args+=(--template "$template") - fi - Scripts/apple/profile-entitlements.py "${args[@]}" -} - -apple_signing_available() { - if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "google-kms-staged" || "$signing_mode" == "kms" ]]; then - return 0 - fi - [[ "$signing_ready" == "1" ]] -} - -kms_sign_bundle() { - local family="$1" - local bundle_path="$2" - local entitlements="$3" - local runtime_flag=() - if [[ "$family" == "developer-id" ]]; then - runtime_flag=(--runtime) - fi - Scripts/apple/sign-with-google-kms.sh \ - --family "$family" \ - --path "$bundle_path" \ - --entitlements "$entitlements" \ - "${runtime_flag[@]}" -} - -kms_sign_file() { - local family="$1" - local file_path="$2" - local runtime_flag=() - if [[ "$family" == "developer-id" ]]; then - runtime_flag=(--runtime) - fi - Scripts/apple/sign-with-google-kms.sh \ - --family "$family" \ - --path "$file_path" \ - "${runtime_flag[@]}" -} - -write_version() { - mkdir -p Apple/Configuration - printf 'CURRENT_PROJECT_VERSION = %s\n' "$build_number" > Apple/Configuration/Version.xcconfig -} - -xcode_common_args=( - -project "$project" - -scheme "$scheme" - -configuration Release - -derivedDataPath "$derived_data" - -clonedSourcePackagesDirPath "$source_packages" - CURRENT_PROJECT_VERSION="$build_number" - APP_BUNDLE_IDENTIFIER="$bundle_id" - NETWORK_EXTENSION_BUNDLE_IDENTIFIER="$network_extension_bundle_id" - BURROW_SPARKLE_FEED_URL="$sparkle_feed_url" - BURROW_SPARKLE_PUBLIC_ED_KEY="$sparkle_public_ed_key" -) - -xcode_auth_args=() -if [[ -n "${ASC_API_KEY_ID:-}" && -n "${ASC_API_ISSUER_ID:-}" && -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then - xcode_auth_args+=( - -authenticationKeyID "$ASC_API_KEY_ID" - -authenticationKeyIssuerID "$ASC_API_ISSUER_ID" - -authenticationKeyPath "$ASC_API_KEY_PATH" - ) -fi - -if [[ -n "${BURROW_APPLE_KEYCHAIN_PATH:-}" ]]; then - xcode_common_args+=(OTHER_CODE_SIGN_FLAGS="--keychain ${BURROW_APPLE_KEYCHAIN_PATH}") -fi - -require_xcodebuild() { - if ! command -v xcodebuild >/dev/null 2>&1; then - echo "xcodebuild is required for Apple release artifacts" >&2 - exit 1 - fi - xcodebuild -version - xcrun --find swiftc >/dev/null -} - -unsigned_note() { - local platform_name="$1" - local artifact_name="$2" - { - echo "Burrow ${platform_name} signing assets were not available." - echo - echo "The lane built an unsigned validation artifact instead of an uploadable release artifact." - echo "Seal the Apple release credentials with agenix and let CI sync provisioning profiles from App Store Connect to produce ${artifact_name}." - } > "${apple_root}/README-${platform_name}-unsigned.txt" -} - -ensure_signing_or_note() { - local platform_name="$1" - local artifact_name="$2" - if apple_signing_available; then - return 0 - fi - if truthy "$require_signing"; then - echo "Apple signing is required for ${platform_name}, but signing assets are not configured." >&2 - exit 1 - fi - unsigned_note "$platform_name" "$artifact_name" - return 1 -} - -write_export_options() { - local method="$1" - local path="$2" - cat > "$path" < - - - - method - ${method} - destination - export - signingStyle - automatic - stripSwiftSymbols - - teamID - ${DEVELOPMENT_TEAM:-P6PV2R9443} - - -EOF -} - -build_ios_unsigned() { - local product_dir zip_path - xcodebuild build \ - "${xcode_common_args[@]}" \ - -destination "generic/platform=iOS Simulator" \ - ARCHS=arm64 \ - ONLY_ACTIVE_ARCH=YES \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO - - product_dir="${derived_data}/Build/Products/Release-iphonesimulator" - zip_path="${apple_root}/Burrow-iOS-simulator-${build_number}.unsigned.zip" - if [[ -d "${product_dir}/Burrow.app" ]]; then - (cd "$product_dir" && zip -qr "$zip_path" Burrow.app) - sha256_file "$zip_path" - fi -} - -build_ios_release() { - local archive_path export_dir export_options ipa - if ! ensure_signing_or_note "iOS" "Burrow-iOS-${build_number}.ipa"; then - build_ios_unsigned - return 0 - fi - if [[ "$signing_mode" == "google-kms-staged" ]]; then - build_ios_kms_staged - return 0 - fi - if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "kms" ]]; then - build_ios_kms_release - return 0 - fi - - archive_path="${archives_root}/Burrow-iOS-${build_number}.xcarchive" - export_dir="${apple_root}/ios-export" - export_options="${apple_root}/ExportOptions-iOS.plist" - write_export_options "app-store-connect" "$export_options" - - xcodebuild archive \ - "${xcode_common_args[@]}" \ - -destination "generic/platform=iOS" \ - -archivePath "$archive_path" \ - ARCHS=arm64 \ - -allowProvisioningUpdates \ - "${xcode_auth_args[@]}" - - xcodebuild -exportArchive \ - -archivePath "$archive_path" \ - -exportPath "$export_dir" \ - -exportOptionsPlist "$export_options" \ - -allowProvisioningUpdates \ - "${xcode_auth_args[@]}" - - ipa="$(find "$export_dir" -maxdepth 1 -type f -name '*.ipa' | sort | head -n 1 || true)" - if [[ -z "$ipa" ]]; then - echo "iOS export completed but did not produce an IPA" >&2 - exit 1 - fi - cp "$ipa" "${apple_root}/Burrow-iOS-${build_number}.ipa" - sha256_file "${apple_root}/Burrow-iOS-${build_number}.ipa" -} - -build_ios_kms_staged() { - local product_dir source_app stage_dir ipa - xcodebuild build \ - "${xcode_common_args[@]}" \ - -destination "generic/platform=iOS" \ - ARCHS=arm64 \ - ONLY_ACTIVE_ARCH=YES \ - SKIP_INSTALL=NO \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY= - - product_dir="${derived_data}/Build/Products/Release-iphoneos" - source_app="${product_dir}/Burrow.app" - if [[ ! -d "$source_app" ]]; then - echo "iOS build completed but did not produce Burrow.app" >&2 - exit 1 - fi - - stage_dir="${apple_root}/ios-unsigned/Payload" - rm -rf "$stage_dir" - mkdir -p "$stage_dir" - ditto "$source_app" "${stage_dir}/Burrow.app" - ipa="${apple_root}/Burrow-iOS-${build_number}.unsigned.ipa" - rm -f "$ipa" - (cd "${apple_root}/ios-unsigned" && zip -qry "$ipa" Payload) - sha256_file "$ipa" -} - -build_ios_kms_release() { - local product_dir source_app stage_dir app ext app_profile ext_profile app_entitlements ext_entitlements ipa - xcodebuild build \ - "${xcode_common_args[@]}" \ - -destination "generic/platform=iOS" \ - ARCHS=arm64 \ - ONLY_ACTIVE_ARCH=YES \ - SKIP_INSTALL=NO \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY= - - product_dir="${derived_data}/Build/Products/Release-iphoneos" - source_app="${product_dir}/Burrow.app" - if [[ ! -d "$source_app" ]]; then - echo "iOS build completed but did not produce Burrow.app" >&2 - exit 1 - fi - - stage_dir="${apple_root}/ios-kms/Payload" - rm -rf "$stage_dir" - mkdir -p "$stage_dir" - ditto "$source_app" "${stage_dir}/Burrow.app" - app="${stage_dir}/Burrow.app" - ext="${app}/PlugIns/BurrowNetworkExtension.appex" - if [[ ! -d "$ext" ]]; then - echo "iOS app is missing BurrowNetworkExtension.appex" >&2 - exit 1 - fi - - app_profile="$(profile_for "$bundle_id" "appstore")" || { - echo "missing iOS App Store provisioning profile for ${bundle_id}" >&2 - exit 1 - } - ext_profile="$(profile_for "$network_extension_bundle_id" "appstore")" || { - echo "missing iOS App Store provisioning profile for ${network_extension_bundle_id}" >&2 - exit 1 - } - cp "$app_profile" "${app}/embedded.mobileprovision" - cp "$ext_profile" "${ext}/embedded.mobileprovision" - - app_entitlements="${apple_root}/ios-kms/app-entitlements.plist" - ext_entitlements="${apple_root}/ios-kms/extension-entitlements.plist" - generate_entitlements_from_profile "$app_profile" "$app_entitlements" Apple/App/App-iOS.entitlements - generate_entitlements_from_profile "$ext_profile" "$ext_entitlements" Apple/NetworkExtension/NetworkExtension-iOS.entitlements - - rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature" - kms_sign_bundle ios "$ext" "$ext_entitlements" - kms_sign_bundle ios "$app" "$app_entitlements" - - ipa="${apple_root}/Burrow-iOS-${build_number}.ipa" - rm -f "$ipa" - (cd "${apple_root}/ios-kms" && zip -qry "$ipa" Payload) - sha256_file "$ipa" -} - -build_macos_unsigned() { - local product_dir zip_path - xcodebuild build \ - "${xcode_common_args[@]}" \ - -destination "platform=macOS" \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO - - product_dir="${derived_data}/Build/Products/Release" - zip_path="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip" - if [[ -d "${product_dir}/Burrow.app" ]]; then - ditto -c -k --keepParent "${product_dir}/Burrow.app" "$zip_path" - sha256_file "$zip_path" - fi -} - -build_macos_release() { - local archive_path export_dir export_options app dmg channel sparkle_dir length pubdate url - if ! ensure_signing_or_note "macOS" "Burrow-macOS-${build_number}.dmg"; then - build_macos_unsigned - return 0 - fi - if [[ "$signing_mode" == "google-kms-staged" ]]; then - build_macos_kms_staged - return 0 - fi - if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "kms" ]]; then - build_macos_kms_release - return 0 - fi - - archive_path="${archives_root}/Burrow-macOS-${build_number}.xcarchive" - export_dir="${apple_root}/macos-export" - export_options="${apple_root}/ExportOptions-macOS.plist" - write_export_options "${BURROW_MACOS_EXPORT_METHOD:-developer-id}" "$export_options" - - xcodebuild archive \ - "${xcode_common_args[@]}" \ - -destination "generic/platform=macOS" \ - -archivePath "$archive_path" \ - -allowProvisioningUpdates \ - "${xcode_auth_args[@]}" - - xcodebuild -exportArchive \ - -archivePath "$archive_path" \ - -exportPath "$export_dir" \ - -exportOptionsPlist "$export_options" \ - -allowProvisioningUpdates \ - "${xcode_auth_args[@]}" - - app="$(find "$export_dir" -maxdepth 2 -type d -name 'Burrow.app' | sort | head -n 1 || true)" - if [[ -z "$app" ]]; then - echo "macOS export completed but did not produce Burrow.app" >&2 - exit 1 - fi - - dmg="${apple_root}/Burrow-macOS-${build_number}.dmg" - rm -f "$dmg" - hdiutil create -volname "Burrow ${build_number}" -srcfolder "$app" -ov -format UDZO "$dmg" - sha256_file "$dmg" - - channel="${SPARKLE_CHANNEL:-build-${build_number}}" - sparkle_dir="${out_root}/sparkle/${channel}" - mkdir -p "$sparkle_dir" - cp "$dmg" "$sparkle_dir/" - length="$(wc -c < "$dmg" | tr -d ' ')" - pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" - url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$dmg")" - cat > "${sparkle_dir}/appcast.xml" < - - - Burrow ${channel} - - Burrow ${build_number} - ${pubdate} - ${build_number} - 0.1 - - - - -EOF - - if truthy "$sparkle_sign_with_kms"; then - Scripts/sparkle/sign-appcast-kms.py \ - --appcast "${sparkle_dir}/appcast.xml" \ - --artifact-dir "$sparkle_dir" - fi -} - -build_macos_kms_staged() { - local product_dir source_app zip_path - xcodebuild build \ - "${xcode_common_args[@]}" \ - -destination "platform=macOS" \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY= - - product_dir="${derived_data}/Build/Products/Release" - source_app="${product_dir}/Burrow.app" - if [[ ! -d "$source_app" ]]; then - echo "macOS build completed but did not produce Burrow.app" >&2 - exit 1 - fi - - zip_path="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip" - rm -f "$zip_path" - ditto -c -k --keepParent "$source_app" "$zip_path" - sha256_file "$zip_path" -} - -write_sparkle_appcast() { - local dmg="$1" - local channel sparkle_dir length pubdate url - channel="${SPARKLE_CHANNEL:-build-${build_number}}" - sparkle_dir="${out_root}/sparkle/${channel}" - mkdir -p "$sparkle_dir" - cp "$dmg" "$sparkle_dir/" - length="$(wc -c < "$dmg" | tr -d ' ')" - pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" - url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$dmg")" - cat > "${sparkle_dir}/appcast.xml" < - - - Burrow ${channel} - - Burrow ${build_number} - ${pubdate} - ${build_number} - 0.1 - - - - -EOF - - if truthy "$sparkle_sign_with_kms"; then - Scripts/sparkle/sign-appcast-kms.py \ - --appcast "${sparkle_dir}/appcast.xml" \ - --artifact-dir "$sparkle_dir" - fi -} - -notarize_dmg_if_requested() { - local dmg="$1" - local key_json - if ! truthy "$notarize_macos"; then - return 0 - fi - if [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" || -z "${ASC_API_KEY_PATH:-}" ]]; then - echo "macOS notarization requires ASC_API_KEY_ID, ASC_API_ISSUER_ID, and ASC_API_KEY_PATH" >&2 - exit 1 - fi - key_json="${apple_root}/notary-api-key.json" - rcodesign encode-app-store-connect-api-key \ - --output-path "$key_json" \ - "$ASC_API_ISSUER_ID" \ - "$ASC_API_KEY_ID" \ - "$ASC_API_KEY_PATH" - rcodesign notary-submit --api-key-file "$key_json" --wait --staple "$dmg" - rm -f "$key_json" -} - -build_macos_kms_release() { - local product_dir source_app app ext app_profile ext_profile app_entitlements ext_entitlements dmg - xcodebuild build \ - "${xcode_common_args[@]}" \ - -destination "platform=macOS" \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY= - - product_dir="${derived_data}/Build/Products/Release" - source_app="${product_dir}/Burrow.app" - if [[ ! -d "$source_app" ]]; then - echo "macOS build completed but did not produce Burrow.app" >&2 - exit 1 - fi - - app="${apple_root}/macos-kms/Burrow.app" - rm -rf "$app" - mkdir -p "$(dirname "$app")" - ditto "$source_app" "$app" - ext="${app}/Contents/PlugIns/BurrowNetworkExtension.appex" - - app_profile="$(profile_for "$bundle_id" "developerid")" || { - echo "missing macOS Developer ID provisioning profile for ${bundle_id}" >&2 - exit 1 - } - cp "$app_profile" "${app}/Contents/embedded.provisionprofile" - app_entitlements="${apple_root}/macos-kms/app-entitlements.plist" - generate_entitlements_from_profile "$app_profile" "$app_entitlements" - - if [[ -d "$ext" ]]; then - ext_profile="$(profile_for "$network_extension_bundle_id" "developerid")" || { - echo "missing macOS Developer ID provisioning profile for ${network_extension_bundle_id}" >&2 - exit 1 - } - cp "$ext_profile" "${ext}/Contents/embedded.provisionprofile" - ext_entitlements="${apple_root}/macos-kms/extension-entitlements.plist" - generate_entitlements_from_profile "$ext_profile" "$ext_entitlements" - rm -rf "${ext}/Contents/_CodeSignature" - kms_sign_bundle developer-id "$ext" "$ext_entitlements" - fi - - rm -rf "${app}/Contents/_CodeSignature" - kms_sign_bundle developer-id "$app" "$app_entitlements" - - dmg="${apple_root}/Burrow-macOS-${build_number}.dmg" - rm -f "$dmg" - hdiutil create -volname "Burrow ${build_number}" -srcfolder "$app" -ov -format UDZO "$dmg" - kms_sign_file developer-id "$dmg" - notarize_dmg_if_requested "$dmg" - sha256_file "$dmg" - write_sparkle_appcast "$dmg" -} - -require_xcodebuild -write_version - -case "$platform" in - all) - build_ios_release - build_macos_release - ;; - ios) - build_ios_release - ;; - macos) - build_macos_release - ;; - *) - echo "unknown Apple release platform: $platform" >&2 - exit 64 - ;; -esac - -find "$out_root" -type f -print | sort diff --git a/Scripts/ci/package-release-artifacts.sh b/Scripts/ci/package-release-artifacts.sh deleted file mode 100755 index 4ee85ee..0000000 --- a/Scripts/ci/package-release-artifacts.sh +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -platform="${1:-all}" -build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}" -out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}" - -mkdir -p "${out_root}" - -sha256_file() { - local file="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" > "${file}.sha256" - else - sha256sum "$file" > "${file}.sha256" - fi -} - -burrow_crate_version() { - awk -F '"' '/^version = / { print $2; exit }' burrow/Cargo.toml -} - -package_release_suffix() { - printf '%s' "$build_number" | tr -c 'A-Za-z0-9.+~' '.' -} - -arch_package_suffix() { - printf '%s' "$build_number" | tr -c 'A-Za-z0-9.+_' '.' -} - -deb_arch_for_target() { - case "$1" in - x86_64-unknown-linux-gnu) printf 'amd64' ;; - aarch64-unknown-linux-gnu) printf 'arm64' ;; - riscv64gc-unknown-linux-gnu) printf 'riscv64' ;; - *) return 1 ;; - esac -} - -rpm_arch_for_target() { - case "$1" in - x86_64-unknown-linux-gnu) printf 'x86_64' ;; - aarch64-unknown-linux-gnu) printf 'aarch64' ;; - riscv64gc-unknown-linux-gnu) printf 'riscv64' ;; - *) return 1 ;; - esac -} - -pacman_arch_for_target() { - case "$1" in - x86_64-unknown-linux-gnu) printf 'x86_64' ;; - aarch64-unknown-linux-gnu) printf 'aarch64' ;; - riscv64gc-unknown-linux-gnu) printf 'riscv64' ;; - *) return 1 ;; - esac -} - -copy_license_readme() { - local dst="$1" - cp README.md "$dst/README.md" - if [[ -f LICENSE ]]; then - cp LICENSE "$dst/LICENSE" - elif [[ -f LICENSE.md ]]; then - cp LICENSE.md "$dst/LICENSE.md" - fi -} - -package_deb() { - local target binary deb_arch pkg_version suffix staging deb_file installed_size - target="$1" - binary="$2" - if ! deb_arch="$(deb_arch_for_target "$target")"; then - echo "warning: no Debian architecture mapping for ${target}; skipping .deb package" >&2 - return 0 - fi - if ! command -v dpkg-deb >/dev/null 2>&1; then - echo "warning: dpkg-deb is not available; skipping .deb package" >&2 - return 0 - fi - - suffix="$(package_release_suffix)" - pkg_version="${BURROW_PACKAGE_VERSION:-$(burrow_crate_version)+${suffix}}" - staging="${out_root}/linux/pkg-deb/burrow_${pkg_version}_${deb_arch}" - rm -rf "$staging" - mkdir -p \ - "$staging/DEBIAN" \ - "$staging/usr/bin" \ - "$staging/usr/lib/systemd/system" \ - "$staging/usr/share/doc/burrow" \ - "$staging/usr/share/licenses/burrow" - - install -m 0755 "$binary" "$staging/usr/bin/burrow" - install -m 0644 systemd/burrow.service "$staging/usr/lib/systemd/system/burrow.service" - install -m 0644 systemd/burrow.socket "$staging/usr/lib/systemd/system/burrow.socket" - install -m 0644 README.md "$staging/usr/share/doc/burrow/README.md" - install -m 0644 LICENSE.md "$staging/usr/share/licenses/burrow/LICENSE.md" - installed_size="$(du -sk "$staging/usr" | awk '{ print $1 }')" - - cat > "$staging/DEBIAN/control" < -Installed-Size: ${installed_size} -Depends: systemd -Description: Burrow VPN daemon and CLI - Burrow installs the daemon entrypoint and systemd socket used by the desktop UI. -EOF - - deb_file="${out_root}/linux/burrow_${pkg_version}_${deb_arch}.deb" - dpkg-deb --build --root-owner-group "$staging" "$deb_file" - sha256_file "$deb_file" -} - -package_rpm() { - local target binary rpm_arch - target="$1" - binary="$2" - if ! rpm_arch="$(rpm_arch_for_target "$target")"; then - echo "warning: no RPM architecture mapping for ${target}; skipping .rpm package" >&2 - return 0 - fi - if ! command -v cargo-generate-rpm >/dev/null 2>&1; then - echo "warning: cargo-generate-rpm is not available; skipping .rpm package" >&2 - return 0 - fi - - mkdir -p target/release - cp "$binary" target/release/burrow - if cargo generate-rpm -p burrow; then - find target/generate-rpm -type f -name '*.rpm' -exec cp {} "${out_root}/linux/" \; 2>/dev/null || true - find "${out_root}/linux" -maxdepth 1 -type f -name '*.rpm' -print | while read -r rpm; do - sha256_file "$rpm" - done - else - echo "warning: cargo-generate-rpm failed for ${rpm_arch}; skipping .rpm package" >&2 - fi -} - -package_pacman() { - local target binary pacman_arch pkg_version pkgrel work source_sum service_sum socket_sum readme_sum license_sum - target="$1" - binary="$2" - if ! pacman_arch="$(pacman_arch_for_target "$target")"; then - echo "warning: no pacman architecture mapping for ${target}; skipping Arch package" >&2 - return 0 - fi - if ! command -v makepkg >/dev/null 2>&1; then - echo "warning: makepkg is not available; skipping Arch package" >&2 - return 0 - fi - - pkg_version="${BURROW_ARCH_PKGVER:-$(burrow_crate_version)_$(arch_package_suffix)}" - pkgrel="${BURROW_ARCH_PKGREL:-1}" - work="${out_root}/linux/pkg-arch/${pacman_arch}" - rm -rf "$work" - mkdir -p "$work" - install -m 0755 "$binary" "$work/burrow" - install -m 0644 systemd/burrow.service "$work/burrow.service" - install -m 0644 systemd/burrow.socket "$work/burrow.socket" - install -m 0644 README.md "$work/README.md" - install -m 0644 LICENSE.md "$work/LICENSE.md" - - source_sum="$(sha256sum "$work/burrow" | awk '{ print $1 }')" - service_sum="$(sha256sum "$work/burrow.service" | awk '{ print $1 }')" - socket_sum="$(sha256sum "$work/burrow.socket" | awk '{ print $1 }')" - readme_sum="$(sha256sum "$work/README.md" | awk '{ print $1 }')" - license_sum="$(sha256sum "$work/LICENSE.md" | awk '{ print $1 }')" - - cat > "$work/PKGBUILD" < -pkgname=burrow -pkgver=${pkg_version} -pkgrel=${pkgrel} -pkgdesc="Burrow VPN daemon and CLI" -arch=("${pacman_arch}") -url="https://git.burrow.net/hackclub/burrow" -license=("GPL-3.0-or-later") -depends=("systemd") -source=("burrow" "burrow.service" "burrow.socket" "README.md" "LICENSE.md") -sha256sums=("${source_sum}" "${service_sum}" "${socket_sum}" "${readme_sum}" "${license_sum}") - -package() { - install -Dm755 "\${srcdir}/burrow" "\${pkgdir}/usr/bin/burrow" - install -Dm644 "\${srcdir}/burrow.service" "\${pkgdir}/usr/lib/systemd/system/burrow.service" - install -Dm644 "\${srcdir}/burrow.socket" "\${pkgdir}/usr/lib/systemd/system/burrow.socket" - install -Dm644 "\${srcdir}/README.md" "\${pkgdir}/usr/share/doc/burrow/README.md" - install -Dm644 "\${srcdir}/LICENSE.md" "\${pkgdir}/usr/share/licenses/burrow/LICENSE.md" -} -EOF - - ( - cd "$work" - makepkg --force --nodeps --noconfirm - ) - find "$work" -maxdepth 1 -type f -name '*.pkg.tar.*' -exec cp {} "${out_root}/linux/" \; - find "${out_root}/linux" -maxdepth 1 -type f -name '*.pkg.tar.*' -print | while read -r package; do - sha256_file "$package" - done -} - -package_linux() { - local target host archive staging - host="$(rustc -vV | awk '/^host:/ { print $2 }')" - target="${BURROW_LINUX_TARGET:-$host}" - if [[ "$target" != *-linux-* && -z "${BURROW_LINUX_TARGET:-}" ]]; then - mkdir -p "${out_root}/linux" - echo "warning: host target ${target} is not Linux; skipping Linux artifact on this host" >&2 - printf 'Linux build was skipped on host target %s. Run this lane on a Linux Namespace runner or set BURROW_LINUX_TARGET explicitly.\n' "$target" > "${out_root}/linux/README.txt" - return 0 - fi - staging="${out_root}/linux/burrow-${build_number}-${target}" - mkdir -p "$staging" - - cargo build --locked --release -p burrow --bin burrow --target "$target" - install -m 0755 "target/${target}/release/burrow" "$staging/burrow" - copy_license_readme "$staging" - - archive="${out_root}/linux/burrow-${build_number}-${target}.tar.gz" - tar -C "${out_root}/linux" -czf "$archive" "$(basename "$staging")" - sha256_file "$archive" - - package_deb "$target" "target/${target}/release/burrow" - package_rpm "$target" "target/${target}/release/burrow" - package_pacman "$target" "target/${target}/release/burrow" -} - -package_windows_stub_unix() { - local target exe archive staging - target="${BURROW_WINDOWS_TARGET:-x86_64-pc-windows-gnu}" - staging="${out_root}/windows/burrow-${build_number}-${target}" - mkdir -p "$staging" - - target_libdir="$(rustc --print target-libdir --target "$target" 2>/dev/null || true)" - if [[ -z "$target_libdir" || ! -d "$target_libdir" ]]; then - echo "warning: Rust target ${target} is not installed on this host" >&2 - printf 'Windows stub build for %s was skipped on this host. Use the Namespace Windows lane for the native artifact.\n' "$target" > "${out_root}/windows/README.txt" - return 0 - fi - - if cargo build --locked --release -p burrow-windows-stub --target "$target"; then - exe="target/${target}/release/burrow.exe" - else - echo "warning: unable to build the Windows release stub on this host" >&2 - printf 'Windows stub build for %s was skipped on this host. Use the Namespace Windows lane for the native artifact.\n' "$target" > "${out_root}/windows/README.txt" - return 0 - fi - - install -m 0755 "$exe" "$staging/burrow.exe" - copy_license_readme "$staging" - printf 'Burrow Windows stub build %s\n' "$build_number" > "$staging/README-Windows.txt" - - archive="${out_root}/windows/burrow-${build_number}-${target}.zip" - (cd "${out_root}/windows" && zip -qr "$archive" "$(basename "$staging")") - sha256_file "$archive" -} - -package_sparkle_unsigned() { - local macos_dir appcast channel dmg - channel="${SPARKLE_CHANNEL:-build-${build_number}}" - macos_dir="${out_root}/macos" - appcast="${out_root}/sparkle/${channel}/appcast.xml" - mkdir -p "$(dirname "$appcast")" - - dmg="$(find "$macos_dir" -maxdepth 1 -type f -name '*.dmg' -print 2>/dev/null | sort | tail -n 1 || true)" - if [[ -z "$dmg" ]]; then - echo "No macOS DMG exists yet; Sparkle appcast generation is waiting on the macOS signed artifact." > "${out_root}/sparkle/${channel}/README.txt" - return 0 - fi - - local name length pubdate url - name="$(basename "$dmg")" - length="$(wc -c < "$dmg" | tr -d ' ')" - pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" - url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/${name}" - cat > "$appcast" < - - - Burrow ${channel} - - Burrow ${build_number} - ${pubdate} - ${build_number} - 0.1 - - - - -EOF - cp "$dmg" "${out_root}/sparkle/${channel}/" -} - -case "$platform" in - all) - package_linux - package_windows_stub_unix - if [[ "$(uname -s)" == "Darwin" ]] && command -v xcodebuild >/dev/null 2>&1; then - Scripts/ci/package-apple-artifacts.sh all - else - mkdir -p "${out_root}/apple" - printf 'Apple artifacts require a macOS runner with Xcode. The release workflow builds them in the dedicated Apple lane.\n' > "${out_root}/apple/README.txt" - fi - package_sparkle_unsigned - ;; - linux) - package_linux - ;; - windows-stub|windows) - package_windows_stub_unix - ;; - sparkle) - package_sparkle_unsigned - ;; - apple) - Scripts/ci/package-apple-artifacts.sh all - ;; - ios|macos) - Scripts/ci/package-apple-artifacts.sh "$platform" - ;; - *) - echo "unknown release platform: $platform" >&2 - exit 64 - ;; -esac - -find "$out_root" -type f -print | sort diff --git a/Scripts/ci/publish-forgejo-release.sh b/Scripts/ci/publish-forgejo-release.sh deleted file mode 100755 index 5a976b8..0000000 --- a/Scripts/ci/publish-forgejo-release.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${API_URL:?API_URL is required}" -: "${REPOSITORY:?REPOSITORY is required}" -: "${RELEASE_TAG:?RELEASE_TAG is required}" -: "${TOKEN:?TOKEN is required}" - -release_api="${API_URL}/repos/${REPOSITORY}/releases" -encoded_release_tag="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "${RELEASE_TAG}")" -tag_api="${release_api}/tags/${encoded_release_tag}" -release_json="$(mktemp)" -create_json="$(mktemp)" -trap 'rm -f "${release_json}" "${create_json}"' EXIT - -status="$( - curl -sS -o "${release_json}" -w '%{http_code}' \ - -H "Authorization: token ${TOKEN}" \ - "${tag_api}" -)" - -if [[ "${status}" == "404" ]]; then - jq -n \ - --arg tag "${RELEASE_TAG}" \ - --arg name "Burrow ${RELEASE_TAG}" \ - '{ - tag_name: $tag, - target_commitish: $tag, - name: $name, - body: "Automated prerelease built on Forgejo Namespace runners.", - draft: false, - prerelease: true - }' > "${create_json}" - - curl -fsS \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d @"${create_json}" \ - "${release_api}" > "${release_json}" -elif [[ "${status}" != "200" ]]; then - echo "failed to query Forgejo release for ${RELEASE_TAG} (HTTP ${status})" >&2 - cat "${release_json}" >&2 - exit 1 -fi - -release_id="$(jq -r '.id' "${release_json}")" -if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then - echo "Forgejo release payload is missing an id" >&2 - cat "${release_json}" >&2 - exit 1 -fi - -for file in dist/*; do - if [[ ! -f "${file}" ]]; then - continue - fi - name="$(basename "${file}")" - asset_id="$(jq -r --arg name "${name}" '.assets[]? | select(.name == $name) | .id' "${release_json}" | head -n1)" - if [[ -n "${asset_id}" ]]; then - curl -fsS -X DELETE \ - -H "Authorization: token ${TOKEN}" \ - "${release_api}/${release_id}/assets/${asset_id}" >/dev/null - fi - - curl -fsS \ - -H "Authorization: token ${TOKEN}" \ - -F "attachment=@${file}" \ - "${release_api}/${release_id}/assets?name=${name}" >/dev/null -done diff --git a/Scripts/ci/publish-nix-cache.sh b/Scripts/ci/publish-nix-cache.sh deleted file mode 100755 index f8e55e3..0000000 --- a/Scripts/ci/publish-nix-cache.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -cache_server_name="${BURROW_NIX_CACHE_SERVER_NAME:-burrow}" -cache_server_url="${BURROW_NIX_CACHE_SERVER_URL:-https://nix.burrow.net}" -cache_name="${BURROW_NIX_CACHE_NAME:-burrow}" -cache_ref="${cache_server_name}:${cache_name}" -token="${BURROW_NIX_CACHE_PUSH_TOKEN:-}" - -if [[ -z "$token" ]]; then - if [[ "${BURROW_NIX_CACHE_REQUIRED:-false}" == "true" ]]; then - echo "BURROW_NIX_CACHE_PUSH_TOKEN is required to publish Nix cache paths" >&2 - exit 1 - fi - echo "::notice ::BURROW_NIX_CACHE_PUSH_TOKEN is not set; skipping Nix cache publish." - exit 0 -fi - -if ! command -v attic >/dev/null 2>&1; then - echo "attic is required to publish Nix cache paths" >&2 - exit 127 -fi - -if ! command -v nix >/dev/null 2>&1; then - echo "nix is required to build paths for cache publishing" >&2 - exit 127 -fi - -attic login "$cache_server_name" "$cache_server_url" "$token" --set-default - -push_inputs=("$@") -if [[ "${#push_inputs[@]}" -eq 0 ]]; then - read -r -a attrs <<< "${BURROW_NIX_CACHE_ATTRS:-.#burrow .#forgejo-nsc-dispatcher .#forgejo-nsc-autoscaler}" - if [[ "${#attrs[@]}" -eq 0 ]]; then - echo "no Nix attrs were supplied for cache publishing" >&2 - exit 1 - fi - mapfile -t push_inputs < <(nix build --no-link --print-out-paths "${attrs[@]}") -fi - -if [[ "${#push_inputs[@]}" -eq 0 ]]; then - echo "no Nix output paths were produced for cache publishing" >&2 - exit 1 -fi - -attic push "$cache_ref" "${push_inputs[@]}" diff --git a/Scripts/ci/resolve-nix-bin.sh b/Scripts/ci/resolve-nix-bin.sh deleted file mode 100755 index 47f0cb0..0000000 --- a/Scripts/ci/resolve-nix-bin.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -find_nix_bin() { - local candidate - for candidate in \ - "${NIX_BIN:-}" \ - "$(command -v nix 2>/dev/null || true)" \ - "${HOME:-}/.nix-profile/bin/nix" \ - "${HOME:-}/.local/state/nix/profile/bin/nix" \ - "/nix/var/nix/profiles/per-user/${USER:-runner}/profile/bin/nix" \ - /nix/var/nix/profiles/default/bin/nix \ - /run/current-system/sw/bin/nix \ - /etc/profiles/per-user/runner/bin/nix \ - /Users/runner/.nix-profile/bin/nix \ - /Users/runner/.local/state/nix/profile/bin/nix \ - /home/runner/.nix-profile/bin/nix \ - /home/runner/.local/state/nix/profile/bin/nix \ - /nix/profile/bin/nix; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - - candidate="$( - find \ - "${HOME:-/nonexistent}" \ - /Users/runner \ - /home/runner \ - /nix/var/nix/profiles \ - /etc/profiles/per-user \ - -path '*/bin/nix' -type f 2>/dev/null | head -n 1 || true - )" - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - - return 1 -} - -if nix_bin="$(find_nix_bin)"; then - printf '%s\n' "$nix_bin" - exit 0 -fi - -if bash Scripts/ci/ensure-nix.sh >/dev/null 2>&1; then - if nix_bin="$(find_nix_bin)"; then - printf '%s\n' "$nix_bin" - exit 0 - fi -fi - -echo "::error ::nix is required but is not on PATH and no standard install path exists" >&2 -exit 1 diff --git a/Scripts/ci/sync-apple-provisioning-profiles.sh b/Scripts/ci/sync-apple-provisioning-profiles.sh deleted file mode 100755 index 72fe6d8..0000000 --- a/Scripts/ci/sync-apple-provisioning-profiles.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$repo_root" - -platform="${1:-all}" -out_dir="${BURROW_APPLE_PROFILES_OUT_DIR:-.forgejo/actions/export}" -app_id="${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}" -network_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${app_id}.network}" -replace_certificate_mismatch="${BURROW_APPLE_REPLACE_CERTIFICATE_MISMATCH:-false}" -if [[ -n "${BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID:-}" || -n "${BURROW_DEVELOPER_ID_CERTIFICATE_ID:-}" ]]; then - replace_certificate_mismatch="${BURROW_APPLE_REPLACE_CERTIFICATE_MISMATCH:-true}" -fi - -key_id="${ASC_API_KEY_ID:-${APPSTORE_KEY_ID:-}}" -issuer_id="${ASC_API_ISSUER_ID:-${APPSTORE_KEY_ISSUER_ID:-}}" -key_path="${ASC_API_KEY_PATH:-${APPSTORE_KEY_PATH:-}}" -key_base64="${ASC_API_KEY_BASE64:-}" - -if [[ -z "$key_id" || -z "$issuer_id" ]]; then - echo "::notice ::App Store Connect credentials are not available; provisioning profile sync skipped." - exit 0 -fi - -temp_key="" -cleanup() { - [[ -z "$temp_key" ]] || rm -f "$temp_key" -} -trap cleanup EXIT - -if [[ -z "$key_path" ]]; then - if [[ -z "$key_base64" ]]; then - echo "::notice ::ASC_API_KEY_PATH/ASC_API_KEY_BASE64 is not available; provisioning profile sync skipped." - exit 0 - fi - temp_key="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-asc-key.XXXXXX.p8")" - printf '%s' "$key_base64" | base64 --decode > "$temp_key" - chmod 600 "$temp_key" - key_path="$temp_key" -fi - -if [[ ! -f "$key_path" ]]; then - echo "::error ::App Store Connect key path does not exist: ${key_path}" >&2 - exit 1 -fi - -mkdir -p "$out_dir" -profile_args=( - --platform "$platform" - --key-id "$key_id" - --issuer-id "$issuer_id" - --key-file "$key_path" - --out-dir "$out_dir" - --app-id "$app_id" - --network-id "$network_id" - --create-missing-bundle-ids "${BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS:-false}" - --enable-capabilities "${BURROW_APPLE_ENABLE_CAPABILITIES:-false}" - --replace-certificate-mismatch "$replace_certificate_mismatch" - --bundle-platform "${BURROW_APPLE_BUNDLE_PLATFORM:-UNIVERSAL}" - --associated-domains "${BURROW_APPLE_ASSOCIATED_DOMAINS:-applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer}" -) - -if [[ -n "${BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID:-}" ]]; then - profile_args+=(--ios-certificate-id "$BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID") -fi - -if [[ -n "${BURROW_DEVELOPER_ID_CERTIFICATE_ID:-}" ]]; then - profile_args+=(--macos-certificate-id "$BURROW_DEVELOPER_ID_CERTIFICATE_ID") -fi - -node Scripts/apple/sync-provisioning-profiles.mjs "${profile_args[@]}" diff --git a/Scripts/ci/upload-package-garage.sh b/Scripts/ci/upload-package-garage.sh deleted file mode 100755 index 5dd87ba..0000000 --- a/Scripts/ci/upload-package-garage.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}" -: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage upload}" -: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage upload}" - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -source_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}" - -if [[ ! -d "$source_dir" ]]; then - echo "package repository directory not found: $source_dir" >&2 - exit 1 -fi - -if ! command -v aws >/dev/null 2>&1; then - echo "aws CLI is required for Garage package repository upload" >&2 - exit 127 -fi - -bucket="${BURROW_PACKAGE_GARAGE_BUCKET:-burrow-packages}" -prefix="${BURROW_PACKAGE_GARAGE_PREFIX:-${BURROW_PACKAGE_GCS_PREFIX:-}}" -region="${BURROW_GARAGE_REGION:-garage}" -destination="s3://${bucket}" - -if [[ -n "$prefix" ]]; then - destination="${destination}/${prefix}" -fi - -export AWS_DEFAULT_REGION="$region" -export AWS_EC2_METADATA_DISABLED=true - -aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "$source_dir" "$destination" --no-progress diff --git a/Scripts/ci/upload-package-repositories.sh b/Scripts/ci/upload-package-repositories.sh deleted file mode 100755 index f6f0b8c..0000000 --- a/Scripts/ci/upload-package-repositories.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -prefix="${BURROW_PACKAGE_GCS_PREFIX:-}" -source_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}" - -if [[ ! -d "$source_dir" ]]; then - echo "package repository directory not found: $source_dir" >&2 - exit 1 -fi - -bucket="${BURROW_PACKAGE_GCS_BUCKET:-burrow-net-packages}" -if ! command -v gcloud >/dev/null 2>&1; then - echo "gcloud is required for GCS package repository upload" >&2 - exit 127 -fi -gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q . || { - echo "gcloud has no active account; run Scripts/ci/google-wif-auth.sh first" >&2 - exit 1 -} -destination="gs://${bucket}" -if [[ -n "$prefix" ]]; then - destination="${destination}/${prefix}" -fi -gcloud storage rsync --recursive "$source_dir" "$destination" diff --git a/Scripts/ci/upload-package-storage.sh b/Scripts/ci/upload-package-storage.sh deleted file mode 100755 index 7c4eb63..0000000 --- a/Scripts/ci/upload-package-storage.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -uploaded=0 -garage_configured=0 - -if [[ -n "${BURROW_GARAGE_ENDPOINT:-}" && -n "${AWS_ACCESS_KEY_ID:-}" && -n "${AWS_SECRET_ACCESS_KEY:-}" ]]; then - garage_configured=1 -fi - -if [[ "$garage_configured" == "1" ]]; then - Scripts/ci/upload-package-garage.sh - uploaded=1 -elif [[ "${BURROW_PACKAGE_REQUIRE_GARAGE:-false}" == "true" ]]; then - echo "Garage package repository upload is required, but BURROW_GARAGE_ENDPOINT or AWS credentials are missing" >&2 - exit 1 -else - echo "::notice ::Garage package repository upload not configured; skipping primary S3-compatible target." -fi - -if [[ "${BURROW_PACKAGE_GCS_BACKUP:-true}" != "false" ]]; then - if ! command -v gcloud >/dev/null 2>&1; then - echo "gcloud is required for GCS package repository backup" >&2 - exit 127 - fi - if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then - Scripts/ci/google-wif-auth.sh - fi - Scripts/ci/upload-package-repositories.sh - uploaded=1 -fi - -if [[ "$uploaded" != "1" ]]; then - echo "no package repository storage targets were enabled" >&2 - exit 1 -fi diff --git a/Scripts/ci/upload-release-garage.sh b/Scripts/ci/upload-release-garage.sh deleted file mode 100755 index f5019aa..0000000 --- a/Scripts/ci/upload-release-garage.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BUILD_NUMBER:?BUILD_NUMBER is required}" -: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}" -: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage upload}" -: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage upload}" - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -source_dir="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${BUILD_NUMBER}}" - -if [[ ! -d "$source_dir" ]]; then - echo "release artifact directory not found: $source_dir" >&2 - exit 1 -fi - -if ! command -v aws >/dev/null 2>&1; then - echo "aws CLI is required for Garage release upload" >&2 - exit 127 -fi - -bucket="${BURROW_RELEASE_GARAGE_BUCKET:-burrow-releases}" -region="${BURROW_GARAGE_REGION:-garage}" -destination="s3://${bucket}/builds/${BUILD_NUMBER}" - -export AWS_DEFAULT_REGION="$region" -export AWS_EC2_METADATA_DISABLED=true -export AWS_REQUEST_CHECKSUM_CALCULATION="${AWS_REQUEST_CHECKSUM_CALCULATION:-when_required}" -export AWS_RESPONSE_CHECKSUM_VALIDATION="${AWS_RESPONSE_CHECKSUM_VALIDATION:-when_required}" - -aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "$source_dir" "$destination" --no-progress diff --git a/Scripts/ci/upload-release-gcs.sh b/Scripts/ci/upload-release-gcs.sh deleted file mode 100755 index 14bbce4..0000000 --- a/Scripts/ci/upload-release-gcs.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BUILD_NUMBER:?BUILD_NUMBER is required}" - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -source_dir="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${BUILD_NUMBER}}" - -if [[ ! -d "$source_dir" ]]; then - echo "release artifact directory not found: $source_dir" >&2 - exit 1 -fi - -bucket="${BURROW_RELEASE_GCS_BUCKET:-burrow-net-releases}" -if ! command -v gcloud >/dev/null 2>&1; then - echo "gcloud is required for GCS release upload" >&2 - exit 127 -fi -gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q . || { - echo "gcloud has no active account; run Scripts/ci/google-wif-auth.sh first" >&2 - exit 1 -} -gcloud storage rsync --recursive "$source_dir" "gs://${bucket}/builds/${BUILD_NUMBER}" diff --git a/Scripts/ci/upload-release-storage.sh b/Scripts/ci/upload-release-storage.sh deleted file mode 100755 index 3e6bd5f..0000000 --- a/Scripts/ci/upload-release-storage.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BUILD_NUMBER:?BUILD_NUMBER is required}" - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "${repo_root}" - -uploaded=0 -garage_configured=0 - -if [[ -n "${BURROW_GARAGE_ENDPOINT:-}" && -n "${AWS_ACCESS_KEY_ID:-}" && -n "${AWS_SECRET_ACCESS_KEY:-}" ]]; then - garage_configured=1 -fi - -if [[ "$garage_configured" == "1" ]]; then - Scripts/ci/upload-release-garage.sh - uploaded=1 -elif [[ "${BURROW_RELEASE_REQUIRE_GARAGE:-false}" == "true" ]]; then - echo "Garage release upload is required, but BURROW_GARAGE_ENDPOINT or AWS credentials are missing" >&2 - exit 1 -else - echo "::notice ::Garage release upload not configured; skipping primary S3-compatible target." -fi - -if [[ "${BURROW_RELEASE_GCS_BACKUP:-true}" != "false" ]]; then - if ! command -v gcloud >/dev/null 2>&1; then - echo "gcloud is required for GCS release backup" >&2 - exit 127 - fi - if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then - Scripts/ci/google-wif-auth.sh - fi - Scripts/ci/upload-release-gcs.sh - uploaded=1 -fi - -if [[ "$uploaded" != "1" ]]; then - echo "no release storage targets were enabled" >&2 - exit 1 -fi 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/forgejo-dispatch-via-host.sh b/Scripts/forgejo-dispatch-via-host.sh deleted file mode 100755 index eee0c3f..0000000 --- a/Scripts/forgejo-dispatch-via-host.sh +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: Scripts/forgejo-dispatch-via-host.sh [--ref ] [--inputs ] [--inputs-file ] [--return-run-info] - -Dispatch a Burrow Forgejo workflow by SSHing to the live forge host and using -the managed dispatcher token from the host runtime. - -Environment: - FORGEJO_BASE_URL Default: https://git.burrow.net - FORGEJO_REPO Default: hackclub/burrow - BURROW_FORGE_HOST Default: root@git.burrow.net - BURROW_FORGE_HOST_ADDRESS Optional SSH HostName override - BURROW_FORGE_SSH_KEY Optional explicit SSH private key - AGE_FORGE_SSH_KEY Optional injected SSH private key material - BURROW_FORGE_DISPATCHER_CONFIG_FILE Default: /run/agenix/burrowForgejoNscDispatcherConfig -EOF -} - -base_url="${FORGEJO_BASE_URL:-https://git.burrow.net}" -repo_slug="${FORGEJO_REPO:-hackclub/burrow}" -forge_host="${BURROW_FORGE_HOST:-root@git.burrow.net}" -forge_host_address="${BURROW_FORGE_HOST_ADDRESS:-}" -dispatcher_config_file="${BURROW_FORGE_DISPATCHER_CONFIG_FILE:-/run/agenix/burrowForgejoNscDispatcherConfig}" -workflow="" -ref="main" -inputs_json="" -return_run_info="false" - -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help) - usage - exit 0 - ;; - --ref) - ref="${2:-}" - shift 2 - ;; - --inputs) - inputs_json="${2:-}" - shift 2 - ;; - --inputs-file) - inputs_json="$(cat "${2:-}")" - shift 2 - ;; - --return-run-info) - return_run_info="true" - shift - ;; - *) - if [[ -z "$workflow" ]]; then - workflow="$1" - shift - else - echo "Unexpected argument: $1" >&2 - usage >&2 - exit 64 - fi - ;; - esac -done - -if [[ -z "$workflow" ]]; then - echo "Missing workflow name or file, for example build-release.yml." >&2 - usage >&2 - exit 64 -fi - -script_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -resolved_forge_ssh_key="$("${script_dir}/resolve-forge-ssh-key.sh")" -eval "$resolved_forge_ssh_key" -ssh_key="${BURROW_FORGE_SSH_KEY}" - -if [[ -n "$inputs_json" ]]; then - if ! python3 - "$inputs_json" <<'PY' >/dev/null -import json -import sys - -value = json.loads(sys.argv[1]) -if not isinstance(value, dict): - raise SystemExit(1) -PY - then - echo "--inputs must be a JSON object." >&2 - exit 64 - fi -fi - -workflow_path="$(python3 - "$workflow" <<'PY' -import sys -import urllib.parse - -print(urllib.parse.quote(sys.argv[1], safe="")) -PY -)" - -payload="$(python3 - "$ref" "$inputs_json" "$return_run_info" <<'PY' -import json -import sys - -ref = sys.argv[1] -inputs_arg = sys.argv[2] -return_run_info = sys.argv[3] == "true" - -payload = {"ref": ref} -if inputs_arg: - inputs = json.loads(inputs_arg) - payload["inputs"] = {key: str(value) for key, value in inputs.items()} -if return_run_info: - payload["return_run_info"] = True -print(json.dumps(payload)) -PY -)" - -payload_b64="$(python3 - "$payload" <<'PY' -import base64 -import sys - -print(base64.b64encode(sys.argv[1].encode()).decode()) -PY -)" - -ssh_opts=( - -i "$ssh_key" - -o IdentitiesOnly=yes - -o UserKnownHostsFile=/dev/null - -o GlobalKnownHostsFile=/dev/null - -o StrictHostKeyChecking=accept-new -) -if [[ -n "$forge_host_address" ]]; then - ssh_opts+=(-o "HostName=$forge_host_address") -fi - -response="$( - ssh "${ssh_opts[@]}" "$forge_host" bash -s -- \ - "$base_url" "$repo_slug" "$workflow_path" "$dispatcher_config_file" "$payload_b64" <<'EOF' -set -euo pipefail - -base_url="$1" -repo_slug="$2" -workflow_path="$3" -dispatcher_config_file="$4" -payload_b64="$5" - -payload="$(python3 - "$payload_b64" <<'PY' -import base64 -import sys - -print(base64.b64decode(sys.argv[1]).decode()) -PY -)" - -if [[ ! -s "$dispatcher_config_file" ]]; then - echo "Forge dispatcher config is missing or empty: $dispatcher_config_file" >&2 - exit 1 -fi - -runtime_token="$( - awk ' - /^[A-Za-z0-9_-]+:/ { in_forgejo = ($0 ~ /^forgejo:/); next } - in_forgejo && /^[[:space:]]+token:/ { - sub(/^[[:space:]]+token:[[:space:]]*/, "") - gsub(/^"/, "") - gsub(/"$/, "") - gsub(/^\047/, "") - gsub(/\047$/, "") - print - exit - } - ' "$dispatcher_config_file" | tr -d "\r\n" -)" - -if [[ -z "$runtime_token" ]]; then - echo "Forge dispatcher token not found in $dispatcher_config_file" >&2 - exit 1 -fi - -url="${base_url%/}/api/v1/repos/${repo_slug}/actions/workflows/${workflow_path}/dispatches" -response_file="$(mktemp)" -http_code="$( - curl -sS -o "$response_file" -w '%{http_code}' -X POST \ - -H "Authorization: token ${runtime_token}" \ - -H "Content-Type: application/json" \ - --data-binary "$payload" \ - "$url" -)" -if [[ "$http_code" != 2* ]]; then - cat "$response_file" >&2 - rm -f "$response_file" - exit 22 -fi -cat "$response_file" -rm -f "$response_file" -EOF -)" - -if [[ "$return_run_info" == "true" ]]; then - printf '%s\n' "$response" -else - echo "Dispatched ${workflow} on ${ref} via ${forge_host}." -fi diff --git a/Scripts/forgejo-dispatch.sh b/Scripts/forgejo-dispatch.sh deleted file mode 100755 index a9a7090..0000000 --- a/Scripts/forgejo-dispatch.sh +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: Scripts/forgejo-dispatch.sh [--ref ] [--inputs ] [--inputs-file ] [--return-run-info] - -Dispatch a Burrow Forgejo workflow through the Forgejo API. - -Environment: - FORGEJO_BASE_URL Default: https://git.burrow.net - FORGEJO_REPO Default: hackclub/burrow - FORGEJO_PAT Token string - FORGEJO_PAT_FILE Path to plaintext token - FORGEJO_DISPATCH_HOST_FALLBACK auto, always, or never. Default: auto -EOF -} - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -base_url="${FORGEJO_BASE_URL:-https://git.burrow.net}" -repo_slug="${FORGEJO_REPO:-hackclub/burrow}" -host_fallback="${FORGEJO_DISPATCH_HOST_FALLBACK:-auto}" -original_args=("$@") -workflow="" -ref="main" -inputs_json="" -return_run_info="false" - -case "$host_fallback" in - auto|always|never) ;; - *) - echo "FORGEJO_DISPATCH_HOST_FALLBACK must be auto, always, or never." >&2 - exit 64 - ;; -esac - -host_dispatch() { - local reason="$1" - local fallback="${repo_root}/Scripts/forgejo-dispatch-via-host.sh" - - if [[ ! -x "$fallback" ]]; then - return 1 - fi - - echo "Forgejo API dispatch ${reason}; retrying through the forge host." >&2 - exec "$fallback" "${original_args[@]}" -} - -if [[ "$host_fallback" == "always" ]]; then - if ! host_dispatch "forced by FORGEJO_DISPATCH_HOST_FALLBACK=always"; then - echo "Forge host dispatcher is not available." >&2 - exit 1 - fi -fi - -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help) - usage - exit 0 - ;; - --ref) - ref="${2:-}" - shift 2 - ;; - --inputs) - inputs_json="${2:-}" - shift 2 - ;; - --inputs-file) - inputs_json="$(cat "${2:-}")" - shift 2 - ;; - --return-run-info) - return_run_info="true" - shift - ;; - *) - if [[ -z "$workflow" ]]; then - workflow="$1" - shift - else - echo "Unexpected argument: $1" >&2 - usage >&2 - exit 64 - fi - ;; - esac -done - -if [[ -z "$workflow" ]]; then - echo "Missing workflow name or file, for example build-release.yml." >&2 - usage >&2 - exit 64 -fi - -extract_dispatcher_token() { - python3 - "$1" <<'PY' -import re -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -in_forgejo = False -for line in path.read_text(encoding="utf-8").splitlines(): - if re.match(r"^[A-Za-z0-9_-]+:", line): - in_forgejo = line.startswith("forgejo:") - continue - if not in_forgejo: - continue - match = re.match(r"\s+token:\s*['\"]?([^'\"]+)['\"]?\s*$", line) - if match: - print(match.group(1)) - break -PY -} - -decrypt_age_secret() { - local path="$1" - - if [[ ! -f "$path" ]]; then - return 1 - fi - - if command -v nix >/dev/null 2>&1; then - nix run "${repo_root}#agenix" -- -d "$path" 2>/dev/null && return 0 - fi - - if command -v agenix >/dev/null 2>&1; then - agenix -d "$path" 2>/dev/null && return 0 - fi - - return 1 -} - -pat="${FORGEJO_PAT:-}" -pat_source="none" -if [[ -n "$pat" ]]; then - pat_source="FORGEJO_PAT" -fi - -if [[ -z "$pat" && -n "${FORGEJO_PAT_FILE:-}" && -f "$FORGEJO_PAT_FILE" ]]; then - pat="$(tr -d '\r\n' < "$FORGEJO_PAT_FILE")" - if [[ -n "$pat" ]]; then - pat_source="FORGEJO_PAT_FILE" - fi -fi - -if [[ -z "$pat" ]]; then - for default_file in \ - "${repo_root}/intake/forgejo_dispatch_pat.txt" \ - "${repo_root}/intake/forgejo_nsc_dispatcher.yaml" - do - if [[ -s "$default_file" ]]; then - if [[ "$default_file" == *.yaml ]]; then - pat="$(extract_dispatcher_token "$default_file" | tr -d '\r\n')" - else - pat="$(tr -d '\r\n' < "$default_file")" - fi - fi - if [[ -n "$pat" ]]; then - pat_source="$default_file" - break - fi - done -fi - -if [[ -z "$pat" ]]; then - decrypted_config="$(mktemp)" - trap 'rm -f "$decrypted_config"' EXIT - if decrypt_age_secret "${repo_root}/secrets/infra/forgejo-nsc-dispatcher-config.age" > "$decrypted_config"; then - pat="$(extract_dispatcher_token "$decrypted_config" | tr -d '\r\n')" - if [[ -n "$pat" ]]; then - pat_source="secrets/infra/forgejo-nsc-dispatcher-config.age" - fi - fi -fi - -if [[ -z "$pat" ]]; then - if [[ "$host_fallback" == "auto" ]]; then - if host_dispatch "has no local token"; then - exit 0 - fi - fi - echo "Forgejo token not found. Set FORGEJO_PAT or FORGEJO_PAT_FILE." >&2 - exit 1 -fi - -if [[ -n "$inputs_json" ]]; then - if ! printf '%s\n' "$inputs_json" | jq -e 'type == "object"' >/dev/null 2>&1; then - echo "--inputs must be a JSON object." >&2 - exit 64 - fi - inputs_json="$(printf '%s\n' "$inputs_json" | jq -c 'with_entries(.value |= tostring)')" - payload="$(jq -n --arg ref "$ref" --argjson inputs "$inputs_json" --argjson return_run_info "$return_run_info" \ - '{ref:$ref, inputs:$inputs} + (if $return_run_info then {return_run_info:true} else {} end)')" -else - payload="$(jq -n --arg ref "$ref" --argjson return_run_info "$return_run_info" \ - '{ref:$ref} + (if $return_run_info then {return_run_info:true} else {} end)')" -fi - -workflow_path="$(python3 - "$workflow" <<'PY' -import sys -import urllib.parse - -print(urllib.parse.quote(sys.argv[1], safe="")) -PY -)" - -url="${base_url%/}/api/v1/repos/${repo_slug}/actions/workflows/${workflow_path}/dispatches" -response_file="$(mktemp)" -trap 'rm -f "$response_file" "${decrypted_config:-}"' EXIT - -curl_status=0 -http_code="$( - curl -sS -o "$response_file" -w '%{http_code}' -X POST \ - -H "Authorization: token ${pat}" \ - -H "Content-Type: application/json" \ - --data-binary "$payload" \ - "$url" -)" || curl_status="$?" - -if [[ "$curl_status" != "0" || "$http_code" != 2* ]]; then - if [[ "$host_fallback" == "auto" && "$pat_source" != "FORGEJO_PAT" && ( "$http_code" == "401" || "$http_code" == "403" ) ]]; then - if host_dispatch "returned HTTP ${http_code} with ${pat_source}"; then - exit 0 - fi - fi - - cat "$response_file" >&2 || true - if [[ "$curl_status" != "0" ]]; then - exit "$curl_status" - fi - exit 22 -fi - -cat "$response_file" -if [[ "$return_run_info" != "true" ]]; then - echo "Dispatched ${workflow} on ${ref}." -fi diff --git a/Scripts/forgejo-prune-runners.py b/Scripts/forgejo-prune-runners.py new file mode 100755 index 0000000..65c9ae9 --- /dev/null +++ b/Scripts/forgejo-prune-runners.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import time +import urllib.error +import urllib.request + + +def _read_token() -> str: + token = os.environ.get("FORGEJO_API_TOKEN", "").strip() + token_file = os.environ.get("FORGEJO_API_TOKEN_FILE", "").strip() + if not token and token_file: + token = pathlib.Path(token_file).read_text().strip() + if not token: + raise SystemExit("Forgejo API token is missing") + if token.startswith("PENDING-"): + raise SystemExit("Forgejo API token is pending") + return token + + +def _request(method: str, url: str, token: str) -> tuple[int, str]: + headers = {"Authorization": f"token {token}", "Accept": "application/json"} + req = urllib.request.Request(url, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + body = resp.read().decode("utf-8") + return resp.getcode(), body + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8") + return exc.code, body + + +def _list_runners(api_url: str, token: str, org: str | None) -> tuple[str, list[dict]]: + if org: + list_url = f"{api_url}/orgs/{org}/actions/runners" + else: + list_url = f"{api_url}/actions/runners" + status, body = _request("GET", list_url, token) + if status == 404: + return list_url, [] + if status >= 400: + raise RuntimeError(f"list runners failed ({status}) {body}") + try: + runners = json.loads(body) + except json.JSONDecodeError as exc: + raise RuntimeError(f"invalid runner list response: {exc}") from exc + if not isinstance(runners, list): + raise RuntimeError("runner list response is not a list") + return list_url, runners + + +def _delete_runner(api_url: str, token: str, org: str | None, runner_id: int) -> bool: + if org: + delete_url = f"{api_url}/orgs/{org}/actions/runners/{runner_id}" + else: + delete_url = f"{api_url}/actions/runners/{runner_id}" + status, body = _request("DELETE", delete_url, token) + if status in (200, 204): + return True + print(f"[forgejo-prune-runners] delete {runner_id} failed: {status} {body}") + return False + + +def _prune_db(ttl_seconds: int) -> int: + cutoff = int(time.time()) - ttl_seconds + now = int(time.time()) + sql = ( + "WITH updated AS (" + "UPDATE action_runner " + f"SET deleted = {now} " + "WHERE (deleted IS NULL OR deleted = 0) " + f"AND ((last_online IS NOT NULL AND last_online > 0 AND last_online < {cutoff}) " + f"OR (COALESCE(last_online, 0) = 0 AND created < {cutoff})) " + "RETURNING 1" + ") SELECT count(*) FROM updated;" + ) + result = subprocess.run( + ["psql", "-h", "/run/postgresql", "-U", "forgejo", "forgejo", "-tAc", sql], + check=True, + capture_output=True, + text=True, + ) + output = (result.stdout or "").strip() + try: + return int(output) + except ValueError: + return 0 + + +def main() -> None: + api_url = os.environ.get("FORGEJO_API_URL", "https://git.burrow.net/api/v1").rstrip("/") + org = os.environ.get("FORGEJO_ORG", "hackclub").strip() or None + dry_run = os.environ.get("FORGEJO_DRY_RUN", "0") == "1" + db_only = os.environ.get("FORGEJO_PRUNE_DB", "0") == "1" + ttl_seconds = int(os.environ.get("FORGEJO_RUNNER_TTL_SEC", "3600")) + + if db_only: + removed = _prune_db(ttl_seconds) + print(f"[forgejo-prune-runners] pruned {removed} runners via DB") + return + + token = _read_token() + + try: + _, runners = _list_runners(api_url, token, org) + except RuntimeError as exc: + if org is not None: + print(f"[forgejo-prune-runners] org runner list failed ({exc}); retrying instance scope") + _, runners = _list_runners(api_url, token, None) + org = None + else: + raise SystemExit(str(exc)) + + if not runners: + removed = _prune_db(ttl_seconds) + print(f"[forgejo-prune-runners] pruned {removed} runners via DB fallback") + return + + removed = 0 + for runner in runners: + runner_id = runner.get("id") + name = runner.get("name", "unknown") + status = (runner.get("status") or "").lower() + busy = bool(runner.get("busy")) + if status == "online" or busy: + continue + if runner_id is None: + continue + if dry_run: + print(f"[forgejo-prune-runners] would delete runner {runner_id} ({name}) status={status}") + continue + if _delete_runner(api_url, token, org, int(runner_id)): + removed += 1 + print(f"[forgejo-prune-runners] deleted runner {runner_id} ({name})") + + print(f"[forgejo-prune-runners] done; removed {removed} runners") + + +if __name__ == "__main__": + main() diff --git a/Scripts/generate-brand-icons.py b/Scripts/generate-brand-icons.py deleted file mode 100755 index 02dd15d..0000000 --- a/Scripts/generate-brand-icons.py +++ /dev/null @@ -1,450 +0,0 @@ -#!/usr/bin/env python3 -"""Generate Burrow app icons from locked raster panel masters.""" - -from __future__ import annotations - -import json -import shutil -import subprocess -import sys -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -BRAND = ROOT / "design" / "brand" -PANEL_DIR = BRAND / "panel-variants" -APPLE_ASSETS = ROOT / "Apple" / "UI" / "Assets.xcassets" -APPICON_SET = APPLE_ASSETS / "AppIcon.appiconset" -WATCHOS_BRAND_APPICON_SET = BRAND / "watchos" / "AppIcon.appiconset" -ANDROID_RES = ROOT / "Android" / "app" / "src" / "main" / "res" -GTK_ICONS = ROOT / "burrow-gtk" / "data" / "icons" / "hicolor" - -VARIANTS = [ - ("panel-01-guardian", "BurrowPanel01", "Guardian", "top-left panel"), - ("panel-02-field", "BurrowPanel02", "Field", "top-middle panel"), - ("panel-03-burrow", "BurrowPanel03", "Burrow", "top-right panel"), - ("panel-04-sentinel", "BurrowPanel04", "Sentinel", "bottom-left panel"), - ("panel-05-stride", "BurrowPanel05", "Stride", "bottom-middle panel"), - ("panel-06-post", "BurrowPanel06", "Post", "bottom-right panel"), -] - -PRIMARY_IOS = "panel-01-guardian" -PRIMARY_MACOS = "panel-05-stride" -PRIMARY_WATCHOS = "panel-03-burrow" -PRIMARY_ANDROID = PRIMARY_IOS -PRIMARY_LINUX = PRIMARY_IOS - -# Rough cells in the supplied 2928x1896 panel screenshot. Extraction trims the -# white sheet background, makes the crop square with transparent padding, then -# normalizes to 1024px. -EXTRACTION_CELLS = { - "panel-01-guardian": (40, 40, 900, 900), - "panel-02-field": (940, 40, 900, 900), - "panel-03-burrow": (1840, 40, 900, 900), - "panel-04-sentinel": (40, 980, 900, 900), - "panel-05-stride": (940, 980, 900, 900), - "panel-06-post": (1840, 980, 900, 900), -} - -IOS_ROWS = [ - ("iphone", "20x20", "2x", 40, {}), - ("iphone", "20x20", "3x", 60, {}), - ("iphone", "29x29", "1x", 29, {}), - ("iphone", "29x29", "2x", 58, {}), - ("iphone", "29x29", "3x", 87, {}), - ("iphone", "40x40", "2x", 80, {}), - ("iphone", "40x40", "3x", 120, {}), - ("iphone", "57x57", "1x", 57, {}), - ("iphone", "57x57", "2x", 114, {}), - ("iphone", "60x60", "2x", 120, {}), - ("iphone", "60x60", "3x", 180, {}), - ("ipad", "20x20", "1x", 20, {}), - ("ipad", "20x20", "2x", 40, {}), - ("ipad", "29x29", "1x", 29, {}), - ("ipad", "29x29", "2x", 58, {}), - ("ipad", "40x40", "1x", 40, {}), - ("ipad", "40x40", "2x", 80, {}), - ("ipad", "50x50", "1x", 50, {}), - ("ipad", "50x50", "2x", 100, {}), - ("ipad", "72x72", "1x", 72, {}), - ("ipad", "72x72", "2x", 144, {}), - ("ipad", "76x76", "1x", 76, {}), - ("ipad", "76x76", "2x", 152, {}), - ("ipad", "83.5x83.5", "2x", 167, {}), - ("ios-marketing", "1024x1024", "1x", 1024, {}), -] - -MAC_ROWS = [ - ("mac", "16x16", "1x", 16, {}), - ("mac", "16x16", "2x", 32, {}), - ("mac", "32x32", "1x", 32, {}), - ("mac", "32x32", "2x", 64, {}), - ("mac", "128x128", "1x", 128, {}), - ("mac", "128x128", "2x", 256, {}), - ("mac", "256x256", "1x", 256, {}), - ("mac", "256x256", "2x", 512, {}), - ("mac", "512x512", "1x", 512, {}), - ("mac", "512x512", "2x", 1024, {}), -] - -WATCH_ROWS = [ - ("watch", "24x24", "2x", 48, {"role": "notificationCenter", "subtype": "38mm"}), - ("watch", "27.5x27.5", "2x", 55, {"role": "notificationCenter", "subtype": "42mm"}), - ("watch", "33x33", "2x", 66, {"role": "notificationCenter", "subtype": "45mm"}), - ("watch", "29x29", "2x", 58, {"role": "companionSettings"}), - ("watch", "29x29", "3x", 87, {"role": "companionSettings"}), - ("watch", "40x40", "2x", 80, {"role": "appLauncher", "subtype": "38mm"}), - ("watch", "44x44", "2x", 88, {"role": "appLauncher", "subtype": "40mm"}), - ("watch", "46x46", "2x", 92, {"role": "appLauncher", "subtype": "41mm"}), - ("watch", "50x50", "2x", 100, {"role": "appLauncher", "subtype": "44mm"}), - ("watch", "51x51", "2x", 102, {"role": "appLauncher", "subtype": "45mm"}), - ("watch", "54x54", "2x", 108, {"role": "appLauncher", "subtype": "49mm"}), - ("watch", "86x86", "2x", 172, {"role": "quickLook", "subtype": "38mm"}), - ("watch", "98x98", "2x", 196, {"role": "quickLook", "subtype": "42mm"}), - ("watch", "108x108", "2x", 216, {"role": "quickLook", "subtype": "44mm"}), - ("watch", "117x117", "2x", 234, {"role": "quickLook", "subtype": "45mm"}), - ("watch", "129x129", "2x", 258, {"role": "quickLook", "subtype": "49mm"}), - ("watch-marketing", "1024x1024", "1x", 1024, {}), -] - -ANDROID_MIPMAPS = { - "mipmap-mdpi": 48, - "mipmap-hdpi": 72, - "mipmap-xhdpi": 96, - "mipmap-xxhdpi": 144, - "mipmap-xxxhdpi": 192, -} - -LINUX_SIZES = [16, 24, 32, 48, 64, 128, 256, 512] - - -def run(args: list[str]) -> None: - subprocess.run(args, cwd=ROOT, check=True) - - -def output(args: list[str]) -> str: - return subprocess.check_output(args, cwd=ROOT, text=True).strip() - - -def write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def write_json(path: Path, value: object) -> None: - write_text(path, json.dumps(value, indent=2) + "\n") - - -def variant(asset_id: str) -> tuple[str, str, str, str]: - for item in VARIANTS: - if item[0] == asset_id: - return item - raise KeyError(asset_id) - - -def master_path(asset_id: str) -> Path: - return PANEL_DIR / f"{asset_id}.png" - - -def extract_masters(sheet: Path) -> None: - PANEL_DIR.mkdir(parents=True, exist_ok=True) - shutil.copyfile(sheet, PANEL_DIR / "source-sheet.png") - for asset_id, (x, y, w, h) in EXTRACTION_CELLS.items(): - tmp = PANEL_DIR / f".{asset_id}-trim.png" - dest = master_path(asset_id) - run( - [ - "magick", - str(sheet), - "-crop", - f"{w}x{h}+{x}+{y}", - "-fuzz", - "8%", - "-trim", - "+repage", - str(tmp), - ] - ) - geometry = output(["magick", str(tmp), "-format", "%w %h", "info:"]) - width, height = [int(part) for part in geometry.split()] - extent = max(width, height) - run( - [ - "magick", - str(tmp), - "-alpha", - "set", - "-fuzz", - "6%", - "-fill", - "none", - "-draw", - "color 0,0 floodfill", - "-draw", - f"color {width - 1},0 floodfill", - "-draw", - f"color 0,{height - 1} floodfill", - "-draw", - f"color {width - 1},{height - 1} floodfill", - "-background", - "none", - "-gravity", - "center", - "-extent", - f"{extent}x{extent}", - "-resize", - "1024x1024!", - "(", - "-size", - "1024x1024", - "xc:none", - "-fill", - "white", - "-draw", - "roundrectangle 10,10 1013,1013 166,166", - ")", - "-compose", - "DstIn", - "-composite", - "-strip", - f"PNG32:{dest}", - ] - ) - tmp.unlink() - - -def ensure_masters() -> None: - missing = [asset_id for asset_id, *_ in VARIANTS if not master_path(asset_id).exists()] - if missing: - raise SystemExit(f"missing panel masters: {', '.join(missing)}") - - -def clean_generated() -> None: - APPICON_SET.mkdir(parents=True, exist_ok=True) - for path in APPICON_SET.glob("*.png"): - path.unlink() - for path in APPLE_ASSETS.glob("BurrowPanel*.appiconset"): - shutil.rmtree(path) - for path in APPLE_ASSETS.glob("BurrowPanel*Preview.imageset"): - shutil.rmtree(path) - shutil.rmtree(WATCHOS_BRAND_APPICON_SET, ignore_errors=True) - - low_res = BRAND / "low-res" - low_res.mkdir(parents=True, exist_ok=True) - for path in low_res.glob("*.png"): - path.unlink() - report = low_res / "report.csv" - if report.exists(): - report.unlink() - - for folder in ANDROID_MIPMAPS: - shutil.rmtree(ANDROID_RES / folder, ignore_errors=True) - - for size in LINUX_SIZES: - shutil.rmtree(GTK_ICONS / str(size), ignore_errors=True) - - for path in PANEL_DIR.glob("*-preview.png"): - path.unlink() - preview = PANEL_DIR / "preview-strip.png" - if preview.exists(): - preview.unlink() - - -def render_png(source: Path, dest: Path, size: int, *, png32: bool = True) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - prefix = "PNG32:" if png32 else "PNG24:" - run( - [ - "magick", - str(source), - "-resize", - f"{size}x{size}!", - "-strip", - f"{prefix}{dest}", - ] - ) - - -def image_entry(idiom: str, size: str, scale: str, filename: str, extra: dict[str, str]) -> dict[str, str]: - row = {"filename": filename, "idiom": idiom, "scale": scale, "size": size} - row.update(extra) - return row - - -def write_appicon_contents(path: Path, rows: list[dict[str, str]]) -> None: - write_json(path / "Contents.json", {"images": rows, "info": {"author": "xcode", "version": 1}}) - - -def generate_appicon_set( - path: Path, - source: Path, - prefix: str, - rows_def: list[tuple[str, str, str, int, dict[str, str]]], -) -> list[dict[str, str]]: - path.mkdir(parents=True, exist_ok=True) - rows = [] - rendered: set[tuple[str, int]] = set() - for idiom, size_label, scale, pixels, extra in rows_def: - filename = f"{prefix}-{pixels}.png" - rows.append(image_entry(idiom, size_label, scale, filename, extra)) - key = (filename, pixels) - if key not in rendered: - render_png(source, path / filename, pixels) - rendered.add(key) - return rows - - -def generate_apple_assets() -> None: - primary_rows = [] - primary_rows.extend(generate_appicon_set(APPICON_SET, master_path(PRIMARY_IOS), "ios-panel-01", IOS_ROWS)) - primary_rows.extend(generate_appicon_set(APPICON_SET, master_path(PRIMARY_MACOS), "mac-panel-05", MAC_ROWS)) - write_appicon_contents(APPICON_SET, primary_rows) - - watch_rows = generate_appicon_set( - WATCHOS_BRAND_APPICON_SET, - master_path(PRIMARY_WATCHOS), - "watch-panel-03", - WATCH_ROWS, - ) - write_appicon_contents(WATCHOS_BRAND_APPICON_SET, watch_rows) - - for asset_id, asset_name, _, _ in VARIANTS[1:]: - set_path = APPLE_ASSETS / f"{asset_name}.appiconset" - rows = generate_appicon_set(set_path, master_path(asset_id), asset_name, IOS_ROWS) - write_appicon_contents(set_path, rows) - - for asset_id, asset_name, _, _ in VARIANTS: - preview_dir = APPLE_ASSETS / f"{asset_name}Preview.imageset" - preview_dir.mkdir(parents=True, exist_ok=True) - render_png(master_path(asset_id), preview_dir / "preview.png", 256) - write_json( - preview_dir / "Contents.json", - { - "images": [{"filename": "preview.png", "idiom": "universal", "scale": "1x"}], - "info": {"author": "xcode", "version": 1}, - }, - ) - - -def generate_brand_outputs() -> None: - primary = master_path(PRIMARY_IOS) - render_png(primary, BRAND / "burrow-owl-icon-master.png", 1024) - render_png(primary, BRAND / "burrowing-owl-icon-flat.png", 1024) - render_png(primary, BRAND / "Burrow.icon" / "Assets" / "owl-figure.png", 1024) - render_png(primary, BRAND / "Burrow.icon" / "Assets" / "owl-body.png", 1024) - transparent = BRAND / "Burrow.icon" / "Assets" / "transparent.png" - run(["magick", "-size", "1024x1024", "xc:none", f"PNG32:{transparent}"]) - for name in ["burrow-ground", "owl-face", "owl-legs", "owl-spots"]: - shutil.copyfile(transparent, BRAND / "Burrow.icon" / "Assets" / f"{name}.png") - transparent.unlink() - write_json( - BRAND / "Burrow.icon" / "icon.json", - { - "fill": {"type": "solid", "color": "#00000000"}, - "layers": [ - { - "blend-mode": "normal", - "image-name": "owl-figure.png", - "name": "panel-raster", - "position": {"scale": 1, "translation-in-points": [0, 0]}, - } - ], - }, - ) - - low_res = BRAND / "low-res" - report_rows = ["size,colors,gray_stddev,status"] - preview_inputs = [] - pixel_inputs = [] - for size in [16, 20, 24, 29, 32, 40, 48, 64, 80, 128, 256, 1024]: - icon = low_res / f"burrow-icon-{size}.png" - render_png(primary, icon, size) - if size <= 256: - colors = output(["magick", str(icon), "-format", "%k", "info:"]) - stddev = output(["magick", str(icon), "-colorspace", "Gray", "-format", "%[fx:standard_deviation]", "info:"]) - status = "ok" if int(colors) >= 8 and float(stddev) >= 0.025 else "check" - report_rows.append(f"{size},{colors},{stddev},{status}") - preview = low_res / f"preview-{size}.png" - filter_name = "Point" if size <= 32 else "Lanczos" - run(["magick", str(icon), "-filter", filter_name, "-resize", "128x128", str(preview)]) - preview_inputs.append(preview) - if size <= 32: - pixel = low_res / f"pixel-preview-{size}.png" - run(["magick", str(icon), "-filter", "Point", "-resize", "128x128", str(pixel)]) - pixel_inputs.append(pixel) - - write_text(low_res / "report.csv", "\n".join(report_rows) + "\n") - preview_inputs.reverse() - pixel_inputs.reverse() - run(["magick", "-background", "#202020", "-gravity", "center", *map(str, preview_inputs), "+append", str(low_res / "preview-strip.png")]) - run(["magick", "-background", "#202020", "-gravity", "center", *map(str, pixel_inputs), "+append", str(low_res / "pixel-preview-strip.png")]) - - variant_previews = [] - for asset_id, _, _, _ in VARIANTS: - preview = PANEL_DIR / f"{asset_id}-preview.png" - render_png(master_path(asset_id), preview, 256) - variant_previews.append(preview) - run(["magick", "-background", "#fbfaf6", "-gravity", "center", *map(str, variant_previews), "+append", str(PANEL_DIR / "preview-strip.png")]) - - lines = [ - "# Burrow Raster Panel Icon Variants", - "", - "The six PNG masters here are cropped from `source-sheet.png` and are the", - "locked app icon source. Do not redraw them; regenerate package assets from", - "these rasters.", - "", - "| Variant | Use | Source |", - "| --- | --- | --- |", - ] - for asset_id, _, title, source in VARIANTS: - uses = [] - if asset_id == PRIMARY_IOS: - uses.append("iOS primary") - else: - uses.append("iOS alternate") - if asset_id == PRIMARY_MACOS: - uses.append("macOS primary") - if asset_id == PRIMARY_WATCHOS: - uses.append("watchOS primary") - if asset_id == PRIMARY_ANDROID: - uses.append("Android primary") - if asset_id == PRIMARY_LINUX: - uses.append("Linux primary") - use_text = ", ".join(uses) - lines.append(f"| `{asset_id}` | {use_text} | {title}, {source} |") - lines.append("") - lines.append("`preview-strip.png` shows the six raster masters side by side.") - write_text(PANEL_DIR / "README.md", "\n".join(lines) + "\n") - - -def generate_android_assets() -> None: - primary = master_path(PRIMARY_ANDROID) - for folder, size in ANDROID_MIPMAPS.items(): - out_dir = ANDROID_RES / folder - render_png(primary, out_dir / "ic_launcher.png", size) - render_png(primary, out_dir / "ic_launcher_round.png", size) - render_png(primary, ROOT / "store-metadata" / "android" / "icon.png", 512) - - -def generate_linux_assets() -> None: - primary = master_path(PRIMARY_LINUX) - for size in LINUX_SIZES: - render_png(primary, GTK_ICONS / str(size) / "apps" / "burrow-gtk.png", size) - - -def main(argv: list[str]) -> None: - if len(argv) == 3 and argv[1] == "--extract-from": - extract_masters(Path(argv[2])) - elif len(argv) != 1: - raise SystemExit("usage: generate-brand-icons.py [--extract-from /path/to/panel-sheet.png]") - - ensure_masters() - clean_generated() - generate_apple_assets() - generate_brand_outputs() - generate_android_assets() - generate_linux_assets() - - -if __name__ == "__main__": - main(sys.argv) diff --git a/Scripts/grafana-tofu.sh b/Scripts/grafana-tofu.sh deleted file mode 100755 index d1f6a91..0000000 --- a/Scripts/grafana-tofu.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$( - git rev-parse --show-toplevel 2>/dev/null || { - cd "$(dirname "${BASH_SOURCE[0]}")/.." - pwd - } -)" - -work_dir="${BURROW_GRAFANA_TOFU_DIR:-$ROOT/infra/grafana}" -plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}" - -usage() { - cat <<'EOF' -Usage: Scripts/grafana-tofu.sh [tofu args...] - -Runs OpenTofu for Burrow Grafana API configuration. This stack creates folders -and dashboards only; Nix owns the Grafana service, authentication, data sources, -and boot-time provisioning files. - -Examples: - Scripts/grafana-tofu.sh init -backend-config=backend.hcl - Scripts/grafana-tofu.sh plan -var-file=terraform.tfvars - -Local syntax check: - Scripts/grafana-tofu.sh init -backend=false - Scripts/grafana-tofu.sh validate -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -mkdir -p "$plugin_cache_dir" -export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir" -export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}" - -exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@" 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/identity-tofu.sh b/Scripts/identity-tofu.sh deleted file mode 100755 index 882717c..0000000 --- a/Scripts/identity-tofu.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$( - git rev-parse --show-toplevel 2>/dev/null || { - cd "$(dirname "${BASH_SOURCE[0]}")/.." - pwd - } -)" - -work_dir="${BURROW_IDENTITY_TOFU_DIR:-$ROOT/infra/identity}" -plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}" - -usage() { - cat <<'EOF' -Usage: Scripts/identity-tofu.sh [tofu args...] - -Runs OpenTofu for Burrow Google KMS and Workload Identity Federation resources. -Cloud provider credentials must come from the normal provider environment or a -WIF flow; do not put credentials in backend config. - -Examples: - Scripts/identity-tofu.sh init -backend-config=backend.hcl - Scripts/identity-tofu.sh plan -var-file=terraform.tfvars - -Local syntax check: - Scripts/identity-tofu.sh init -backend=false - Scripts/identity-tofu.sh validate -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -mkdir -p "$plugin_cache_dir" -export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir" -export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}" - -exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@" 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/openbao-tofu.sh b/Scripts/openbao-tofu.sh deleted file mode 100755 index 96f8d87..0000000 --- a/Scripts/openbao-tofu.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$( - git rev-parse --show-toplevel 2>/dev/null || { - cd "$(dirname "${BASH_SOURCE[0]}")/.." - pwd - } -)" - -work_dir="${BURROW_OPENBAO_TOFU_DIR:-$ROOT/infra/openbao}" -plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}" - -usage() { - cat <<'EOF' -Usage: Scripts/openbao-tofu.sh [tofu args...] - -Runs OpenTofu for Burrow OpenBao cloud seal and OpenBao API configuration. The -Vault/OpenBao provider reads VAULT_TOKEN from the environment when managing -OpenBao mounts, policies, or auth backends. - -Examples: - Scripts/openbao-tofu.sh init -backend-config=backend.hcl - Scripts/openbao-tofu.sh plan -var-file=terraform.tfvars - -Local syntax check: - Scripts/openbao-tofu.sh init -backend=false - Scripts/openbao-tofu.sh validate -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -mkdir -p "$plugin_cache_dir" -export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir" -export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}" - -exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@" diff --git a/Scripts/package/bootstrap-native-daemon.sh b/Scripts/package/bootstrap-native-daemon.sh deleted file mode 100755 index 2188e20..0000000 --- a/Scripts/package/bootstrap-native-daemon.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -apply=0 -prefer_packagekit=1 - -usage() { - cat <<'EOF' -Usage: Scripts/package/bootstrap-native-daemon.sh [--apply] [--no-packagekit] - -Detects the host package manager and prints, or with --apply runs, the native -install command for the Burrow daemon package. This is the host-side bootstrap -equivalent the Flatpak GUI can point users at when PackageKit is unavailable. - -This assumes the Burrow native repository for the detected package manager is -already configured. Repository setup should be explicit because it establishes -package trust roots. -EOF -} - -while [[ "$#" -gt 0 ]]; do - case "$1" in - --apply) - apply=1 - ;; - --no-packagekit) - prefer_packagekit=0 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown argument: $1" >&2 - exit 64 - ;; - esac - shift -done - -run_or_print() { - if [[ "$apply" == "1" ]]; then - "$@" - else - printf '%q ' "$@" - printf '\n' - fi -} - -if [[ "$prefer_packagekit" == "1" ]] && command -v pkcon >/dev/null 2>&1; then - run_or_print pkcon install burrow - exit 0 -fi - -if command -v apt-get >/dev/null 2>&1; then - run_or_print sudo apt-get install burrow -elif command -v dnf >/dev/null 2>&1; then - run_or_print sudo dnf install burrow -elif command -v zypper >/dev/null 2>&1; then - run_or_print sudo zypper install burrow -elif command -v pacman >/dev/null 2>&1; then - run_or_print sudo pacman -S burrow -elif command -v apk >/dev/null 2>&1; then - run_or_print sudo apk add burrow -elif command -v xbps-install >/dev/null 2>&1; then - run_or_print sudo xbps-install -S burrow -elif command -v eopkg >/dev/null 2>&1; then - run_or_print sudo eopkg install burrow -elif command -v emerge >/dev/null 2>&1; then - run_or_print sudo emerge --ask net-vpn/burrow -elif command -v swupd >/dev/null 2>&1; then - run_or_print sudo swupd bundle-add burrow -elif command -v nix >/dev/null 2>&1; then - run_or_print nix profile install git+https://git.burrow.net/hackclub/burrow -else - echo "No supported native package manager found. Install Burrow through DEB, RPM, pacman, AUR, Flatpak GUI plus daemon package, or the NixOS flake." >&2 - exit 69 -fi diff --git a/Scripts/package/build-repositories.sh b/Scripts/package/build-repositories.sh deleted file mode 100755 index 05f5656..0000000 --- a/Scripts/package/build-repositories.sh +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$( - git rev-parse --show-toplevel 2>/dev/null || { - cd "$(dirname "${BASH_SOURCE[0]}")/../.." - pwd - } -)" - -input_dir="${BURROW_PACKAGE_INPUT_DIR:-${repo_root}/dist/packages}" -output_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}" -channel="${BURROW_PACKAGE_CHANNEL:-stable}" -suite="${BURROW_APT_SUITE:-stable}" -component="${BURROW_APT_COMPONENT:-main}" -apt_arch="${BURROW_APT_ARCH:-${BURROW_PACKAGE_ARCH:-amd64}}" -rpm_arch="${BURROW_RPM_ARCH:-x86_64}" -pacman_arch="${BURROW_PACMAN_ARCH:-x86_64}" -repo_name="${BURROW_ARCH_REPO_NAME:-burrow}" -generic_kms_public_key_pem="${BURROW_PACKAGE_REPO_KMS_PUBLIC_KEY_PEM:-}" -generic_kms_key="${BURROW_PACKAGE_REPO_KMS_KEY:-}" -apt_kms_public_key_pem="${BURROW_APT_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}" -apt_kms_key="${BURROW_APT_REPO_KMS_KEY:-${generic_kms_key:-package-apt-repository}}" -rpm_kms_public_key_pem="${BURROW_RPM_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}" -rpm_kms_key="${BURROW_RPM_REPO_KMS_KEY:-${generic_kms_key:-package-rpm-repository}}" -pacman_kms_public_key_pem="${BURROW_PACMAN_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}" -pacman_kms_key="${BURROW_PACMAN_REPO_KMS_KEY:-${generic_kms_key:-package-pacman-repository}}" - -usage() { - cat <<'EOF' -Usage: Scripts/package/build-repositories.sh [apt|rpm|arch|keys|all] - -Builds native Linux package repositories from existing package artifacts. - -Inputs: - BURROW_PACKAGE_INPUT_DIR directory containing .deb, .rpm, and .pkg.tar.* files - BURROW_PACKAGE_REPOSITORY_DIR output directory, default publish/repositories - BURROW_APT_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for APT OpenPGP signatures - BURROW_APT_REPO_KMS_KEY Google KMS key name for APT signatures - BURROW_RPM_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for RPM repository signatures - BURROW_RPM_REPO_KMS_KEY Google KMS key name for RPM repository signatures - BURROW_PACMAN_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for pacman signatures - BURROW_PACMAN_REPO_KMS_KEY Google KMS key name for pacman signatures - BURROW_PACKAGE_REPO_KMS_PUBLIC_KEY_PEM fallback public key PEM for all formats - BURROW_PACKAGE_REPO_KMS_KEY fallback KMS key name for all formats - BURROW_KMS_KEYRING/BURROW_KMS_LOCATION/BURROW_KMS_VERSION/GOOGLE_CLOUD_PROJECT - -The repository metadata is signed with Google KMS-backed OpenPGP detached -signatures when each format's KMS public key and key name are configured. -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -mode="${1:-all}" - -sha256_file() { - local file="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" > "${file}.sha256" - else - sha256sum "$file" > "${file}.sha256" - fi -} - -sign_file() { - local file="$1" - local output="${2:-${file}.sig}" - local armor="${3:-0}" - local kms_key="$4" - local kms_public_key_pem="$5" - if [[ -z "$kms_public_key_pem" || -z "$kms_key" ]]; then - echo "warning: KMS OpenPGP signing disabled for ${file}; set the ${file} repository KMS public key and key name" >&2 - return 0 - fi - args=( - "${repo_root}/Scripts/package/google-kms-openpgp.py" - detach-sign - --public-key-pem "$kms_public_key_pem" - --kms-key "$kms_key" - --input "$file" - --output "$output" - ) - if [[ "$armor" == "1" ]]; then - args+=(--armor) - fi - "${args[@]}" -} - -write_public_key() { - local name="$1" - local kms_key="$2" - local kms_public_key_pem="$3" - local user_id="$4" - local output="${output_dir}/keys/${name}.asc" - - if [[ -z "$kms_public_key_pem" || -z "$kms_key" ]]; then - echo "warning: KMS OpenPGP public key export disabled for ${name}; set the repository KMS public key and key name" >&2 - return 0 - fi - - mkdir -p "$(dirname "$output")" - "${repo_root}/Scripts/package/google-kms-openpgp.py" \ - public-key \ - --public-key-pem "$kms_public_key_pem" \ - --kms-key "$kms_key" \ - --user-id "$user_id" \ - --output "$output" \ - --armor -} - -write_release_file() { - local release_path="$1" - local dist_dir="$2" - local now - now="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" - { - printf 'Origin: Burrow\n' - printf 'Label: Burrow\n' - printf 'Suite: %s\n' "$suite" - printf 'Codename: %s\n' "$suite" - printf 'Date: %s\n' "$now" - printf 'Architectures: %s\n' "$apt_arch" - printf 'Components: %s\n' "$component" - printf 'Description: Burrow native package repository\n' - printf 'SHA256:\n' - ( - cd "$dist_dir" - find . -type f ! -name Release -print | LC_ALL=C sort | while read -r file; do - clean="${file#./}" - sum="$(sha256sum "$clean" | awk '{print $1}')" - size="$(wc -c < "$clean" | tr -d ' ')" - printf ' %s %s %s\n' "$sum" "$size" "$clean" - done - ) - } > "$release_path" -} - -build_apt() { - local root binary_dir pool_dir release_dir release_file - root="${output_dir}/apt" - pool_dir="${root}/pool/${component}/b/burrow" - binary_dir="${root}/dists/${suite}/${component}/binary-${apt_arch}" - release_dir="${root}/dists/${suite}" - mkdir -p "$pool_dir" "$binary_dir" "$release_dir" - - find "$input_dir" -type f -name '*.deb' -exec cp {} "$pool_dir/" \; 2>/dev/null || true - if ! find "$pool_dir" -type f -name '*.deb' -print -quit | grep -q .; then - echo "warning: no .deb packages found under ${input_dir}; apt repository contains README only" >&2 - printf 'Place Burrow .deb artifacts in this repository pool before publishing.\n' > "${root}/README.txt" - return 0 - fi - if ! command -v dpkg-scanpackages >/dev/null 2>&1; then - echo "error: dpkg-scanpackages is required to build the apt repository" >&2 - exit 127 - fi - - ( - cd "$root" - dpkg-scanpackages --arch "$apt_arch" "pool/${component}" /dev/null > "dists/${suite}/${component}/binary-${apt_arch}/Packages" - gzip -9cn "dists/${suite}/${component}/binary-${apt_arch}/Packages" > "dists/${suite}/${component}/binary-${apt_arch}/Packages.gz" - ) - release_file="${release_dir}/Release" - write_release_file "$release_file" "$release_dir" - sign_file "$release_file" "${release_file}.gpg" 1 "$apt_kms_key" "$apt_kms_public_key_pem" - sha256_file "$release_file" -} - -build_rpm() { - local root repomd - root="${output_dir}/rpm/${channel}/${rpm_arch}" - mkdir -p "$root" - find "$input_dir" -type f -name '*.rpm' -exec cp {} "$root/" \; 2>/dev/null || true - if ! find "$root" -maxdepth 1 -type f -name '*.rpm' -print -quit | grep -q .; then - echo "warning: no .rpm packages found under ${input_dir}; rpm repository contains README only" >&2 - printf 'Place Burrow .rpm artifacts in this directory before publishing.\n' > "${root}/README.txt" - return 0 - fi - if ! command -v createrepo_c >/dev/null 2>&1; then - echo "error: createrepo_c is required to build the rpm repository" >&2 - exit 127 - fi - createrepo_c "$root" - repomd="${root}/repodata/repomd.xml" - sign_file "$repomd" "${repomd}.asc" 1 "$rpm_kms_key" "$rpm_kms_public_key_pem" - sha256_file "$repomd" -} - -build_arch() { - local root db - root="${output_dir}/arch/${repo_name}/${pacman_arch}" - mkdir -p "$root" - find "$input_dir" -type f \( -name '*.pkg.tar.zst' -o -name '*.pkg.tar.xz' -o -name '*.pkg.tar.gz' \) -exec cp {} "$root/" \; 2>/dev/null || true - if ! find "$root" -maxdepth 1 -type f -name '*.pkg.tar.*' -print -quit | grep -q .; then - echo "warning: no Arch packages found under ${input_dir}; pacman repository contains README only" >&2 - printf 'Place Burrow .pkg.tar.* artifacts in this directory before publishing.\n' > "${root}/README.txt" - return 0 - fi - if ! command -v repo-add >/dev/null 2>&1; then - echo "error: repo-add is required to build the Arch repository" >&2 - exit 127 - fi - ( - cd "$root" - repo-add "${repo_name}.db.tar.gz" ./*.pkg.tar.* - ) - db="${root}/${repo_name}.db.tar.gz" - sign_file "$db" "${db}.sig" 0 "$pacman_kms_key" "$pacman_kms_public_key_pem" - find "$root" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print | while read -r package; do - sign_file "$package" "${package}.sig" 0 "$pacman_kms_key" "$pacman_kms_public_key_pem" - done - sha256_file "$db" -} - -build_keys() { - write_public_key "burrow-package-apt-repository" "$apt_kms_key" "$apt_kms_public_key_pem" "Burrow APT Package Repository " - write_public_key "burrow-package-rpm-repository" "$rpm_kms_key" "$rpm_kms_public_key_pem" "Burrow RPM Package Repository " - write_public_key "burrow-package-pacman-repository" "$pacman_kms_key" "$pacman_kms_public_key_pem" "Burrow pacman Package Repository " -} - -mkdir -p "$output_dir" -case "$mode" in - apt) - build_apt - ;; - rpm) - build_rpm - ;; - arch) - build_arch - ;; - keys) - build_keys - ;; - all) - build_keys - build_apt - build_rpm - build_arch - ;; - *) - echo "unknown repository mode: ${mode}" >&2 - exit 64 - ;; -esac diff --git a/Scripts/package/google-kms-openpgp.py b/Scripts/package/google-kms-openpgp.py deleted file mode 100755 index c349069..0000000 --- a/Scripts/package/google-kms-openpgp.py +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python3 -"""Create OpenPGP public keys and detached signatures backed by Google KMS. - -This intentionally implements only the narrow RSA/SHA-256 subset Burrow needs -for Linux package repository metadata. The private key lives in Google Cloud KMS; -this script builds OpenPGP packets locally and asks KMS to produce the RSA -signature value. -""" - -from __future__ import annotations - -import argparse -import base64 -import binascii -import hashlib -import os -import struct -import subprocess -import sys -import tempfile -import time -from pathlib import Path - - -RSA_PUBLIC_KEY_ALGO = 1 -SHA256_HASH_ALGO = 8 - - -def parse_der_length(data: bytes, offset: int) -> tuple[int, int]: - first = data[offset] - offset += 1 - if first < 0x80: - return first, offset - count = first & 0x7F - if count == 0 or count > 4: - raise ValueError("unsupported DER length") - value = int.from_bytes(data[offset : offset + count], "big") - return value, offset + count - - -def parse_der_tlv(data: bytes, offset: int, expected_tag: int | None = None) -> tuple[int, bytes, int]: - tag = data[offset] - offset += 1 - length, offset = parse_der_length(data, offset) - value = data[offset : offset + length] - if len(value) != length: - raise ValueError("truncated DER value") - if expected_tag is not None and tag != expected_tag: - raise ValueError(f"expected DER tag {expected_tag:#x}, got {tag:#x}") - return tag, value, offset + length - - -def pem_to_der(pem: bytes) -> bytes: - lines = [ - line.strip() - for line in pem.splitlines() - if line and not line.startswith(b"-----") - ] - return base64.b64decode(b"".join(lines)) - - -def parse_rsa_public_key_from_pem(path: Path) -> tuple[int, int]: - der = pem_to_der(path.read_bytes()) - _, spki, end = parse_der_tlv(der, 0, 0x30) - if end != len(der): - raise ValueError("trailing data after SubjectPublicKeyInfo") - _, _alg, offset = parse_der_tlv(spki, 0, 0x30) - _, bit_string, offset = parse_der_tlv(spki, offset, 0x03) - if offset != len(spki): - raise ValueError("trailing data after SPKI bit string") - if not bit_string or bit_string[0] != 0: - raise ValueError("unsupported SPKI bit string") - rsa_der = bit_string[1:] - _, rsa_seq, end = parse_der_tlv(rsa_der, 0, 0x30) - if end != len(rsa_der): - raise ValueError("trailing data after RSA public key") - _, n_bytes, offset = parse_der_tlv(rsa_seq, 0, 0x02) - _, e_bytes, offset = parse_der_tlv(rsa_seq, offset, 0x02) - if offset != len(rsa_seq): - raise ValueError("trailing data after RSA integers") - return int.from_bytes(n_bytes, "big"), int.from_bytes(e_bytes, "big") - - -def packet_header(tag: int, length: int) -> bytes: - if length < 192: - return bytes([0xC0 | tag, length]) - if length < 8384: - length -= 192 - return bytes([0xC0 | tag, (length >> 8) + 192, length & 0xFF]) - return bytes([0xC0 | tag, 0xFF]) + struct.pack(">I", length) - - -def old_packet_header(tag: int, length: int) -> bytes: - if length > 0xFFFF: - raise ValueError("old-format two-octet packet length exceeded") - return bytes([(tag << 2) | 1]) + struct.pack(">H", length) - - -def mpi(value: int) -> bytes: - if value < 0: - raise ValueError("MPI value must be non-negative") - raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big") - return struct.pack(">H", value.bit_length()) + raw.lstrip(b"\x00") - - -def subpacket(kind: int, body: bytes) -> bytes: - payload = bytes([kind]) + body - length = len(payload) - if length < 192: - return bytes([length]) + payload - if length < 8384: - length -= 192 - return bytes([(length >> 8) + 192, length & 0xFF]) + payload - return b"\xff" + struct.pack(">I", length) + payload - - -def public_key_body(n: int, e: int, created_at: int) -> bytes: - return ( - b"\x04" - + struct.pack(">I", created_at) - + bytes([RSA_PUBLIC_KEY_ALGO]) - + mpi(n) - + mpi(e) - ) - - -def fingerprint(public_body: bytes) -> bytes: - return hashlib.sha1(old_packet_header(6, len(public_body)) + public_body).digest() - - -def armor(kind: str, body: bytes) -> str: - b64 = base64.b64encode(body).decode("ascii") - crc = crc24(body) - checksum = base64.b64encode(crc.to_bytes(3, "big")).decode("ascii") - lines = [f"-----BEGIN PGP {kind}-----", ""] - lines.extend(b64[i : i + 64] for i in range(0, len(b64), 64)) - lines.append(f"={checksum}") - lines.append(f"-----END PGP {kind}-----") - return "\n".join(lines) + "\n" - - -def crc24(data: bytes) -> int: - crc = 0xB704CE - for octet in data: - crc ^= octet << 16 - for _ in range(8): - crc <<= 1 - if crc & 0x1000000: - crc ^= 0x1864CFB - return crc & 0xFFFFFF - - -def kms_sign(payload: bytes, args: argparse.Namespace) -> bytes: - with tempfile.TemporaryDirectory(prefix="burrow-kms-openpgp.") as tmp: - input_path = Path(tmp) / "payload" - signature_path = Path(tmp) / "signature.b64" - input_path.write_bytes(payload) - command = [ - "gcloud", - "kms", - "asymmetric-sign", - "--location", - args.kms_location, - "--keyring", - args.kms_keyring, - "--key", - args.kms_key, - "--version", - args.kms_version, - "--digest-algorithm", - "sha256", - "--input-file", - str(input_path), - "--signature-file", - str(signature_path), - ] - if args.gcloud_project: - command.extend(["--project", args.gcloud_project]) - subprocess.run(command, check=True) - return base64.b64decode(signature_path.read_text(encoding="utf-8").strip()) - - -def signature_packet( - signed_payload: bytes, - public_body: bytes, - sig_type: int, - created_at: int, - args: argparse.Namespace, -) -> bytes: - fpr = fingerprint(public_body) - key_id = fpr[-8:] - hashed = b"".join( - [ - subpacket(2, struct.pack(">I", created_at)), - subpacket(33, b"\x04" + fpr), - ] - ) - unhashed = subpacket(16, key_id) - sig_data = ( - b"\x04" - + bytes([sig_type, RSA_PUBLIC_KEY_ALGO, SHA256_HASH_ALGO]) - + struct.pack(">H", len(hashed)) - + hashed - ) - trailer = b"\x04\xff" + struct.pack(">I", len(sig_data)) - to_hash = signed_payload + sig_data + trailer - digest = hashlib.sha256(to_hash).digest() - signature = kms_sign(to_hash, args) - body = ( - sig_data - + struct.pack(">H", len(unhashed)) - + unhashed - + digest[:2] - + mpi(int.from_bytes(signature, "big")) - ) - return packet_header(2, len(body)) + body - - -def public_key_command(args: argparse.Namespace) -> int: - created_at = args.created_at or int(time.time()) - n, e = parse_rsa_public_key_from_pem(Path(args.public_key_pem)) - public_body = public_key_body(n, e, created_at) - public_packet = packet_header(6, len(public_body)) + public_body - uid = args.user_id.encode("utf-8") - uid_packet = packet_header(13, len(uid)) + uid - uid_signed_payload = ( - old_packet_header(6, len(public_body)) - + public_body - + b"\xb4" - + struct.pack(">I", len(uid)) - + uid - ) - cert_packet = signature_packet( - uid_signed_payload, - public_body, - sig_type=0x13, - created_at=created_at, - args=args, - ) - key = public_packet + uid_packet + cert_packet - output = Path(args.output) - if args.armor: - output.write_text(armor("PUBLIC KEY BLOCK", key), encoding="utf-8") - else: - output.write_bytes(key) - return 0 - - -def detach_sign_command(args: argparse.Namespace) -> int: - created_at = args.created_at or int(time.time()) - n, e = parse_rsa_public_key_from_pem(Path(args.public_key_pem)) - public_body = public_key_body(n, e, args.key_created_at) - payload = Path(args.input).read_bytes() - sig_type = 0x01 if args.text else 0x00 - packet = signature_packet(payload, public_body, sig_type, created_at, args) - output = Path(args.output) - if args.armor: - output.write_text(armor("SIGNATURE", packet), encoding="utf-8") - else: - output.write_bytes(packet) - return 0 - - -def add_kms_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", "global")) - parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", "burrow-identity")) - parser.add_argument("--kms-key", required=not os.environ.get("BURROW_KMS_KEY"), default=os.environ.get("BURROW_KMS_KEY")) - parser.add_argument("--kms-version", default=os.environ.get("BURROW_KMS_VERSION", "1")) - parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT")) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subcommands = parser.add_subparsers(dest="command", required=True) - - public_key = subcommands.add_parser("public-key") - public_key.add_argument("--public-key-pem", required=True) - public_key.add_argument("--user-id", default="Burrow Package Repository ") - public_key.add_argument("--output", required=True) - public_key.add_argument("--armor", action="store_true") - public_key.add_argument("--created-at", type=int) - add_kms_args(public_key) - public_key.set_defaults(func=public_key_command) - - detach = subcommands.add_parser("detach-sign") - detach.add_argument("--public-key-pem", required=True) - detach.add_argument("--key-created-at", type=int, default=1704067200) - detach.add_argument("--input", required=True) - detach.add_argument("--output", required=True) - detach.add_argument("--armor", action="store_true") - detach.add_argument("--text", action="store_true") - detach.add_argument("--created-at", type=int) - add_kms_args(detach) - detach.set_defaults(func=detach_sign_command) - - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - try: - return args.func(args) - except subprocess.CalledProcessError as exc: - print(f"gcloud signing failed: {exc}", file=sys.stderr) - return exc.returncode or 1 - except Exception as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/Scripts/provision-forgejo-nsc.sh b/Scripts/provision-forgejo-nsc.sh index b82757c..537107e 100755 --- a/Scripts/provision-forgejo-nsc.sh +++ b/Scripts/provision-forgejo-nsc.sh @@ -6,21 +6,24 @@ 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) @@ -30,7 +33,7 @@ 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 +42,11 @@ CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}" SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-hackclub}" SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}" BURROW_FLAKE_TMPDIRS=() +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 @@ -97,13 +103,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,29 +136,77 @@ 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 - "${NSC_BIN}" auth check-login --duration 20m >/dev/null - token_name="${TOKEN_NAME_PREFIX}-namespace-$(date -u +%Y%m%dT%H%M%SZ)" - "${NSC_BIN}" token create \ - --name "${token_name}" \ - --description "Burrow Forgejo Namespace runner dispatcher" \ - --expires_in "${NSC_TOKEN_EXPIRES_IN:-30d}" \ - --grant '{"resource_type":"instance","resource_id":"*","actions":["create","destroy","dial_host","exec","get","list","refresh","ssh","wait"]}' \ - --grant '{"resource_type":"instance/ingress","resource_id":"*","actions":["list","register"]}' \ - --grant '{"resource_type":"instance/notification","resource_id":"*","actions":["list"]}' \ - --grant '{"resource_type":"instance/o11y/logs","resource_id":"*","actions":["get"]}' \ - --grant '{"resource_type":"cache/httpcache","resource_id":"*","actions":["ensure","read","write"]}' \ - --grant '{"resource_type":"bazel/cache","resource_id":"*","actions":["ensure"]}' \ - --grant '{"resource_type":"artifact","resource_id":"*","actions":["create","resolve","list"]}' \ - --token_file "${token_file}" \ - --output json >/dev/null +if [[ "${REFRESH_TOKEN}" -eq 1 ]]; then + ssh \ + -i "${SSH_KEY}" \ + -o IdentitiesOnly=yes \ + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \ + -o StrictHostKeyChecking=accept-new \ + "${HOST}" \ + 'sudo -u forgejo-nsc python3 - <<'"'"'PY'"'"' +import json +from pathlib import Path + +payload = {} + +token_json = Path("/var/lib/forgejo-nsc/.config/ns/token.json") +if token_json.exists(): + data = json.loads(token_json.read_text(encoding="utf-8")) + session = str(data.get("session_token", "")).strip() + if session: + payload["session_token"] = session + +token_cache = Path("/var/lib/forgejo-nsc/.config/ns/token.cache") +if token_cache.exists(): + bearer = token_cache.read_text(encoding="utf-8").strip() + if bearer: + payload["bearer_token"] = bearer + +if not payload: + raise SystemExit("forgejo-nsc host does not have a usable Namespace session") + +print(json.dumps(payload, indent=2)) +PY' > "${token_file}" chmod 600 "${token_file}" +elif [[ -f "${token_secret}" ]]; then + burrow_decrypt_age_secret_to_temp "${REPO_ROOT}" "${token_secret}" > "${token_file}" +fi + +if [[ -s "${token_file}" ]]; then + TOKEN_FILE="${token_file}" python3 - <<'PY' +import json +import os +from pathlib import Path + +path = Path(os.environ["TOKEN_FILE"]) +raw = path.read_text(encoding="utf-8").strip() +if not raw: + raise SystemExit(0) + +try: + parsed = json.loads(raw) +except json.JSONDecodeError: + parsed = None + +if isinstance(parsed, dict): + bearer = parsed.get("bearer_token") + session = parsed.get("session_token") + if isinstance(bearer, str) and bearer.strip(): + raise SystemExit(0) + if isinstance(session, str) and session.strip(): + raise SystemExit(0) + +path.write_text(json.dumps({"bearer_token": raw}, indent=2) + "\n", encoding="utf-8") +PY fi webhook_secret="$(python3 - <<'PY' @@ -168,7 +224,6 @@ forgejo_pat="$( -o StrictHostKeyChecking=accept-new \ "${HOST}" \ "set -euo pipefail; forgejo_bin=\$(systemctl show -p ExecStart forgejo.service --value | sed -E 's/^\\{ path=([^ ;]+).*/\\1/'); sudo -u forgejo \"\${forgejo_bin}\" --config /var/lib/forgejo/custom/conf/app.ini --custom-path /var/lib/forgejo/custom --work-path /var/lib/forgejo admin user generate-access-token --username '${CONTACT_USER}' --scopes all --raw --token-name '${token_name}'" \ - | awk 'NF { line = $0 } END { print line }' \ | tr -d '\r\n' )" @@ -247,5 +302,9 @@ 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." +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}" + +echo "Updated secrets/forgejo/{nsc-token,nsc-dispatcher-config,nsc-autoscaler-config}.age." echo "Minted Forgejo PAT ${token_name} for ${CONTACT_USER} on ${HOST}." diff --git a/Scripts/release/google-kms-openssl-dgst-shim.sh b/Scripts/release/google-kms-openssl-dgst-shim.sh deleted file mode 100755 index 4925386..0000000 --- a/Scripts/release/google-kms-openssl-dgst-shim.sh +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -real_openssl="${BURROW_REAL_OPENSSL:-openssl}" -gcloud_bin="${BURROW_GCLOUD_BIN:-gcloud}" -original_args=("$@") - -exec_real_openssl() { - exec "$real_openssl" "$@" -} - -truthy() { - case "${1:-}" in - 1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;; - *) return 1 ;; - esac -} - -parse_pkcs11_key_name() { - local uri="$1" - local value - value="${uri#*;object=}" - if [[ "$value" != "$uri" ]]; then - printf '%s\n' "${value%%;*}" - return 0 - fi - value="${uri#*;id=}" - if [[ "$value" != "$uri" ]]; then - printf '%s\n' "${value%%;*}" - return 0 - fi - return 1 -} - -select_single_enabled_version() { - local project_id="$1" - local kms_location="$2" - local kms_keyring_name="$3" - local crypto_key_name="$4" - local versions=() - local version - - while IFS= read -r version; do - [[ -n "$version" ]] && versions+=("$version") - done < <( - "$gcloud_bin" kms keys versions list \ - --project="$project_id" \ - --location="$kms_location" \ - --keyring="$kms_keyring_name" \ - --key="$crypto_key_name" \ - --filter=state=ENABLED \ - --format='value(name)' - ) - if [[ "${#versions[@]}" -ne 1 || -z "${versions[0]:-}" ]]; then - echo "error: Google KMS crypto key ${crypto_key_name} has ${#versions[@]} ENABLED versions; set BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY_VERSION" >&2 - return 1 - fi - printf '%s\n' "${versions[0]##*/}" -} - -if [[ "${1:-}" != "dgst" ]]; then - exec_real_openssl "${original_args[@]}" -fi - -if ! truthy "${BURROW_APPLE_PKCS11_SIGN_WITH_GCLOUD:-true}"; then - exec_real_openssl "${original_args[@]}" -fi - -shift -digest_algorithm="" -signature_file="" -input_file="" -sign_uri="" - -while [[ $# -gt 0 ]]; do - case "$1" in - -sha256) - digest_algorithm="sha256" - shift - ;; - -sign) - [[ $# -ge 2 ]] || { - echo "error: openssl dgst shim received -sign without a key URI" >&2 - exit 2 - } - sign_uri="$2" - shift 2 - ;; - -out) - [[ $# -ge 2 ]] || { - echo "error: openssl dgst shim received -out without a path" >&2 - exit 2 - } - signature_file="$2" - shift 2 - ;; - -provider|-passin) - [[ $# -ge 2 ]] || { - echo "error: openssl dgst shim received $1 without a value" >&2 - exit 2 - } - shift 2 - ;; - -*) - exec_real_openssl "${original_args[@]}" - ;; - *) - input_file="$1" - shift - ;; - esac -done - -if [[ "$digest_algorithm" != "sha256" || "$sign_uri" != pkcs11:* ]]; then - exec_real_openssl "${original_args[@]}" -fi -if [[ -z "$signature_file" || -z "$input_file" ]]; then - echo "error: openssl dgst shim requires -out and an input file" >&2 - exit 2 -fi -if [[ ! -f "$input_file" ]]; then - echo "error: openssl dgst shim input file does not exist: $input_file" >&2 - exit 1 -fi -command -v "$gcloud_bin" >/dev/null 2>&1 || { - echo "error: gcloud is required for Google KMS direct signing; set BURROW_GCLOUD_BIN" >&2 - exit 1 -} -command -v python3 >/dev/null 2>&1 || { - echo "error: python3 is required to decode Google KMS signatures" >&2 - exit 1 -} - -crypto_key="${BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY:-}" -if [[ -z "$crypto_key" ]]; then - crypto_key="$(parse_pkcs11_key_name "$sign_uri" || true)" -fi -if [[ -z "$crypto_key" ]]; then - echo "error: BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY or a pkcs11 object selector is required" >&2 - exit 1 -fi - -key_version="${BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY_VERSION:-}" -key_ring="${BURROW_GCP_KMS_KEY_RING:-${GOOGLE_KMS_KEY_RING:-projects/${GOOGLE_CLOUD_PROJECT:-project-88c23ce9-918a-470a-b33}/locations/${BURROW_KMS_LOCATION:-global}/keyRings/${BURROW_KMS_KEYRING:-burrow-identity}}}" -project_id="${GOOGLE_CLOUD_PROJECT:-project-88c23ce9-918a-470a-b33}" -kms_location="" -kms_keyring_name="" -crypto_key_name="" - -if [[ "$crypto_key" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)/cryptoKeyVersions/([^/]+)$ ]]; then - project_id="${BASH_REMATCH[1]}" - kms_location="${BASH_REMATCH[2]}" - kms_keyring_name="${BASH_REMATCH[3]}" - crypto_key_name="${BASH_REMATCH[4]}" - key_version="${BASH_REMATCH[5]}" -elif [[ "$crypto_key" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)$ ]]; then - project_id="${BASH_REMATCH[1]}" - kms_location="${BASH_REMATCH[2]}" - kms_keyring_name="${BASH_REMATCH[3]}" - crypto_key_name="${BASH_REMATCH[4]}" -else - if [[ ! "$key_ring" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)$ ]]; then - echo "error: BURROW_GCP_KMS_KEY_RING must be projects//locations//keyRings/, got: ${key_ring}" >&2 - exit 1 - fi - project_id="${BASH_REMATCH[1]}" - kms_location="${BASH_REMATCH[2]}" - kms_keyring_name="${BASH_REMATCH[3]}" - crypto_key_name="$crypto_key" -fi - -if [[ -z "$key_version" ]]; then - key_version="$(select_single_enabled_version "$project_id" "$kms_location" "$kms_keyring_name" "$crypto_key_name")" -fi - -encoded_signature_file="$(mktemp "${TMPDIR:-/tmp}/burrow-google-kms-signature.XXXXXX")" -trap 'rm -f "$encoded_signature_file"' EXIT -"$gcloud_bin" kms asymmetric-sign \ - --project="$project_id" \ - --location="$kms_location" \ - --keyring="$kms_keyring_name" \ - --key="$crypto_key_name" \ - --version="$key_version" \ - --digest-algorithm="$digest_algorithm" \ - --input-file="$input_file" \ - --signature-file="$encoded_signature_file" \ - --quiet >/dev/null - -python3 - "$encoded_signature_file" "$signature_file" <<'PY' -import pathlib -import sys - -encoded_path = pathlib.Path(sys.argv[1]) -signature_path = pathlib.Path(sys.argv[2]) -signature_path.write_bytes(encoded_path.read_bytes()) -PY diff --git a/Scripts/release/google-kms-pkcs11-materialize.sh b/Scripts/release/google-kms-pkcs11-materialize.sh deleted file mode 100755 index 2e0dde8..0000000 --- a/Scripts/release/google-kms-pkcs11-materialize.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -emit_env=false -ROOT="$( - git rev-parse --show-toplevel 2>/dev/null || { - cd "$(dirname "${BASH_SOURCE[0]}")/../.." - pwd - } -)" -runtime_dir="${BURROW_GCP_KMS_PKCS11_RUNTIME_DIR:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-google-kms-pkcs11-runtime}" -config_file="${BURROW_GCP_KMS_PKCS11_CONFIG:-${KMS_PKCS11_CONFIG:-}}" -config_yaml="${BURROW_GCP_KMS_PKCS11_CONFIG_YAML:-}" -key_ring="${BURROW_GCP_KMS_KEY_RING:-${GOOGLE_KMS_KEY_RING:-projects/${GOOGLE_CLOUD_PROJECT:-project-88c23ce9-918a-470a-b33}/locations/${BURROW_KMS_LOCATION:-global}/keyRings/${BURROW_KMS_KEYRING:-burrow-identity}}}" -token_label="${BURROW_GCP_KMS_PKCS11_TOKEN_LABEL:-${BURROW_PKCS11_TOKEN_LABEL:-burrow-release}}" - -usage() { - cat <<'EOF' -Usage: Scripts/release/google-kms-pkcs11-materialize.sh [--emit-env] - -Materializes optional Google KMS PKCS#11 config YAML into a runner-local file -and emits environment variables consumed by the Apple release signer. -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --emit-env) - emit_env=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -write_config_file() { - local path="$1" - local value="$2" - mkdir -p "$(dirname "$path")" - printf '%s\n' "$value" >"$path" - chmod 600 "$path" -} - -resolve_config_path() { - local path="$1" - if [[ -z "$path" ]]; then - return 1 - fi - if [[ "$path" != /* ]]; then - path="$ROOT/$path" - fi - printf '%s\n' "$path" -} - -env_lines=() -if [[ -z "$config_file" && -n "$config_yaml" ]]; then - config_file="$runtime_dir/pkcs11-config.yaml" - write_config_file "$config_file" "$config_yaml" -fi - -if [[ -z "$config_file" && -n "$key_ring" ]]; then - config_file="$runtime_dir/pkcs11-config.yaml" - write_config_file "$config_file" "--- -tokens: - - key_ring: \"${key_ring}\" - label: \"${token_label}\"" -fi - -if [[ -n "$config_file" ]]; then - config_file="$(resolve_config_path "$config_file")" - if [[ ! -s "$config_file" ]]; then - echo "error: Google KMS PKCS#11 config is missing or empty: $config_file" >&2 - exit 1 - fi - env_lines+=("BURROW_GCP_KMS_PKCS11_CONFIG=$config_file") - env_lines+=("KMS_PKCS11_CONFIG=$config_file") -fi - -if [[ "${#env_lines[@]}" -gt 0 && -n "${GITHUB_ENV:-}" ]]; then - printf '%s\n' "${env_lines[@]}" >>"$GITHUB_ENV" -fi - -if [[ "$emit_env" == "true" && "${#env_lines[@]}" -gt 0 ]]; then - printf 'export %q\n' "${env_lines[@]}" -fi diff --git a/Scripts/releases-tofu.sh b/Scripts/releases-tofu.sh deleted file mode 100755 index 0168c5b..0000000 --- a/Scripts/releases-tofu.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$( - git rev-parse --show-toplevel 2>/dev/null || { - cd "$(dirname "${BASH_SOURCE[0]}")/.." - pwd - } -)" - -work_dir="${BURROW_RELEASES_TOFU_DIR:-$ROOT/infra/releases}" -plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}" - -usage() { - cat <<'EOF' -Usage: Scripts/releases-tofu.sh [tofu args...] - -Runs OpenTofu for Burrow Google Cloud Storage release and package repository -buckets. Cloud provider credentials must come from ADC or Workload Identity -Federation; do not put service-account JSON in this repository. - -Examples: - Scripts/releases-tofu.sh init -backend-config=backend.hcl - Scripts/releases-tofu.sh plan -var-file=terraform.tfvars - -Local syntax check: - Scripts/releases-tofu.sh init -backend=false - Scripts/releases-tofu.sh validate -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -mkdir -p "$plugin_cache_dir" -export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir" -export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}" - -exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@" diff --git a/Scripts/resolve-age-identity.sh b/Scripts/resolve-age-identity.sh deleted file mode 100755 index 321a0a1..0000000 --- a/Scripts/resolve-age-identity.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -identity_is_valid() { - local path="$1" - if [[ -z "$path" || ! -f "$path" || ! -s "$path" ]]; then - return 1 - fi - - chmod 600 "$path" 2>/dev/null || true - if command -v age-keygen >/dev/null 2>&1 && age-keygen -y "$path" >/dev/null 2>&1; then - return 0 - fi - if openssh_private_identity_has_markers "$path"; then - return 0 - fi - if command -v ssh-keygen >/dev/null 2>&1 && ssh-keygen -y -f "$path" >/dev/null 2>&1; then - return 0 - fi - - local header - header="$(LC_ALL=C head -c 80 "$path" 2>/dev/null || true)" - case "$header" in - AGE-SECRET-KEY-*) return 0 ;; - esac - - echo "::warning ::Ignoring invalid age identity candidate at ${path}" >&2 - return 1 -} - -openssh_private_identity_has_markers() { - local path="$1" - local first_line - first_line="$(LC_ALL=C sed -n '1p' "$path" 2>/dev/null || true)" - if [[ "$first_line" != "-----BEGIN OPENSSH PRIVATE KEY-----" ]]; then - return 1 - fi - if LC_ALL=C grep -Fq '\n' "$path" 2>/dev/null; then - return 1 - fi - LC_ALL=C grep -q '^-----END OPENSSH PRIVATE KEY-----$' "$path" 2>/dev/null -} - -candidate_path() { - local path="$1" - if identity_is_valid "$path"; then - printf '%s\n' "$path" - return 0 - fi - return 1 -} - -preferred_home() { - if [[ -n "${RUNNER_HOME:-}" ]]; then - printf '%s\n' "$RUNNER_HOME" - elif [[ -n "${FORGEJO_RUNNER_HOME:-}" ]]; then - printf '%s\n' "$FORGEJO_RUNNER_HOME" - elif [[ -n "${HOME:-}" ]]; then - printf '%s\n' "$HOME" - elif [[ -n "${FORGEJO_RUNNER_WORKDIR:-}" ]]; then - printf '%s\n' "${FORGEJO_RUNNER_WORKDIR}/home" - else - printf '%s\n' "/tmp/forgejo-runner/home" - fi -} - -decode_base64() { - local value="$1" - local path="$2" - if command -v base64 >/dev/null 2>&1; then - if printf '%s' "$value" | base64 -d > "$path" 2>/dev/null; then - return 0 - fi - if printf '%s' "$value" | base64 -D > "$path" 2>/dev/null; then - return 0 - fi - fi - return 1 -} - -materialize_secret() { - local raw="$1" - local home="$2" - local path="${home}/.ssh/age_keystore" - install -d -m 700 "${home}/.ssh" - printf '%s\n' "$raw" > "$path" - perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true - chmod 600 "$path" - if identity_is_valid "$path"; then - printf '%s\n' "$path" - return 0 - fi - - if [[ "$raw" == *"\\n"* ]]; then - printf '%b' "$raw" > "$path" - perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true - chmod 600 "$path" - if identity_is_valid "$path"; then - printf '%s\n' "$path" - return 0 - fi - fi - - if decode_base64 "$raw" "$path"; then - perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true - chmod 600 "$path" - if identity_is_valid "$path"; then - printf '%s\n' "$path" - return 0 - fi - fi - - rm -f "$path" - echo "::warning ::Injected age identity was present but not parseable; trying other candidates" >&2 - return 1 -} - -resolve_from_existing_files() { - local home - home="$(preferred_home)" - - local candidate - while IFS= read -r candidate; do - candidate_path "$candidate" && return 0 - done <&2 -exit 1 diff --git a/Scripts/resolve-forge-ssh-key.sh b/Scripts/resolve-forge-ssh-key.sh deleted file mode 100755 index 0ea566b..0000000 --- a/Scripts/resolve-forge-ssh-key.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -preferred_home() { - if [[ -n "${RUNNER_HOME:-}" ]]; then - printf '%s\n' "$RUNNER_HOME" - elif [[ -n "${FORGEJO_RUNNER_HOME:-}" ]]; then - printf '%s\n' "$FORGEJO_RUNNER_HOME" - elif [[ -n "${HOME:-}" ]]; then - printf '%s\n' "$HOME" - elif [[ -n "${FORGEJO_RUNNER_WORKDIR:-}" ]]; then - printf '%s\n' "${FORGEJO_RUNNER_WORKDIR}/home" - else - printf '%s\n' "/tmp/forgejo-runner/home" - fi -} - -is_ssh_private_key() { - local candidate="$1" - [[ -n "$candidate" && -f "$candidate" && -s "$candidate" ]] || return 1 - chmod 600 "$candidate" 2>/dev/null || true - ssh-keygen -y -f "$candidate" >/dev/null 2>&1 -} - -candidate_path() { - local path="$1" - if is_ssh_private_key "$path"; then - printf '%s\n' "$path" - return 0 - fi - return 1 -} - -decode_base64() { - local value="$1" - local path="$2" - if command -v base64 >/dev/null 2>&1; then - if printf '%s' "$value" | base64 -d > "$path" 2>/dev/null; then - return 0 - fi - if printf '%s' "$value" | base64 -D > "$path" 2>/dev/null; then - return 0 - fi - fi - return 1 -} - -materialize_secret() { - local raw="$1" - local home="$2" - local path="${home}/.ssh/burrow_forge_ed25519" - install -d -m 700 "${home}/.ssh" - - printf '%s\n' "$raw" > "$path" - if candidate_path "$path"; then - return 0 - fi - - if [[ "$raw" == *"\\n"* ]]; then - printf '%b' "$raw" > "$path" - if candidate_path "$path"; then - return 0 - fi - fi - - if decode_base64 "$raw" "$path" && candidate_path "$path"; then - return 0 - fi - - rm -f "$path" - echo "::warning ::Injected Forge SSH key was present but not parseable; trying file candidates" >&2 - return 1 -} - -resolve_key() { - local home - home="$(preferred_home)" - - if [[ -n "${BURROW_FORGE_SSH_KEY:-}" ]]; then - if candidate_path "$BURROW_FORGE_SSH_KEY"; then - return 0 - fi - echo "::error ::BURROW_FORGE_SSH_KEY is set but is not an SSH private key: ${BURROW_FORGE_SSH_KEY}" >&2 - return 1 - fi - - if [[ -n "${AGE_FORGE_SSH_KEY:-}" ]]; then - materialize_secret "$AGE_FORGE_SSH_KEY" "$home" && return 0 - fi - - local candidate - while IFS= read -r candidate; do - candidate_path "$candidate" && return 0 - done <&2 -exit 1 diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh deleted file mode 100755 index 2638c39..0000000 --- a/Scripts/run-ios-tailnet-ui-tests.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-net.burrow.app}" -simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}" -simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}" -simulator_id="${BURROW_UI_TEST_SIMULATOR_ID:-}" -derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}" -source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}" -fallback_dir="/tmp/${bundle_id}/SimulatorFallback" -socket_path="${fallback_dir}/burrow.sock" -tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState" -daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}" -ui_test_config_path="${BURROW_UI_TEST_CONFIG_PATH:-/tmp/burrow-ui-test-config.json}" -ui_test_runner_bundle_id="${bundle_id}.uitests.xctrunner" -ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}" -ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}" -ui_test_tailnet_mode="${BURROW_UI_TEST_TAILNET_MODE:-tailscale}" -password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age" -age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}" - -ui_test_password="${BURROW_UI_TEST_PASSWORD:-}" -if [[ -z "$ui_test_password" ]]; then - if [[ -f "$password_secret" && -f "$age_identity" ]]; then - ui_test_password="$(age -d -i "$age_identity" "$password_secret" | tr -d '\r\n')" - else - echo "error: BURROW_UI_TEST_PASSWORD is unset and ${password_secret} could not be decrypted" >&2 - exit 1 - fi -fi - -rm -rf "$fallback_dir" "$tailnet_state_root" -mkdir -p "$fallback_dir" "$tailnet_state_root" "$derived_data_path" "$source_packages_path" -rm -f "$socket_path" - -resolve_simulator_id() { - xcrun simctl list devices available -j | python3 -c ' -import json -import os -import sys - -target_name = sys.argv[1] -target_os = sys.argv[2] -target_runtime = "com.apple.CoreSimulator.SimRuntime.iOS-" + target_os.replace(".", "-") -devices = json.load(sys.stdin).get("devices", {}) -healthy = [] -for runtime, entries in devices.items(): - if runtime != target_runtime: - continue - for entry in entries: - if not entry.get("isAvailable", False): - continue - if not os.path.isdir(entry.get("dataPath", "")): - continue - healthy.append(entry) -for entry in healthy: - if entry.get("name") == target_name: - print(entry["udid"]) - raise SystemExit(0) -for entry in healthy: - if target_name in entry.get("name", ""): - print(entry["udid"]) - raise SystemExit(0) -raise SystemExit(1) -' "$simulator_name" "$simulator_os" -} - -if [[ -z "$simulator_id" ]]; then - simulator_id="$(resolve_simulator_id || true)" -fi - -if [[ -n "$simulator_id" ]]; then - xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true - xcrun simctl bootstatus "$simulator_id" -b - xcrun simctl terminate "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true - xcrun simctl terminate "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true - xcrun simctl uninstall "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true - xcrun simctl uninstall "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true - destination="id=${simulator_id}" -else - destination="platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" -fi - -cleanup() { - rm -f "$ui_test_config_path" - if [[ -n "${daemon_pid:-}" ]]; then - kill "$daemon_pid" >/dev/null 2>&1 || true - wait "$daemon_pid" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -umask 077 -python3 - <<'PY' "$ui_test_config_path" "$ui_test_email" "$ui_test_username" "$ui_test_password" "$ui_test_tailnet_mode" -import json -import pathlib -import sys - -config_path = pathlib.Path(sys.argv[1]) -config_path.write_text( - json.dumps( - { - "email": sys.argv[2], - "username": sys.argv[3], - "password": sys.argv[4], - "mode": sys.argv[5], - } - ), - encoding="utf-8", -) -PY - -cargo build -p burrow --bin burrow - -( - cd "$fallback_dir" - RUST_LOG="${BURROW_UI_TEST_RUST_LOG:-info,burrow=debug}" \ - BURROW_SOCKET_PATH="burrow.sock" \ - BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ - "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 -) & -daemon_pid=$! - -for _ in $(seq 1 50); do - [[ -S "$socket_path" ]] && break - sleep 0.2 -done - -if [[ ! -S "$socket_path" ]]; then - echo "error: Burrow daemon did not create ${socket_path}" >&2 - [[ -f "$daemon_log" ]] && cat "$daemon_log" >&2 - exit 1 -fi - -common_xcodebuild_args=( - -quiet - -skipPackagePluginValidation - -project "${repo_root}/Apple/Burrow.xcodeproj" - -scheme App - -configuration Debug - -destination "$destination" - -derivedDataPath "$derived_data_path" - -clonedSourcePackagesDirPath "$source_packages_path" - -only-testing:BurrowUITests - -parallel-testing-enabled NO - -maximum-concurrent-test-simulator-destinations 1 - -maximum-parallel-testing-workers 1 - CODE_SIGNING_ALLOWED=NO -) - -xcodebuild \ - "${common_xcodebuild_args[@]}" \ - build-for-testing - -BURROW_UI_TEST_EMAIL="$ui_test_email" \ -BURROW_UI_TEST_USERNAME="$ui_test_username" \ -BURROW_UI_TEST_PASSWORD="$ui_test_password" \ -BURROW_UI_TEST_CONFIG_PATH="$ui_test_config_path" \ -BURROW_UI_TEST_EPHEMERAL_AUTH=1 \ -xcodebuild \ - "${common_xcodebuild_args[@]}" \ - test-without-building diff --git a/Scripts/run-tailnet-connectivity-smoke.sh b/Scripts/run-tailnet-connectivity-smoke.sh deleted file mode 100755 index dea2f68..0000000 --- a/Scripts/run-tailnet-connectivity-smoke.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-net.burrow.app}" -smoke_root="${BURROW_TAILNET_SMOKE_ROOT:-/tmp/burrow-tailnet-connectivity}" -socket_path="${smoke_root}/burrow.sock" -db_path="${smoke_root}/burrow.db" -daemon_log="${BURROW_TAILNET_SMOKE_DAEMON_LOG:-${smoke_root}/daemon.log}" -payload_path="${smoke_root}/tailnet.json" -authority="${BURROW_TAILNET_SMOKE_AUTHORITY:-https://ts.burrow.net}" -account_name="${BURROW_TAILNET_SMOKE_ACCOUNT:-ui-test}" -identity_name="${BURROW_TAILNET_SMOKE_IDENTITY:-apple}" -hostname="${BURROW_TAILNET_SMOKE_HOSTNAME:-burrow-apple}" -message="${BURROW_TAILNET_SMOKE_MESSAGE:-burrow-tailnet-smoke}" -timeout_ms="${BURROW_TAILNET_SMOKE_TIMEOUT_MS:-8000}" -remote_ip="${BURROW_TAILNET_SMOKE_REMOTE_IP:-}" -remote_port="${BURROW_TAILNET_SMOKE_REMOTE_PORT:-18081}" -remote_hostname="${BURROW_TAILNET_SMOKE_REMOTE_HOSTNAME:-burrow-echo}" -remote_authkey="${BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY:-}" -helper_bin="${BURROW_TAILNET_SMOKE_HELPER_BIN:-${smoke_root}/tailscale-login-bridge}" -remote_state_root="${BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT:-${smoke_root}/remote-state}" -remote_stdout="${smoke_root}/remote-helper.stdout" -remote_stderr="${BURROW_TAILNET_SMOKE_REMOTE_LOG:-${smoke_root}/remote-helper.log}" - -if [[ -n "${TS_AUTHKEY:-}" ]]; then - default_tailnet_state_root="${smoke_root}/local-state" -else - default_tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState" -fi -tailnet_state_root="${BURROW_TAILNET_STATE_ROOT:-${default_tailnet_state_root}}" - -need_login=0 -if [[ -z "${TS_AUTHKEY:-}" ]] && { [[ ! -d "$tailnet_state_root" ]] || [[ -z "$(find "$tailnet_state_root" -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null)" ]]; }; then - need_login=1 -fi - -if [[ "$need_login" -eq 1 ]]; then - echo "Tailnet state root is empty; running iOS login bootstrap first..." - "${repo_root}/Scripts/run-ios-tailnet-ui-tests.sh" -fi - -rm -rf "$smoke_root" -mkdir -p "$smoke_root" - -cleanup() { - rm -f "$payload_path" - if [[ -n "${daemon_pid:-}" ]]; then - kill "$daemon_pid" >/dev/null 2>&1 || true - wait "$daemon_pid" >/dev/null 2>&1 || true - fi - if [[ -n "${remote_pid:-}" ]]; then - kill "$remote_pid" >/dev/null 2>&1 || true - wait "$remote_pid" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -wait_for_helper_listen() { - python3 - <<'PY' "$1" -import json -import pathlib -import sys -import time - -path = pathlib.Path(sys.argv[1]) -deadline = time.time() + 20 -while time.time() < deadline: - if path.exists(): - with path.open("r", encoding="utf-8") as handle: - line = handle.readline().strip() - if line: - hello = json.loads(line) - print(hello["listen_addr"]) - raise SystemExit(0) - time.sleep(0.1) -raise SystemExit("timed out waiting for helper startup line") -PY -} - -wait_for_helper_ip() { - python3 - <<'PY' "$1" -import json -import sys -import time -import urllib.request - -url = sys.argv[1] -deadline = time.time() + 30 -while time.time() < deadline: - with urllib.request.urlopen(url, timeout=5) as response: - status = json.load(response) - if status.get("running") and status.get("tailscale_ips"): - print(status["tailscale_ips"][0]) - raise SystemExit(0) - time.sleep(0.25) -raise SystemExit("timed out waiting for helper to become ready") -PY -} - -python3 - <<'PY' "$payload_path" "$authority" "$account_name" "$identity_name" "$hostname" -import json -import pathlib -import sys - -path = pathlib.Path(sys.argv[1]) -payload = { - "authority": sys.argv[2], - "account": sys.argv[3], - "identity": sys.argv[4], - "hostname": sys.argv[5], -} -path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") -PY - -cargo build -p burrow --bin burrow -( - cd "${repo_root}/Tools/tailscale-login-bridge" - GOWORK=off go build -o "$helper_bin" . -) - -if [[ -z "$remote_ip" ]]; then - if [[ -z "$remote_authkey" ]] && { [[ ! -d "$remote_state_root" ]] || [[ -z "$(find "$remote_state_root" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; }; then - echo "error: set BURROW_TAILNET_SMOKE_REMOTE_IP, BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY, or BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT to an existing logged-in helper state" >&2 - exit 1 - fi - - if [[ -n "$remote_authkey" ]]; then - rm -rf "$remote_state_root" - mkdir -p "$remote_state_root" - fi - - ( - cd "$repo_root" - if [[ -n "$remote_authkey" ]]; then - export TS_AUTHKEY="$remote_authkey" - fi - "$helper_bin" \ - --listen 127.0.0.1:0 \ - --state-dir "$remote_state_root" \ - --hostname "$remote_hostname" \ - --control-url "$authority" \ - --udp-echo-port "$remote_port" \ - >"$remote_stdout" 2>"$remote_stderr" - ) & - remote_pid=$! - - remote_listen_addr="$(wait_for_helper_listen "$remote_stdout")" - remote_ip="$(wait_for_helper_ip "http://${remote_listen_addr}/status")" -fi - -( - cd "$smoke_root" - RUST_LOG="${BURROW_TAILNET_SMOKE_RUST_LOG:-info,burrow=debug}" \ - BURROW_SOCKET_PATH="$socket_path" \ - BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ - "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 -) & -daemon_pid=$! - -for _ in $(seq 1 50); do - [[ -S "$socket_path" ]] && break - sleep 0.2 -done - -if [[ ! -S "$socket_path" ]]; then - echo "error: Burrow daemon did not create ${socket_path}" >&2 - [[ -f "$daemon_log" ]] && cat "$daemon_log" >&2 - exit 1 -fi - -run_burrow() { - BURROW_SOCKET_PATH="$socket_path" \ - BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ - "${repo_root}/target/debug/burrow" "$@" -} - -run_burrow network-add 1 1 "$payload_path" -run_burrow start -run_burrow tunnel-config -run_burrow tailnet-udp-echo "${remote_ip}:${remote_port}" --message "$message" --timeout-ms "$timeout_ms" - -echo -echo "Tailnet connectivity smoke passed." -echo "State root: $tailnet_state_root" -echo "Remote: ${remote_ip}:${remote_port}" diff --git a/Scripts/seal-forgejo-nsc-secrets.sh b/Scripts/seal-forgejo-nsc-secrets.sh deleted file mode 100755 index a6b3918..0000000 --- a/Scripts/seal-forgejo-nsc-secrets.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/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 SSH target forwarded to provision-forgejo-nsc.sh. - --ssh-key SSH private key forwarded to provision-forgejo-nsc.sh. - --nsc-bin 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." diff --git a/Scripts/seal-release-secrets.sh b/Scripts/seal-release-secrets.sh deleted file mode 100755 index 86281ee..0000000 --- a/Scripts/seal-release-secrets.sh +++ /dev/null @@ -1,261 +0,0 @@ -#!/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-release-secrets.sh [--source-dir ] [--only ] - -Encrypt Burrow release inputs into the agenix secrets consumed by CI. - -Expected files in --source-dir (default: intake/release): - appstore-key-id.txt - appstore-key-issuer-id.txt - appstore-key.p8 - distribution-cert.p12 - distribution-cert-password.txt - sparkle-eddsa.key - -Pre-composed appstore-connect.env, distribution-signing.env, and sparkle.env -files are also accepted. - -Provisioning profiles are synced from App Store Connect credentials in CI. -The temporary keychain password is always the distribution certificate password. - -Secrets: - all - appstore-connect - distribution-signing - sparkle -EOF -} - -SOURCE_DIR="${BURROW_RELEASE_SECRET_SOURCE_DIR:-${REPO_ROOT}/intake/release}" -ONLY="all" - -while [[ $# -gt 0 ]]; do - case "$1" in - --source-dir) - SOURCE_DIR="${2:?missing value for --source-dir}" - shift 2 - ;; - --only) - ONLY="${2:?missing value for --only}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown option: $1" >&2 - usage >&2 - exit 64 - ;; - esac -done - -case "$ONLY" in - all|appstore-connect|app-store|appstore|distribution-signing|apple-signing|sparkle) ;; - *) - echo "unknown release secret selector: $ONLY" >&2 - usage >&2 - exit 64 - ;; -esac - -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 [[ ! -d "$SOURCE_DIR" ]]; then - echo "release secret source directory does not exist: $SOURCE_DIR" >&2 - exit 1 -fi - -tmpdir="$(mktemp -d)" -cleanup() { - rm -rf "$tmpdir" -} -trap cleanup EXIT - -seal_secret() { - local target="$1" - local source_path="$2" - local required="${3:-required}" - local recipients_file="${tmpdir}/$(basename "${target}").recipients" - - if [[ ! -s "$source_path" ]]; then - if [[ "$required" == "optional" ]]; then - return 0 - fi - echo "required release secret source missing or empty: $source_path" >&2 - exit 1 - fi - - mkdir -p "${REPO_ROOT}/$(dirname "$target")" - 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" - chmod 600 "${REPO_ROOT}/${target}" - echo "Sealed ${target}" -} - -base64_file() { - python3 -c 'import base64, pathlib, sys; print(base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode())' "$1" -} - -read_secret_text() { - python3 -c 'import pathlib, sys; sys.stdout.write(pathlib.Path(sys.argv[1]).read_text().strip())' "$1" -} - -base64_text() { - python3 -c 'import base64, sys; print(base64.b64encode(sys.stdin.buffer.read()).decode())' -} - -reject_placeholder() { - local path="$1" - if grep -Eq '^(TODO|PASTE_|REPLACE_|#)' "$path"; then - echo "intake file still contains placeholder text: $path" >&2 - exit 1 - fi -} - -reject_placeholder_text() { - local path="$1" - if grep -Iq . "$path"; then - reject_placeholder "$path" - fi -} - -first_existing() { - local path - for path in "$@"; do - if [[ -s "$path" ]]; then - printf '%s\n' "$path" - return 0 - fi - done - return 1 -} - -compose_appstore_connect_env() { - local direct="${SOURCE_DIR}/appstore-connect.env" - if [[ -s "$direct" ]]; then - printf '%s\n' "$direct" - return 0 - fi - - local key_id_file="${SOURCE_DIR}/appstore-key-id.txt" - local issuer_id_file="${SOURCE_DIR}/appstore-key-issuer-id.txt" - local key_file="${SOURCE_DIR}/appstore-key.p8" - if [[ ! -s "$key_file" ]]; then - echo "required App Store Connect source missing or empty: appstore-key.p8" >&2 - exit 1 - fi - for path in "$key_id_file" "$issuer_id_file" "$key_file"; do - if [[ ! -s "$path" ]]; then - echo "required App Store Connect source missing or empty: $path" >&2 - exit 1 - fi - reject_placeholder_text "$path" - done - - local out="${tmpdir}/appstore-connect.env" - { - printf 'ASC_API_KEY_ID=%s\n' "$(read_secret_text "$key_id_file")" - printf 'ASC_API_ISSUER_ID=%s\n' "$(read_secret_text "$issuer_id_file")" - printf 'ASC_API_KEY_BASE64=%s\n' "$(base64_file "$key_file")" - } > "$out" - printf '%s\n' "$out" -} - -compose_distribution_signing_env() { - local direct="${SOURCE_DIR}/distribution-signing.env" - if [[ -s "$direct" ]]; then - printf '%s\n' "$direct" - return 0 - fi - - local cert_file - cert_file="${SOURCE_DIR}/distribution-cert.p12" - local password_file="${SOURCE_DIR}/distribution-cert-password.txt" - if [[ ! -s "$cert_file" ]]; then - echo "required distribution signing source missing or empty: distribution-cert.p12" >&2 - exit 1 - fi - for path in "$password_file"; do - if [[ ! -s "$path" ]]; then - echo "required distribution signing source missing or empty: $path" >&2 - exit 1 - fi - reject_placeholder_text "$path" - done - - local out="${tmpdir}/distribution-signing.env" - { - printf 'APPLE_SIGNING_CERTIFICATE_BASE64=%s\n' "$(base64_file "$cert_file")" - printf 'APPLE_SIGNING_CERTIFICATE_PASSWORD_BASE64=%s\n' "$(read_secret_text "$password_file" | base64_text)" - } > "$out" - printf '%s\n' "$out" -} - -compose_sparkle_env() { - local direct="${SOURCE_DIR}/sparkle.env" - if [[ -s "$direct" ]]; then - printf '%s\n' "$direct" - return 0 - fi - - local key_file - key_file="${SOURCE_DIR}/sparkle-eddsa.key" - if [[ ! -s "$key_file" ]]; then - return 1 - fi - reject_placeholder_text "$key_file" - - local out="${tmpdir}/sparkle.env" - printf 'SPARKLE_EDDSA_KEY_BASE64=%s\n' "$(base64_file "$key_file")" > "$out" - printf '%s\n' "$out" -} - -case "$ONLY" in - all) - appstore_env="$(compose_appstore_connect_env)" - seal_secret "secrets/apple/appstore-connect.env.age" "$appstore_env" - distribution_env="$(compose_distribution_signing_env)" - seal_secret "secrets/apple/distribution-signing.env.age" "$distribution_env" - if sparkle_env="$(compose_sparkle_env)"; then - seal_secret "secrets/apple/sparkle.env.age" "$sparkle_env" - fi - ;; - appstore-connect|app-store|appstore) - appstore_env="$(compose_appstore_connect_env)" - seal_secret "secrets/apple/appstore-connect.env.age" "$appstore_env" - ;; - distribution-signing|apple-signing) - distribution_env="$(compose_distribution_signing_env)" - seal_secret "secrets/apple/distribution-signing.env.age" "$distribution_env" - ;; - sparkle) - if sparkle_env="$(compose_sparkle_env)"; then - seal_secret "secrets/apple/sparkle.env.age" "$sparkle_env" - else - echo "required Sparkle source missing or empty: sparkle-eddsa.key" >&2 - exit 1 - fi - ;; -esac - -echo "Release secrets are sealed. Commit the .age files and deploy/use CI with an authorized age identity." diff --git a/Scripts/sparkle/sign-appcast-kms.py b/Scripts/sparkle/sign-appcast-kms.py deleted file mode 100755 index 0e22c7c..0000000 --- a/Scripts/sparkle/sign-appcast-kms.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Sign Sparkle appcast enclosures. - -Google Cloud KMS Ed25519 signing can only sign small payloads directly. Sparkle -requires a standard EdDSA signature over the full update archive, so normal -release artifacts sign directly from the decrypted release seed. -""" - -from __future__ import annotations - -import argparse -import base64 -import os -import subprocess -import sys -import tempfile -import urllib.parse -import xml.etree.ElementTree as ET -from pathlib import Path - - -SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" -DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33" -DEFAULT_LOCATION = "global" -DEFAULT_KEYRING = "burrow-identity" -DEFAULT_KEY = "sparkle-ed25519" -DEFAULT_VERSION = "1" -DEFAULT_KMS_MAX_INPUT_BYTES = 65536 - - -def run(command: list[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run(command, check=True, text=True, stdout=subprocess.PIPE) - - -def kms_sign(args: argparse.Namespace, payload: Path) -> str: - with tempfile.TemporaryDirectory(prefix="burrow-sparkle-kms.") as tmp: - signature_path = Path(tmp) / "signature.bin" - command = [ - args.gcloud, - "kms", - "asymmetric-sign", - "--location", - args.kms_location, - "--keyring", - args.kms_keyring, - "--key", - args.kms_key, - "--version", - args.kms_version, - "--input-file", - str(payload), - "--signature-file", - str(signature_path), - ] - if args.gcloud_project: - command.extend(["--project", args.gcloud_project]) - run(command) - return base64.b64encode(signature_path.read_bytes()).decode("ascii") - - -def read_sparkle_seed(key_path: Path) -> bytes: - key_data = key_path.read_bytes().strip() - if len(key_data) == 32: - return key_data - try: - decoded = base64.b64decode(key_data, validate=True) - except ValueError as exc: - raise ValueError(f"Sparkle key is not raw or base64 Ed25519 seed data: {key_path}") from exc - if len(decoded) != 32: - raise ValueError(f"Sparkle key must decode to a 32-byte Ed25519 seed: {key_path}") - return decoded - - -def sign_with_release_seed(args: argparse.Namespace, payload: Path) -> str: - key_path = Path(args.ed_key_path) if args.ed_key_path else None - if key_path is None or not key_path.is_file(): - raise FileNotFoundError( - "Sparkle artifact is too large for direct Google KMS Ed25519 signing " - "and SPARKLE_EDDSA_KEY_PATH does not point to a key file" - ) - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - - seed = read_sparkle_seed(key_path) - signature = Ed25519PrivateKey.from_private_bytes(seed).sign(payload.read_bytes()) - return base64.b64encode(signature).decode("ascii") - - -def sparkle_signature(args: argparse.Namespace, payload: Path) -> str: - if args.ed_key_path: - return sign_with_release_seed(args, payload) - if payload.stat().st_size <= args.kms_max_input_bytes: - return kms_sign(args, payload) - return sign_with_release_seed(args, payload) - - -def enclosure_path(enclosure: ET.Element, artifact_dir: Path) -> Path: - url = enclosure.attrib.get("url") - if not url: - raise ValueError("Sparkle enclosure is missing a url attribute") - parsed = urllib.parse.urlparse(url) - name = Path(parsed.path).name - if not name: - raise ValueError(f"Sparkle enclosure URL has no file name: {url}") - path = artifact_dir / name - if not path.is_file(): - raise FileNotFoundError(f"Sparkle enclosure artifact not found: {path}") - return path - - -def sign_appcast(args: argparse.Namespace) -> None: - appcast = Path(args.appcast) - artifact_dir = Path(args.artifact_dir) if args.artifact_dir else appcast.parent - ET.register_namespace("sparkle", SPARKLE_NS) - tree = ET.parse(appcast) - root = tree.getroot() - enclosures = root.findall(".//enclosure") - if not enclosures: - raise ValueError(f"no Sparkle enclosures found in {appcast}") - - for enclosure in enclosures: - artifact = enclosure_path(enclosure, artifact_dir) - enclosure.attrib[f"{{{SPARKLE_NS}}}edSignature"] = sparkle_signature(args, artifact) - - tree.write(appcast, encoding="utf-8", xml_declaration=True) - print(f"signed {len(enclosures)} enclosure(s) in {appcast}") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Sign Sparkle appcast enclosures with Google Cloud KMS.") - parser.add_argument("--appcast", required=True) - parser.add_argument("--artifact-dir") - parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT) - parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION)) - parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING)) - parser.add_argument("--kms-key", default=os.environ.get("BURROW_SPARKLE_KMS_KEY", DEFAULT_KEY)) - parser.add_argument("--kms-version", default=os.environ.get("BURROW_SPARKLE_KMS_VERSION", DEFAULT_VERSION)) - parser.add_argument("--kms-max-input-bytes", type=int, default=DEFAULT_KMS_MAX_INPUT_BYTES) - parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud")) - parser.add_argument("--ed-key-path", default=os.environ.get("SPARKLE_EDDSA_KEY_PATH")) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - sign_appcast(parse_args(argv)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/Scripts/sync-forgejo-nsc-config.sh b/Scripts/sync-forgejo-nsc-config.sh index 2ce7114..d6ac48c 100755 --- a/Scripts/sync-forgejo-nsc-config.sh +++ b/Scripts/sync-forgejo-nsc-config.sh @@ -1,7 +1,109 @@ #!/usr/bin/env bash set -euo pipefail -echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2 -echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2 -echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2 -exit 1 +usage() { + cat <<'EOF' +Usage: Scripts/sync-forgejo-nsc-config.sh [options] + +Deploy Burrow forgejo-nsc runtime inputs from age secrets onto the forge host. + +Options: + --host SSH target (default: root@git.burrow.net) + --ssh-key SSH private key (default: secrets/forgejo/agent-ssh-key.age, then intake/) + --rotate-pat Re-render the encrypted runtime inputs before deploying. + --no-restart Validate the encrypted inputs only; do not deploy. + -h, --help Show this help text. +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:-}" +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 + --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 + fi +} + +burrow_require_cmd ssh + +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}/secrets/forgejo/nsc-token.age" +dispatcher_file="${REPO_ROOT}/secrets/forgejo/nsc-dispatcher-config.age" +autoscaler_file="${REPO_ROOT}/secrets/forgejo/nsc-autoscaler-config.age" + +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 + +if [[ "${NO_RESTART}" -eq 0 ]]; then + BURROW_FORGE_HOST="${HOST}" \ + BURROW_FORGE_SSH_KEY="${SSH_KEY}" \ + BURROW_FORGE_KNOWN_HOSTS_FILE="${KNOWN_HOSTS_FILE}" \ + "${SCRIPT_DIR}/forge-deploy.sh" --switch +fi + +echo "forgejo-nsc runtime sync complete (host=${HOST}, deployed=$((1 - NO_RESTART)))." diff --git a/Scripts/version.sh b/Scripts/version.sh deleted file mode 100755 index 60b8544..0000000 --- a/Scripts/version.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -burrow_user="${USER:-${LOGNAME:-runner}}" -export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/${burrow_user}/bin" - -cd "$(dirname "${BASH_SOURCE[0]}")/.." - -TAG_PREFIX="builds/" -DEFAULT_VERSION_REMOTE="origin" -if git remote get-url forgejo >/dev/null 2>&1; then - DEFAULT_VERSION_REMOTE="forgejo" -fi -VERSION_REMOTE="${BURROW_VERSION_REMOTE:-$DEFAULT_VERSION_REMOTE}" -VERSION_REQUIRE_REMOTE="${BURROW_VERSION_REQUIRE_REMOTE:-false}" -COMMAND="${1:-}" - -truthy() { - case "$1" in - 1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;; - *) return 1 ;; - esac -} - -list_local_build_tags() { - git tag -l "${TAG_PREFIX}[0-9]*" -} - -latest_local_build_tag() { - list_local_build_tags | sort -n -t'/' -k2 | tail -n 1 -} - -latest_remote_build_record() { - local raw rc=0 - raw="$(git ls-remote --tags --refs "$VERSION_REMOTE" "refs/tags/${TAG_PREFIX}[0-9]*" 2>/dev/null)" || rc=$? - if [[ $rc -ne 0 ]]; then - return $rc - fi - if [[ -z "$raw" ]]; then - return 0 - fi - printf '%s\n' "$raw" | awk -F'\t' -v prefix="refs/tags/${TAG_PREFIX}" ' - $2 ~ ("^" prefix "[0-9]+$") { - build = $2 - sub("^refs/tags/", "", build) - print build "\t" $1 - } - ' | sort -n -t'/' -k2 | tail -n 1 -} - -resolve_build_state() { - local remote_record local_latest remote_status=0 - BUILD_STATE_SOURCE="local" - LATEST_BUILD="" - LATEST_BUILD_COMMIT="" - - remote_record="$(latest_remote_build_record)" || remote_status=$? - if [[ $remote_status -eq 0 ]]; then - BUILD_STATE_SOURCE="remote" - if [[ -n "$remote_record" ]]; then - LATEST_BUILD="$(printf '%s\n' "$remote_record" | cut -f1)" - LATEST_BUILD_COMMIT="$(printf '%s\n' "$remote_record" | cut -f2)" - fi - else - if truthy "$VERSION_REQUIRE_REMOTE"; then - echo "error: unable to resolve authoritative build tags from ${VERSION_REMOTE}" >&2 - exit "$remote_status" - fi - echo "warning: unable to resolve authoritative build tags from ${VERSION_REMOTE}; falling back to local tags" >&2 - local_latest="$(latest_local_build_tag)" - if [[ -n "$local_latest" ]]; then - LATEST_BUILD="$local_latest" - LATEST_BUILD_COMMIT="$(git rev-parse -q --verify "refs/tags/${LATEST_BUILD}^{commit}" 2>/dev/null || true)" - fi - fi - - if [[ -z "$LATEST_BUILD" ]]; then - LATEST_BUILD_NUMBER="0" - else - LATEST_BUILD_NUMBER="${LATEST_BUILD#$TAG_PREFIX}" - fi -} - -resolve_build_state - -HEAD_COMMIT="$(git rev-parse HEAD)" - -ANCESTRY_STATE="ok" -if [[ -n "${LATEST_BUILD_NUMBER}" && "${LATEST_BUILD_NUMBER}" != "0" ]]; then - if [[ -n "${LATEST_BUILD_COMMIT}" ]] && git cat-file -e "${LATEST_BUILD_COMMIT}^{commit}" 2>/dev/null; then - if ! git merge-base --is-ancestor "${LATEST_BUILD_COMMIT}" HEAD; then - ANCESTRY_STATE="not-descended" - if [[ "$COMMAND" != "status" ]]; then - echo "error: HEAD is not descended from build ${LATEST_BUILD_NUMBER}" >&2 - exit 1 - fi - fi - else - ANCESTRY_STATE="missing-tag-commit" - echo "notice: skipping build ancestry check (missing ${LATEST_BUILD} commit, likely shallow checkout)" >&2 - fi -fi - -BUILD_NUMBER="$LATEST_BUILD_NUMBER" - -if [[ "$COMMAND" == "increment" || "$COMMAND" == "pre-increment" ]]; then - if [[ -n "${BURROW_BUILD_NUMBER_OVERRIDE:-}" ]]; then - if [[ ! "${BURROW_BUILD_NUMBER_OVERRIDE}" =~ ^[0-9]+$ ]]; then - echo "error: BURROW_BUILD_NUMBER_OVERRIDE must be an integer" >&2 - exit 1 - fi - if (( BURROW_BUILD_NUMBER_OVERRIDE <= LATEST_BUILD_NUMBER )); then - echo "error: BURROW_BUILD_NUMBER_OVERRIDE must be greater than ${LATEST_BUILD_NUMBER}" >&2 - exit 1 - fi - NEW_BUILD_NUMBER="${BURROW_BUILD_NUMBER_OVERRIDE}" - else - NEW_BUILD_NUMBER=$((LATEST_BUILD_NUMBER + 1)) - fi - NEW_TAG="$TAG_PREFIX$NEW_BUILD_NUMBER" - BUILD_NUMBER="$NEW_BUILD_NUMBER" -fi - -if [[ "$COMMAND" == "increment" ]]; then - git tag "$NEW_TAG" - git push --quiet "$VERSION_REMOTE" "$NEW_TAG" -fi - -CONFIG_PATH="Apple/Configuration/Version.xcconfig" -if [[ "$COMMAND" != "status" && -f "$CONFIG_PATH" ]]; then - current_config_version="$(sed -n 's/^[[:space:]]*CURRENT_PROJECT_VERSION[[:space:]]*=[[:space:]]*//p' "$CONFIG_PATH" 2>/dev/null | tail -n 1 || true)" - if [[ "$current_config_version" != "$BUILD_NUMBER" ]]; then - echo "CURRENT_PROJECT_VERSION = $BUILD_NUMBER" > "$CONFIG_PATH" - fi - git update-index --assume-unchanged "$CONFIG_PATH" 2>/dev/null || true -fi - -if [[ "$COMMAND" == "status" ]]; then - if [[ "${ANCESTRY_STATE}" == "ok" && "${LATEST_BUILD_NUMBER}" != "0" && -n "${LATEST_BUILD_COMMIT}" && "${HEAD_COMMIT}" == "${LATEST_BUILD_COMMIT}" ]]; then - echo "clean" - else - echo "dirty" - fi - exit 0 -fi - -echo "$BUILD_NUMBER" 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/Tools/tailscale-login-bridge/go.mod b/Tools/tailscale-login-bridge/go.mod deleted file mode 100644 index 0e19f33..0000000 --- a/Tools/tailscale-login-bridge/go.mod +++ /dev/null @@ -1,66 +0,0 @@ -module burrow.dev/tailscale-login-bridge - -go 1.26.1 - -require tailscale.com v1.96.5 - -require ( - filippo.io/edwards25519 v1.2.0 // indirect - github.com/akutz/memconn v0.1.0 // indirect - github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect - github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect - github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect - github.com/aws/smithy-go v1.24.0 // indirect - github.com/coder/websocket v1.8.12 // indirect - github.com/creachadair/msync v0.7.1 // indirect - github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gaissmai/bart v0.26.1 // indirect - github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect - github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hdevalence/ed25519consensus v0.2.0 // indirect - github.com/huin/goupnp v1.3.0 // indirect - github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect - github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect - github.com/mdlayher/socket v0.5.0 // indirect - github.com/mitchellh/go-ps v1.0.0 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect - github.com/prometheus-community/pro-bing v0.4.0 // indirect - github.com/safchain/ethtool v0.3.0 // indirect - github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect - github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect - github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect - github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect - github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect - github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect - github.com/x448/float16 v0.8.4 // indirect - go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect - go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.33.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect -) diff --git a/Tools/tailscale-login-bridge/go.sum b/Tools/tailscale-login-bridge/go.sum deleted file mode 100644 index 5393a62..0000000 --- a/Tools/tailscale-login-bridge/go.sum +++ /dev/null @@ -1,229 +0,0 @@ -9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= -9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= -filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= -github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= -github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= -github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= -github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= -github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= -github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= -github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= -github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= -github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA= -github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= -github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= -github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= -github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= -github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= -github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= -github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= -github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= -github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= -github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= -github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= -github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= -github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= -github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= -github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= -github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= -github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= -github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= -github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= -github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= -github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= -github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= -github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= -github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= -github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= -github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= -github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= -github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= -github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= -github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= -github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= -github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= -github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= -github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= -github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= -github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= -github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= -github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= -github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= -github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= -github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= -github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= -github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= -github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= -github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= -github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= -github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= -github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= -github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= -github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= -github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= -github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= -github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= -github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= -github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= -github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= -github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= -github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= -github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= -github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= -github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= -github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= -github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= -github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= -github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= -github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= -github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= -github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= -go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= -golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= -golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= -golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= -golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= -golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= -gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= -honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho= -honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= -howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= -howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= -software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA= -tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY= diff --git a/Tools/tailscale-login-bridge/main.go b/Tools/tailscale-login-bridge/main.go deleted file mode 100644 index 877d0e4..0000000 --- a/Tools/tailscale-login-bridge/main.go +++ /dev/null @@ -1,523 +0,0 @@ -package main - -import ( - "context" - "encoding/binary" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "net" - "net/netip" - "net/http" - "os" - "strconv" - "sync" - "time" - - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/client/local" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" -) - -type statusResponse struct { - BackendState string `json:"backend_state"` - AuthURL string `json:"auth_url,omitempty"` - Running bool `json:"running"` - NeedsLogin bool `json:"needs_login"` - TailnetName string `json:"tailnet_name,omitempty"` - MagicDNSSuffix string `json:"magic_dns_suffix,omitempty"` - SelfDNSName string `json:"self_dns_name,omitempty"` - TailscaleIPs []string `json:"tailscale_ips,omitempty"` - Health []string `json:"health,omitempty"` - Peers []peerSummary `json:"peers,omitempty"` -} - -type peerSummary struct { - Name string `json:"name,omitempty"` - DNSName string `json:"dns_name,omitempty"` - TailscaleIPs []string `json:"tailscale_ips,omitempty"` - Online bool `json:"online"` - Active bool `json:"active"` - Relay string `json:"relay,omitempty"` - CurAddr string `json:"cur_addr,omitempty"` - LastSeenUnix int64 `json:"last_seen_unix,omitempty"` -} - -type pingResponse struct { - Result *ipnstate.PingResult `json:"result,omitempty"` -} - -type helperHello struct { - ListenAddr string `json:"listen_addr"` - PacketSocket string `json:"packet_socket,omitempty"` -} - -type helperState struct { - mu sync.RWMutex - authURL string -} - -func (s *helperState) authURLSnapshot() string { - s.mu.RLock() - defer s.mu.RUnlock() - return s.authURL -} - -func (s *helperState) setAuthURL(url string) { - s.mu.Lock() - defer s.mu.Unlock() - s.authURL = url -} - -func (s *helperState) clearAuthURL() { - s.setAuthURL("") -} - -// chanTUN is a tun.Device backed by channels so another process can feed and -// consume raw IP packets while tsnet handles the Tailnet control/data plane. -type chanTUN struct { - Inbound chan []byte - Outbound chan []byte - closed chan struct{} - events chan tun.Event -} - -func newChanTUN() *chanTUN { - t := &chanTUN{ - Inbound: make(chan []byte, 1024), - Outbound: make(chan []byte, 1024), - closed: make(chan struct{}), - events: make(chan tun.Event, 1), - } - t.events <- tun.EventUp - return t -} - -func (t *chanTUN) File() *os.File { return nil } - -func (t *chanTUN) Close() error { - select { - case <-t.closed: - default: - close(t.closed) - close(t.Inbound) - } - return nil -} - -func (t *chanTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) { - select { - case <-t.closed: - return 0, io.EOF - case pkt, ok := <-t.Outbound: - if !ok { - return 0, io.EOF - } - sizes[0] = copy(bufs[0][offset:], pkt) - return 1, nil - } -} - -func (t *chanTUN) Write(bufs [][]byte, offset int) (int, error) { - for _, buf := range bufs { - pkt := buf[offset:] - if len(pkt) == 0 { - continue - } - select { - case <-t.closed: - return 0, errors.New("closed") - case t.Inbound <- append([]byte(nil), pkt...): - default: - } - } - return len(bufs), nil -} - -func (t *chanTUN) MTU() (int, error) { return 1280, nil } -func (t *chanTUN) Name() (string, error) { return "burrow-tailnet", nil } -func (t *chanTUN) Events() <-chan tun.Event { return t.events } -func (t *chanTUN) BatchSize() int { return 1 } - -func main() { - listen := flag.String("listen", "127.0.0.1:0", "local listen address") - stateDir := flag.String("state-dir", "", "persistent state directory") - hostname := flag.String("hostname", "burrow-apple", "tailnet hostname") - controlURL := flag.String("control-url", "", "optional control URL") - packetSocket := flag.String("packet-socket", "", "optional unix socket path for raw packet bridging") - udpEchoPort := flag.Int("udp-echo-port", 0, "optional tailnet UDP echo port") - flag.Parse() - - if *stateDir == "" { - log.Fatal("--state-dir is required") - } - - if err := os.MkdirAll(*stateDir, 0o755); err != nil { - log.Fatalf("create state dir: %v", err) - } - - server := &tsnet.Server{ - Dir: *stateDir, - Hostname: *hostname, - UserLogf: log.Printf, - } - - var tunDevice *chanTUN - var packetListener net.Listener - if *packetSocket != "" { - _ = os.Remove(*packetSocket) - ln, err := net.Listen("unix", *packetSocket) - if err != nil { - log.Fatalf("packet listen: %v", err) - } - packetListener = ln - defer func() { - packetListener.Close() - _ = os.Remove(*packetSocket) - }() - - tunDevice = newChanTUN() - server.Tun = tunDevice - } - if *controlURL != "" { - server.ControlURL = *controlURL - } - defer server.Close() - - if err := server.Start(); err != nil { - log.Fatalf("start tsnet: %v", err) - } - - localClient, err := server.LocalClient() - if err != nil { - log.Fatalf("local client: %v", err) - } - state := &helperState{} - - ln, err := net.Listen("tcp", *listen) - if err != nil { - log.Fatalf("listen: %v", err) - } - defer ln.Close() - - if packetListener != nil { - go servePacketBridge(packetListener, tunDevice) - } - if *udpEchoPort > 0 { - go serveUDPEcho(context.Background(), server, localClient, *udpEchoPort) - } - - hello := helperHello{ - ListenAddr: ln.Addr().String(), - } - if *packetSocket != "" { - hello.PacketSocket = *packetSocket - } - if err := json.NewEncoder(os.Stdout).Encode(hello); err != nil { - log.Fatalf("write hello: %v", err) - } - _ = os.Stdout.Sync() - - mux := http.NewServeMux() - mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { - status, err := snapshot(r.Context(), localClient, state) - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - w.Header().Set("content-type", "application/json") - _ = json.NewEncoder(w).Encode(status) - }) - mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { - ip := r.URL.Query().Get("ip") - if ip == "" { - http.Error(w, "missing ip", http.StatusBadRequest) - return - } - target, err := netip.ParseAddr(ip) - if err != nil { - http.Error(w, fmt.Sprintf("invalid ip: %v", err), http.StatusBadRequest) - return - } - - pingType := tailcfg.PingTSMP - switch r.URL.Query().Get("type") { - case "", "tsmp", "TSMP": - pingType = tailcfg.PingTSMP - case "icmp", "ICMP": - pingType = tailcfg.PingICMP - case "peerapi": - pingType = tailcfg.PingPeerAPI - default: - http.Error(w, "unsupported ping type", http.StatusBadRequest) - return - } - - result, err := localClient.Ping(r.Context(), target, pingType) - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - - w.Header().Set("content-type", "application/json") - _ = json.NewEncoder(w).Encode(&pingResponse{Result: result}) - }) - mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - go func() { - _ = server.Close() - time.Sleep(100 * time.Millisecond) - os.Exit(0) - }() - }) - - httpServer := &http.Server{ - Handler: mux, - } - log.Fatal(httpServer.Serve(ln)) -} - -func servePacketBridge(listener net.Listener, device *chanTUN) { - for { - conn, err := listener.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) { - return - } - log.Printf("packet accept: %v", err) - continue - } - log.Printf("packet bridge connected") - if err := bridgePacketConn(conn, device); err != nil && !errors.Is(err, io.EOF) { - log.Printf("packet bridge error: %v", err) - } - _ = conn.Close() - log.Printf("packet bridge disconnected") - } -} - -func bridgePacketConn(conn net.Conn, device *chanTUN) error { - errCh := make(chan error, 2) - - go func() { - for { - pkt, err := readFrame(conn) - if err != nil { - errCh <- err - return - } - select { - case <-device.closed: - errCh <- io.EOF - return - case device.Outbound <- pkt: - } - } - }() - - go func() { - for { - select { - case <-device.closed: - errCh <- io.EOF - return - case pkt, ok := <-device.Inbound: - if !ok { - errCh <- io.EOF - return - } - if err := writeFrame(conn, pkt); err != nil { - errCh <- err - return - } - } - } - }() - - return <-errCh -} - -func readFrame(r io.Reader) ([]byte, error) { - var size [4]byte - if _, err := io.ReadFull(r, size[:]); err != nil { - return nil, err - } - length := binary.BigEndian.Uint32(size[:]) - if length == 0 { - return []byte{}, nil - } - packet := make([]byte, length) - if _, err := io.ReadFull(r, packet); err != nil { - return nil, err - } - return packet, nil -} - -func writeFrame(w io.Writer, packet []byte) error { - var size [4]byte - binary.BigEndian.PutUint32(size[:], uint32(len(packet))) - if _, err := w.Write(size[:]); err != nil { - return err - } - if len(packet) == 0 { - return nil - } - _, err := w.Write(packet) - return err -} - -func snapshot(ctx context.Context, localClient *local.Client, state *helperState) (*statusResponse, error) { - status, err := localClient.Status(ctx) - if err != nil { - return nil, err - } - - authURL := status.AuthURL - if authURL == "" { - authURL = state.authURLSnapshot() - } - if status.BackendState == ipn.Running.String() { - state.clearAuthURL() - authURL = "" - } else if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && authURL == "" { - authURL, err = awaitAuthURL(ctx, localClient, state) - if err != nil { - return nil, err - } - } - - response := &statusResponse{ - BackendState: status.BackendState, - AuthURL: authURL, - Running: status.BackendState == ipn.Running.String(), - NeedsLogin: status.BackendState == ipn.NeedsLogin.String(), - Health: append([]string(nil), status.Health...), - } - - if status.CurrentTailnet != nil { - response.TailnetName = status.CurrentTailnet.Name - response.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix - } - if status.Self != nil { - response.SelfDNSName = status.Self.DNSName - } - for _, ip := range status.TailscaleIPs { - response.TailscaleIPs = append(response.TailscaleIPs, ip.String()) - } - for _, key := range status.Peers() { - peer := status.Peer[key] - if peer == nil { - continue - } - summary := peerSummary{ - Name: peer.HostName, - DNSName: peer.DNSName, - Online: peer.Online, - Active: peer.Active, - Relay: peer.Relay, - CurAddr: peer.CurAddr, - LastSeenUnix: peer.LastSeen.Unix(), - } - for _, ip := range peer.TailscaleIPs { - summary.TailscaleIPs = append(summary.TailscaleIPs, ip.String()) - } - response.Peers = append(response.Peers, summary) - } - return response, nil -} - -func serveUDPEcho(ctx context.Context, server *tsnet.Server, localClient *local.Client, port int) { - ip, err := awaitTailscaleIP(ctx, localClient) - if err != nil { - log.Printf("udp echo setup failed: %v", err) - return - } - - listenAddr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) - pc, err := server.ListenPacket("udp", listenAddr) - if err != nil { - log.Printf("udp echo listen failed on %s: %v", listenAddr, err) - return - } - defer pc.Close() - - log.Printf("udp echo listening on %s", pc.LocalAddr()) - buf := make([]byte, 64<<10) - for { - n, addr, err := pc.ReadFrom(buf) - if err != nil { - if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { - return - } - log.Printf("udp echo read failed: %v", err) - return - } - if _, err := pc.WriteTo(buf[:n], addr); err != nil { - log.Printf("udp echo write failed: %v", err) - return - } - } -} - -func awaitTailscaleIP(ctx context.Context, localClient *local.Client) (netip.Addr, error) { - for range 60 { - status, err := localClient.StatusWithoutPeers(ctx) - if err == nil { - for _, ip := range status.TailscaleIPs { - if ip.Is4() { - return ip, nil - } - } - for _, ip := range status.TailscaleIPs { - if ip.Is6() { - return ip, nil - } - } - } - select { - case <-ctx.Done(): - return netip.Addr{}, ctx.Err() - case <-time.After(250 * time.Millisecond): - } - } - return netip.Addr{}, errors.New("timed out waiting for tailscale IP") -} - -func awaitAuthURL(ctx context.Context, localClient *local.Client, state *helperState) (string, error) { - watchCtx, cancel := context.WithTimeout(ctx, 8*time.Second) - defer cancel() - - watcher, err := localClient.WatchIPNBus(watchCtx, ipn.NotifyInitialState) - if err != nil { - return "", err - } - defer watcher.Close() - - if err := localClient.StartLoginInteractive(ctx); err != nil { - return "", err - } - - for { - notify, err := watcher.Next() - if err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return state.authURLSnapshot(), nil - } - return "", err - } - if notify.BrowseToURL != nil && *notify.BrowseToURL != "" { - state.setAuthURL(*notify.BrowseToURL) - return *notify.BrowseToURL, nil - } - if notify.State != nil && *notify.State == ipn.Running { - state.clearAuthURL() - return "", nil - } - } -} diff --git a/bazel/android/BUILD.bazel b/bazel/android/BUILD.bazel deleted file mode 100644 index 64b7cff..0000000 --- a/bazel/android/BUILD.bazel +++ /dev/null @@ -1,14 +0,0 @@ -load("@rules_shell//shell:sh_binary.bzl", "sh_binary") -load(":android_targets.bzl", "android_stub_stamp") - -package(default_visibility = ["//visibility:public"]) - -sh_binary( - name = "runner", - srcs = ["runner.sh"], -) - -android_stub_stamp( - name = "check_stub_stamp", - command = "check-stub", -) diff --git a/bazel/android/android_targets.bzl b/bazel/android/android_targets.bzl deleted file mode 100644 index c6c67bd..0000000 --- a/bazel/android/android_targets.bzl +++ /dev/null @@ -1,12 +0,0 @@ -def android_stub_stamp(name, command): - native.genrule( - name = name, - outs = [name + ".stamp"], - cmd = "$(location :runner) %s && : > \"$@\"" % command, - srcs = [ - "//:android_project_files", - "//:mobile_rust_core_files", - ], - tools = [":runner"], - tags = ["no-sandbox"], - ) diff --git a/bazel/android/runner.sh b/bazel/android/runner.sh deleted file mode 100755 index c273218..0000000 --- a/bazel/android/runner.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -command="${1:-}" - -resolve_repo_root() { - if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" && -f "${BUILD_WORKSPACE_DIRECTORY}/MODULE.bazel" ]]; then - printf '%s\n' "$BUILD_WORKSPACE_DIRECTORY" - return 0 - fi - - local dir="$PWD" - while [[ "$dir" != "/" ]]; do - if [[ -f "$dir/MODULE.bazel" && -d "$dir/Android" ]]; then - printf '%s\n' "$dir" - return 0 - fi - dir="$(dirname "$dir")" - done - - echo "unable to resolve Burrow repository root" >&2 - return 1 -} - -repo_root="$(resolve_repo_root)" -cd "$repo_root" - -cargo_check_locked() { - local -a cargo_path_prefixes - local home_dir user_name - cargo_path_prefixes=() - home_dir="${HOME:-}" - if [[ -n "$home_dir" ]]; then - cargo_path_prefixes+=("$home_dir/.cargo/bin") - fi - user_name="$(id -un 2>/dev/null || true)" - if [[ -n "$user_name" ]]; then - cargo_path_prefixes+=( - "/Users/${user_name}/.cargo/bin" - "/home/${user_name}/.cargo/bin" - "/etc/profiles/per-user/${user_name}/bin" - ) - fi - cargo_path_prefixes+=("/opt/homebrew/bin" "/usr/local/bin" "/nix/var/nix/profiles/default/bin" "/run/current-system/sw/bin") - export PATH="$(IFS=:; printf '%s' "${cargo_path_prefixes[*]}"):$PATH" - - if command -v cargo >/dev/null 2>&1; then - cargo check --locked -p burrow-mobile-ffi - return - fi - - if [[ "${BURROW_ANDROID_BAZEL_USE_NIX:-0}" == "1" ]] && command -v nix >/dev/null 2>&1; then - nix develop .#ci -c cargo check --locked -p burrow-mobile-ffi - return - fi - - echo "cargo or nix is required to check the Android Rust core stub" >&2 - exit 127 -} - -case "$command" in - check-stub) - test -f Android/settings.gradle.kts - test -f Android/app/src/main/java/net/burrow/android/MainActivity.kt - test -f crates/burrow-core/src/lib.rs - test -f crates/burrow-mobile-ffi/src/lib.rs - cargo_check_locked - ;; - *) - echo "unknown Android runner command: ${command}" >&2 - exit 64 - ;; -esac diff --git a/bazel/apple/BUILD.bazel b/bazel/apple/BUILD.bazel deleted file mode 100644 index f35ec71..0000000 --- a/bazel/apple/BUILD.bazel +++ /dev/null @@ -1,25 +0,0 @@ -load("@rules_shell//shell:sh_binary.bzl", "sh_binary") -load(":apple_targets.bzl", "apple_bazel_stamp") - -package(default_visibility = ["//visibility:public"]) - -sh_binary( - name = "runner", - srcs = ["runner.sh"], - target_compatible_with = ["@platforms//os:macos"], -) - -apple_bazel_stamp( - name = "release_ios_stamp", - command = "release-ios", -) - -apple_bazel_stamp( - name = "release_macos_stamp", - command = "release-macos", -) - -apple_bazel_stamp( - name = "release_all_stamp", - command = "release-all", -) diff --git a/bazel/apple/apple_targets.bzl b/bazel/apple/apple_targets.bzl deleted file mode 100644 index cedaf47..0000000 --- a/bazel/apple/apple_targets.bzl +++ /dev/null @@ -1,13 +0,0 @@ -def apple_bazel_stamp(name, command): - native.genrule( - name = name, - outs = [name + ".stamp"], - cmd = "$(location :runner) %s && : > \"$@\"" % command, - srcs = ["//:apple_project_files"], - tools = [":runner"], - # The Xcode script stages release artifacts under dist/builds as a - # deliberate CI side effect; keep the wrapper uncached so the staging - # step always runs in fresh and reused workspaces. - tags = ["no-cache", "no-remote-cache", "no-sandbox"], - target_compatible_with = ["@platforms//os:macos"], - ) diff --git a/bazel/apple/runner.sh b/bazel/apple/runner.sh deleted file mode 100755 index cdf272a..0000000 --- a/bazel/apple/runner.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -command="${1:-}" - -resolve_repo_root() { - if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" && -f "${BUILD_WORKSPACE_DIRECTORY}/MODULE.bazel" ]]; then - printf '%s\n' "$BUILD_WORKSPACE_DIRECTORY" - return 0 - fi - - local dir="$PWD" - while [[ "$dir" != "/" ]]; do - if [[ -f "$dir/MODULE.bazel" && -d "$dir/Apple" ]]; then - printf '%s\n' "$dir" - return 0 - fi - dir="$(dirname "$dir")" - done - - echo "unable to resolve Burrow repository root" >&2 - return 1 -} - -repo_root="$(resolve_repo_root)" -cd "$repo_root" - -case "$command" in - release-ios) - Scripts/ci/package-apple-artifacts.sh ios - ;; - release-macos) - Scripts/ci/package-apple-artifacts.sh macos - ;; - release-all) - Scripts/ci/package-apple-artifacts.sh all - ;; - *) - echo "unknown apple runner command: ${command}" >&2 - exit 64 - ;; -esac diff --git a/burrow-gtk/Cargo.toml b/burrow-gtk/Cargo.toml index b12577a..21cb52e 100644 --- a/burrow-gtk/Cargo.toml +++ b/burrow-gtk/Cargo.toml @@ -11,8 +11,6 @@ relm4 = { version = "0.6", features = ["libadwaita", "gnome_44"]} burrow = { version = "*", path = "../burrow/" } tokio = { version = "1.35.0", features = ["time", "sync"] } gettext-rs = { version = "0.7.0", features = ["gettext-system"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" [build-dependencies] anyhow = "1.0" diff --git a/burrow-gtk/data/icons/hicolor/128/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/128/apps/burrow-gtk.png deleted file mode 100644 index 54b67da..0000000 Binary files a/burrow-gtk/data/icons/hicolor/128/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/16/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/16/apps/burrow-gtk.png deleted file mode 100644 index cf418ae..0000000 Binary files a/burrow-gtk/data/icons/hicolor/16/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/24/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/24/apps/burrow-gtk.png deleted file mode 100644 index cfe8e33..0000000 Binary files a/burrow-gtk/data/icons/hicolor/24/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/256/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/256/apps/burrow-gtk.png deleted file mode 100644 index 561abcb..0000000 Binary files a/burrow-gtk/data/icons/hicolor/256/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/32/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/32/apps/burrow-gtk.png deleted file mode 100644 index 52e7a25..0000000 Binary files a/burrow-gtk/data/icons/hicolor/32/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/48/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/48/apps/burrow-gtk.png deleted file mode 100644 index 68cf88b..0000000 Binary files a/burrow-gtk/data/icons/hicolor/48/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/512/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/512/apps/burrow-gtk.png deleted file mode 100644 index c5997ae..0000000 Binary files a/burrow-gtk/data/icons/hicolor/512/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/64/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/64/apps/burrow-gtk.png deleted file mode 100644 index 46872b4..0000000 Binary files a/burrow-gtk/data/icons/hicolor/64/apps/burrow-gtk.png and /dev/null differ diff --git a/burrow-gtk/data/icons/hicolor/scalable/apps/burrow-gtk.svg b/burrow-gtk/data/icons/hicolor/scalable/apps/burrow-gtk.svg new file mode 100644 index 0000000..a74c4df --- /dev/null +++ b/burrow-gtk/data/icons/hicolor/scalable/apps/burrow-gtk.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + application-x-executable + + + + + + + + + + + + + + + + diff --git a/burrow-gtk/data/icons/hicolor/symbolic/apps/burrow-gtk-symbolic.svg b/burrow-gtk/data/icons/hicolor/symbolic/apps/burrow-gtk-symbolic.svg index 66ca58d..5352e0a 100644 --- a/burrow-gtk/data/icons/hicolor/symbolic/apps/burrow-gtk-symbolic.svg +++ b/burrow-gtk/data/icons/hicolor/symbolic/apps/burrow-gtk-symbolic.svg @@ -1,11 +1 @@ - - - - - - - - - - - + diff --git a/burrow-gtk/data/meson.build b/burrow-gtk/data/meson.build index 4abb3c5..2c3ffd8 100644 --- a/burrow-gtk/data/meson.build +++ b/burrow-gtk/data/meson.build @@ -77,14 +77,11 @@ if appstream_util.found() ) endif -foreach size : ['16', '24', '32', '48', '64', '128', '256', '512'] - size_dir = size + 'x' + size - install_data( - 'icons/hicolor/' + size + '/apps/' + app_name + '.png', - install_dir: datadir / 'icons' / 'hicolor' / size_dir / 'apps', - rename: app_id + '.png', - ) -endforeach +install_data( + 'icons/hicolor/scalable/apps/' + app_name + '.svg', + install_dir: datadir / 'icons' / 'hicolor' / 'scalable' / 'apps', + rename: app_id + '.svg', +) install_data( 'icons/hicolor/symbolic/apps/' + app_name + '-symbolic.svg', diff --git a/burrow-gtk/src/account_store.rs b/burrow-gtk/src/account_store.rs deleted file mode 100644 index 6aee78b..0000000 --- a/burrow-gtk/src/account_store.rs +++ /dev/null @@ -1,139 +0,0 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::{ - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, -}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountRecord { - pub id: String, - pub kind: AccountKind, - pub title: String, - pub authority: Option, - pub account: String, - pub identity: String, - pub hostname: Option, - pub tailnet: Option, - pub note: Option, - pub created_at: u64, - pub updated_at: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AccountKind { - WireGuard, - Tor, - Tailnet, -} - -impl AccountKind { - pub fn title(self) -> &'static str { - match self { - Self::WireGuard => "WireGuard", - Self::Tor => "Tor", - Self::Tailnet => "Tailnet", - } - } - - fn sort_rank(self) -> u8 { - match self { - Self::Tailnet => 0, - Self::Tor => 1, - Self::WireGuard => 2, - } - } -} - -pub fn load() -> Result> { - let path = storage_path()?; - if !path.exists() { - return Ok(Vec::new()); - } - let data = - std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; - serde_json::from_slice(&data).with_context(|| format!("failed to parse {}", path.display())) -} - -pub fn upsert(mut record: AccountRecord) -> Result> { - let mut accounts = load()?; - let now = timestamp(); - record.updated_at = now; - if record.created_at == 0 { - record.created_at = now; - } - - if let Some(index) = accounts.iter().position(|account| account.id == record.id) { - accounts[index] = record; - } else { - accounts.push(record); - } - accounts.sort_by(|lhs, rhs| { - lhs.kind - .sort_rank() - .cmp(&rhs.kind.sort_rank()) - .then_with(|| lhs.title.to_lowercase().cmp(&rhs.title.to_lowercase())) - }); - persist(&accounts)?; - Ok(accounts) -} - -pub fn new_record( - kind: AccountKind, - title: String, - authority: Option, - account: String, - identity: String, - hostname: Option, - tailnet: Option, - note: Option, -) -> AccountRecord { - let now = timestamp(); - AccountRecord { - id: format!("{}-{now}", kind.title().to_ascii_lowercase()), - kind, - title, - authority, - account, - identity, - hostname, - tailnet, - note, - created_at: now, - updated_at: now, - } -} - -fn persist(accounts: &[AccountRecord]) -> Result<()> { - let path = storage_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let data = serde_json::to_vec_pretty(accounts).context("failed to encode account store")?; - std::fs::write(&path, data).with_context(|| format!("failed to write {}", path.display())) -} - -fn storage_path() -> Result { - if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { - return Ok(PathBuf::from(data_home) - .join("burrow") - .join("accounts.json")); - } - if let Some(home) = std::env::var_os("HOME") { - return Ok(PathBuf::from(home) - .join(".local") - .join("share") - .join("burrow") - .join("accounts.json")); - } - Ok(std::env::temp_dir().join("burrow-accounts.json")) -} - -fn timestamp() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} diff --git a/burrow-gtk/src/components/app.rs b/burrow-gtk/src/components/app.rs index 7354825..62c98c0 100644 --- a/burrow-gtk/src/components/app.rs +++ b/burrow-gtk/src/components/app.rs @@ -1,19 +1,24 @@ use super::*; use anyhow::Context; +use std::time::Duration; + +const RECONNECT_POLL_TIME: Duration = Duration::from_secs(5); pub struct App { - _home_screen: AsyncController, + daemon_client: Arc>>, + settings_screen: Controller, + switch_screen: AsyncController, } #[derive(Debug)] pub enum AppMsg { None, + PostInit, } impl App { pub fn run() { let app = RelmApp::new(config::ID); - relm4::set_global_css(APP_CSS); Self::setup_gresources().unwrap(); Self::setup_i18n().unwrap(); @@ -44,7 +49,7 @@ impl AsyncComponent for App { view! { adw::Window { set_title: Some("Burrow"), - set_default_size: (900, 760), + set_default_size: (640, 480), } } @@ -53,84 +58,100 @@ impl AsyncComponent for App { root: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { - let home_screen = home_screen::HomeScreen::builder() - .launch(()) + let daemon_client = Arc::new(Mutex::new(DaemonClient::new().await.ok())); + + let switch_screen = switch_screen::SwitchScreen::builder() + .launch(switch_screen::SwitchScreenInit { + daemon_client: Arc::clone(&daemon_client), + }) + .forward(sender.input_sender(), |_| AppMsg::None); + + let settings_screen = settings_screen::SettingsScreen::builder() + .launch(settings_screen::SettingsScreenInit { + daemon_client: Arc::clone(&daemon_client), + }) .forward(sender.input_sender(), |_| AppMsg::None); let widgets = view_output!(); + let view_stack = adw::ViewStack::new(); + view_stack.add_titled(switch_screen.widget(), None, "Switch"); + view_stack.add_titled(settings_screen.widget(), None, "Settings"); + + let view_switcher_bar = adw::ViewSwitcherBar::builder().stack(&view_stack).build(); + view_switcher_bar.set_reveal(true); + + // When libadwaita 1.4 support becomes more avaliable, this approach is more appropriate + // + // let toolbar = adw::ToolbarView::new(); + // toolbar.add_top_bar( + // &adw::HeaderBar::builder() + // .title_widget(>k::Label::new(Some("Burrow"))) + // .build(), + // ); + // toolbar.add_bottom_bar(&view_switcher_bar); + // toolbar.set_content(Some(&view_stack)); + // root.set_content(Some(&toolbar)); + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.append( &adw::HeaderBar::builder() .title_widget(>k::Label::new(Some("Burrow"))) .build(), ); - content.append(home_screen.widget()); + content.append(&view_stack); + content.append(&view_switcher_bar); root.set_content(Some(&content)); - let model = App { _home_screen: home_screen }; + sender.input(AppMsg::PostInit); + + let model = App { + daemon_client, + switch_screen, + settings_screen, + }; AsyncComponentParts { model, widgets } } async fn update( &mut self, - msg: Self::Input, + _msg: Self::Input, _sender: AsyncComponentSender, _root: &Self::Root, ) { - match msg { - AppMsg::None => {} + loop { + tokio::time::sleep(RECONNECT_POLL_TIME).await; + { + let mut daemon_client = self.daemon_client.lock().await; + let mut disconnected_daemon_client = false; + + if let Some(daemon_client) = daemon_client.as_mut() { + if let Err(_e) = daemon_client.send_command(DaemonCommand::ServerInfo).await { + disconnected_daemon_client = true; + self.switch_screen + .emit(switch_screen::SwitchScreenMsg::DaemonDisconnect); + self.settings_screen + .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) + } + } + + if disconnected_daemon_client || daemon_client.is_none() { + match DaemonClient::new().await { + Ok(new_daemon_client) => { + *daemon_client = Some(new_daemon_client); + self.switch_screen + .emit(switch_screen::SwitchScreenMsg::DaemonReconnect); + self.settings_screen + .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) + } + Err(_e) => { + // TODO: Handle Error + } + } + } + } } } } - -const APP_CSS: &str = r#" -.empty-state { - border-radius: 18px; - padding: 22px; - background: alpha(@card_bg_color, 0.72); -} - -.summary-card { - border-radius: 18px; - padding: 14px; - background: alpha(@card_bg_color, 0.72); -} - -.network-card { - border-radius: 10px; - padding: 16px; - box-shadow: 0 2px 6px alpha(black, 0.14); -} - -.wireguard-card { - background: linear-gradient(135deg, #3277d8, #174ea6); -} - -.tailnet-card { - background: linear-gradient(135deg, #31b891, #147d69); -} - -.network-card-kind, -.network-card-title, -.network-card-detail { - color: white; -} - -.network-card-kind { - opacity: 0.86; - font-weight: 700; -} - -.network-card-title { - font-size: 1.22em; - font-weight: 700; -} - -.network-card-detail { - opacity: 0.92; - font-family: monospace; -} -"#; diff --git a/burrow-gtk/src/components/home_screen.rs b/burrow-gtk/src/components/home_screen.rs deleted file mode 100644 index 0bfdda2..0000000 --- a/burrow-gtk/src/components/home_screen.rs +++ /dev/null @@ -1,1178 +0,0 @@ -use super::*; -use crate::account_store::{self, AccountKind, AccountRecord}; -use std::time::Duration; - -pub struct HomeScreen { - daemon_banner: adw::Banner, - network_status: gtk::Label, - network_cards: gtk::Box, - account_status: gtk::Label, - account_rows: gtk::Box, - tunnel_status: gtk::Label, - tunnel_button: gtk::Button, - tunnel_state: Option, - tailnet_session_id: Option, - tailnet_running: bool, -} - -#[derive(Debug)] -pub enum HomeScreenMsg { - EnsureDaemon, - Refresh, - TunnelAction, - OpenWireGuard, - OpenTor, - OpenTailnet, - AddWireGuard { - title: String, - account: String, - identity: String, - config: String, - }, - SaveTor { - title: String, - account: String, - identity: String, - note: String, - }, - DiscoverTailnet(String), - ProbeTailnet(String), - StartTailnetLogin { - authority: String, - account: String, - identity: String, - hostname: Option, - }, - PollTailnetLogin, - CancelTailnetLogin, - AddTailnet { - authority: String, - account: String, - identity: String, - hostname: Option, - tailnet: Option, - }, -} - -#[relm4::component(pub, async)] -impl AsyncComponent for HomeScreen { - type Init = (); - type Input = HomeScreenMsg; - type Output = (); - type CommandOutput = (); - - view! { - gtk::ScrolledWindow { - set_vexpand: true, - - adw::Clamp { - set_maximum_size: 900, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 24, - set_margin_all: 24, - - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 16, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 6, - set_hexpand: true, - - gtk::Label { - add_css_class: "title-1", - set_xalign: 0.0, - set_label: "Burrow", - }, - - gtk::Label { - add_css_class: "heading", - add_css_class: "dim-label", - set_xalign: 0.0, - set_label: "Networks and accounts", - }, - }, - - #[name(add_button)] - gtk::MenuButton { - add_css_class: "flat", - set_icon_name: "list-add-symbolic", - set_tooltip_text: Some("Add"), - set_valign: Align::Start, - }, - }, - - #[name(daemon_banner)] - adw::Banner { - set_title: "Starting Burrow daemon", - set_revealed: false, - }, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 12, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 4, - - gtk::Label { - add_css_class: "title-2", - set_xalign: 0.0, - set_label: "Networks", - }, - - #[name(network_status)] - gtk::Label { - add_css_class: "dim-label", - set_xalign: 0.0, - set_wrap: true, - set_label: "Stored daemon networks and their active account selectors", - }, - }, - - gtk::ScrolledWindow { - set_policy: (gtk::PolicyType::Automatic, gtk::PolicyType::Never), - set_min_content_height: 190, - - #[name(network_cards)] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 14, - }, - }, - }, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 12, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 4, - - gtk::Label { - add_css_class: "title-2", - set_xalign: 0.0, - set_label: "Accounts", - }, - - gtk::Label { - add_css_class: "dim-label", - set_xalign: 0.0, - set_wrap: true, - set_label: "Per-network identities and sign-in state", - }, - }, - - #[name(account_rows)] - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 8, - set_margin_all: 0, - set_valign: Align::Center, - }, - - #[name(account_status)] - gtk::Label { - add_css_class: "dim-label", - set_xalign: 0.0, - set_wrap: true, - set_label: "", - }, - }, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 8, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 4, - - gtk::Label { - add_css_class: "title-2", - set_xalign: 0.0, - set_label: "Tunnel", - }, - - gtk::Label { - add_css_class: "dim-label", - set_xalign: 0.0, - set_label: "Current daemon tunnel state", - }, - }, - - #[name(tunnel_status)] - gtk::Label { - set_xalign: 0.0, - set_label: "Checking daemon status", - }, - - #[name(tunnel_button)] - gtk::Button { - add_css_class: "suggested-action", - set_label: "Start", - set_halign: Align::Start, - connect_clicked => HomeScreenMsg::TunnelAction, - }, - }, - } - } - } - } - - async fn init( - _: Self::Init, - _root: Self::Root, - sender: AsyncComponentSender, - ) -> AsyncComponentParts { - let widgets = view_output!(); - configure_add_popover(&widgets.add_button, &sender); - - let refresh_sender = sender.input_sender().clone(); - relm4::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(5)).await; - refresh_sender.emit(HomeScreenMsg::Refresh); - } - }); - - let model = HomeScreen { - daemon_banner: widgets.daemon_banner.clone(), - network_status: widgets.network_status.clone(), - network_cards: widgets.network_cards.clone(), - account_status: widgets.account_status.clone(), - account_rows: widgets.account_rows.clone(), - tunnel_status: widgets.tunnel_status.clone(), - tunnel_button: widgets.tunnel_button.clone(), - tunnel_state: None, - tailnet_session_id: None, - tailnet_running: false, - }; - - sender.input(HomeScreenMsg::EnsureDaemon); - - AsyncComponentParts { model, widgets } - } - - async fn update( - &mut self, - msg: Self::Input, - sender: AsyncComponentSender, - root: &Self::Root, - ) { - match msg { - HomeScreenMsg::EnsureDaemon => self.ensure_daemon().await, - HomeScreenMsg::Refresh => self.refresh().await, - HomeScreenMsg::TunnelAction => self.perform_tunnel_action().await, - HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender), - HomeScreenMsg::OpenTor => open_tor_window(root, &sender), - HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender), - HomeScreenMsg::AddWireGuard { - title, - account, - identity, - config, - } => self.add_wireguard(title, account, identity, config).await, - HomeScreenMsg::SaveTor { title, account, identity, note } => { - self.save_tor(title, account, identity, note) - } - HomeScreenMsg::DiscoverTailnet(email) => self.discover_tailnet(email).await, - HomeScreenMsg::ProbeTailnet(authority) => self.probe_tailnet(authority).await, - HomeScreenMsg::StartTailnetLogin { - authority, - account, - identity, - hostname, - } => { - self.start_tailnet_login(authority, account, identity, hostname, sender) - .await; - } - HomeScreenMsg::PollTailnetLogin => self.poll_tailnet_login(sender).await, - HomeScreenMsg::CancelTailnetLogin => self.cancel_tailnet_login().await, - HomeScreenMsg::AddTailnet { - authority, - account, - identity, - hostname, - tailnet, - } => { - self.add_tailnet(authority, account, identity, hostname, tailnet) - .await; - } - } - } -} - -impl HomeScreen { - async fn ensure_daemon(&mut self) { - self.daemon_banner.set_title("Starting Burrow daemon"); - self.daemon_banner.set_revealed(true); - match daemon_api::ensure_daemon().await { - Ok(()) => { - self.daemon_banner.set_revealed(false); - self.refresh().await; - } - Err(error) => { - self.daemon_banner - .set_title(&format!("Burrow daemon is not reachable: {error}")); - self.daemon_banner.set_revealed(true); - self.tunnel_state = None; - self.tunnel_status.set_label("Daemon unavailable"); - self.tunnel_button.set_label("Enable"); - self.tunnel_button.set_sensitive(true); - self.network_status - .set_label("Stored daemon networks are unavailable until the daemon starts."); - self.render_networks(&[]); - } - } - } - - async fn refresh(&mut self) { - match daemon_api::tunnel_state().await { - Ok(state) => { - self.daemon_banner.set_revealed(false); - self.tunnel_state = Some(state); - match state { - daemon_api::TunnelState::Running => { - self.tunnel_status.set_label("Connected"); - self.tunnel_button.set_label("Stop"); - } - daemon_api::TunnelState::Stopped => { - self.tunnel_status.set_label("Disconnected"); - self.tunnel_button.set_label("Start"); - } - } - self.tunnel_button.set_sensitive(true); - } - Err(error) => { - self.tunnel_state = None; - self.daemon_banner - .set_title(&format!("Burrow daemon is not reachable: {error}")); - self.daemon_banner.set_revealed(true); - self.tunnel_status.set_label("Unknown"); - self.tunnel_button.set_label("Enable"); - self.tunnel_button.set_sensitive(true); - } - } - - match daemon_api::list_networks().await { - Ok(networks) => { - self.render_networks(&networks); - self.network_status.set_label(if networks.is_empty() { - "Stored daemon networks and their active account selectors" - } else { - "Stored daemon networks and their active account selectors" - }); - } - Err(error) => { - self.render_networks(&[]); - self.network_status - .set_label(&format!("Unable to read daemon networks: {error}")); - } - } - - match account_store::load() { - Ok(accounts) => { - self.account_status.set_label(""); - self.render_accounts(&accounts); - } - Err(error) => { - self.render_accounts(&[]); - self.account_status - .set_label(&format!("Unable to read account store: {error}")); - } - } - } - - async fn perform_tunnel_action(&mut self) { - match self.tunnel_state { - Some(daemon_api::TunnelState::Running) => { - self.tunnel_button.set_sensitive(false); - self.tunnel_status.set_label("Disconnecting..."); - if let Err(error) = daemon_api::stop_tunnel().await { - self.tunnel_status - .set_label(&format!("Stop failed: {error}")); - } - self.refresh().await; - } - Some(daemon_api::TunnelState::Stopped) => { - self.tunnel_button.set_sensitive(false); - self.tunnel_status.set_label("Connecting..."); - if let Err(error) = daemon_api::start_tunnel().await { - self.tunnel_status - .set_label(&format!("Start failed: {error}")); - } - self.refresh().await; - } - None => self.ensure_daemon().await, - } - } - - async fn add_wireguard( - &mut self, - title: String, - account: String, - identity: String, - config: String, - ) { - if config.trim().is_empty() { - self.network_status - .set_label("Paste a WireGuard configuration before adding a network."); - return; - } - match daemon_api::add_wireguard(config).await { - Ok(id) => { - let title = daemon_api::normalized(&title, &format!("WireGuard {id}")); - let record = account_store::new_record( - AccountKind::WireGuard, - title, - None, - daemon_api::normalized(&account, "default"), - daemon_api::normalized(&identity, &format!("network-{id}")), - None, - None, - Some(format!("Linked to daemon network #{id}.")), - ); - match account_store::upsert(record) { - Ok(accounts) => self.render_accounts(&accounts), - Err(error) => self - .account_status - .set_label(&format!("WireGuard account save failed: {error}")), - } - self.network_status - .set_label(&format!("Added WireGuard network #{id}.")); - self.refresh().await; - } - Err(error) => self - .network_status - .set_label(&format!("Unable to add WireGuard network: {error}")), - } - } - - fn save_tor(&mut self, title: String, account: String, identity: String, note: String) { - let record = account_store::new_record( - AccountKind::Tor, - daemon_api::normalized( - &title, - &format!("Tor {}", daemon_api::normalized(&identity, "linux")), - ), - Some("arti://local".to_owned()), - daemon_api::normalized(&account, "default"), - daemon_api::normalized(&identity, "linux"), - None, - None, - Some(note), - ); - match account_store::upsert(record) { - Ok(accounts) => { - self.account_status.set_label("Saved Tor account."); - self.render_accounts(&accounts); - } - Err(error) => self - .account_status - .set_label(&format!("Unable to save Tor account: {error}")), - } - } - - async fn discover_tailnet(&mut self, email: String) { - let Ok(email) = daemon_api::require_value(&email, "Email address") else { - self.account_status - .set_label("Enter an email address before Tailnet discovery."); - return; - }; - - self.account_status.set_label("Finding Tailnet server..."); - match daemon_api::discover_tailnet(email).await { - Ok(discovery) => { - let kind = if discovery.managed { - "managed authority" - } else { - "custom authority" - }; - let issuer = discovery - .oidc_issuer - .map(|issuer| format!(" OIDC: {issuer}.")) - .unwrap_or_default(); - self.account_status.set_label(&format!( - "Discovered {kind}: {}.{issuer}", - discovery.authority - )); - } - Err(error) => self - .account_status - .set_label(&format!("Tailnet discovery failed: {error}")), - } - } - - async fn probe_tailnet(&mut self, authority: String) { - let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { - self.account_status - .set_label("Enter a Tailnet server URL before checking it."); - return; - }; - - self.account_status.set_label("Checking Tailnet server..."); - match daemon_api::probe_tailnet(authority).await { - Ok(probe) => { - let detail = probe - .detail - .unwrap_or_else(|| format!("HTTP {}", probe.status_code)); - self.account_status - .set_label(&format!("{}: {detail}", probe.summary)); - } - Err(error) => self - .account_status - .set_label(&format!("Tailnet probe failed: {error}")), - } - } - - async fn start_tailnet_login( - &mut self, - authority: String, - account: String, - identity: String, - hostname: Option, - sender: AsyncComponentSender, - ) { - let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { - self.account_status - .set_label("Enter a Tailnet server URL before sign-in."); - return; - }; - - self.account_status.set_label("Starting Tailnet sign-in..."); - match daemon_api::start_tailnet_login(authority, account, identity, hostname).await { - Ok(status) => { - self.apply_login_status(&status); - if let Some(auth_url) = status.auth_url.as_deref() { - if let Err(error) = open_auth_url(auth_url) { - self.account_status.set_label(&format!( - "{} Open this URL manually: {auth_url}. Browser launch failed: {error}", - self.account_status.text() - )); - } - } - if !status.running { - sender.input(HomeScreenMsg::PollTailnetLogin); - } - } - Err(error) => self - .account_status - .set_label(&format!("Tailnet sign-in failed: {error}")), - } - } - - async fn poll_tailnet_login(&mut self, sender: AsyncComponentSender) { - let Some(session_id) = self.tailnet_session_id.clone() else { - return; - }; - if self.tailnet_running { - return; - } - - tokio::time::sleep(Duration::from_secs(1)).await; - match daemon_api::tailnet_login_status(session_id).await { - Ok(status) => { - self.apply_login_status(&status); - if !status.running { - sender.input(HomeScreenMsg::PollTailnetLogin); - } - } - Err(error) => { - self.account_status - .set_label(&format!("Tailnet sign-in status failed: {error}")); - self.tailnet_session_id = None; - } - } - } - - async fn cancel_tailnet_login(&mut self) { - let Some(session_id) = self.tailnet_session_id.clone() else { - self.account_status - .set_label("No Tailnet sign-in is active."); - return; - }; - match daemon_api::cancel_tailnet_login(session_id).await { - Ok(()) => { - self.tailnet_session_id = None; - self.tailnet_running = false; - self.account_status.set_label("Tailnet sign-in cancelled."); - } - Err(error) => self - .account_status - .set_label(&format!("Unable to cancel Tailnet sign-in: {error}")), - } - } - - async fn add_tailnet( - &mut self, - authority: String, - account: String, - identity: String, - hostname: Option, - tailnet: Option, - ) { - let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { - self.account_status - .set_label("Enter a Tailnet server URL before saving."); - return; - }; - if self.tailnet_session_id.is_some() && !self.tailnet_running { - self.account_status - .set_label("Finish browser sign-in before saving this Tailnet account."); - return; - } - - let stored_authority = daemon_api::normalized_optional(&authority) - .unwrap_or_else(|| daemon_api::default_tailnet_authority().to_owned()); - let stored_account = daemon_api::normalized(&account, "default"); - let stored_identity = daemon_api::normalized(&identity, "linux"); - let stored_hostname = hostname.clone(); - let stored_tailnet = tailnet.clone(); - - match daemon_api::add_tailnet(authority, account, identity, hostname, tailnet).await { - Ok(id) => { - let title = stored_tailnet - .clone() - .or(stored_hostname.clone()) - .unwrap_or_else(|| format!("Tailnet {id}")); - let record = account_store::new_record( - AccountKind::Tailnet, - title, - Some(stored_authority), - stored_account, - stored_identity, - stored_hostname, - stored_tailnet, - Some(format!("Linked to daemon network #{id}.")), - ); - match account_store::upsert(record) { - Ok(accounts) => self.render_accounts(&accounts), - Err(error) => self - .account_status - .set_label(&format!("Tailnet account save failed: {error}")), - } - self.account_status - .set_label(&format!("Saved Tailnet account and network #{id}.")); - self.refresh().await; - } - Err(error) => self - .account_status - .set_label(&format!("Unable to save Tailnet account: {error}")), - } - } - - fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) { - self.tailnet_session_id = Some(status.session_id.clone()); - self.tailnet_running = status.running; - - let mut parts = Vec::new(); - if status.running { - parts.push("Signed In".to_owned()); - } else if status.needs_login { - parts.push("Browser Sign-In Required".to_owned()); - } else { - parts.push("Checking Sign-In".to_owned()); - } - if !status.backend_state.is_empty() { - parts.push(format!("State: {}", status.backend_state)); - } - if let Some(tailnet_name) = &status.tailnet_name { - parts.push(format!("Tailnet: {tailnet_name}")); - } - if let Some(self_dns_name) = &status.self_dns_name { - parts.push(self_dns_name.clone()); - } - if !status.tailnet_ips.is_empty() { - parts.push(status.tailnet_ips.join(", ")); - } - if !status.health.is_empty() { - parts.push(status.health.join(" / ")); - } - self.account_status.set_label(&parts.join("\n")); - } - - fn render_networks(&self, networks: &[daemon_api::NetworkSummary]) { - while let Some(child) = self.network_cards.first_child() { - self.network_cards.remove(&child); - } - - if networks.is_empty() { - self.network_cards.append(&empty_networks_view()); - return; - } - - for network in networks { - self.network_cards.append(&network_card(network)); - } - } - - fn render_accounts(&self, accounts: &[AccountRecord]) { - while let Some(child) = self.account_rows.first_child() { - self.account_rows.remove(&child); - } - - if accounts.is_empty() { - self.account_rows.append(&empty_accounts_view()); - return; - } - - for account in accounts { - self.account_rows.append(&account_card(account)); - } - } -} - -fn configure_add_popover(button: >k::MenuButton, sender: &AsyncComponentSender) { - let popover = gtk::Popover::new(); - let box_ = gtk::Box::new(gtk::Orientation::Vertical, 4); - box_.set_margin_all(6); - - for (label, msg) in [ - ("Add WireGuard Network", HomeScreenMsg::OpenWireGuard), - ("Save Tor Account", HomeScreenMsg::OpenTor), - ("Add Tailnet Account", HomeScreenMsg::OpenTailnet), - ] { - let item = gtk::Button::with_label(label); - item.add_css_class("flat"); - item.set_halign(Align::Fill); - let input = sender.input_sender().clone(); - item.connect_clicked(move |_| input.emit(msg_from_template(&msg))); - box_.append(&item); - } - - popover.set_child(Some(&box_)); - button.set_popover(Some(&popover)); -} - -fn msg_from_template(msg: &HomeScreenMsg) -> HomeScreenMsg { - match msg { - HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard, - HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor, - HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet, - _ => unreachable!(), - } -} - -fn network_card(network: &daemon_api::NetworkSummary) -> gtk::Box { - let card = gtk::Box::new(gtk::Orientation::Vertical, 10); - card.add_css_class("network-card"); - if network.title.to_ascii_lowercase().contains("wireguard") { - card.add_css_class("wireguard-card"); - } else { - card.add_css_class("tailnet-card"); - } - card.set_size_request(360, 175); - card.set_margin_bottom(8); - - let kind = if network.title.to_ascii_lowercase().contains("wireguard") { - "WireGuard" - } else { - "Tailnet" - }; - let kind_label = gtk::Label::new(Some(kind)); - kind_label.add_css_class("network-card-kind"); - kind_label.set_xalign(0.0); - - let title = gtk::Label::new(Some(&network.title)); - title.add_css_class("network-card-title"); - title.set_xalign(0.0); - title.set_wrap(true); - - let spacer = gtk::Box::new(gtk::Orientation::Vertical, 0); - spacer.set_vexpand(true); - - let detail = gtk::Label::new(Some(&network.detail)); - detail.add_css_class("network-card-detail"); - detail.set_xalign(0.0); - detail.set_wrap(true); - detail.set_lines(4); - - card.append(&kind_label); - card.append(&title); - card.append(&spacer); - card.append(&detail); - card -} - -fn empty_networks_view() -> gtk::Box { - let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); - box_.add_css_class("empty-state"); - box_.set_size_request(520, 175); - box_.set_hexpand(true); - - let title = gtk::Label::new(Some("No Networks Yet")); - title.add_css_class("title-3"); - title.set_xalign(0.0); - let detail = gtk::Label::new(Some( - "Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.", - )); - detail.add_css_class("dim-label"); - detail.set_wrap(true); - detail.set_xalign(0.0); - - box_.append(&title); - box_.append(&detail); - box_ -} - -fn empty_accounts_view() -> gtk::Box { - let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); - box_.add_css_class("empty-state"); - box_.set_hexpand(true); - - let title = gtk::Label::new(Some("No Accounts Yet")); - title.add_css_class("title-3"); - title.set_justify(gtk::Justification::Center); - let detail = gtk::Label::new(Some( - "Save a Tor account or sign in to Tailnet to keep network identities ready on this device.", - )); - detail.add_css_class("dim-label"); - detail.set_wrap(true); - detail.set_justify(gtk::Justification::Center); - - box_.append(&title); - box_.append(&detail); - box_ -} - -fn account_card(account: &AccountRecord) -> gtk::Box { - let card = gtk::Box::new(gtk::Orientation::Vertical, 8); - card.add_css_class("summary-card"); - card.set_hexpand(true); - - let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let title = gtk::Label::new(Some(&account.title)); - title.add_css_class("title-3"); - title.set_xalign(0.0); - title.set_hexpand(true); - let kind = gtk::Label::new(Some(account.kind.title())); - kind.add_css_class("dim-label"); - header.append(&title); - header.append(&kind); - card.append(&header); - - append_account_value(&card, "Account", &account.account); - append_account_value(&card, "Identity", &account.identity); - if let Some(authority) = &account.authority { - append_account_value(&card, "Authority", authority); - } - if let Some(hostname) = &account.hostname { - append_account_value(&card, "Hostname", hostname); - } - if let Some(tailnet) = &account.tailnet { - append_account_value(&card, "Tailnet", tailnet); - } - if let Some(note) = &account.note { - let note_label = gtk::Label::new(Some(note)); - note_label.add_css_class("dim-label"); - note_label.set_wrap(true); - note_label.set_xalign(0.0); - card.append(¬e_label); - } - - card -} - -fn append_account_value(card: >k::Box, label: &str, value: &str) { - let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let key = gtk::Label::new(Some(label)); - key.add_css_class("dim-label"); - key.set_xalign(0.0); - key.set_width_chars(9); - let value = gtk::Label::new(Some(value)); - value.set_xalign(0.0); - value.set_wrap(true); - value.set_hexpand(true); - row.append(&key); - row.append(&value); - card.append(&row); -} - -fn open_wireguard_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { - let window = sheet_window(root, "WireGuard", 560, 620); - let content = sheet_content( - &window, - "Import WireGuard", - "Import a tunnel and optional account metadata.", - ); - - let title = gtk::Entry::new(); - title.set_placeholder_text(Some("Title")); - let account = gtk::Entry::new(); - account.set_placeholder_text(Some("Account")); - let identity = gtk::Entry::new(); - identity.set_placeholder_text(Some("Identity")); - let text = gtk::TextView::new(); - text.set_monospace(true); - text.set_wrap_mode(gtk::WrapMode::WordChar); - - let editor = gtk::ScrolledWindow::new(); - editor.set_min_content_height(220); - editor.set_child(Some(&text)); - - content.append(§ion_label("Identity")); - content.append(&title); - content.append(&account); - content.append(&identity); - content.append(§ion_label("WireGuard Configuration")); - content.append(&editor); - - let add = gtk::Button::with_label("Add Network"); - add.add_css_class("suggested-action"); - let input = sender.input_sender().clone(); - let window_for_click = window.clone(); - add.connect_clicked(move |_| { - input.emit(HomeScreenMsg::AddWireGuard { - title: title.text().to_string(), - account: account.text().to_string(), - identity: identity.text().to_string(), - config: text_view_text(&text), - }); - window_for_click.close(); - }); - content.append(&add); - - window.set_child(Some(&content)); - window.present(); -} - -fn open_tor_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { - let window = sheet_window(root, "Tor", 520, 540); - let content = sheet_content( - &window, - "Configure Tor", - "Store Arti account and identity preferences.", - ); - - let title = entry_with_text("Title", "Default Tor"); - let account = entry_with_text("Account", "default"); - let identity = entry_with_text("Identity", "linux"); - let addresses = entry_with_text("Virtual Addresses", "100.64.0.2/32"); - let dns = entry_with_text("DNS Resolvers", "1.1.1.1, 1.0.0.1"); - let mtu = entry_with_text("MTU", "1400"); - let listen = entry_with_text("Transparent Listener", "127.0.0.1:9040"); - - content.append(§ion_label("Identity")); - content.append(&title); - content.append(&account); - content.append(&identity); - content.append(§ion_label("Tor Preferences")); - content.append(&addresses); - content.append(&dns); - content.append(&mtu); - content.append(&listen); - - let save = gtk::Button::with_label("Save Account"); - save.add_css_class("suggested-action"); - let input = sender.input_sender().clone(); - let window_for_click = window.clone(); - save.connect_clicked(move |_| { - let note = [ - format!( - "Addresses: {}", - normalized_entry(&addresses, "100.64.0.2/32") - ), - format!("DNS: {}", normalized_entry(&dns, "1.1.1.1, 1.0.0.1")), - format!("MTU: {}", normalized_entry(&mtu, "1400")), - format!("Listen: {}", normalized_entry(&listen, "127.0.0.1:9040")), - ] - .join(" - "); - input.emit(HomeScreenMsg::SaveTor { - title: normalized_entry(&title, "Default Tor"), - account: normalized_entry(&account, "default"), - identity: normalized_entry(&identity, "linux"), - note, - }); - window_for_click.close(); - }); - content.append(&save); - - window.set_child(Some(&content)); - window.present(); -} - -fn open_tailnet_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { - let window = sheet_window(root, "Tailnet", 560, 680); - let content = sheet_content( - &window, - "Connect Tailnet", - "Save Tailnet authority, identity defaults, and login material.", - ); - - let email = gtk::Entry::new(); - email.set_placeholder_text(Some("Email address")); - let authority = entry_with_text("Server URL", daemon_api::default_tailnet_authority()); - let tailnet = gtk::Entry::new(); - tailnet.set_placeholder_text(Some("Tailnet")); - let account = entry_with_text("Account", "default"); - let identity = entry_with_text("Identity", "linux"); - let hostname = entry_with_text("Hostname", &hostname_fallback()); - - content.append(§ion_label("Connection")); - content.append(&email); - content.append(&authority); - content.append(&tailnet); - content.append(§ion_label("Identity")); - content.append(&account); - content.append(&identity); - content.append(&hostname); - - let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let discover = gtk::Button::with_label("Refresh Server Lookup"); - let probe = gtk::Button::with_label("Check Server"); - let sign_in = gtk::Button::with_label("Start Sign-In"); - actions.append(&discover); - actions.append(&probe); - actions.append(&sign_in); - content.append(§ion_label("Authentication")); - content.append(&actions); - - let input = sender.input_sender().clone(); - let email_for_click = email.clone(); - discover.connect_clicked(move |_| { - input.emit(HomeScreenMsg::DiscoverTailnet( - email_for_click.text().to_string(), - )); - }); - - let input = sender.input_sender().clone(); - let authority_for_probe = authority.clone(); - probe.connect_clicked(move |_| { - input.emit(HomeScreenMsg::ProbeTailnet( - authority_for_probe.text().to_string(), - )); - }); - - let input = sender.input_sender().clone(); - let authority_for_login = authority.clone(); - let account_for_login = account.clone(); - let identity_for_login = identity.clone(); - let hostname_for_login = hostname.clone(); - sign_in.connect_clicked(move |_| { - input.emit(HomeScreenMsg::StartTailnetLogin { - authority: authority_for_login.text().to_string(), - account: normalized_entry(&account_for_login, "default"), - identity: normalized_entry(&identity_for_login, "linux"), - hostname: daemon_api::normalized_optional(&hostname_for_login.text()), - }); - }); - - let save = gtk::Button::with_label("Save Account"); - save.add_css_class("suggested-action"); - let input = sender.input_sender().clone(); - let window_for_click = window.clone(); - save.connect_clicked(move |_| { - input.emit(HomeScreenMsg::AddTailnet { - authority: authority.text().to_string(), - account: normalized_entry(&account, "default"), - identity: normalized_entry(&identity, "linux"), - hostname: daemon_api::normalized_optional(&hostname.text()), - tailnet: daemon_api::normalized_optional(&tailnet.text()), - }); - window_for_click.close(); - }); - - let cancel = gtk::Button::with_label("Cancel Sign-In"); - let input = sender.input_sender().clone(); - cancel.connect_clicked(move |_| { - input.emit(HomeScreenMsg::CancelTailnetLogin); - }); - - content.append(&save); - content.append(&cancel); - - window.set_child(Some(&content)); - window.present(); -} - -fn sheet_window(root: >k::ScrolledWindow, title: &str, width: i32, height: i32) -> gtk::Window { - let window = gtk::Window::builder() - .title(title) - .default_width(width) - .default_height(height) - .modal(true) - .build(); - if let Some(root) = root.root() { - if let Ok(parent) = root.downcast::() { - window.set_transient_for(Some(&parent)); - } - } - window -} - -fn sheet_content(window: >k::Window, title: &str, detail: &str) -> gtk::Box { - let content = gtk::Box::new(gtk::Orientation::Vertical, 12); - content.set_margin_all(18); - - let summary = gtk::Box::new(gtk::Orientation::Horizontal, 12); - summary.add_css_class("summary-card"); - - let copy = gtk::Box::new(gtk::Orientation::Vertical, 4); - copy.set_hexpand(true); - - let title_label = gtk::Label::new(Some(title)); - title_label.add_css_class("title-3"); - title_label.set_xalign(0.0); - - let detail_label = gtk::Label::new(Some(detail)); - detail_label.add_css_class("dim-label"); - detail_label.set_wrap(true); - detail_label.set_xalign(0.0); - - copy.append(&title_label); - copy.append(&detail_label); - summary.append(©); - - let close = gtk::Button::builder() - .icon_name("window-close-symbolic") - .tooltip_text("Close") - .valign(Align::Start) - .build(); - close.add_css_class("flat"); - let window_for_click = window.clone(); - close.connect_clicked(move |_| window_for_click.close()); - summary.append(&close); - - content.append(&summary); - content -} - -fn section_label(label: &str) -> gtk::Label { - let section = gtk::Label::new(Some(label)); - section.add_css_class("heading"); - section.set_xalign(0.0); - section -} - -fn entry_with_text(placeholder: &str, value: &str) -> gtk::Entry { - let entry = gtk::Entry::new(); - entry.set_placeholder_text(Some(placeholder)); - entry.set_text(value); - entry -} - -fn normalized_entry(entry: >k::Entry, fallback: &str) -> String { - daemon_api::normalized(&entry.text(), fallback) -} - -fn hostname_fallback() -> String { - std::env::var("HOSTNAME").unwrap_or_else(|_| "linux".to_owned()) -} - -fn text_view_text(text_view: >k::TextView) -> String { - let buffer = text_view.buffer(); - buffer - .text(&buffer.start_iter(), &buffer.end_iter(), true) - .to_string() -} - -fn open_auth_url(url: &str) -> anyhow::Result<()> { - gtk::gio::AppInfo::launch_default_for_uri(url, None::<>k::gio::AppLaunchContext>) - .map_err(anyhow::Error::from) -} diff --git a/burrow-gtk/src/components/mod.rs b/burrow-gtk/src/components/mod.rs index 8e60fa7..b134809 100644 --- a/burrow-gtk/src/components/mod.rs +++ b/burrow-gtk/src/components/mod.rs @@ -1,6 +1,6 @@ use super::*; -use crate::daemon_api; use adw::prelude::*; +use burrow::{DaemonClient, DaemonCommand, DaemonResponseData}; use gtk::Align; use relm4::{ component::{ @@ -9,9 +9,13 @@ use relm4::{ }, prelude::*, }; +use std::sync::Arc; +use tokio::sync::Mutex; mod app; -mod home_screen; +mod settings; +mod settings_screen; +mod switch_screen; pub use app::*; -pub use home_screen::{HomeScreen, HomeScreenMsg}; +pub use settings::{DaemonGroupMsg, DiagGroupMsg}; diff --git a/burrow-gtk/src/components/networks_screen.rs b/burrow-gtk/src/components/networks_screen.rs deleted file mode 100644 index 21df982..0000000 --- a/burrow-gtk/src/components/networks_screen.rs +++ /dev/null @@ -1,515 +0,0 @@ -use super::*; -use anyhow::{Context, Result}; -use tokio::time::Duration; - -pub struct NetworksScreen { - list_box: gtk::ListBox, - network_status: gtk::Label, - wireguard_text: gtk::TextView, - tailnet_email_entry: gtk::Entry, - tailnet_authority_entry: gtk::Entry, - tailnet_account_entry: gtk::Entry, - tailnet_identity_entry: gtk::Entry, - tailnet_hostname_entry: gtk::Entry, - tailnet_name_entry: gtk::Entry, - custom_authority_switch: gtk::Switch, - tailnet_status: gtk::Label, - tailnet_session_id: Option, - tailnet_running: bool, -} - -#[derive(Debug)] -pub enum NetworksScreenMsg { - Refresh, - AddWireGuard, - DiscoverTailnet, - ProbeTailnet, - StartTailnetLogin, - PollTailnetLogin, - CancelTailnetLogin, - AddTailnet, -} - -#[relm4::component(pub, async)] -impl AsyncComponent for NetworksScreen { - type Init = (); - type Input = NetworksScreenMsg; - type Output = (); - type CommandOutput = (); - - view! { - gtk::ScrolledWindow { - set_vexpand: true, - - adw::Clamp { - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 12, - set_margin_all: 12, - - adw::PreferencesGroup { - set_title: "Stored Networks", - set_description: Some("Networks saved in the Burrow daemon"), - - #[name(network_status)] - gtk::Label { - set_xalign: 0.0, - set_wrap: true, - set_label: "Loading networks", - }, - - #[name(list_box)] - gtk::ListBox { - add_css_class: "boxed-list", - }, - - gtk::Button { - set_label: "Refresh", - connect_clicked => NetworksScreenMsg::Refresh - }, - }, - - adw::PreferencesGroup { - set_title: "Add WireGuard", - set_description: Some("Import a WireGuard configuration into the daemon"), - - gtk::ScrolledWindow { - set_min_content_height: 150, - set_vexpand: false, - - #[name(wireguard_text)] - gtk::TextView { - set_monospace: true, - set_wrap_mode: gtk::WrapMode::WordChar, - } - }, - - gtk::Button { - set_label: "Add WireGuard Network", - connect_clicked => NetworksScreenMsg::AddWireGuard - }, - }, - - adw::PreferencesGroup { - set_title: "Tailnet", - set_description: Some("Discover, probe, sign in, and save Tailnet authorities through the daemon"), - - #[name(tailnet_email_entry)] - gtk::Entry { - set_placeholder_text: Some("Email address"), - }, - - #[name(custom_authority_switch)] - gtk::Switch { - set_halign: Align::Start, - set_active: false, - }, - - #[name(tailnet_authority_entry)] - gtk::Entry { - set_placeholder_text: Some("Tailnet server URL"), - }, - - #[name(tailnet_account_entry)] - gtk::Entry { - set_placeholder_text: Some("Account name"), - }, - - #[name(tailnet_identity_entry)] - gtk::Entry { - set_placeholder_text: Some("Identity name"), - }, - - #[name(tailnet_hostname_entry)] - gtk::Entry { - set_placeholder_text: Some("Device hostname"), - }, - - #[name(tailnet_name_entry)] - gtk::Entry { - set_placeholder_text: Some("Tailnet name"), - }, - - #[name(tailnet_status)] - gtk::Label { - set_xalign: 0.0, - set_wrap: true, - set_selectable: true, - set_label: "Use discovery for the managed Tailscale authority or enter a custom Tailnet server.", - }, - - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 8, - - gtk::Button { - set_label: "Discover", - connect_clicked => NetworksScreenMsg::DiscoverTailnet - }, - - gtk::Button { - set_label: "Probe", - connect_clicked => NetworksScreenMsg::ProbeTailnet - }, - - gtk::Button { - set_label: "Sign In", - connect_clicked => NetworksScreenMsg::StartTailnetLogin - }, - - gtk::Button { - set_label: "Cancel", - connect_clicked => NetworksScreenMsg::CancelTailnetLogin - }, - }, - - gtk::Button { - set_label: "Save Tailnet Network", - connect_clicked => NetworksScreenMsg::AddTailnet - }, - }, - } - } - } - } - - async fn init( - _: Self::Init, - root: Self::Root, - sender: AsyncComponentSender, - ) -> AsyncComponentParts { - let widgets = view_output!(); - widgets - .tailnet_authority_entry - .set_text(daemon_api::default_tailnet_authority()); - widgets.tailnet_account_entry.set_text("default"); - widgets.tailnet_identity_entry.set_text("linux"); - - let mut model = NetworksScreen { - list_box: widgets.list_box.clone(), - network_status: widgets.network_status.clone(), - wireguard_text: widgets.wireguard_text.clone(), - tailnet_email_entry: widgets.tailnet_email_entry.clone(), - tailnet_authority_entry: widgets.tailnet_authority_entry.clone(), - tailnet_account_entry: widgets.tailnet_account_entry.clone(), - tailnet_identity_entry: widgets.tailnet_identity_entry.clone(), - tailnet_hostname_entry: widgets.tailnet_hostname_entry.clone(), - tailnet_name_entry: widgets.tailnet_name_entry.clone(), - custom_authority_switch: widgets.custom_authority_switch.clone(), - tailnet_status: widgets.tailnet_status.clone(), - tailnet_session_id: None, - tailnet_running: false, - }; - - model.refresh_networks().await; - - AsyncComponentParts { model, widgets } - } - - async fn update( - &mut self, - msg: Self::Input, - sender: AsyncComponentSender, - _root: &Self::Root, - ) { - match msg { - NetworksScreenMsg::Refresh => self.refresh_networks().await, - NetworksScreenMsg::AddWireGuard => self.add_wireguard().await, - NetworksScreenMsg::DiscoverTailnet => self.discover_tailnet().await, - NetworksScreenMsg::ProbeTailnet => self.probe_tailnet().await, - NetworksScreenMsg::StartTailnetLogin => self.start_tailnet_login(sender).await, - NetworksScreenMsg::PollTailnetLogin => self.poll_tailnet_login(sender).await, - NetworksScreenMsg::CancelTailnetLogin => self.cancel_tailnet_login().await, - NetworksScreenMsg::AddTailnet => self.add_tailnet().await, - } - } -} - -impl NetworksScreen { - async fn refresh_networks(&mut self) { - match daemon_api::list_networks().await { - Ok(networks) => { - self.render_networks(&networks); - self.network_status.set_label(if networks.is_empty() { - "No daemon networks saved yet" - } else { - "Daemon networks are up to date" - }); - } - Err(error) => { - self.clear_networks(); - self.network_status - .set_label(&format!("Unable to read daemon networks: {error}")); - } - } - } - - async fn add_wireguard(&mut self) { - let config = text_view_text(&self.wireguard_text); - if config.trim().is_empty() { - self.network_status - .set_label("Paste a WireGuard configuration before adding a network"); - return; - } - - match daemon_api::add_wireguard(config).await { - Ok(id) => { - self.network_status - .set_label(&format!("Added WireGuard network #{id}")); - self.wireguard_text.buffer().set_text(""); - self.refresh_networks().await; - } - Err(error) => self - .network_status - .set_label(&format!("Unable to add WireGuard network: {error}")), - } - } - - async fn discover_tailnet(&mut self) { - let email = self.tailnet_email_entry.text().to_string(); - let Ok(email) = daemon_api::require_value(&email, "Email address") else { - self.tailnet_status - .set_label("Enter an email address before discovery"); - return; - }; - - self.tailnet_status.set_label("Discovering Tailnet server"); - match daemon_api::discover_tailnet(email).await { - Ok(discovery) => { - self.tailnet_authority_entry.set_text(&discovery.authority); - self.custom_authority_switch.set_active(!discovery.managed); - let issuer = discovery - .oidc_issuer - .map(|issuer| format!(" OIDC issuer: {issuer}.")) - .unwrap_or_default(); - let kind = if discovery.managed { - "managed authority" - } else { - "custom authority" - }; - self.tailnet_status.set_label(&format!( - "Discovered {kind}: {}.{issuer}", - discovery.authority - )); - } - Err(error) => self - .tailnet_status - .set_label(&format!("Tailnet discovery failed: {error}")), - } - } - - async fn probe_tailnet(&mut self) { - let authority = self.tailnet_authority(); - let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { - self.tailnet_status - .set_label("Enter a Tailnet server URL before probing"); - return; - }; - - self.tailnet_status.set_label("Checking Tailnet server"); - match daemon_api::probe_tailnet(authority).await { - Ok(probe) => { - let detail = probe - .detail - .unwrap_or_else(|| format!("HTTP {}", probe.status_code)); - self.tailnet_status - .set_label(&format!("{}: {detail}", probe.summary)); - } - Err(error) => self - .tailnet_status - .set_label(&format!("Tailnet probe failed: {error}")), - } - } - - async fn start_tailnet_login(&mut self, sender: AsyncComponentSender) { - let authority = self.tailnet_authority(); - let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { - self.tailnet_status - .set_label("Enter a Tailnet server URL before sign-in"); - return; - }; - let account = daemon_api::normalized(&self.tailnet_account_entry.text(), "default"); - let identity = daemon_api::normalized(&self.tailnet_identity_entry.text(), "linux"); - let hostname = daemon_api::normalized_optional(&self.tailnet_hostname_entry.text()); - - self.tailnet_status.set_label("Starting Tailnet sign-in"); - match daemon_api::start_tailnet_login(authority, account, identity, hostname).await { - Ok(status) => { - self.apply_login_status(&status); - if let Some(auth_url) = status.auth_url.as_deref() { - if let Err(error) = open_auth_url(auth_url) { - self.tailnet_status.set_label(&format!( - "{} Open this URL manually: {auth_url}. Browser launch failed: {error}", - self.tailnet_status.text() - )); - } - } - if !status.running { - sender.input(NetworksScreenMsg::PollTailnetLogin); - } - } - Err(error) => self - .tailnet_status - .set_label(&format!("Tailnet sign-in failed: {error}")), - } - } - - async fn poll_tailnet_login(&mut self, sender: AsyncComponentSender) { - let Some(session_id) = self.tailnet_session_id.clone() else { - return; - }; - if self.tailnet_running { - return; - } - - tokio::time::sleep(Duration::from_secs(1)).await; - match daemon_api::tailnet_login_status(session_id).await { - Ok(status) => { - self.apply_login_status(&status); - if !status.running { - sender.input(NetworksScreenMsg::PollTailnetLogin); - } - } - Err(error) => { - self.tailnet_status - .set_label(&format!("Tailnet sign-in status failed: {error}")); - self.tailnet_session_id = None; - } - } - } - - async fn cancel_tailnet_login(&mut self) { - let Some(session_id) = self.tailnet_session_id.clone() else { - self.tailnet_status - .set_label("No Tailnet sign-in is active"); - return; - }; - - match daemon_api::cancel_tailnet_login(session_id).await { - Ok(()) => { - self.tailnet_session_id = None; - self.tailnet_running = false; - self.tailnet_status.set_label("Tailnet sign-in cancelled"); - } - Err(error) => self - .tailnet_status - .set_label(&format!("Unable to cancel Tailnet sign-in: {error}")), - } - } - - async fn add_tailnet(&mut self) { - let authority = self.tailnet_authority(); - let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { - self.tailnet_status - .set_label("Enter a Tailnet server URL before saving"); - return; - }; - if self.tailnet_session_id.is_some() && !self.tailnet_running { - self.tailnet_status - .set_label("Finish browser sign-in before saving this Tailnet network"); - return; - } - - let account = daemon_api::normalized(&self.tailnet_account_entry.text(), "default"); - let identity = daemon_api::normalized(&self.tailnet_identity_entry.text(), "linux"); - let hostname = daemon_api::normalized_optional(&self.tailnet_hostname_entry.text()); - let tailnet = daemon_api::normalized_optional(&self.tailnet_name_entry.text()); - - match daemon_api::add_tailnet(authority, account, identity, hostname, tailnet).await { - Ok(id) => { - self.tailnet_status - .set_label(&format!("Saved Tailnet network #{id}")); - self.refresh_networks().await; - } - Err(error) => self - .tailnet_status - .set_label(&format!("Unable to save Tailnet network: {error}")), - } - } - - fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) { - self.tailnet_session_id = Some(status.session_id.clone()); - self.tailnet_running = status.running; - - let mut parts = Vec::new(); - if status.running { - parts.push("Signed in".to_owned()); - } else if status.needs_login { - parts.push("Browser sign-in required".to_owned()); - } else { - parts.push("Checking sign-in".to_owned()); - } - if !status.backend_state.is_empty() { - parts.push(format!("State: {}", status.backend_state)); - } - if let Some(tailnet_name) = &status.tailnet_name { - parts.push(format!("Tailnet: {tailnet_name}")); - } - if let Some(self_dns_name) = &status.self_dns_name { - parts.push(self_dns_name.clone()); - } - if !status.tailnet_ips.is_empty() { - parts.push(status.tailnet_ips.join(", ")); - } - if !status.health.is_empty() { - parts.push(status.health.join(" / ")); - } - if let Some(auth_url) = &status.auth_url { - parts.push(format!("Auth URL: {auth_url}")); - } - - self.tailnet_status.set_label(&parts.join("\n")); - } - - fn tailnet_authority(&self) -> String { - if self.custom_authority_switch.is_active() { - self.tailnet_authority_entry.text().to_string() - } else { - let authority = self.tailnet_authority_entry.text(); - if authority.trim().is_empty() { - daemon_api::default_tailnet_authority().to_owned() - } else { - authority.to_string() - } - } - } - - fn clear_networks(&self) { - while let Some(child) = self.list_box.first_child() { - self.list_box.remove(&child); - } - } - - fn render_networks(&self, networks: &[daemon_api::NetworkSummary]) { - self.clear_networks(); - if networks.is_empty() { - let row = adw::ActionRow::builder() - .title("No networks") - .subtitle("Save a WireGuard or Tailnet network to use it here.") - .build(); - self.list_box.append(&row); - return; - } - - for network in networks { - let title = format!("{} (#{})", network.title, network.id); - let row = adw::ActionRow::builder() - .title(&title) - .subtitle(&network.detail) - .build(); - self.list_box.append(&row); - } - } -} - -fn text_view_text(text_view: >k::TextView) -> String { - let buffer = text_view.buffer(); - buffer - .text(&buffer.start_iter(), &buffer.end_iter(), true) - .to_string() -} - -fn open_auth_url(url: &str) -> Result<()> { - gtk::gio::AppInfo::launch_default_for_uri(url, None::<>k::gio::AppLaunchContext>) - .with_context(|| format!("failed to open {url}")) -} diff --git a/burrow-gtk/src/components/settings/daemon_group.rs b/burrow-gtk/src/components/settings/daemon_group.rs index d04e30f..3817ca6 100644 --- a/burrow-gtk/src/components/settings/daemon_group.rs +++ b/burrow-gtk/src/components/settings/daemon_group.rs @@ -4,10 +4,12 @@ use std::process::Command; #[derive(Debug)] pub struct DaemonGroup { system_setup: SystemSetup, + daemon_client: Arc>>, already_running: bool, } pub struct DaemonGroupInit { + pub daemon_client: Arc>>, pub system_setup: SystemSetup, } @@ -28,8 +30,8 @@ impl AsyncComponent for DaemonGroup { #[name(group)] adw::PreferencesGroup { #[watch] - set_sensitive: - (model.system_setup == SystemSetup::AppImage || model.system_setup == SystemSetup::Other) && + set_sensitive: + (model.system_setup == SystemSetup::AppImage || model.system_setup == SystemSetup::Other) && !model.already_running, set_title: "Local Daemon", set_description: Some("Run Local Daemon"), @@ -49,7 +51,8 @@ impl AsyncComponent for DaemonGroup { // Should be impossible to panic here let model = DaemonGroup { system_setup: init.system_setup, - already_running: daemon_api::daemon_available().await, + daemon_client: init.daemon_client.clone(), + already_running: init.daemon_client.lock().await.is_some(), }; let widgets = view_output!(); @@ -101,7 +104,7 @@ mv $TEMP /tmp/burrow-detached-daemon"#, .unwrap(); } DaemonGroupMsg::DaemonStateChange => { - self.already_running = daemon_api::daemon_available().await; + self.already_running = self.daemon_client.lock().await.is_some(); } } } diff --git a/burrow-gtk/src/components/settings/diag_group.rs b/burrow-gtk/src/components/settings/diag_group.rs index e1e9b78..a15e0ea 100644 --- a/burrow-gtk/src/components/settings/diag_group.rs +++ b/burrow-gtk/src/components/settings/diag_group.rs @@ -2,6 +2,8 @@ use super::*; #[derive(Debug)] pub struct DiagGroup { + daemon_client: Arc>>, + system_setup: SystemSetup, service_installed: StatusTernary, socket_installed: StatusTernary, @@ -10,13 +12,14 @@ pub struct DiagGroup { } pub struct DiagGroupInit { + pub daemon_client: Arc>>, pub system_setup: SystemSetup, } impl DiagGroup { - async fn new() -> Result { + async fn new(daemon_client: Arc>>) -> Result { let system_setup = SystemSetup::new(); - let daemon_running = daemon_api::daemon_available().await; + let daemon_running = daemon_client.lock().await.is_some(); Ok(Self { service_installed: system_setup.is_service_installed()?, @@ -24,6 +27,7 @@ impl DiagGroup { socket_enabled: system_setup.is_socket_enabled()?, daemon_running, system_setup, + daemon_client, }) } } @@ -91,8 +95,7 @@ impl AsyncComponent for DiagGroup { sender: AsyncComponentSender, ) -> AsyncComponentParts { // Should be impossible to panic here - let mut model = DiagGroup::new().await.unwrap(); - model.system_setup = init.system_setup; + let model = DiagGroup::new(init.daemon_client).await.unwrap(); let widgets = view_output!(); @@ -108,7 +111,7 @@ impl AsyncComponent for DiagGroup { match msg { DiagGroupMsg::Refresh => { // Should be impossible to panic here - *self = Self::new().await.unwrap(); + *self = Self::new(Arc::clone(&self.daemon_client)).await.unwrap(); } } } diff --git a/burrow-gtk/src/components/settings_screen.rs b/burrow-gtk/src/components/settings_screen.rs index 9828c31..971f262 100644 --- a/burrow-gtk/src/components/settings_screen.rs +++ b/burrow-gtk/src/components/settings_screen.rs @@ -6,6 +6,10 @@ pub struct SettingsScreen { daemon_group: AsyncController, } +pub struct SettingsScreenInit { + pub daemon_client: Arc>>, +} + #[derive(Debug, PartialEq, Eq)] pub enum SettingsScreenMsg { DaemonStateChange, @@ -13,7 +17,7 @@ pub enum SettingsScreenMsg { #[relm4::component(pub)] impl SimpleComponent for SettingsScreen { - type Init = (); + type Init = SettingsScreenInit; type Input = SettingsScreenMsg; type Output = (); @@ -23,20 +27,26 @@ impl SimpleComponent for SettingsScreen { } fn init( - _init: Self::Init, + init: Self::Init, root: &Self::Root, sender: ComponentSender, ) -> ComponentParts { let system_setup = SystemSetup::new(); let diag_group = settings::DiagGroup::builder() - .launch(settings::DiagGroupInit { system_setup }) + .launch(settings::DiagGroupInit { + system_setup, + daemon_client: Arc::clone(&init.daemon_client), + }) .forward(sender.input_sender(), |_| { SettingsScreenMsg::DaemonStateChange }); let daemon_group = settings::DaemonGroup::builder() - .launch(settings::DaemonGroupInit { system_setup }) + .launch(settings::DaemonGroupInit { + system_setup, + daemon_client: Arc::clone(&init.daemon_client), + }) .forward(sender.input_sender(), |_| { SettingsScreenMsg::DaemonStateChange }); diff --git a/burrow-gtk/src/components/switch_screen.rs b/burrow-gtk/src/components/switch_screen.rs index bbc5f82..f660536 100644 --- a/burrow-gtk/src/components/switch_screen.rs +++ b/burrow-gtk/src/components/switch_screen.rs @@ -1,22 +1,27 @@ use super::*; pub struct SwitchScreen { + daemon_client: Arc>>, switch: gtk::Switch, switch_screen: gtk::Box, disconnected_banner: adw::Banner, - status_label: gtk::Label, +} + +pub struct SwitchScreenInit { + pub daemon_client: Arc>>, } #[derive(Debug, PartialEq, Eq)] pub enum SwitchScreenMsg { - Refresh, + DaemonReconnect, + DaemonDisconnect, Start, Stop, } #[relm4::component(pub, async)] impl AsyncComponent for SwitchScreen { - type Init = (); + type Init = SwitchScreenInit; type Input = SwitchScreenMsg; type Output = (); type CommandOutput = (); @@ -34,7 +39,7 @@ impl AsyncComponent for SwitchScreen { #[name(setup_banner)] adw::Banner { - set_title: "Burrow daemon is not reachable", + set_title: "Burrow is not running!", }, }, @@ -47,12 +52,7 @@ impl AsyncComponent for SwitchScreen { set_vexpand: true, gtk::Label { - set_label: "Burrow Tunnel", - }, - - #[name(status_label)] - gtk::Label { - set_label: "Checking daemon status", + set_label: "Burrow Switch", }, #[name(switch)] @@ -68,76 +68,91 @@ impl AsyncComponent for SwitchScreen { } async fn init( - _: Self::Init, + init: Self::Init, root: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { + let mut initial_switch_status = false; + let mut initial_daemon_server_down = false; + + if let Some(daemon_client) = init.daemon_client.lock().await.as_mut() { + if let Ok(res) = daemon_client + .send_command(DaemonCommand::ServerInfo) + .await + .as_ref() + { + initial_switch_status = match res.result.as_ref() { + Ok(DaemonResponseData::None) => false, + Ok(DaemonResponseData::ServerInfo(_)) => true, + _ => false, + }; + } else { + initial_daemon_server_down = true; + } + } else { + initial_daemon_server_down = true; + } + let widgets = view_output!(); - let mut model = SwitchScreen { + widgets.switch.set_active(initial_switch_status); + + if initial_daemon_server_down { + *init.daemon_client.lock().await = None; + widgets.switch.set_active(false); + widgets.switch_screen.set_sensitive(false); + widgets.setup_banner.set_revealed(true); + } + + let model = SwitchScreen { + daemon_client: init.daemon_client, switch: widgets.switch.clone(), switch_screen: widgets.switch_screen.clone(), disconnected_banner: widgets.setup_banner.clone(), - status_label: widgets.status_label.clone(), }; - model.refresh().await; - AsyncComponentParts { model, widgets } } async fn update( &mut self, msg: Self::Input, - _sender: AsyncComponentSender, + _: AsyncComponentSender, _root: &Self::Root, ) { - match msg { - SwitchScreenMsg::Refresh => self.refresh().await, - SwitchScreenMsg::Start => { - match daemon_api::start_tunnel().await { - Ok(()) => self.status_label.set_label("Tunnel running"), - Err(error) => self - .status_label - .set_label(&format!("Start failed: {error}")), - } - self.refresh().await; - } - SwitchScreenMsg::Stop => { - match daemon_api::stop_tunnel().await { - Ok(()) => self.status_label.set_label("Tunnel stopped"), - Err(error) => self - .status_label - .set_label(&format!("Stop failed: {error}")), - } - self.refresh().await; - } - } - } -} + let mut disconnected_daemon_client = false; -impl SwitchScreen { - async fn refresh(&mut self) { - match daemon_api::tunnel_state().await { - Ok(daemon_api::TunnelState::Running) => { - self.disconnected_banner.set_revealed(false); - self.switch_screen.set_sensitive(true); - self.status_label.set_label("Tunnel running"); - self.switch.set_active(true); - } - Ok(daemon_api::TunnelState::Stopped) => { - self.disconnected_banner.set_revealed(false); - self.switch_screen.set_sensitive(true); - self.status_label.set_label("Tunnel stopped"); - self.switch.set_active(false); - } - Err(error) => { - self.disconnected_banner.set_revealed(true); - self.switch_screen.set_sensitive(false); - self.status_label - .set_label(&format!("Daemon unavailable: {error}")); - self.switch.set_active(false); + if let Some(daemon_client) = self.daemon_client.lock().await.as_mut() { + match msg { + Self::Input::Start => { + if let Err(_e) = daemon_client + .send_command(DaemonCommand::Start(Default::default())) + .await + { + disconnected_daemon_client = true; + } + } + Self::Input::Stop => { + if let Err(_e) = daemon_client.send_command(DaemonCommand::Stop).await { + disconnected_daemon_client = true; + } + } + _ => {} } + } else { + disconnected_daemon_client = true; + } + + if msg == Self::Input::DaemonReconnect { + self.disconnected_banner.set_revealed(false); + self.switch_screen.set_sensitive(true); + } + + if disconnected_daemon_client || msg == Self::Input::DaemonDisconnect { + *self.daemon_client.lock().await = None; + self.switch.set_active(false); + self.switch_screen.set_sensitive(false); + self.disconnected_banner.set_revealed(true); } } } diff --git a/burrow-gtk/src/daemon_api.rs b/burrow-gtk/src/daemon_api.rs deleted file mode 100644 index 4ff8bf5..0000000 --- a/burrow-gtk/src/daemon_api.rs +++ /dev/null @@ -1,420 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use burrow::{ - control::{TailnetConfig, TailnetProvider}, - grpc_defs::{ - Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest, - TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest, - }, - BurrowClient, -}; -use std::{path::PathBuf, sync::OnceLock}; -use tokio::time::{timeout, Duration}; - -const RPC_TIMEOUT: Duration = Duration::from_secs(3); -const MANAGED_TAILSCALE_AUTHORITY: &str = "https://controlplane.tailscale.com"; -static EMBEDDED_DAEMON_STARTED: OnceLock<()> = OnceLock::new(); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TunnelState { - Running, - Stopped, -} - -#[derive(Debug, Clone)] -pub struct NetworkSummary { - pub id: i32, - pub title: String, - pub detail: String, -} - -#[derive(Debug, Clone)] -pub struct TailnetDiscovery { - pub authority: String, - pub managed: bool, - pub oidc_issuer: Option, -} - -#[derive(Debug, Clone)] -pub struct TailnetProbe { - pub summary: String, - pub detail: Option, - pub status_code: i32, -} - -#[derive(Debug, Clone)] -pub struct TailnetLoginStatus { - pub session_id: String, - pub backend_state: String, - pub auth_url: Option, - pub running: bool, - pub needs_login: bool, - pub tailnet_name: Option, - pub self_dns_name: Option, - pub tailnet_ips: Vec, - pub health: Vec, -} - -pub fn default_tailnet_authority() -> &'static str { - MANAGED_TAILSCALE_AUTHORITY -} - -pub fn configure_client_paths() -> Result<()> { - if std::env::var_os("BURROW_SOCKET_PATH").is_none() { - std::env::set_var("BURROW_SOCKET_PATH", default_socket_path()?); - } - Ok(()) -} - -pub async fn ensure_daemon() -> Result<()> { - configure_client_paths()?; - if daemon_available().await { - return Ok(()); - } - - let socket_path = socket_path()?; - let db_path = database_path()?; - ensure_parent(&socket_path)?; - ensure_parent(&db_path)?; - - if EMBEDDED_DAEMON_STARTED.get().is_none() { - tokio::task::spawn_blocking(move || { - burrow::spawn_in_process_with_paths(Some(socket_path), Some(db_path)); - }) - .await - .context("failed to join embedded daemon startup")?; - let _ = EMBEDDED_DAEMON_STARTED.set(()); - } - - tunnel_state() - .await - .map(|_| ()) - .context("Burrow daemon started but did not accept tunnel status RPCs") -} - -pub fn infer_tailnet_provider(authority: &str) -> TailnetProvider { - let normalized = authority.trim().trim_end_matches('/').to_ascii_lowercase(); - if normalized == "controlplane.tailscale.com" - || normalized == "http://controlplane.tailscale.com" - || normalized == MANAGED_TAILSCALE_AUTHORITY - { - TailnetProvider::Tailscale - } else { - TailnetProvider::Headscale - } -} - -pub async fn daemon_available() -> bool { - tunnel_state().await.is_ok() -} - -fn socket_path() -> Result { - if let Some(path) = std::env::var_os("BURROW_SOCKET_PATH") { - return Ok(PathBuf::from(path)); - } - default_socket_path() -} - -fn default_socket_path() -> Result { - if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { - return Ok(PathBuf::from(runtime_dir).join("burrow.sock")); - } - let uid = std::env::var("UID").unwrap_or_else(|_| "1000".to_owned()); - Ok(PathBuf::from(format!("/tmp/burrow-{uid}.sock"))) -} - -fn database_path() -> Result { - if let Some(path) = std::env::var_os("BURROW_DB_PATH") { - return Ok(PathBuf::from(path)); - } - if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { - return Ok(PathBuf::from(data_home).join("burrow").join("burrow.db")); - } - if let Some(home) = std::env::var_os("HOME") { - return Ok(PathBuf::from(home) - .join(".local") - .join("share") - .join("burrow") - .join("burrow.db")); - } - Ok(std::env::temp_dir().join("burrow.db")) -} - -fn ensure_parent(path: &PathBuf) -> Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - Ok(()) -} - -pub async fn tunnel_state() -> Result { - let mut client = BurrowClient::from_uds().await?; - let mut stream = timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_status(Empty {})) - .await - .context("timed out connecting to Burrow daemon")?? - .into_inner(); - let status = timeout(RPC_TIMEOUT, stream.message()) - .await - .context("timed out reading Burrow tunnel status")?? - .context("Burrow daemon ended the status stream without a state")?; - Ok(match status.state() { - State::Running => TunnelState::Running, - State::Stopped => TunnelState::Stopped, - }) -} - -pub async fn start_tunnel() -> Result<()> { - let mut client = BurrowClient::from_uds().await?; - timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_start(Empty {})) - .await - .context("timed out starting Burrow tunnel")??; - Ok(()) -} - -pub async fn stop_tunnel() -> Result<()> { - let mut client = BurrowClient::from_uds().await?; - timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_stop(Empty {})) - .await - .context("timed out stopping Burrow tunnel")??; - Ok(()) -} - -pub async fn list_networks() -> Result> { - let mut client = BurrowClient::from_uds().await?; - let mut stream = timeout(RPC_TIMEOUT, client.networks_client.network_list(Empty {})) - .await - .context("timed out connecting to Burrow network list")?? - .into_inner(); - let response = timeout(RPC_TIMEOUT, stream.message()) - .await - .context("timed out reading Burrow network list")?? - .context("Burrow daemon ended the network stream without a snapshot")?; - Ok(response.network.iter().map(summarize_network).collect()) -} - -pub async fn add_wireguard(config: String) -> Result { - add_network(NetworkType::WireGuard, config.into_bytes()).await -} - -pub async fn add_tailnet( - authority: String, - account: String, - identity: String, - hostname: Option, - tailnet: Option, -) -> Result { - let provider = infer_tailnet_provider(&authority); - let config = TailnetConfig { - provider, - authority: Some(authority), - account: Some(account), - identity: Some(identity), - hostname, - tailnet, - }; - let payload = serde_json::to_vec_pretty(&config)?; - add_network(NetworkType::Tailnet, payload).await -} - -pub async fn discover_tailnet(email: String) -> Result { - let mut client = BurrowClient::from_uds().await?; - let response = timeout( - RPC_TIMEOUT, - client - .tailnet_client - .discover(TailnetDiscoverRequest { email }), - ) - .await - .context("timed out discovering Tailnet authority")?? - .into_inner(); - - Ok(TailnetDiscovery { - authority: response.authority, - managed: response.managed, - oidc_issuer: optional(response.oidc_issuer), - }) -} - -pub async fn probe_tailnet(authority: String) -> Result { - let mut client = BurrowClient::from_uds().await?; - let response = timeout( - RPC_TIMEOUT, - client - .tailnet_client - .probe(TailnetProbeRequest { authority }), - ) - .await - .context("timed out probing Tailnet authority")?? - .into_inner(); - - Ok(TailnetProbe { - summary: response.summary, - detail: optional(response.detail), - status_code: response.status_code, - }) -} - -pub async fn start_tailnet_login( - authority: String, - account_name: String, - identity_name: String, - hostname: Option, -) -> Result { - let mut client = BurrowClient::from_uds().await?; - let response = timeout( - RPC_TIMEOUT, - client.tailnet_client.login_start(TailnetLoginStartRequest { - account_name, - identity_name, - hostname: hostname.unwrap_or_default(), - authority, - }), - ) - .await - .context("timed out starting Tailnet sign-in")?? - .into_inner(); - Ok(decode_tailnet_status(response)) -} - -pub async fn tailnet_login_status(session_id: String) -> Result { - let mut client = BurrowClient::from_uds().await?; - let response = timeout( - RPC_TIMEOUT, - client - .tailnet_client - .login_status(TailnetLoginStatusRequest { session_id }), - ) - .await - .context("timed out reading Tailnet sign-in status")?? - .into_inner(); - Ok(decode_tailnet_status(response)) -} - -pub async fn cancel_tailnet_login(session_id: String) -> Result<()> { - let mut client = BurrowClient::from_uds().await?; - timeout( - RPC_TIMEOUT, - client - .tailnet_client - .login_cancel(TailnetLoginCancelRequest { session_id }), - ) - .await - .context("timed out cancelling Tailnet sign-in")??; - Ok(()) -} - -async fn add_network(network_type: NetworkType, payload: Vec) -> Result { - let id = next_network_id().await?; - let mut client = BurrowClient::from_uds().await?; - timeout( - RPC_TIMEOUT, - client.networks_client.network_add(Network { - id, - r#type: network_type.into(), - payload, - }), - ) - .await - .context("timed out saving network to Burrow daemon")??; - Ok(id) -} - -async fn next_network_id() -> Result { - let networks = list_networks().await?; - Ok(networks.iter().map(|network| network.id).max().unwrap_or(0) + 1) -} - -fn summarize_network(network: &Network) -> NetworkSummary { - match network.r#type() { - NetworkType::WireGuard => summarize_wireguard(network), - NetworkType::Tailnet => summarize_tailnet(network), - } -} - -fn summarize_wireguard(network: &Network) -> NetworkSummary { - let payload = String::from_utf8_lossy(&network.payload); - let detail = payload - .lines() - .map(str::trim) - .find(|line| !line.is_empty() && !line.starts_with('[')) - .unwrap_or("Stored WireGuard configuration") - .to_owned(); - NetworkSummary { - id: network.id, - title: format!("WireGuard {}", network.id), - detail, - } -} - -fn summarize_tailnet(network: &Network) -> NetworkSummary { - match TailnetConfig::from_slice(&network.payload) { - Ok(config) => { - let title = config - .tailnet - .clone() - .or(config.hostname.clone()) - .unwrap_or_else(|| "Tailnet".to_owned()); - let authority = config - .authority - .unwrap_or_else(|| "default authority".to_owned()); - let account = config.account.unwrap_or_else(|| "default".to_owned()); - NetworkSummary { - id: network.id, - title, - detail: format!("{authority} - account {account}"), - } - } - Err(error) => NetworkSummary { - id: network.id, - title: "Tailnet".to_owned(), - detail: format!("Unable to read Tailnet payload: {error}"), - }, - } -} - -fn decode_tailnet_status( - response: burrow::grpc_defs::TailnetLoginStatusResponse, -) -> TailnetLoginStatus { - TailnetLoginStatus { - session_id: response.session_id, - backend_state: response.backend_state, - auth_url: optional(response.auth_url), - running: response.running, - needs_login: response.needs_login, - tailnet_name: optional(response.tailnet_name), - self_dns_name: optional(response.self_dns_name), - tailnet_ips: response.tailnet_ips, - health: response.health, - } -} - -fn optional(value: String) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -pub fn normalized(value: &str, fallback: &str) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - fallback.to_owned() - } else { - trimmed.to_owned() - } -} - -pub fn normalized_optional(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -pub fn require_value(value: &str, label: &str) -> Result { - normalized_optional(value).ok_or_else(|| anyhow!("{label} is required")) -} diff --git a/burrow-gtk/src/main.rs b/burrow-gtk/src/main.rs index b47b63e..6f91e2a 100644 --- a/burrow-gtk/src/main.rs +++ b/burrow-gtk/src/main.rs @@ -1,15 +1,11 @@ use anyhow::Result; pub mod components; -mod account_store; -mod daemon_api; +mod diag; // Generated using meson mod config; fn main() { - if let Err(error) = daemon_api::configure_client_paths() { - eprintln!("failed to configure Burrow daemon paths: {error}"); - } components::App::run(); } diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index 22f3d25..15facd1 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -10,13 +10,11 @@ crate-type = ["lib", "staticlib"] [dependencies] anyhow = "1.0" -tokio = { version = "1.37", features = [ +tokio = { version = "1.50.0", features = [ "rt", "macros", "sync", "io-util", - "net", - "process", "rt-multi-thread", "signal", "time", @@ -34,7 +32,6 @@ serde_json = "1.0" blake2 = "0.10" chacha20poly1305 = "0.10" rand = "0.8" -bytes = "1" rand_core = "0.6" aead = "0.5" x25519-dalek = { version = "2.0", features = [ @@ -48,46 +45,40 @@ base64 = "0.21" fehler = "1.0" ip_network_table = "0.2" ip_network = "0.4" -ipnetwork = { version = "0.21", features = ["serde"] } async-channel = "2.1" schemars = "0.8" futures = "0.3.28" once_cell = "1.19" arti-client = "0.40.0" -hickory-proto = "0.25.2" -netstack-smoltcp = "0.2.1" tokio-util = { version = "0.7.18", features = ["compat"] } -tor-rtcompat = "0.40.0" console-subscriber = { version = "0.2.0", optional = true } console = "0.15.8" -axum = "0.7.4" -argon2 = "0.5" -reqwest = { version = "0.12", default-features = false, features = [ +axum = "0.8.8" +reqwest = { version = "0.13.2", default-features = false, features = [ "json", - "rustls-tls", + "rustls", ] } rusqlite = { version = "0.38.0", features = ["blob"] } dotenv = "0.15.0" -tonic = "0.12.0" -prost = "0.13.1" -prost-types = "0.13.1" -tokio-stream = "0.1" +tonic = "0.14.5" +tonic-prost = "0.14.5" +prost = "0.14.3" +prost-types = "0.14.3" +tokio-stream = "0.1.18" async-stream = "0.2" -tower = { version = "0.4.13", features = ["util"] } -hyper-util = "0.1.6" +tower = "0.5.3" +hyper-util = "0.1.20" toml = "0.8.15" rust-ini = "0.21.0" -subtle = "2.6" [target.'cfg(target_os = "linux")'.dependencies] caps = "0.5" -libc = "0.2" libsystemd = "0.7" -nix = { version = "0.27", features = ["fs", "socket", "uio"] } tracing-journald = "0.3" +libc = "0.2" [target.'cfg(target_vendor = "apple")'.dependencies] -nix = { version = "0.27" } +nix = { version = "0.27", features = ["ioctl"] } rusqlite = { version = "0.38.0", features = ["bundled", "blob"] } [target.'cfg(target_os = "macos")'.dependencies] @@ -95,7 +86,6 @@ tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" } [dev-dependencies] insta = { version = "1.32", features = ["yaml"] } -tempfile = "3.13" [package.metadata.generate-rpm] assets = [ @@ -112,4 +102,4 @@ bundled = ["rusqlite/bundled"] [build-dependencies] -tonic-build = "0.12.0" +tonic-prost-build = "0.14.5" diff --git a/burrow/build.rs b/burrow/build.rs index 8eea5dc..9ecd9a8 100644 --- a/burrow/build.rs +++ b/burrow/build.rs @@ -1,4 +1,4 @@ fn main() -> Result<(), Box> { - tonic_build::compile_protos("../proto/burrow.proto")?; + tonic_prost_build::compile_protos("../proto/burrow.proto")?; Ok(()) } diff --git a/burrow/src/auth/client.rs b/burrow/src/auth/client.rs new file mode 100644 index 0000000..e9721f3 --- /dev/null +++ b/burrow/src/auth/client.rs @@ -0,0 +1,24 @@ +use std::env::var; + +use anyhow::Result; +use reqwest::Url; + +pub async fn login() -> Result<()> { + let state = "vt :P"; + let nonce = "no"; + + let mut url = Url::parse("https://slack.com/openid/connect/authorize")?; + let mut q = url.query_pairs_mut(); + q.append_pair("response_type", "code"); + q.append_pair("scope", "openid profile email"); + q.append_pair("client_id", &var("CLIENT_ID")?); + q.append_pair("state", state); + q.append_pair("team", &var("SLACK_TEAM_ID")?); + q.append_pair("nonce", nonce); + q.append_pair("redirect_uri", "https://burrow.rs/callback"); + drop(q); + + println!("Continue auth in your browser:\n{}", url.as_str()); + + Ok(()) +} diff --git a/burrow/src/auth/mod.rs b/burrow/src/auth/mod.rs index 74f47ad..c07f47e 100644 --- a/burrow/src/auth/mod.rs +++ b/burrow/src/auth/mod.rs @@ -1 +1,2 @@ +pub mod client; pub mod server; diff --git a/burrow/src/auth/server/db.rs b/burrow/src/auth/server/db.rs index c31c473..995e64b 100644 --- a/burrow/src/auth/server/db.rs +++ b/burrow/src/auth/server/db.rs @@ -1,627 +1,91 @@ -use anyhow::{anyhow, Context, Result}; -use argon2::{ - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, - Argon2, -}; -use base64::{engine::general_purpose, Engine as _}; -use rand::RngCore; -use rusqlite::{params, Connection, OptionalExtension}; +use anyhow::Result; -use crate::control::{ - DnsConfig, Hostinfo, LocalAuthResponse, MapRequest, MapResponse, Node, NodeCapMap, - PacketFilter, PeerCapMap, RegisterRequest, UserProfile, -}; - -const CREATE_SCHEMA: &str = r#" -CREATE TABLE IF NOT EXISTS auth_user ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - profile_pic_url TEXT, - groups_json TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS auth_local_credential ( - user_id INTEGER PRIMARY KEY REFERENCES auth_user(id) ON DELETE CASCADE, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - rotated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS auth_session ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) -); - -CREATE TABLE IF NOT EXISTS control_node ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - stable_id TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, - name TEXT NOT NULL, - node_key TEXT NOT NULL UNIQUE, - machine_key TEXT, - disco_key TEXT, - addresses_json TEXT NOT NULL, - allowed_ips_json TEXT NOT NULL, - endpoints_json TEXT NOT NULL, - home_derp INTEGER, - hostinfo_json TEXT, - tags_json TEXT NOT NULL DEFAULT '[]', - primary_routes_json TEXT NOT NULL DEFAULT '[]', - cap_version INTEGER NOT NULL DEFAULT 1, - cap_map_json TEXT NOT NULL DEFAULT '{}', - peer_cap_map_json TEXT NOT NULL DEFAULT '{}', - machine_authorized INTEGER NOT NULL DEFAULT 1, - node_key_expired INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - last_seen TEXT, - online INTEGER -); -"#; - -#[derive(Clone, Debug)] -pub struct StoredUser { - pub profile: UserProfile, -} - -pub fn init_db(path: &str) -> Result<()> { - let conn = Connection::open(path)?; - conn.execute_batch(CREATE_SCHEMA)?; - Ok(()) -} - -pub fn ensure_local_identity( - path: &str, - username: &str, - email: &str, - display_name: &str, - password: &str, -) -> Result { - let conn = Connection::open(path)?; - conn.execute( - "INSERT INTO auth_user (email, display_name) VALUES (?, ?) - ON CONFLICT(email) DO UPDATE SET display_name = excluded.display_name", - params![email, display_name], - )?; - let user_id: i64 = - conn.query_row("SELECT id FROM auth_user WHERE email = ?", [email], |row| { - row.get(0) - })?; - - let existing_hash: Option = conn - .query_row( - "SELECT password_hash FROM auth_local_credential WHERE user_id = ?", - [user_id], - |row| row.get(0), - ) - .optional()?; - - let password_hash = match existing_hash { - Some(hash) if verify_password(password, &hash) => hash, - _ => hash_password(password)?, - }; - - conn.execute( - "INSERT INTO auth_local_credential (user_id, username, password_hash) - VALUES (?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, password_hash = excluded.password_hash, rotated_at = datetime('now')", - params![user_id, username, password_hash], - )?; - - load_user_profile(&conn, user_id) -} - -pub fn authenticate_local( - path: &str, - identifier: &str, - password: &str, -) -> Result> { - let conn = Connection::open(path)?; - let record = conn - .query_row( - "SELECT u.id, u.email, u.display_name, u.profile_pic_url, u.groups_json, c.password_hash - FROM auth_user u - JOIN auth_local_credential c ON c.user_id = u.id - WHERE c.username = ? OR u.email = ?", - params![identifier, identifier], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - )) - }, - ) - .optional()?; - - let Some((user_id, email, display_name, profile_pic_url, groups_json, password_hash)) = record - else { - return Ok(None); - }; - - if !verify_password(password, &password_hash) { - return Ok(None); - } - - let token = random_token(); - conn.execute( - "INSERT INTO auth_session (id, user_id) VALUES (?, ?)", - params![token, user_id], - )?; - - Ok(Some(LocalAuthResponse { - access_token: token, - user: UserProfile { - id: user_id, - login_name: email, - display_name, - profile_pic_url, - groups: parse_json(&groups_json)?, - }, - })) -} - -pub fn user_for_session(path: &str, token: &str) -> Result> { - let conn = Connection::open(path)?; - let user_id = conn - .query_row( - "SELECT user_id FROM auth_session WHERE id = ? AND expires_at > datetime('now')", - [token], - |row| row.get::<_, i64>(0), - ) - .optional()?; - let Some(user_id) = user_id else { - return Ok(None); - }; - - Ok(Some(load_user(&conn, user_id)?)) -} - -pub fn upsert_node(path: &str, user: &StoredUser, request: &RegisterRequest) -> Result { - let conn = Connection::open(path)?; - let existing = find_existing_node(&conn, user.profile.id, request)?; - let name = Node::preferred_name(request); - let allowed_ips = Node::normalized_allowed_ips(request); - - match existing { - Some((node_id, stable_id, created_at)) => { - conn.execute( - "UPDATE control_node - SET name = ?, node_key = ?, machine_key = ?, disco_key = ?, addresses_json = ?, allowed_ips_json = ?, - endpoints_json = ?, home_derp = ?, hostinfo_json = ?, tags_json = ?, primary_routes_json = ?, - cap_version = ?, cap_map_json = ?, peer_cap_map_json = ?, updated_at = datetime('now'), - last_seen = datetime('now'), online = 1 - WHERE id = ?", - params![ - name, - request.node_key, - request.machine_key, - request.disco_key, - to_json(&request.addresses)?, - to_json(&allowed_ips)?, - to_json(&request.endpoints)?, - request.home_derp, - optional_json(&request.hostinfo)?, - to_json(&request.tags)?, - to_json(&request.primary_routes)?, - request.version.max(1), - to_json(&request.cap_map)?, - to_json(&request.peer_cap_map)?, - node_id, - ], - )?; - load_node(&conn, node_id, stable_id, Some(created_at)) - } - None => { - conn.execute( - "INSERT INTO control_node ( - stable_id, user_id, name, node_key, machine_key, disco_key, addresses_json, allowed_ips_json, - endpoints_json, home_derp, hostinfo_json, tags_json, primary_routes_json, cap_version, - cap_map_json, peer_cap_map_json, last_seen, online - ) VALUES ('', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 1)", - params![ - user.profile.id, - name, - request.node_key, - request.machine_key, - request.disco_key, - to_json(&request.addresses)?, - to_json(&allowed_ips)?, - to_json(&request.endpoints)?, - request.home_derp, - optional_json(&request.hostinfo)?, - to_json(&request.tags)?, - to_json(&request.primary_routes)?, - request.version.max(1), - to_json(&request.cap_map)?, - to_json(&request.peer_cap_map)?, - ], - )?; - let node_id = conn.last_insert_rowid(); - let stable_id = format!("bn-{node_id}"); - conn.execute( - "UPDATE control_node SET stable_id = ? WHERE id = ?", - params![stable_id, node_id], - )?; - load_node(&conn, node_id, stable_id, None) - } - } -} - -pub fn map_for_node( - path: &str, - user: &StoredUser, - request: &MapRequest, - domain: &str, -) -> Result { - let conn = Connection::open(path)?; - apply_map_request(&conn, user.profile.id, request)?; - let self_row = conn - .query_row( - "SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?", - params![user.profile.id, request.node_key], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }, - ) - .optional()? - .ok_or_else(|| anyhow!("node not registered"))?; - - let node = load_node(&conn, self_row.0, self_row.1, Some(self_row.2))?; - let peers = load_peers(&conn, node.id)?; - Ok(MapResponse { - map_session_handle: Some(format!("map-{}", node.stable_id)), - seq: Some(request.map_session_seq.unwrap_or(0) + 1), - node, - peers, - domain: domain.to_owned(), - dns: Some(DnsConfig { - resolvers: vec!["1.1.1.1".to_owned(), "1.0.0.1".to_owned()], - search_domains: vec![domain.to_owned()], - magic_dns: true, - }), - packet_filters: vec![PacketFilter::default()], - }) -} +use crate::daemon::rpc::grpc_defs::{Network, NetworkType}; pub static PATH: &str = "./server.sqlite3"; -fn apply_map_request(conn: &Connection, user_id: i64, request: &MapRequest) -> Result<()> { - let current = conn - .query_row( - "SELECT id FROM control_node WHERE user_id = ? AND node_key = ?", - params![user_id, request.node_key], - |row| row.get::<_, i64>(0), - ) - .optional()?; +pub fn init_db() -> Result<()> { + let conn = rusqlite::Connection::open(PATH)?; - let Some(node_id) = current else { - return Ok(()); - }; - - let hostinfo_json = optional_json(&request.hostinfo)?; - let endpoints_json = to_json(&request.endpoints)?; conn.execute( - "UPDATE control_node - SET disco_key = COALESCE(?, disco_key), - hostinfo_json = CASE WHEN ? IS NULL THEN hostinfo_json ELSE ? END, - endpoints_json = CASE WHEN ? = '[]' THEN endpoints_json ELSE ? END, - updated_at = datetime('now'), - last_seen = datetime('now'), - online = 1 - WHERE id = ?", - params![ - request.disco_key, - hostinfo_json, - hostinfo_json, - endpoints_json, - endpoints_json, - node_id, - ], + "CREATE TABLE IF NOT EXISTS user ( + id PRIMARY KEY, + created_at TEXT NOT NULL + )", + (), )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS user_connection ( + user_id INTEGER REFERENCES user(id) ON DELETE CASCADE, + openid_provider TEXT NOT NULL, + openid_user_id TEXT NOT NULL, + openid_user_name TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + PRIMARY KEY (openid_provider, openid_user_id) + )", + (), + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS device ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + public_key TEXT NOT NULL, + apns_token TEXT UNIQUE, + user_id INT REFERENCES user(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) CHECK(created_at IS datetime(created_at)), + ipv4 TEXT NOT NULL UNIQUE, + ipv6 TEXT NOT NULL UNIQUE, + access_token TEXT NOT NULL UNIQUE, + refresh_token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) CHECK(expires_at IS datetime(expires_at)) + )", + () + ).unwrap(); + Ok(()) } -fn find_existing_node( - conn: &Connection, - user_id: i64, - request: &RegisterRequest, -) -> Result> { - let mut candidates = vec![request.node_key.as_str()]; - if let Some(old) = request.old_node_key.as_deref() { - if old != request.node_key { - candidates.push(old); - } - } +pub fn store_connection( + openid_user: super::providers::OpenIdUser, + openid_provider: &str, + access_token: &str, + refresh_token: Option<&str>, +) -> Result<()> { + log::debug!("Storing openid user {:#?}", openid_user); + let conn = rusqlite::Connection::open(PATH)?; - for candidate in candidates { - let hit = conn - .query_row( - "SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?", - params![user_id, candidate], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }, - ) - .optional()?; - if hit.is_some() { - return Ok(hit); - } - } - Ok(None) -} - -fn load_peers(conn: &Connection, self_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, stable_id, created_at FROM control_node WHERE id != ? AND machine_authorized = 1 ORDER BY id", + conn.execute( + "INSERT OR IGNORE INTO user (id, created_at) VALUES (?, datetime('now'))", + (&openid_user.sub,), )?; - let peers = stmt - .query_map([self_id], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })? - .collect::>>()?; - peers - .into_iter() - .map(|(id, stable_id, created_at)| load_node(conn, id, stable_id, Some(created_at))) - .collect() -} - -fn load_node( - conn: &Connection, - id: i64, - stable_id: String, - created_at_hint: Option, -) -> Result { - let row = conn.query_row( - "SELECT user_id, name, node_key, machine_key, disco_key, addresses_json, allowed_ips_json, - endpoints_json, home_derp, hostinfo_json, tags_json, primary_routes_json, cap_version, - cap_map_json, peer_cap_map_json, machine_authorized, node_key_expired, - created_at, updated_at, last_seen, online - FROM control_node WHERE id = ?", - [id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, String>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, String>(10)?, - row.get::<_, String>(11)?, - row.get::<_, i32>(12)?, - row.get::<_, String>(13)?, - row.get::<_, String>(14)?, - row.get::<_, i64>(15)?, - row.get::<_, i64>(16)?, - row.get::<_, String>(17)?, - row.get::<_, String>(18)?, - row.get::<_, Option>(19)?, - row.get::<_, Option>(20)?, - )) - }, + conn.execute( + "INSERT INTO user_connection (user_id, openid_provider, openid_user_id, openid_user_name, access_token, refresh_token) VALUES ( + (SELECT id FROM user WHERE id = ?), + ?, + ?, + ?, + ?, + ? + )", + (&openid_user.sub, &openid_provider, &openid_user.sub, &openid_user.name, access_token, refresh_token), )?; - Ok(Node { - id, - stable_id, - user_id: row.0, - name: row.1, - node_key: row.2, - machine_key: row.3, - disco_key: row.4, - addresses: parse_json(&row.5)?, - allowed_ips: parse_json(&row.6)?, - endpoints: parse_json(&row.7)?, - home_derp: row.8, - hostinfo: row.9.map(|raw| parse_json::(&raw)).transpose()?, - tags: parse_json(&row.10)?, - primary_routes: parse_json(&row.11)?, - cap_version: row.12, - cap_map: parse_json::(&row.13)?, - peer_cap_map: parse_json::(&row.14)?, - machine_authorized: row.15 != 0, - node_key_expired: row.16 != 0, - created_at: Some(created_at_hint.unwrap_or(row.17)), - updated_at: Some(row.18), - last_seen: row.19, - online: row.20.map(|value| value != 0), - }) + + Ok(()) } -fn load_user(conn: &Connection, user_id: i64) -> Result { - let profile = load_user_profile(conn, user_id)?; - Ok(StoredUser { profile }) -} - -fn load_user_profile(conn: &Connection, user_id: i64) -> Result { - let row = conn.query_row( - "SELECT email, display_name, profile_pic_url, groups_json FROM auth_user WHERE id = ?", - [user_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, String>(3)?, - )) - }, - )?; - Ok(UserProfile { - id: user_id, - login_name: row.0, - display_name: row.1, - profile_pic_url: row.2, - groups: parse_json(&row.3)?, - }) -} - -fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng); - let hash = Argon2::default() - .hash_password(password.as_bytes(), &salt) - .map_err(|err| anyhow!("failed to hash password: {err}"))?; - Ok(hash.to_string()) -} - -fn verify_password(password: &str, password_hash: &str) -> bool { - PasswordHash::new(password_hash) - .ok() - .and_then(|hash| { - Argon2::default() - .verify_password(password.as_bytes(), &hash) - .ok() - }) - .is_some() -} - -fn random_token() -> String { - let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); - general_purpose::URL_SAFE_NO_PAD.encode(bytes) -} - -fn to_json(value: &T) -> Result { - serde_json::to_string(value).context("failed to serialize json") -} - -fn optional_json(value: &Option) -> Result> { - value.as_ref().map(to_json).transpose() -} - -fn parse_json(value: &str) -> Result { - serde_json::from_str(value) - .with_context(|| format!("failed to decode json payload from '{value}'")) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::control::{Hostinfo, RegisterRequest}; - use tempfile::TempDir; - - fn temp_db() -> Result<(TempDir, String)> { - let dir = tempfile::tempdir()?; - let db_path = dir.path().join("server.sqlite3"); - Ok((dir, db_path.to_string_lossy().to_string())) - } - - #[test] - fn local_auth_and_map_round_trip() -> Result<()> { - let (_dir, db_path) = temp_db()?; - init_db(&db_path)?; - ensure_local_identity( - &db_path, - "contact", - "contact@burrow.net", - "Burrow Contact", - "password-1", - )?; - - let auth = authenticate_local(&db_path, "contact", "password-1")? - .expect("expected login to succeed"); - let user = - user_for_session(&db_path, &auth.access_token)?.expect("expected session to resolve"); - - let node = upsert_node( - &db_path, - &user, - &RegisterRequest { - node_key: "nodekey:aaaa".to_owned(), - machine_key: Some("machinekey:aaaa".to_owned()), - disco_key: Some("discokey:aaaa".to_owned()), - addresses: vec!["100.64.0.1/32".to_owned()], - endpoints: vec!["203.0.113.10:41641".to_owned()], - hostinfo: Some(Hostinfo { - hostname: Some("burrow-dev".to_owned()), - os: Some("linux".to_owned()), - os_version: Some("6.13".to_owned()), - services: vec!["ssh".to_owned()], - request_tags: vec!["tag:dev".to_owned()], - }), - ..RegisterRequest::default() - }, - )?; - assert_eq!(node.name, "burrow-dev"); - assert_eq!(node.allowed_ips, vec!["100.64.0.1/32"]); - - let map = map_for_node( - &db_path, - &user, - &MapRequest { - node_key: "nodekey:aaaa".to_owned(), - stream: true, - endpoints: vec!["203.0.113.10:41641".to_owned()], - ..MapRequest::default() - }, - "burrow.net", - )?; - assert_eq!(map.node.node_key, "nodekey:aaaa"); - assert_eq!(map.domain, "burrow.net"); - assert!(map.dns.expect("dns config").magic_dns); - Ok(()) - } - - #[test] - fn register_can_rotate_node_keys() -> Result<()> { - let (_dir, db_path) = temp_db()?; - init_db(&db_path)?; - ensure_local_identity( - &db_path, - "contact", - "contact@burrow.net", - "Burrow Contact", - "password-1", - )?; - let auth = authenticate_local(&db_path, "contact@burrow.net", "password-1")? - .expect("expected login to succeed"); - let user = - user_for_session(&db_path, &auth.access_token)?.expect("expected session to resolve"); - - upsert_node( - &db_path, - &user, - &RegisterRequest { - node_key: "nodekey:old".to_owned(), - addresses: vec!["100.64.0.2/32".to_owned()], - ..RegisterRequest::default() - }, - )?; - - let rotated = upsert_node( - &db_path, - &user, - &RegisterRequest { - node_key: "nodekey:new".to_owned(), - old_node_key: Some("nodekey:old".to_owned()), - addresses: vec!["100.64.0.3/32".to_owned()], - ..RegisterRequest::default() - }, - )?; - assert_eq!(rotated.node_key, "nodekey:new"); - assert_eq!(rotated.addresses, vec!["100.64.0.3/32"]); - Ok(()) - } +pub fn store_device( + openid_user: super::providers::OpenIdUser, + openid_provider: &str, + access_token: &str, + refresh_token: Option<&str>, +) -> Result<()> { + log::debug!("Storing openid user {:#?}", openid_user); + let conn = rusqlite::Connection::open(PATH)?; + + // TODO + + Ok(()) } diff --git a/burrow/src/auth/server/mod.rs b/burrow/src/auth/server/mod.rs index fdffce3..88b3ff3 100644 --- a/burrow/src/auth/server/mod.rs +++ b/burrow/src/auth/server/mod.rs @@ -1,297 +1,32 @@ pub mod db; -pub mod tailscale; +pub mod providers; -use std::{env, path::Path}; - -use anyhow::{Context, Result}; -use axum::{ - extract::{Json, Path as AxumPath, Query, State}, - http::{header::AUTHORIZATION, HeaderMap, StatusCode}, - response::IntoResponse, - routing::{get, post}, - Router, -}; -use serde::Deserialize; +use anyhow::Result; +use axum::{http::StatusCode, routing::post, Router}; +use providers::slack::auth; use tokio::signal; -use crate::control::{ - discovery, LocalAuthRequest, LocalAuthResponse, MapRequest, MapResponse, RegisterRequest, - RegisterResponse, TailnetDiscovery, BURROW_TAILNET_DOMAIN, -}; - -#[derive(Clone, Debug)] -pub struct BootstrapIdentity { - pub username: String, - pub email: String, - pub display_name: String, - pub password_file: String, -} - -impl Default for BootstrapIdentity { - fn default() -> Self { - Self { - username: "contact".to_owned(), - email: "contact@burrow.net".to_owned(), - display_name: "Burrow Contact".to_owned(), - password_file: "intake/forgejo_pass_contact_at_burrow_net.txt".to_owned(), - } - } -} - -#[derive(Clone, Debug)] -pub struct AuthServerConfig { - pub listen: String, - pub db_path: String, - pub tailnet_domain: String, - pub bootstrap: BootstrapIdentity, -} - -impl Default for AuthServerConfig { - fn default() -> Self { - Self { - listen: "0.0.0.0:8080".to_owned(), - db_path: db::PATH.to_owned(), - tailnet_domain: BURROW_TAILNET_DOMAIN.to_owned(), - bootstrap: BootstrapIdentity::default(), - } - } -} - -impl AuthServerConfig { - pub fn from_env() -> Self { - let mut config = Self::default(); - if let Ok(value) = env::var("BURROW_AUTH_LISTEN") { - config.listen = value; - } - if let Ok(value) = env::var("BURROW_AUTH_DB_PATH") { - config.db_path = value; - } - if let Ok(value) = env::var("BURROW_AUTH_TAILNET_DOMAIN") { - config.tailnet_domain = value; - } - if let Ok(value) = env::var("BURROW_BOOTSTRAP_USERNAME") { - config.bootstrap.username = value; - } - if let Ok(value) = env::var("BURROW_BOOTSTRAP_EMAIL") { - config.bootstrap.email = value; - } - if let Ok(value) = env::var("BURROW_BOOTSTRAP_DISPLAY_NAME") { - config.bootstrap.display_name = value; - } - if let Ok(value) = env::var("BURROW_BOOTSTRAP_PASSWORD_FILE") { - config.bootstrap.password_file = value; - } - config - } - - fn bootstrap_password(&self) -> Result> { - let path = Path::new(&self.bootstrap.password_file); - if !path.exists() { - return Ok(None); - } - let password = std::fs::read_to_string(path).with_context(|| { - format!("failed to read bootstrap password from {}", path.display()) - })?; - let password = password.trim().to_owned(); - if password.is_empty() { - return Ok(None); - } - Ok(Some(password)) - } -} - -#[derive(Clone)] -struct AppState { - config: AuthServerConfig, - tailscale: tailscale::TailscaleBridgeManager, -} - -#[derive(Debug, Deserialize)] -struct TailnetDiscoveryQuery { - email: String, -} - -type AppResult = Result; - pub async fn serve() -> Result<()> { - serve_with_config(AuthServerConfig::from_env()).await -} + db::init_db()?; -pub async fn serve_with_config(config: AuthServerConfig) -> Result<()> { - db::init_db(&config.db_path)?; - if let Some(password) = config.bootstrap_password()? { - db::ensure_local_identity( - &config.db_path, - &config.bootstrap.username, - &config.bootstrap.email, - &config.bootstrap.display_name, - &password, - )?; - } + let app = Router::new() + .route("/slack-auth", post(auth)) + .route("/device/new", post(device_new)); - let app = build_router(config.clone()); - let listener = tokio::net::TcpListener::bind(&config.listen).await?; - log::info!("Starting auth server on {}", config.listen); + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); + log::info!("Starting auth server on port 8080"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) - .await?; + .await + .unwrap(); + Ok(()) } -pub fn build_router(config: AuthServerConfig) -> Router { - Router::new() - .route("/healthz", get(healthz)) - .route("/device/new", post(device_new)) - .route("/v1/auth/login", post(login_local)) - .route("/v1/control/register", post(control_register)) - .route("/v1/control/map", post(control_map)) - .route("/v1/tailnet/discover", get(tailnet_discover)) - .route("/v1/tailscale/login/start", post(tailscale_login_start)) - .route("/v1/tailscale/login/:session_id", get(tailscale_login_status)) - .with_state(AppState { - config, - tailscale: tailscale::TailscaleBridgeManager::default(), - }) -} - -async fn login_local( - State(state): State, - Json(request): Json, -) -> AppResult> { - let db_path = state.config.db_path.clone(); - blocking(move || db::authenticate_local(&db_path, &request.identifier, &request.password)) - .await? - .map(Json) - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "invalid credentials".to_owned())) -} - -async fn control_register( - headers: HeaderMap, - State(state): State, - Json(request): Json, -) -> AppResult> { - let token = bearer_token(&headers)?; - let db_path = state.config.db_path.clone(); - let user = blocking({ - let db_path = db_path.clone(); - let token = token.clone(); - move || db::user_for_session(&db_path, &token) - }) - .await? - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "unknown session".to_owned()))?; - - let response_user = user.profile.clone(); - let node = blocking(move || db::upsert_node(&db_path, &user, &request)).await?; - Ok(Json(RegisterResponse { - user: response_user, - machine_authorized: node.machine_authorized, - node_key_expired: node.node_key_expired, - auth_url: None, - error: None, - node, - })) -} - -async fn control_map( - headers: HeaderMap, - State(state): State, - Json(request): Json, -) -> AppResult> { - let token = bearer_token(&headers)?; - let db_path = state.config.db_path.clone(); - let domain = state.config.tailnet_domain.clone(); - let user = blocking({ - let db_path = db_path.clone(); - let token = token.clone(); - move || db::user_for_session(&db_path, &token) - }) - .await? - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "unknown session".to_owned()))?; - - let response = blocking(move || db::map_for_node(&db_path, &user, &request, &domain)).await?; - Ok(Json(response)) -} - -async fn tailnet_discover( - Query(query): Query, -) -> AppResult> { - if query.email.trim().is_empty() { - return Err((StatusCode::BAD_REQUEST, "email is required".to_owned())); - } - - let discovery = discovery::discover_tailnet(&query.email) - .await - .map_err(|err| (StatusCode::BAD_GATEWAY, err.to_string()))?; - Ok(Json(discovery)) -} - -async fn tailscale_login_start( - State(state): State, - Json(request): Json, -) -> AppResult> { - let response = state - .tailscale - .start_login(request) - .await - .map_err(internal_error)?; - Ok(Json(response)) -} - -async fn tailscale_login_status( - AxumPath(session_id): AxumPath, - State(state): State, -) -> AppResult> { - state - .tailscale - .status(&session_id) - .await - .map_err(internal_error)? - .map(Json) - .ok_or_else(|| (StatusCode::NOT_FOUND, "unknown tailscale login session".to_owned())) -} - -async fn healthz() -> impl IntoResponse { +async fn device_new() -> StatusCode { StatusCode::OK } -async fn device_new() -> impl IntoResponse { - StatusCode::OK -} - -async fn blocking(work: F) -> AppResult -where - F: FnOnce() -> Result + Send + 'static, - T: Send + 'static, -{ - tokio::task::spawn_blocking(work) - .await - .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))? - .map_err(internal_error) -} - -fn internal_error(err: anyhow::Error) -> (StatusCode, String) { - (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) -} - -fn bearer_token(headers: &HeaderMap) -> AppResult { - let value = headers.get(AUTHORIZATION).ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - "missing authorization header".to_owned(), - ) - })?; - let value = value.to_str().map_err(|_| { - ( - StatusCode::BAD_REQUEST, - "invalid authorization header".to_owned(), - ) - })?; - value - .strip_prefix("Bearer ") - .map(ToOwned::to_owned) - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "expected bearer token".to_owned())) -} - async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() @@ -316,115 +51,12 @@ async fn shutdown_signal() { } } -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::{to_bytes, Body}, - http::{Request, StatusCode}, - }; - use tempfile::tempdir; - use tower::ServiceExt; +// mod db { +// use rusqlite::{Connection, Result}; - #[tokio::test] - async fn login_register_and_map_round_trip() -> Result<()> { - let dir = tempdir()?; - let password_file = dir.path().join("bootstrap-password.txt"); - std::fs::write(&password_file, "bootstrap-pass\n")?; - let db_path = dir.path().join("server.sqlite3"); - let config = AuthServerConfig { - listen: "127.0.0.1:0".to_owned(), - db_path: db_path.to_string_lossy().to_string(), - tailnet_domain: "burrow.net".to_owned(), - bootstrap: BootstrapIdentity { - password_file: password_file.to_string_lossy().to_string(), - ..BootstrapIdentity::default() - }, - }; - - db::init_db(&config.db_path)?; - let password = config.bootstrap_password()?.expect("bootstrap password"); - db::ensure_local_identity( - &config.db_path, - &config.bootstrap.username, - &config.bootstrap.email, - &config.bootstrap.display_name, - &password, - )?; - - let app = build_router(config); - - let response = app - .clone() - .oneshot( - Request::post("/v1/auth/login") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&LocalAuthRequest { - identifier: "contact".to_owned(), - password: "bootstrap-pass".to_owned(), - })?))?, - ) - .await?; - assert_eq!(response.status(), StatusCode::OK); - let login: LocalAuthResponse = - serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?; - - let response = app - .clone() - .oneshot( - Request::post("/v1/control/register") - .header("content-type", "application/json") - .header("authorization", format!("Bearer {}", login.access_token)) - .body(Body::from(serde_json::to_vec(&RegisterRequest { - node_key: "nodekey:1234".to_owned(), - machine_key: Some("machinekey:1234".to_owned()), - addresses: vec!["100.64.0.10/32".to_owned()], - endpoints: vec!["198.51.100.10:41641".to_owned()], - hostinfo: Some(crate::control::Hostinfo { - hostname: Some("devbox".to_owned()), - os: Some("linux".to_owned()), - os_version: Some("6.13".to_owned()), - services: vec!["ssh".to_owned()], - request_tags: vec!["tag:dev".to_owned()], - }), - ..RegisterRequest::default() - })?))?, - ) - .await?; - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .oneshot( - Request::post("/v1/control/map") - .header("content-type", "application/json") - .header("authorization", format!("Bearer {}", login.access_token)) - .body(Body::from(serde_json::to_vec(&MapRequest { - node_key: "nodekey:1234".to_owned(), - stream: true, - endpoints: vec!["198.51.100.10:41641".to_owned()], - ..MapRequest::default() - })?))?, - ) - .await?; - assert_eq!(response.status(), StatusCode::OK); - let map: MapResponse = - serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?; - assert_eq!(map.domain, "burrow.net"); - assert_eq!(map.node.name, "devbox"); - assert!(map.dns.expect("dns").magic_dns); - Ok(()) - } - - #[tokio::test] - async fn tailnet_discover_requires_email() -> Result<()> { - let app = build_router(AuthServerConfig::default()); - let response = app - .oneshot( - Request::get("/v1/tailnet/discover?email=") - .body(Body::empty())?, - ) - .await?; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - Ok(()) - } -} +// #[derive(Debug)] +// struct User { +// id: i32, +// created_at: String, +// } +// } diff --git a/burrow/src/auth/server/providers/mod.rs b/burrow/src/auth/server/providers/mod.rs new file mode 100644 index 0000000..36ff0bd --- /dev/null +++ b/burrow/src/auth/server/providers/mod.rs @@ -0,0 +1,8 @@ +pub mod slack; +pub use super::db; + +#[derive(serde::Deserialize, Default, Debug)] +pub struct OpenIdUser { + pub sub: String, + pub name: String, +} diff --git a/burrow/src/auth/server/providers/slack.rs b/burrow/src/auth/server/providers/slack.rs new file mode 100644 index 0000000..581cd1e --- /dev/null +++ b/burrow/src/auth/server/providers/slack.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use axum::{ + extract::Json, + http::StatusCode, + routing::{get, post}, +}; +use reqwest::header::AUTHORIZATION; +use serde::Deserialize; + +use super::db::store_connection; + +#[derive(Deserialize)] +pub struct SlackToken { + slack_token: String, +} +pub async fn auth(Json(payload): Json) -> (StatusCode, String) { + let slack_user = match fetch_slack_user(&payload.slack_token).await { + Ok(user) => user, + Err(e) => { + log::error!("Failed to fetch Slack user: {:?}", e); + return (StatusCode::UNAUTHORIZED, String::new()); + } + }; + + log::info!( + "Slack user {} ({}) logged in.", + slack_user.name, + slack_user.sub + ); + + let conn = match store_connection(slack_user, "slack", &payload.slack_token, None) { + Ok(user) => user, + Err(e) => { + log::error!("Failed to fetch Slack user: {:?}", e); + return (StatusCode::UNAUTHORIZED, String::new()); + } + }; + + (StatusCode::OK, String::new()) +} + +async fn fetch_slack_user(access_token: &str) -> Result { + let client = reqwest::Client::new(); + let res = client + .get("https://slack.com/api/openid.connect.userInfo") + .header(AUTHORIZATION, format!("Bearer {}", access_token)) + .send() + .await? + .json::() + .await?; + + let res_ok = res + .get("ok") + .and_then(|v| v.as_bool()) + .ok_or(anyhow::anyhow!("Slack user object not ok!"))?; + + if !res_ok { + return Err(anyhow::anyhow!("Slack user object not ok!")); + } + + Ok(serde_json::from_value(res)?) +} + +// async fn fetch_save_slack_user_data(query: Query) -> anyhow::Result<()> { +// let client = reqwest::Client::new(); +// log::trace!("Code was {}", &query.code); +// let mut url = Url::parse("https://slack.com/api/openid.connect.token")?; + +// { +// let mut q = url.query_pairs_mut(); +// q.append_pair("client_id", &var("CLIENT_ID")?); +// q.append_pair("client_secret", &var("CLIENT_SECRET")?); +// q.append_pair("code", &query.code); +// q.append_pair("grant_type", "authorization_code"); +// q.append_pair("redirect_uri", "https://burrow.rs/callback"); +// } + +// let data = client +// .post(url) +// .send() +// .await? +// .json::() +// .await?; + +// if !data.ok { +// return Err(anyhow::anyhow!("Slack code exchange response not ok!")); +// } + +// if let Some(access_token) = data.access_token { +// log::trace!("Access token is {access_token}"); +// let user = slack::fetch_slack_user(&access_token) +// .await +// .map_err(|err| anyhow::anyhow!("Failed to fetch Slack user info {:#?}", err))?; + +// db::store_user(user, access_token, String::new()) +// .map_err(|_| anyhow::anyhow!("Failed to store user in db"))?; + +// Ok(()) +// } else { +// Err(anyhow::anyhow!("Access token not found in response")) +// } +// } diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs deleted file mode 100644 index d08c807..0000000 --- a/burrow/src/auth/server/tailscale.rs +++ /dev/null @@ -1,519 +0,0 @@ -use std::{ - collections::HashMap, - env, - path::{Path, PathBuf}, - process::Stdio, - sync::Arc, - time::Duration, -}; - -use anyhow::{anyhow, Context, Result}; -use rand::RngCore; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::{Child, Command}, - sync::Mutex, - task::JoinHandle, -}; - -#[derive(Clone, Debug, Default, Deserialize)] -pub struct TailscaleLoginStartRequest { - pub account_name: String, - pub identity_name: String, - #[serde(default)] - pub hostname: Option, - #[serde(default)] - pub control_url: Option, - #[serde(default)] - pub packet_socket: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -pub struct TailscaleLoginStatus { - pub backend_state: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_url: Option, - #[serde(default)] - pub running: bool, - #[serde(default)] - pub needs_login: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tailnet_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub magic_dns_suffix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub self_dns_name: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tailscale_ips: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub health: Vec, -} - -#[derive(Clone, Debug, Serialize)] -pub struct TailscaleLoginStartResponse { - pub session_id: String, - pub status: TailscaleLoginStatus, -} - -pub struct TailscaleLoginSession { - pub session_id: String, - pub helper: Arc, - pub status: TailscaleLoginStatus, -} - -#[derive(Clone, Default)] -pub struct TailscaleBridgeManager { - client: Client, - sessions: Arc>>>, -} - -pub struct TailscaleHelperProcess { - session_id: String, - listen_url: String, - packet_socket: Option, - control_url: Option, - state_dir: PathBuf, - child: Arc>, - _stderr_task: JoinHandle<()>, -} - -type ManagedSession = TailscaleHelperProcess; - -#[derive(Debug, Deserialize)] -struct HelperHello { - listen_addr: String, - #[serde(default)] - packet_socket: Option, -} - -impl TailscaleBridgeManager { - pub async fn start_login( - &self, - request: TailscaleLoginStartRequest, - ) -> Result { - let session = self.ensure_session(request).await?; - Ok(TailscaleLoginStartResponse { - session_id: session.session_id, - status: session.status, - }) - } - - pub async fn ensure_session( - &self, - request: TailscaleLoginStartRequest, - ) -> Result { - let key = session_key_for_request(&request); - let requested_packet_socket = request - .packet_socket - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - let requested_control_url = request - .control_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - - if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { - let needs_restart_for_socket = match (requested_packet_socket, existing.packet_socket()) - { - (Some(requested), Some(current)) => current != Path::new(requested), - (Some(_), None) => true, - _ => false, - }; - let needs_restart_for_control_url = - requested_control_url != existing.control_url().map(|value| value.trim()); - - if !needs_restart_for_socket && !needs_restart_for_control_url { - match self.fetch_status(existing.as_ref()).await { - Ok(status) => { - return Ok(TailscaleLoginSession { - session_id: existing.session_id.clone(), - helper: existing, - status, - }); - } - Err(err) => { - log::warn!( - "tailscale login session {} is stale, restarting: {err}", - existing.session_id - ); - } - } - } else { - log::info!( - "tailscale login session {} no longer matches requested transport, restarting", - existing.session_id - ); - } - - self.sessions.lock().await.remove(&key); - let _ = self.shutdown_session(existing.as_ref()).await; - } - - let session = Arc::new(spawn_tailscale_helper(&request).await?); - let status = self.wait_for_status(session.as_ref()).await?; - let response = TailscaleLoginSession { - session_id: session.session_id.clone(), - helper: session.clone(), - status, - }; - - self.sessions.lock().await.insert(key, session); - Ok(response) - } - - pub async fn status(&self, session_id: &str) -> Result> { - let session = { - let sessions = self.sessions.lock().await; - sessions - .values() - .find(|session| session.session_id == session_id) - .cloned() - }; - - match session { - Some(session) => match self.fetch_status(session.as_ref()).await { - Ok(status) => Ok(Some(status)), - Err(err) => { - self.remove_session_by_id(session_id).await; - Err(err) - } - }, - None => Ok(None), - } - } - - pub async fn cancel(&self, session_id: &str) -> Result { - let session = self.remove_session_by_id(session_id).await; - match session { - Some(session) => { - self.shutdown_session(session.as_ref()).await?; - Ok(true) - } - None => Ok(false), - } - } - - async fn wait_for_status(&self, session: &ManagedSession) -> Result { - let mut last_error = None; - let mut last_status = None; - for _ in 0..40 { - match session.status_with_client(&self.client).await { - Ok(status) if status.running || status.auth_url.is_some() => return Ok(status), - Ok(status) => last_status = Some(status), - Err(err) => last_error = Some(err), - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - if let Some(status) = last_status { - return Ok(status); - } - Err(last_error.unwrap_or_else(|| anyhow!("tailscale helper did not become ready"))) - } - - async fn fetch_status(&self, session: &ManagedSession) -> Result { - session.status_with_client(&self.client).await - } - - async fn remove_session_by_id(&self, session_id: &str) -> Option> { - let mut sessions = self.sessions.lock().await; - let key = sessions - .iter() - .find_map(|(key, session)| (session.session_id == session_id).then(|| key.clone()))?; - sessions.remove(&key) - } - - async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> { - session.shutdown_with_client(&self.client).await - } -} - -impl TailscaleHelperProcess { - pub fn session_id(&self) -> &str { - &self.session_id - } - - pub fn packet_socket(&self) -> Option<&Path> { - self.packet_socket.as_deref() - } - - pub fn control_url(&self) -> Option<&str> { - self.control_url.as_deref() - } - - pub fn state_dir(&self) -> &Path { - &self.state_dir - } - - pub async fn status(&self) -> Result { - self.status_with_client(&Client::new()).await - } - - pub async fn shutdown(&self) -> Result<()> { - self.shutdown_with_client(&Client::new()).await - } - - async fn status_with_client(&self, client: &Client) -> Result { - let mut child = self.child.lock().await; - if let Some(status) = child.try_wait()? { - return Err(anyhow!( - "tailscale helper exited with status {status} for {}", - self.state_dir.display() - )); - } - drop(child); - - let response = client - .get(format!("{}/status", self.listen_url)) - .send() - .await - .context("failed to query tailscale helper status")? - .error_for_status() - .context("tailscale helper status request failed")?; - - let status = response - .json::() - .await - .context("invalid tailscale helper status response")?; - - log::info!( - "tailscale helper status session={} backend_state={} running={} needs_login={} auth_url={:?}", - self.session_id, - status.backend_state, - status.running, - status.needs_login, - status.auth_url - ); - Ok(status) - } - - async fn shutdown_with_client(&self, client: &Client) -> Result<()> { - let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await; - - for _ in 0..10 { - let mut child = self.child.lock().await; - if child.try_wait()?.is_some() { - return Ok(()); - } - drop(child); - tokio::time::sleep(Duration::from_millis(100)).await; - } - - let mut child = self.child.lock().await; - child - .start_kill() - .context("failed to kill tailscale helper")?; - let _ = child.wait().await; - Ok(()) - } -} - -pub async fn spawn_tailscale_helper( - request: &TailscaleLoginStartRequest, -) -> Result { - let state_dir = state_root().join(session_dir_name(request)); - tokio::fs::create_dir_all(&state_dir) - .await - .with_context(|| format!("failed to create {}", state_dir.display()))?; - - let mut child = helper_command(request, &state_dir)? - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn tailscale login helper")?; - - let stdout = child - .stdout - .take() - .context("tailscale helper stdout unavailable")?; - let stderr = child - .stderr - .take() - .context("tailscale helper stderr unavailable")?; - - let hello_line = tokio::time::timeout(Duration::from_secs(20), async move { - let mut lines = BufReader::new(stdout).lines(); - lines.next_line().await - }) - .await - .context("timed out waiting for tailscale helper startup")?? - .context("tailscale helper exited before reporting listen address")?; - - let hello: HelperHello = - serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?; - - let stderr_task = tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - log::info!("tailscale-login-bridge: {line}"); - } - }); - - Ok(TailscaleHelperProcess { - session_id: random_session_id(), - listen_url: format!("http://{}", hello.listen_addr), - packet_socket: hello.packet_socket.map(PathBuf::from), - control_url: request.control_url.clone(), - state_dir, - child: Arc::new(Mutex::new(child)), - _stderr_task: stderr_task, - }) -} - -fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result { - let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") { - Command::new(path) - } else { - let helper_dir = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("Tools/tailscale-login-bridge"); - let mut command = Command::new("go"); - command.current_dir(helper_dir).arg("run").arg("."); - command.env("GOWORK", "off"); - command - }; - - command - .arg("--listen") - .arg("127.0.0.1:0") - .arg("--state-dir") - .arg(state_dir) - .arg("--hostname") - .arg(default_hostname(request)); - - if let Some(control_url) = request.control_url.as_deref() { - let trimmed = control_url.trim(); - if !trimmed.is_empty() { - command.arg("--control-url").arg(trimmed); - } - } - - if let Some(packet_socket) = request.packet_socket.as_deref() { - let trimmed = packet_socket.trim(); - if !trimmed.is_empty() { - command.arg("--packet-socket").arg(trimmed); - } - } - - Ok(command) -} - -pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf { - state_root().join(session_dir_name(request)).join("packet.sock") -} - -pub(crate) fn state_root() -> PathBuf { - if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") { - return PathBuf::from(path); - } - - let home = env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - if cfg!(target_vendor = "apple") { - return home - .join("Library") - .join("Application Support") - .join("Burrow") - .join("tailscale"); - } - home.join(".local") - .join("share") - .join("burrow") - .join("tailscale") -} - -pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { - format!( - "{}-{}-{}", - slug(&request.account_name), - slug(&request.identity_name), - slug(control_scope(request)) - ) -} - -fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String { - format!( - "{}:{}:{}", - request.account_name, - request.identity_name, - control_scope(request) - ) -} - -fn control_scope(request: &TailscaleLoginStartRequest) -> &str { - request - .control_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("tailscale-managed") -} - -pub(crate) fn default_hostname(request: &TailscaleLoginStartRequest) -> String { - request - .hostname - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| format!("burrow-{}", slug(&request.identity_name))) -} - -fn random_session_id() -> String { - let mut bytes = [0_u8; 12]; - rand::thread_rng().fill_bytes(&mut bytes); - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn slug(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - for ch in input.chars() { - if ch.is_ascii_alphanumeric() { - output.push(ch.to_ascii_lowercase()); - } else if ch == '-' || ch == '_' { - output.push('-'); - } - } - if output.is_empty() { - "default".to_owned() - } else { - output - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slug_sanitizes_input() { - assert_eq!(slug("Apple Phone"), "applephone"); - assert_eq!(slug("default_identity"), "default-identity"); - assert_eq!(slug(""), "default"); - } - - #[test] - fn state_dir_is_scoped_by_account_identity_and_control_plane() { - let request = TailscaleLoginStartRequest { - account_name: "default".to_owned(), - identity_name: "apple".to_owned(), - hostname: None, - control_url: None, - packet_socket: None, - }; - assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed"); - assert_eq!(default_hostname(&request), "burrow-apple"); - - let custom_request = TailscaleLoginStartRequest { - control_url: Some("https://ts.burrow.net".to_owned()), - ..request - }; - assert_eq!( - session_dir_name(&custom_request), - "default-apple-httpstsburrownet" - ); - } -} diff --git a/burrow/src/control/config.rs b/burrow/src/control/config.rs deleted file mode 100644 index 3862bcd..0000000 --- a/burrow/src/control/config.rs +++ /dev/null @@ -1,87 +0,0 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TailnetProvider { - Tailscale, - Headscale, - Burrow, -} - -impl Default for TailnetProvider { - fn default() -> Self { - Self::Tailscale - } -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct TailnetConfig { - #[serde(default)] - pub provider: TailnetProvider, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub authority: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub account: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub identity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tailnet: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hostname: Option, -} - -impl TailnetConfig { - pub fn from_slice(bytes: &[u8]) -> Result { - let payload = std::str::from_utf8(bytes).context("tailnet payload must be valid UTF-8")?; - Self::from_str(payload) - } - - pub fn from_str(payload: &str) -> Result { - let trimmed = payload.trim(); - if trimmed.starts_with('{') { - return serde_json::from_str(trimmed).context("invalid tailnet JSON payload"); - } - toml::from_str(trimmed).context("invalid tailnet TOML payload") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_json_payload() { - let config = TailnetConfig::from_str( - r#"{ - "provider":"tailscale", - "account":"default", - "identity":"apple", - "tailnet":"example.ts.net", - "hostname":"burrow-phone" - }"#, - ) - .unwrap(); - assert_eq!(config.provider, TailnetProvider::Tailscale); - assert_eq!(config.account.as_deref(), Some("default")); - assert_eq!(config.identity.as_deref(), Some("apple")); - } - - #[test] - fn parses_toml_payload() { - let config = TailnetConfig::from_str( - r#" -provider = "headscale" -authority = "https://headscale.example.com" -account = "default" -identity = "apple" -"#, - ) - .unwrap(); - assert_eq!(config.provider, TailnetProvider::Headscale); - assert_eq!( - config.authority.as_deref(), - Some("https://headscale.example.com") - ); - } -} diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs deleted file mode 100644 index d044a62..0000000 --- a/burrow/src/control/discovery.rs +++ /dev/null @@ -1,359 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use reqwest::{Client, StatusCode, Url}; -use serde::{Deserialize, Serialize}; -use tracing::{debug, info}; - -use super::TailnetProvider; - -pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server"; -const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet"; -const WEBFINGER_PATH: &str = "/.well-known/webfinger"; -const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com"; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct TailnetDiscovery { - pub domain: String, - pub provider: TailnetProvider, - pub authority: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oidc_issuer: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct TailnetAuthorityProbe { - pub authority: String, - pub status_code: i32, - pub summary: String, - pub detail: String, - pub reachable: bool, -} - -#[derive(Clone, Debug, Default, Deserialize)] -struct WebFingerDocument { - #[serde(default)] - links: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize)] -struct WebFingerLink { - #[serde(default)] - rel: String, - #[serde(default)] - href: Option, -} - -pub async fn discover_tailnet(email: &str) -> Result { - let domain = email_domain(email)?; - info!(%email, %domain, "tailnet discovery requested"); - let base_url = Url::parse(&format!("https://{domain}")) - .with_context(|| format!("invalid discovery domain {domain}"))?; - let client = Client::builder() - .user_agent("burrow-tailnet-discovery") - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("failed to build tailnet discovery client")?; - discover_tailnet_at(&client, email, &base_url).await -} - -pub fn normalize_authority(authority: &str) -> String { - let trimmed = authority.trim(); - if trimmed.contains("://") { - trimmed.to_owned() - } else { - format!("https://{trimmed}") - } -} - -pub fn is_managed_tailscale_authority(authority: &str) -> bool { - let normalized = normalize_authority(authority) - .trim_end_matches('/') - .to_ascii_lowercase(); - normalized == format!("https://{MANAGED_TAILSCALE_AUTHORITY}") - || normalized == format!("http://{MANAGED_TAILSCALE_AUTHORITY}") -} - -pub async fn probe_tailnet_authority(authority: &str) -> Result { - let authority = normalize_authority(authority); - if is_managed_tailscale_authority(&authority) { - return Ok(TailnetAuthorityProbe { - authority, - status_code: 200, - summary: "Tailscale-managed control plane".to_owned(), - detail: "Using Tailscale's default login server.".to_owned(), - reachable: true, - }); - } - - let base_url = - Url::parse(&authority).with_context(|| format!("invalid tailnet authority {authority}"))?; - let client = Client::builder() - .user_agent("burrow-tailnet-probe") - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("failed to build tailnet authority probe client")?; - - if let Some(status) = - probe_url(&client, base_url.join("/health")?, &authority, "Tailnet server reachable").await? - { - return Ok(status); - } - - if let Some(status) = probe_url( - &client, - base_url.clone(), - &authority, - "Tailnet server reachable", - ) - .await? - { - return Ok(status); - } - - Err(anyhow!("could not connect to the server")) -} - -pub async fn discover_tailnet_at( - client: &Client, - email: &str, - base_url: &Url, -) -> Result { - let domain = email_domain(email)?; - debug!(%email, %domain, base_url = %base_url, "starting tailnet domain discovery"); - - if let Some(discovery) = discover_well_known(client, base_url).await? { - info!( - %email, - %domain, - authority = %discovery.authority, - provider = ?discovery.provider, - "resolved tailnet discovery from well-known document" - ); - return Ok(TailnetDiscovery { domain, ..discovery }); - } - - if let Some(authority) = discover_webfinger(client, email, base_url).await? { - info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger"); - return Ok(TailnetDiscovery { - domain, - provider: inferred_provider(Some(&authority), None), - authority, - oidc_issuer: None, - }); - } - - Err(anyhow!("no tailnet discovery metadata found for {domain}")) -} - -pub fn email_domain(email: &str) -> Result { - let trimmed = email.trim(); - let (_, domain) = trimmed - .rsplit_once('@') - .ok_or_else(|| anyhow!("email address must include a domain"))?; - let domain = domain.trim().trim_matches('.').to_ascii_lowercase(); - if domain.is_empty() { - return Err(anyhow!("email address must include a domain")); - } - Ok(domain) -} - -pub fn inferred_provider( - authority: Option<&str>, - explicit: Option<&TailnetProvider>, -) -> TailnetProvider { - if matches!(explicit, Some(TailnetProvider::Burrow)) { - return TailnetProvider::Burrow; - } - if authority.is_some_and(is_managed_tailscale_authority) { - return TailnetProvider::Tailscale; - } - TailnetProvider::Headscale -} - -async fn discover_well_known(client: &Client, base_url: &Url) -> Result> { - let url = base_url - .join(TAILNET_DISCOVERY_PATH) - .context("failed to build tailnet discovery URL")?; - debug!(%url, "requesting tailnet well-known document"); - let response = client - .get(url) - .header("accept", "application/json") - .send() - .await - .context("tailnet well-known request failed")?; - - match response.status() { - StatusCode::OK => response - .json::() - .await - .context("invalid tailnet discovery document") - .map(Some), - StatusCode::NOT_FOUND => Ok(None), - status => Err(anyhow!("tailnet well-known lookup failed with HTTP {status}")), - } -} - -async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Result> { - let mut url = base_url - .join(WEBFINGER_PATH) - .context("failed to build webfinger URL")?; - url.query_pairs_mut() - .append_pair("resource", &format!("acct:{email}")) - .append_pair("rel", TAILNET_DISCOVERY_REL); - debug!(%email, url = %url, "requesting tailnet webfinger document"); - - let response = client - .get(url) - .header("accept", "application/jrd+json, application/json") - .send() - .await - .context("tailnet webfinger request failed")?; - - match response.status() { - StatusCode::OK => { - let document = response - .json::() - .await - .context("invalid webfinger document")?; - Ok(document - .links - .into_iter() - .find(|link| link.rel == TAILNET_DISCOVERY_REL) - .and_then(|link| link.href) - .filter(|href| !href.trim().is_empty())) - } - StatusCode::NOT_FOUND => Ok(None), - status => Err(anyhow!("tailnet webfinger lookup failed with HTTP {status}")), - } -} - -async fn probe_url( - client: &Client, - url: Url, - authority: &str, - summary: &str, -) -> Result> { - let response = match client - .get(url) - .header("accept", "application/json") - .send() - .await - { - Ok(response) => response, - Err(_) => return Ok(None), - }; - - let status = response.status(); - if !status.is_success() { - return Ok(None); - } - - let detail = response.text().await.unwrap_or_default().trim().to_owned(); - Ok(Some(TailnetAuthorityProbe { - authority: authority.to_owned(), - status_code: i32::from(status.as_u16()), - summary: summary.to_owned(), - detail, - reachable: true, - })) -} - -#[cfg(test)] -mod tests { - use axum::{routing::get, Router}; - use serde_json::json; - use tokio::net::TcpListener; - - use super::*; - - #[test] - fn extracts_domain_from_email() { - assert_eq!(email_domain("Contact@Burrow.net").unwrap(), "burrow.net"); - assert!(email_domain("contact").is_err()); - } - - #[test] - fn detects_managed_tailscale_authority() { - assert!(is_managed_tailscale_authority("controlplane.tailscale.com")); - assert!(is_managed_tailscale_authority("https://controlplane.tailscale.com/")); - assert!(!is_managed_tailscale_authority("https://ts.burrow.net")); - } - - #[tokio::test] - async fn discovers_from_well_known_document() -> Result<()> { - let router = Router::new().route( - TAILNET_DISCOVERY_PATH, - get(|| async { - axum::Json(json!({ - "domain": "burrow.net", - "provider": "headscale", - "authority": "https://ts.burrow.net", - "oidc_issuer": "https://auth.burrow.net/application/o/ts/" - })) - }), - ); - - let listener = TcpListener::bind("127.0.0.1:0").await?; - let base_url = Url::parse(&format!("http://{}", listener.local_addr()?))?; - let server = tokio::spawn(async move { axum::serve(listener, router).await }); - - let client = Client::builder().build()?; - let discovery = discover_tailnet_at(&client, "contact@burrow.net", &base_url).await?; - assert_eq!(discovery.provider, TailnetProvider::Headscale); - assert_eq!(discovery.authority, "https://ts.burrow.net"); - assert_eq!(discovery.domain, "burrow.net"); - - server.abort(); - Ok(()) - } - - #[tokio::test] - async fn falls_back_to_webfinger_authority() -> Result<()> { - let router = Router::new() - .route( - TAILNET_DISCOVERY_PATH, - get(|| async { (StatusCode::NOT_FOUND, "") }), - ) - .route( - WEBFINGER_PATH, - get(|| async { - axum::Json(json!({ - "subject": "acct:contact@burrow.net", - "links": [ - { - "rel": TAILNET_DISCOVERY_REL, - "href": "https://ts.burrow.net" - } - ] - })) - }), - ); - - let listener = TcpListener::bind("127.0.0.1:0").await?; - let base_url = Url::parse(&format!("http://{}", listener.local_addr()?))?; - let server = tokio::spawn(async move { axum::serve(listener, router).await }); - - let client = Client::builder().build()?; - let discovery = discover_tailnet_at(&client, "contact@burrow.net", &base_url).await?; - assert_eq!(discovery.provider, TailnetProvider::Headscale); - assert_eq!(discovery.authority, "https://ts.burrow.net"); - - server.abort(); - Ok(()) - } - - #[tokio::test] - async fn probes_custom_authority() -> Result<()> { - let router = Router::new().route("/health", get(|| async { "ok" })); - let listener = TcpListener::bind("127.0.0.1:0").await?; - let authority = format!("http://{}", listener.local_addr()?); - let server = tokio::spawn(async move { axum::serve(listener, router).await }); - - let status = probe_tailnet_authority(&authority).await?; - assert_eq!(status.authority, authority); - assert_eq!(status.status_code, 200); - assert!(status.reachable); - - server.abort(); - Ok(()) - } -} diff --git a/burrow/src/control/mod.rs b/burrow/src/control/mod.rs deleted file mode 100644 index 472f673..0000000 --- a/burrow/src/control/mod.rs +++ /dev/null @@ -1,255 +0,0 @@ -pub mod config; -pub mod discovery; - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -pub use config::{TailnetConfig, TailnetProvider}; -pub use discovery::{TailnetDiscovery, TAILNET_DISCOVERY_REL}; - -pub const BURROW_CAPABILITY_VERSION: i32 = 1; -pub const BURROW_TAILNET_DOMAIN: &str = "burrow.net"; - -pub type NodeCapMap = BTreeMap>; -pub type PeerCapMap = BTreeMap>; - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct Hostinfo { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hostname: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub os: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub os_version: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub services: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub request_tags: Vec, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct UserProfile { - pub id: i64, - pub login_name: String, - pub display_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_pic_url: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub groups: Vec, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct RegisterAuth { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oauth_access_token: Option, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub struct Node { - pub id: i64, - pub stable_id: String, - pub name: String, - pub user_id: i64, - pub node_key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub machine_key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disco_key: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub addresses: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_ips: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub endpoints: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub home_derp: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hostinfo: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tags: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub primary_routes: Vec, - #[serde(default = "default_capability_version")] - pub cap_version: i32, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub cap_map: NodeCapMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub peer_cap_map: PeerCapMap, - #[serde(default)] - pub machine_authorized: bool, - #[serde(default)] - pub node_key_expired: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub updated_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_seen: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub online: Option, -} - -impl Node { - pub fn preferred_name(request: &RegisterRequest) -> String { - if let Some(name) = request.name.as_deref() { - return name.to_owned(); - } - if let Some(hostname) = request - .hostinfo - .as_ref() - .and_then(|hostinfo| hostinfo.hostname.as_deref()) - { - return hostname.to_owned(); - } - format!("node-{}", short_key(&request.node_key)) - } - - pub fn normalized_allowed_ips(request: &RegisterRequest) -> Vec { - if request.allowed_ips.is_empty() { - return request.addresses.clone(); - } - request.allowed_ips.clone() - } -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct RegisterRequest { - #[serde(default = "default_capability_version")] - pub version: i32, - pub node_key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub old_node_key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub machine_key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disco_key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expiry: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub followup: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hostinfo: Option, - #[serde(default)] - pub ephemeral: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tailnet: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub addresses: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_ips: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub endpoints: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub home_derp: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tags: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub primary_routes: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub cap_map: NodeCapMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub peer_cap_map: PeerCapMap, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub struct RegisterResponse { - pub user: UserProfile, - pub node: Node, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_url: Option, - pub machine_authorized: bool, - pub node_key_expired: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct MapRequest { - #[serde(default = "default_capability_version")] - pub version: i32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub compress: Option, - #[serde(default)] - pub keep_alive: bool, - pub node_key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disco_key: Option, - #[serde(default)] - pub stream: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hostinfo: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub map_session_handle: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub map_session_seq: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub endpoints: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub debug_flags: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub connection_handle: Option, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct DnsConfig { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub resolvers: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub search_domains: Vec, - #[serde(default)] - pub magic_dns: bool, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct PacketFilter { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub sources: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub destinations: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub protocols: Vec, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub struct MapResponse { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub map_session_handle: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option, - pub node: Node, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub peers: Vec, - pub domain: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dns: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub packet_filters: Vec, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct LocalAuthRequest { - pub identifier: String, - pub password: String, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct LocalAuthResponse { - pub access_token: String, - pub user: UserProfile, -} - -fn default_capability_version() -> i32 { - BURROW_CAPABILITY_VERSION -} - -fn short_key(key: &str) -> String { - key.chars().take(8).collect() -} diff --git a/burrow/src/daemon/apple.rs b/burrow/src/daemon/apple.rs index f369ea9..c60f131 100644 --- a/burrow/src/daemon/apple.rs +++ b/burrow/src/daemon/apple.rs @@ -1,11 +1,11 @@ use std::{ ffi::{c_char, CStr}, path::PathBuf, - sync::{Arc, Mutex}, + sync::Arc, thread, }; -use once_cell::sync::{Lazy, OnceCell}; +use once_cell::sync::OnceCell; use tokio::{ runtime::{Builder, Handle}, sync::Notify, @@ -14,35 +14,25 @@ use tracing::error; use crate::daemon::daemon_main; +static BURROW_NOTIFY: OnceCell> = OnceCell::new(); static BURROW_HANDLE: OnceCell = OnceCell::new(); -static BURROW_READY: OnceCell<()> = OnceCell::new(); -static BURROW_SPAWN_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); #[no_mangle] pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c_char) { - let path_buf = if path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) - }; - let db_path_buf = if db_path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) - }; - spawn_in_process_with_paths(path_buf, db_path_buf); -} - -pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Option) { crate::tracing::initialize(); - let _guard = BURROW_SPAWN_LOCK.lock().unwrap(); - if BURROW_READY.get().is_some() { - return; - } - - let notify = Arc::new(Notify::new()); + let notify = BURROW_NOTIFY.get_or_init(|| Arc::new(Notify::new())); let handle = BURROW_HANDLE.get_or_init(|| { + let path_buf = if path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) + }; + let db_path_buf = if db_path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) + }; let sender = notify.clone(); let (handle_tx, handle_rx) = tokio::sync::oneshot::channel(); @@ -72,5 +62,4 @@ pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Optio let receiver = notify.clone(); handle.block_on(async move { receiver.notified().await }); - let _ = BURROW_READY.set(()); } diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 9b2e138..f21678e 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -3,35 +3,32 @@ use std::{ sync::Arc, }; -use anyhow::Result; +use anyhow::{anyhow, Context, Result}; use rusqlite::Connection; -use tokio::sync::{mpsc, watch, RwLock}; +use tokio::{ + sync::{mpsc, watch, RwLock}, + task::JoinHandle, +}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status as RspStatus}; -use tracing::{debug, info, warn}; -use tun::tokio::TunInterface; +use tracing::warn; +use tun::{tokio::TunInterface, TunOptions}; -use super::{ - rpc::grpc_defs::{ - networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, - Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, - State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, - TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket, - TunnelStatusResponse, +use super::rpc::{ + grpc_defs::{ + networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, + NetworkListResponse, NetworkReorderRequest, NetworkType, State as RPCTunnelState, + TunnelConfigurationResponse, TunnelStatusResponse, }, - runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel}, + ServerConfig, }; use crate::{ - auth::server::tailscale::{ - packet_socket_path, TailscaleBridgeManager, - TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus, - }, - control::discovery, - daemon::rpc::ServerConfig, database::{add_network, delete_network, get_connection, list_networks, reorder_network}, + tor::{self, Config as TorConfig, TorHandle}, + wireguard::{Config as WireGuardConfig, Interface as WireGuardInterface}, }; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] enum RunState { Running, Idle, @@ -46,25 +43,167 @@ impl RunState { } } +#[derive(Clone, Debug, PartialEq, Eq)] +enum RuntimeIdentity { + DefaultWireGuard, + Network { id: i32, network_type: NetworkType }, +} + +#[derive(Clone, Debug)] +enum ResolvedTunnel { + WireGuard { + identity: RuntimeIdentity, + config: WireGuardConfig, + }, + Tor { + identity: RuntimeIdentity, + config: TorConfig, + }, +} + +impl ResolvedTunnel { + fn from_networks(networks: &[Network], fallback: &WireGuardConfig) -> Result { + let Some(network) = networks.first() else { + return Ok(Self::WireGuard { + identity: RuntimeIdentity::DefaultWireGuard, + config: fallback.clone(), + }); + }; + + let identity = RuntimeIdentity::Network { + id: network.id, + network_type: network.r#type(), + }; + + match network.r#type() { + NetworkType::WireGuard => { + let payload = String::from_utf8(network.payload.clone()) + .context("wireguard payload must be valid UTF-8")?; + let config = WireGuardConfig::from_content_fmt(&payload, "ini")?; + Ok(Self::WireGuard { identity, config }) + } + NetworkType::Tor => { + let config = TorConfig::from_payload(&network.payload)?; + Ok(Self::Tor { identity, config }) + } + NetworkType::HackClub => { + Err(anyhow!("HackClub runtime is not available on this branch")) + } + } + } + + fn identity(&self) -> &RuntimeIdentity { + match self { + Self::WireGuard { identity, .. } | Self::Tor { identity, .. } => identity, + } + } + + fn server_config(&self) -> Result { + match self { + Self::WireGuard { config, .. } => ServerConfig::try_from(config), + Self::Tor { config, .. } => Ok(ServerConfig { + address: config.address.clone(), + name: config.tun_name.clone(), + mtu: config.mtu.map(|mtu| mtu as i32), + }), + } + } + + async fn start(self, tun_interface: Arc>>) -> Result { + match self { + Self::WireGuard { identity, config } => { + let tun = TunOptions::new() + .address(config.interface.address.clone()) + .open()?; + tun_interface.write().await.replace(tun); + + let mut interface: WireGuardInterface = config.try_into()?; + interface.set_tun_ref(tun_interface.clone()).await; + let interface = Arc::new(RwLock::new(interface)); + let run_interface = interface.clone(); + let task = tokio::spawn(async move { + let guard = run_interface.read().await; + guard.run().await + }); + + Ok(ActiveTunnel::WireGuard { identity, interface, task }) + } + Self::Tor { identity, config } => { + let mut tun_options = TunOptions::new().address(config.address.clone()); + if let Some(name) = config.tun_name.as_deref() { + tun_options = tun_options.name(name); + } + let tun = tun_options.open()?; + tun_interface.write().await.replace(tun); + + match tor::spawn(config).await { + Ok(handle) => Ok(ActiveTunnel::Tor { identity, handle }), + Err(err) => { + tun_interface.write().await.take(); + Err(err) + } + } + } + } + } +} + +enum ActiveTunnel { + WireGuard { + identity: RuntimeIdentity, + interface: Arc>, + task: JoinHandle>, + }, + Tor { + identity: RuntimeIdentity, + handle: TorHandle, + }, +} + +impl ActiveTunnel { + fn identity(&self) -> &RuntimeIdentity { + match self { + Self::WireGuard { identity, .. } | Self::Tor { identity, .. } => identity, + } + } + + async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { + match self { + Self::WireGuard { interface, task, .. } => { + interface.read().await.remove_tun().await; + let task_result = task.await; + tun_interface.write().await.take(); + task_result??; + Ok(()) + } + Self::Tor { handle, .. } => { + let result = handle.shutdown().await; + tun_interface.write().await.take(); + result + } + } + } +} + #[derive(Clone)] pub struct DaemonRPCServer { tun_interface: Arc>>, + default_config: Arc>, db_path: Option, wg_state_chan: (watch::Sender, watch::Receiver), network_update_chan: (watch::Sender<()>, watch::Receiver<()>), active_tunnel: Arc>>, - tailnet_login: TailscaleBridgeManager, } impl DaemonRPCServer { - pub fn new(db_path: Option<&Path>) -> Result { + pub fn new(config: Arc>, db_path: Option<&Path>) -> Result { Ok(Self { tun_interface: Arc::new(RwLock::new(None)), + default_config: config, db_path: db_path.map(Path::to_owned), wg_state_chan: watch::channel(RunState::Idle), network_update_chan: watch::channel(()), active_tunnel: Arc::new(RwLock::new(None)), - tailnet_login: TailscaleBridgeManager::default(), }) } @@ -83,25 +222,20 @@ impl DaemonRPCServer { async fn resolve_tunnel(&self) -> Result { let conn = self.get_connection()?; let networks = list_networks(&conn).map_err(proc_err)?; - ResolvedTunnel::from_networks(&networks).map_err(proc_err) + let fallback = self.default_config.read().await.clone(); + ResolvedTunnel::from_networks(&networks, &fallback).map_err(proc_err) } async fn current_tunnel_configuration(&self) -> Result { - let config = { - let active = self.active_tunnel.read().await; - active - .as_ref() - .map(|tunnel| tunnel.server_config().clone()) - }; - let config = match config { - Some(config) => config, - None => self - .resolve_tunnel() - .await? - .server_config() - .map_err(proc_err)?, - }; - Ok(configuration_rsp(config)) + let config = self + .resolve_tunnel() + .await? + .server_config() + .map_err(proc_err)?; + Ok(TunnelConfigurationResponse { + addresses: config.address, + mtu: config.mtu.unwrap_or(1500), + }) } async fn stop_active_tunnel(&self) -> Result { @@ -120,18 +254,8 @@ impl DaemonRPCServer { async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> { let _ = self.stop_active_tunnel().await?; - let tailnet_helper = match &desired { - ResolvedTunnel::Tailnet { identity, config } => Some( - self.tailnet_login - .ensure_session(tailnet_helper_request(identity, config)) - .await - .map_err(proc_err)? - .helper, - ), - _ => None, - }; let active = desired - .start(self.tun_interface.clone(), tailnet_helper) + .start(self.tun_interface.clone()) .await .map_err(proc_err)?; self.active_tunnel.write().await.replace(active); @@ -155,34 +279,11 @@ impl DaemonRPCServer { Ok(()) } - - fn tailnet_bridge_request( - account_name: String, - identity_name: String, - hostname: String, - authority: String, - ) -> BridgeLoginStartRequest { - let mut request = BridgeLoginStartRequest { - account_name, - identity_name, - hostname: (!hostname.trim().is_empty()).then_some(hostname), - control_url: Self::tailnet_control_url(&authority), - packet_socket: None, - }; - request.packet_socket = Some(packet_socket_path(&request).display().to_string()); - request - } - - fn tailnet_control_url(authority: &str) -> Option { - let authority = discovery::normalize_authority(authority); - (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) - } } #[tonic::async_trait] impl Tunnel for DaemonRPCServer { type TunnelConfigurationStream = ReceiverStream>; - type TunnelPacketsStream = ReceiverStream>; type TunnelStatusStream = ReceiverStream>; async fn tunnel_configuration( @@ -208,62 +309,6 @@ impl Tunnel for DaemonRPCServer { Ok(Response::new(ReceiverStream::new(rx))) } - async fn tunnel_packets( - &self, - request: Request>, - ) -> Result, RspStatus> { - let (packet_tx, mut packet_rx) = { - let guard = self.active_tunnel.read().await; - let Some(active) = guard.as_ref() else { - return Err(RspStatus::failed_precondition("no active tunnel")); - }; - active.packet_stream().ok_or_else(|| { - RspStatus::failed_precondition( - "active tunnel does not support packet streaming", - ) - })? - }; - - let (tx, rx) = mpsc::channel(128); - tokio::spawn(async move { - loop { - match packet_rx.recv().await { - Ok(payload) => { - if tx.send(Ok(TunnelPacket { payload })).await.is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - - let mut inbound = request.into_inner(); - tokio::spawn(async move { - loop { - match inbound.message().await { - Ok(Some(packet)) => { - debug!( - "daemon tunnel packet stream received {} bytes from client", - packet.payload.len() - ); - if packet_tx.send(packet.payload).await.is_err() { - break; - } - } - Ok(None) => break, - Err(error) => { - warn!("tailnet packet stream receive error: {error}"); - break; - } - } - } - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - async fn tunnel_start(&self, _request: Request) -> Result, RspStatus> { let desired = self.resolve_tunnel().await?; let already_running = { @@ -373,168 +418,13 @@ impl Networks for DaemonRPCServer { } } -#[tonic::async_trait] -impl TailnetControl for DaemonRPCServer { - async fn discover( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - info!(email = %request.email, "daemon tailnet discover RPC received"); - let discovery = discovery::discover_tailnet(&request.email) - .await - .map_err(proc_err)?; - info!( - email = %request.email, - authority = %discovery.authority, - provider = ?discovery.provider, - "daemon tailnet discover RPC resolved" - ); - - Ok(Response::new(TailnetDiscoverResponse { - domain: discovery.domain, - authority: discovery.authority.clone(), - oidc_issuer: discovery.oidc_issuer.unwrap_or_default(), - managed: matches!( - discovery::inferred_provider(Some(&discovery.authority), Some(&discovery.provider)), - crate::control::TailnetProvider::Tailscale - ), - })) - } - - async fn probe( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - let status = discovery::probe_tailnet_authority(&request.authority) - .await - .map_err(proc_err)?; - - Ok(Response::new(TailnetProbeResponse { - authority: status.authority, - status_code: status.status_code, - summary: status.summary, - detail: status.detail, - reachable: status.reachable, - })) - } - - async fn login_start( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - info!( - account = %request.account_name, - identity = %request.identity_name, - authority = %request.authority, - "daemon tailnet login start RPC received" - ); - let response = self - .tailnet_login - .start_login(Self::tailnet_bridge_request( - request.account_name, - request.identity_name, - request.hostname, - request.authority, - )) - .await - .map_err(proc_err)?; - - info!( - session_id = %response.session_id, - backend_state = %response.status.backend_state, - running = response.status.running, - needs_login = response.status.needs_login, - auth_url = ?response.status.auth_url, - "daemon tailnet login start RPC resolved" - ); - - Ok(Response::new(tailnet_login_rsp( - response.session_id, - response.status, - ))) - } - - async fn login_status( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - info!(session_id = %request.session_id, "daemon tailnet login status RPC received"); - let status = self - .tailnet_login - .status(&request.session_id) - .await - .map_err(proc_err)?; - let Some(status) = status else { - return Err(RspStatus::not_found("tailnet login session not found")); - }; - info!( - session_id = %request.session_id, - backend_state = %status.backend_state, - running = status.running, - needs_login = status.needs_login, - auth_url = ?status.auth_url, - "daemon tailnet login status RPC resolved" - ); - Ok(Response::new(tailnet_login_rsp(request.session_id, status))) - } - - async fn login_cancel( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - let canceled = self - .tailnet_login - .cancel(&request.session_id) - .await - .map_err(proc_err)?; - if !canceled { - return Err(RspStatus::not_found("tailnet login session not found")); - } - Ok(Response::new(Empty {})) - } -} - fn proc_err(err: impl ToString) -> RspStatus { RspStatus::internal(err.to_string()) } -fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { - TunnelConfigurationResponse { - addresses: config.address, - mtu: config.mtu.unwrap_or(1000), - routes: config.routes, - dns_servers: config.dns_servers, - search_domains: config.search_domains, - include_default_route: config.include_default_route, - } -} - fn status_rsp(state: RunState) -> TunnelStatusResponse { TunnelStatusResponse { state: state.to_rpc().into(), - start: None, // TODO: Add timestamp - } -} - -fn tailnet_login_rsp( - session_id: String, - status: TailscaleLoginStatus, -) -> super::rpc::grpc_defs::TailnetLoginStatusResponse { - super::rpc::grpc_defs::TailnetLoginStatusResponse { - session_id, - backend_state: status.backend_state, - auth_url: status.auth_url.unwrap_or_default(), - running: status.running, - needs_login: status.needs_login, - tailnet_name: status.tailnet_name.unwrap_or_default(), - magic_dns_suffix: status.magic_dns_suffix.unwrap_or_default(), - self_dns_name: status.self_dns_name.unwrap_or_default(), - tailnet_ips: status.tailscale_ips, - health: status.health, + start: None, } } diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index 724e3bb..8ec0ce2 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -4,23 +4,22 @@ pub mod apple; mod instance; mod net; pub mod rpc; -mod runtime; use anyhow::{Error as AhError, Result}; use instance::DaemonRPCServer; pub use net::{get_socket_path, DaemonClient}; pub use rpc::{DaemonCommand, DaemonResponseData, DaemonStartOptions}; -use tokio::{net::UnixListener, sync::Notify}; +use tokio::{ + net::UnixListener, + sync::{Notify, RwLock}, +}; use tokio_stream::wrappers::UnixListenerStream; use tonic::transport::Server; use tracing::info; use crate::{ - daemon::rpc::grpc_defs::{ - networks_server::NetworksServer, tailnet_control_server::TailnetControlServer, - tunnel_server::TunnelServer, - }, - database::get_connection, + daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer}, + database::{get_connection, load_interface}, }; pub async fn daemon_main( @@ -28,8 +27,12 @@ pub async fn daemon_main( db_path: Option<&Path>, notify_ready: Option>, ) -> Result<()> { - let _conn = get_connection(db_path)?; - let burrow_server = DaemonRPCServer::new(db_path)?; + if let Some(n) = notify_ready { + n.notify_one() + } + let conn = get_connection(db_path)?; + let config = load_interface(&conn, "1")?; + let burrow_server = DaemonRPCServer::new(Arc::new(RwLock::new(config)), db_path.clone())?; let spp = socket_path.clone(); let tmp = get_socket_path(); let sock_path = spp.unwrap_or(Path::new(tmp.as_str())); @@ -39,243 +42,17 @@ pub async fn daemon_main( let uds = UnixListener::bind(sock_path)?; let serve_job = tokio::spawn(async move { let uds_stream = UnixListenerStream::new(uds); - let tailnet_server = burrow_server.clone(); let _srv = Server::builder() .add_service(TunnelServer::new(burrow_server.clone())) .add_service(NetworksServer::new(burrow_server)) - .add_service(TailnetControlServer::new(tailnet_server)) .serve_with_incoming(uds_stream) .await?; Ok::<(), AhError>(()) }); - if let Some(n) = notify_ready { - n.notify_one(); - } - info!("Starting daemon..."); tokio::try_join!(serve_job) .map(|_| ()) .map_err(|e| e.into()) } - -#[cfg(test)] -mod tests { - use std::{ - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; - - use anyhow::{anyhow, Result}; - use tokio::time::{timeout, Duration}; - - use super::*; - use crate::daemon::rpc::{ - client::BurrowClient, - grpc_defs::{ - Empty, Network, NetworkListResponse, NetworkReorderRequest, NetworkType, - TunnelConfigurationResponse, TunnelStatusResponse, - }, - }; - - #[tokio::test] - async fn daemon_tracks_network_priority_via_grpc() -> Result<()> { - let socket_path = temp_path("sock"); - let db_path = temp_path("sqlite3"); - let ready = Arc::new(Notify::new()); - - let daemon_ready = ready.clone(); - let daemon_socket_path = socket_path.clone(); - let daemon_db_path = db_path.clone(); - let daemon_task = tokio::spawn(async move { - daemon_main( - Some(daemon_socket_path.as_path()), - Some(daemon_db_path.as_path()), - Some(daemon_ready), - ) - .await - }); - - timeout(Duration::from_secs(5), ready.notified()).await?; - - let mut client = timeout( - Duration::from_secs(5), - BurrowClient::from_uds_path(&socket_path), - ) - .await??; - let mut config_stream = client - .tunnel_client - .tunnel_configuration(Empty {}) - .await? - .into_inner(); - let mut network_stream = client - .networks_client - .network_list(Empty {}) - .await? - .into_inner(); - let mut status_stream = client - .tunnel_client - .tunnel_status(Empty {}) - .await? - .into_inner(); - - let initial_config = next_configuration(&mut config_stream).await?; - assert!(initial_config.addresses.is_empty()); - assert_eq!(initial_config.mtu, 1500); - - let initial_networks = next_networks(&mut network_stream).await?; - assert!(initial_networks.network.is_empty()); - - let initial_status = next_status(&mut status_stream).await?; - assert_eq!( - initial_status.state(), - crate::daemon::rpc::grpc_defs::State::Stopped - ); - - client.tunnel_client.tunnel_start(Empty {}).await?; - - let passthrough_status = next_status(&mut status_stream).await?; - assert_eq!( - passthrough_status.state(), - crate::daemon::rpc::grpc_defs::State::Running - ); - - client.tunnel_client.tunnel_stop(Empty {}).await?; - - let stopped_status = next_status(&mut status_stream).await?; - assert_eq!( - stopped_status.state(), - crate::daemon::rpc::grpc_defs::State::Stopped - ); - - client - .networks_client - .network_add(Network { - id: 1, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload(), - }) - .await?; - - let networks_after_wg = next_networks(&mut network_stream).await?; - assert_eq!( - network_ids(&networks_after_wg), - vec![(1, NetworkType::WireGuard)] - ); - - let wireguard_config = next_configuration(&mut config_stream).await?; - assert_eq!( - wireguard_config.addresses, - vec!["10.8.0.2/32", "fd00::2/128"] - ); - assert_eq!(wireguard_config.mtu, 1420); - - client - .networks_client - .network_add(Network { - id: 2, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload_with("10.77.0.2/32", 1380), - }) - .await?; - - let networks_after_second_add = next_networks(&mut network_stream).await?; - assert_eq!( - network_ids(&networks_after_second_add), - vec![(1, NetworkType::WireGuard), (2, NetworkType::WireGuard)] - ); - - let still_wireguard = next_configuration(&mut config_stream).await?; - assert_eq!(still_wireguard.addresses, wireguard_config.addresses); - - client - .networks_client - .network_reorder(NetworkReorderRequest { id: 2, index: 0 }) - .await?; - - let networks_after_reorder = next_networks(&mut network_stream).await?; - assert_eq!( - network_ids(&networks_after_reorder), - vec![(2, NetworkType::WireGuard), (1, NetworkType::WireGuard)] - ); - - let second_wireguard_config = next_configuration(&mut config_stream).await?; - assert_eq!(second_wireguard_config.addresses, vec!["10.77.0.2/32"]); - assert_eq!(second_wireguard_config.mtu, 1380); - - daemon_task.abort(); - let _ = daemon_task.await; - cleanup_path(&socket_path); - cleanup_path(&db_path); - - Ok(()) - } - - fn temp_path(ext: &str) -> PathBuf { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time is after unix epoch") - .as_nanos(); - std::env::temp_dir().join(format!("burrow-daemon-test-{now}.{ext}")) - } - - fn cleanup_path(path: &Path) { - let _ = std::fs::remove_file(path); - } - - fn sample_wireguard_payload() -> Vec { - br#"[Interface] -PrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8= -Address = 10.8.0.2/32, fd00::2/128 -ListenPort = 51820 -MTU = 1420 - -[Peer] -PublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM= -PresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698= -AllowedIPs = 0.0.0.0/0, ::/0 -Endpoint = wg.burrow.rs:51820 -"# - .to_vec() - } - - fn sample_wireguard_payload_with(address: &str, mtu: u16) -> Vec { - format!( - "[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = wg.burrow.rs:51820\n" - ) - .into_bytes() - } - - async fn next_configuration( - stream: &mut tonic::Streaming, - ) -> Result { - timeout(Duration::from_secs(5), stream.message()) - .await?? - .ok_or_else(|| anyhow!("configuration stream ended unexpectedly")) - } - - async fn next_networks( - stream: &mut tonic::Streaming, - ) -> Result { - timeout(Duration::from_secs(5), stream.message()) - .await?? - .ok_or_else(|| anyhow!("network stream ended unexpectedly")) - } - - async fn next_status( - stream: &mut tonic::Streaming, - ) -> Result { - timeout(Duration::from_secs(5), stream.message()) - .await?? - .ok_or_else(|| anyhow!("status stream ended unexpectedly")) - } - - fn network_ids(response: &NetworkListResponse) -> Vec<(i32, NetworkType)> { - response - .network - .iter() - .map(|network| (network.id, network.r#type())) - .collect() - } -} diff --git a/burrow/src/daemon/net/unix.rs b/burrow/src/daemon/net/unix.rs index f7f9433..975c470 100644 --- a/burrow/src/daemon/net/unix.rs +++ b/burrow/src/daemon/net/unix.rs @@ -11,7 +11,11 @@ use tokio::{ use tracing::{debug, error, info}; use crate::daemon::rpc::{ - DaemonCommand, DaemonMessage, DaemonNotification, DaemonRequest, DaemonResponse, + DaemonCommand, + DaemonMessage, + DaemonNotification, + DaemonRequest, + DaemonResponse, DaemonResponseData, }; diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index aa84c64..862e34c 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -1,45 +1,30 @@ use anyhow::Result; use hyper_util::rt::TokioIo; -use std::path::Path; use tokio::net::UnixStream; use tonic::transport::{Endpoint, Uri}; use tower::service_fn; -use super::grpc_defs::{ - networks_client::NetworksClient, tailnet_control_client::TailnetControlClient, - tunnel_client::TunnelClient, -}; +use super::grpc_defs::{networks_client::NetworksClient, tunnel_client::TunnelClient}; use crate::daemon::get_socket_path; pub struct BurrowClient { pub networks_client: NetworksClient, - pub tailnet_client: TailnetControlClient, pub tunnel_client: TunnelClient, } impl BurrowClient { #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub async fn from_uds() -> Result { - Self::from_uds_path(get_socket_path()).await - } - - #[cfg(any(target_os = "linux", target_vendor = "apple"))] - pub async fn from_uds_path(path: impl AsRef) -> Result { - let socket_path = path.as_ref().to_owned(); let channel = Endpoint::try_from("http://[::]:50051")? // NOTE: this is a hack(?) - .connect_with_connector(service_fn(move |_: Uri| { - let socket_path = socket_path.clone(); - async move { - Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(&socket_path).await?)) - } + .connect_with_connector(service_fn(|_: Uri| async { + let sock_path = get_socket_path(); + Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(sock_path).await?)) })) .await?; let nw_client = NetworksClient::new(channel.clone()); - let tailnet_client = TailnetControlClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone()); Ok(BurrowClient { networks_client: nw_client, - tailnet_client, tunnel_client: tun_client, }) } diff --git a/burrow/src/daemon/rpc/request.rs b/burrow/src/daemon/rpc/request.rs index 91562cc..e9480aa 100644 --- a/burrow/src/daemon/rpc/request.rs +++ b/burrow/src/daemon/rpc/request.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use tun::TunOptions; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "method", content = "params")] +#[serde(tag="method", content="params")] pub enum DaemonCommand { Start(DaemonStartOptions), ServerInfo, diff --git a/burrow/src/daemon/rpc/response.rs b/burrow/src/daemon/rpc/response.rs index 6d03581..8948ca4 100644 --- a/burrow/src/daemon/rpc/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -68,14 +68,6 @@ impl TryFrom<&TunInterface> for ServerInfo { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct ServerConfig { pub address: Vec, - #[serde(default)] - pub routes: Vec, - #[serde(default)] - pub dns_servers: Vec, - #[serde(default)] - pub search_domains: Vec, - #[serde(default)] - pub include_default_route: bool, pub name: Option, pub mtu: Option, } @@ -86,14 +78,6 @@ impl TryFrom<&Config> for ServerConfig { fn try_from(config: &Config) -> anyhow::Result { Ok(ServerConfig { address: config.interface.address.clone(), - routes: config - .peers - .iter() - .flat_map(|peer| peer.allowed_ips.iter().cloned()) - .collect(), - dns_servers: config.interface.dns.clone(), - search_domains: Vec::new(), - include_default_route: false, name: None, mtu: config.interface.mtu.map(|mtu| mtu as i32), }) @@ -104,10 +88,6 @@ impl Default for ServerConfig { fn default() -> Self { Self { address: vec!["10.13.13.2".to_string()], // Dummy remote address - routes: Vec::new(), - dns_servers: Vec::new(), - search_domains: Vec::new(), - include_default_route: false, name: None, mtu: None, } diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap index 68b4195..c40db25 100644 --- a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/rpc/response.rs expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" --- -{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} +{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"name":null,"mtu":null}},"id":0} diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs deleted file mode 100644 index 31821a2..0000000 --- a/burrow/src/daemon/runtime.rs +++ /dev/null @@ -1,618 +0,0 @@ -use std::{path::PathBuf, sync::Arc}; - -use anyhow::{bail, Context, Result}; -use tokio::{ - io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, - net::UnixStream, - sync::{broadcast, mpsc, RwLock}, - task::JoinHandle, - time::{sleep, Duration}, -}; -use tun::{tokio::TunInterface, TunOptions}; - -use super::rpc::{ - grpc_defs::{Network, NetworkType}, - ServerConfig, -}; -use crate::{ - auth::server::tailscale::{ - default_hostname, packet_socket_path, spawn_tailscale_helper, TailscaleHelperProcess, - TailscaleLoginStartRequest, TailscaleLoginStatus, - }, - control::{discovery, TailnetConfig}, - wireguard::{Config, Interface as WireGuardInterface}, -}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RuntimeIdentity { - Passthrough, - Network { - id: i32, - network_type: NetworkType, - payload: Vec, - }, -} - -#[derive(Clone, Debug)] -pub enum ResolvedTunnel { - Passthrough { - identity: RuntimeIdentity, - }, - Tailnet { - identity: RuntimeIdentity, - config: TailnetConfig, - }, - WireGuard { - identity: RuntimeIdentity, - config: Config, - }, -} - -impl ResolvedTunnel { - pub fn from_networks(networks: &[Network]) -> Result { - let Some(network) = networks.first() else { - return Ok(Self::Passthrough { - identity: RuntimeIdentity::Passthrough, - }); - }; - - let identity = RuntimeIdentity::Network { - id: network.id, - network_type: network.r#type(), - payload: network.payload.clone(), - }; - - match network.r#type() { - NetworkType::Tailnet => { - let config = TailnetConfig::from_slice(&network.payload)?; - Ok(Self::Tailnet { identity, config }) - } - NetworkType::WireGuard => { - let payload = String::from_utf8(network.payload.clone()) - .context("wireguard payload must be valid UTF-8")?; - let config = Config::from_content_fmt(&payload, "ini")?; - Ok(Self::WireGuard { identity, config }) - } - } - } - - pub fn identity(&self) -> &RuntimeIdentity { - match self { - Self::Passthrough { identity } - | Self::Tailnet { identity, .. } - | Self::WireGuard { identity, .. } => identity, - } - } - - pub fn server_config(&self) -> Result { - match self { - Self::Passthrough { .. } => Ok(ServerConfig { - address: Vec::new(), - routes: Vec::new(), - dns_servers: Vec::new(), - search_domains: Vec::new(), - include_default_route: false, - name: None, - mtu: Some(1500), - }), - Self::Tailnet { .. } => Ok(ServerConfig { - address: Vec::new(), - routes: tailnet_routes(), - dns_servers: tailnet_dns_servers(), - search_domains: Vec::new(), - include_default_route: false, - name: None, - mtu: Some(1280), - }), - Self::WireGuard { config, .. } => ServerConfig::try_from(config), - } - } - - pub async fn start( - self, - tun_interface: Arc>>, - tailnet_helper: Option>, - ) -> Result { - match self { - Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { - identity, - server_config: ServerConfig { - address: Vec::new(), - routes: Vec::new(), - dns_servers: Vec::new(), - search_domains: Vec::new(), - include_default_route: false, - name: None, - mtu: Some(1500), - }, - }), - Self::Tailnet { identity, config } => { - let (helper, shutdown_helper_on_stop) = match tailnet_helper { - Some(helper) => (helper, false), - None => { - let helper_request = tailnet_helper_request(&identity, &config); - let helper = Arc::new(spawn_tailscale_helper(&helper_request).await?); - (helper, true) - } - }; - let status = wait_for_tailnet_ready(helper.as_ref()).await?; - let server_config = tailnet_server_config(&status); - let packet_socket = helper - .packet_socket() - .map(PathBuf::from) - .ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?; - let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?; - #[cfg(target_vendor = "apple")] - let tun_task = None; - #[cfg(not(target_vendor = "apple"))] - let tun_task = { - let tun = TunOptions::new().open()?; - tun_interface.write().await.replace(tun); - Some(tokio::spawn(run_tailnet_tun_bridge( - tun_interface.clone(), - packet_bridge.outbound_sender(), - packet_bridge.subscribe(), - ))) - }; - - Ok(ActiveTunnel::Tailnet { - identity, - server_config, - helper, - shutdown_helper_on_stop, - packet_bridge, - tun_task, - }) - } - Self::WireGuard { identity, config } => { - let server_config = ServerConfig::try_from(&config)?; - let tun = TunOptions::new().open()?; - tun_interface.write().await.replace(tun); - - match start_wireguard_runtime(config, tun_interface.clone()).await { - Ok((interface, task)) => Ok(ActiveTunnel::WireGuard { - identity, - server_config, - interface, - task, - }), - Err(err) => { - tun_interface.write().await.take(); - Err(err) - } - } - } - } - } -} - -pub enum ActiveTunnel { - Passthrough { - identity: RuntimeIdentity, - server_config: ServerConfig, - }, - Tailnet { - identity: RuntimeIdentity, - server_config: ServerConfig, - helper: Arc, - shutdown_helper_on_stop: bool, - packet_bridge: TailnetPacketBridge, - tun_task: Option>>, - }, - WireGuard { - identity: RuntimeIdentity, - server_config: ServerConfig, - interface: Arc>, - task: JoinHandle>, - }, -} - -impl ActiveTunnel { - pub fn identity(&self) -> &RuntimeIdentity { - match self { - Self::Passthrough { identity, .. } - | Self::Tailnet { identity, .. } - | Self::WireGuard { identity, .. } => identity, - } - } - - pub fn server_config(&self) -> &ServerConfig { - match self { - Self::Passthrough { server_config, .. } - | Self::Tailnet { server_config, .. } - | Self::WireGuard { server_config, .. } => server_config, - } - } - - pub fn packet_stream( - &self, - ) -> Option<(mpsc::Sender>, broadcast::Receiver>)> { - match self { - Self::Tailnet { packet_bridge, .. } => Some(( - packet_bridge.outbound_sender(), - packet_bridge.subscribe(), - )), - _ => None, - } - } - - pub async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { - match self { - Self::Passthrough { .. } => Ok(()), - Self::Tailnet { - helper, - shutdown_helper_on_stop, - packet_bridge, - tun_task, - .. - } => { - if let Some(tun_task) = tun_task { - tun_task.abort(); - match tun_task.await { - Ok(Ok(())) => {} - Ok(Err(err)) => return Err(err), - Err(err) if err.is_cancelled() => {} - Err(err) => return Err(err.into()), - } - } - packet_bridge.task.abort(); - match packet_bridge.task.await { - Ok(Ok(())) => {} - Ok(Err(err)) => return Err(err), - Err(err) if err.is_cancelled() => {} - Err(err) => return Err(err.into()), - } - tun_interface.write().await.take(); - if shutdown_helper_on_stop { - helper.shutdown().await?; - } - Ok(()) - } - Self::WireGuard { - interface, - task, - .. - } => { - interface.read().await.remove_tun().await; - let task_result = task.await; - tun_interface.write().await.take(); - task_result??; - Ok(()) - } - } - } -} - -pub struct TailnetPacketBridge { - outbound: mpsc::Sender>, - inbound: broadcast::Sender>, - task: JoinHandle>, -} - -impl TailnetPacketBridge { - fn outbound_sender(&self) -> mpsc::Sender> { - self.outbound.clone() - } - - fn subscribe(&self) -> broadcast::Receiver> { - self.inbound.subscribe() - } -} - -async fn start_wireguard_runtime( - config: Config, - tun_interface: Arc>>, -) -> Result<(Arc>, JoinHandle>)> { - let mut interface: WireGuardInterface = config.try_into()?; - interface.set_tun_ref(tun_interface).await; - let interface = Arc::new(RwLock::new(interface)); - let run_interface = interface.clone(); - let task = tokio::spawn(async move { - let guard = run_interface.read().await; - guard.run().await - }); - Ok((interface, task)) -} - -pub(crate) fn tailnet_helper_request( - identity: &RuntimeIdentity, - config: &TailnetConfig, -) -> TailscaleLoginStartRequest { - let account_name = config - .account - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("default") - .to_owned(); - let identity_name = config - .identity - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| match identity { - RuntimeIdentity::Network { id, .. } => format!("network-{id}"), - RuntimeIdentity::Passthrough => "apple".to_owned(), - }); - let control_url = config.authority.as_deref().and_then(|authority| { - let authority = discovery::normalize_authority(authority); - (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) - }); - - let mut request = TailscaleLoginStartRequest { - account_name, - identity_name, - hostname: config.hostname.clone(), - control_url, - packet_socket: None, - }; - request.packet_socket = Some(packet_socket_path(&request).display().to_string()); - if request - .hostname - .as_deref() - .map(|value| value.trim().is_empty()) - .unwrap_or(true) - { - request.hostname = Some(default_hostname(&request)); - } - request -} - -async fn wait_for_tailnet_ready(helper: &TailscaleHelperProcess) -> Result { - let mut last_status = None; - for _ in 0..120 { - let status = helper.status().await?; - if status.running && !status.tailscale_ips.is_empty() { - return Ok(status); - } - if status.needs_login || status.auth_url.is_some() { - bail!("tailnet runtime requires a completed login before the tunnel can start"); - } - last_status = Some(status); - sleep(Duration::from_millis(250)).await; - } - - if let Some(status) = last_status { - bail!( - "tailnet helper never became ready (backend_state={})", - status.backend_state - ); - } - bail!("tailnet helper never produced a status update") -} - -fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig { - let mut search_domains = Vec::new(); - if let Some(suffix) = status.magic_dns_suffix.as_deref() { - let suffix = suffix.trim().trim_end_matches('.'); - if !suffix.is_empty() { - search_domains.push(suffix.to_owned()); - } - } - - ServerConfig { - address: status - .tailscale_ips - .iter() - .map(|ip| tailnet_cidr(ip)) - .collect(), - routes: tailnet_routes(), - dns_servers: tailnet_dns_servers(), - search_domains, - include_default_route: false, - name: status.self_dns_name.clone(), - mtu: Some(1280), - } -} - -fn tailnet_routes() -> Vec { - vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()] -} - -fn tailnet_dns_servers() -> Vec { - vec!["100.100.100.100".to_owned()] -} - -fn tailnet_cidr(ip: &str) -> String { - if ip.contains('/') { - return ip.to_owned(); - } - if ip.contains(':') { - format!("{ip}/128") - } else { - format!("{ip}/32") - } -} - -async fn connect_tailnet_packet_bridge(packet_socket: PathBuf) -> Result { - let mut last_error = None; - let mut stream = None; - for _ in 0..50 { - match UnixStream::connect(&packet_socket).await { - Ok(connected) => { - stream = Some(connected); - break; - } - Err(err) => { - last_error = Some(err); - sleep(Duration::from_millis(100)).await; - } - } - } - let stream = if let Some(stream) = stream { - stream - } else { - return Err(last_error - .context("failed to connect to tailnet helper packet socket")? - .into()); - }; - - let (outbound_tx, outbound_rx) = mpsc::channel(128); - let (inbound_tx, _) = broadcast::channel(128); - let task = tokio::spawn(run_tailnet_socket_bridge( - stream, - outbound_rx, - inbound_tx.clone(), - )); - - Ok(TailnetPacketBridge { - outbound: outbound_tx, - inbound: inbound_tx, - task, - }) -} - -async fn run_tailnet_socket_bridge( - stream: UnixStream, - mut outbound_rx: mpsc::Receiver>, - inbound_tx: broadcast::Sender>, -) -> Result<()> { - let (mut reader, mut writer) = stream.into_split(); - - let inbound = tokio::spawn(async move { - loop { - let packet = read_packet_frame(&mut reader).await?; - tracing::debug!( - "tailnet packet bridge received {} bytes from helper socket", - packet.len() - ); - let _ = inbound_tx.send(packet); - } - #[allow(unreachable_code)] - Result::<()>::Ok(()) - }); - - let outbound = tokio::spawn(async move { - while let Some(packet) = outbound_rx.recv().await { - tracing::debug!( - "tailnet packet bridge writing {} bytes to helper socket", - packet.len() - ); - write_packet_frame(&mut writer, &packet).await?; - } - Result::<()>::Ok(()) - }); - - let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; - inbound_result?; - outbound_result?; - Ok(()) -} - -#[cfg(not(target_vendor = "apple"))] -async fn run_tailnet_tun_bridge( - tun_interface: Arc>>, - outbound_tx: mpsc::Sender>, - mut inbound_rx: broadcast::Receiver>, -) -> Result<()> { - let inbound_tun = tun_interface.clone(); - let inbound = tokio::spawn(async move { - loop { - let packet = match inbound_rx.recv().await { - Ok(packet) => packet, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, - }; - let guard = inbound_tun.read().await; - let Some(tun) = guard.as_ref() else { - bail!("tailnet tun interface unavailable"); - }; - tun.send(&packet) - .await - .context("failed to write tailnet packet to tun")?; - } - Result::<()>::Ok(()) - }); - - let outbound_tun = tun_interface.clone(); - let outbound = tokio::spawn(async move { - let mut buf = vec![0u8; 65_535]; - loop { - let len = { - let guard = outbound_tun.read().await; - let Some(tun) = guard.as_ref() else { - bail!("tailnet tun interface unavailable"); - }; - tun.recv(&mut buf) - .await - .context("failed to read packet from tailnet tun")? - }; - outbound_tx - .send(buf[..len].to_vec()) - .await - .context("failed to forward packet to tailnet helper")?; - } - #[allow(unreachable_code)] - Result::<()>::Ok(()) - }); - - let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; - inbound_result?; - outbound_result?; - Ok(()) -} - -async fn read_packet_frame(reader: &mut R) -> Result> -where - R: AsyncRead + Unpin, -{ - let mut len_buf = [0u8; 4]; - reader - .read_exact(&mut len_buf) - .await - .context("failed to read tailnet packet frame length")?; - let len = u32::from_be_bytes(len_buf) as usize; - let mut packet = vec![0u8; len]; - reader - .read_exact(&mut packet) - .await - .context("failed to read tailnet packet frame payload")?; - Ok(packet) -} - -async fn write_packet_frame(writer: &mut W, packet: &[u8]) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - writer - .write_all(&(packet.len() as u32).to_be_bytes()) - .await - .context("failed to write tailnet packet frame length")?; - writer - .write_all(packet) - .await - .context("failed to write tailnet packet frame payload")?; - writer - .flush() - .await - .context("failed to flush tailnet packet frame") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn no_networks_resolve_to_passthrough() { - let resolved = ResolvedTunnel::from_networks(&[]).unwrap(); - assert_eq!(resolved.identity(), &RuntimeIdentity::Passthrough); - assert_eq!( - resolved.server_config().unwrap().address, - Vec::::new() - ); - } - - #[test] - fn tailnet_server_config_uses_host_prefixes() { - let status = TailscaleLoginStatus { - running: true, - tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()], - ..Default::default() - }; - let config = tailnet_server_config(&status); - assert_eq!( - config.address, - vec!["100.101.102.103/32", "fd7a:115c:a1e0::123/128"] - ); - assert_eq!(config.mtu, Some(1280)); - } -} diff --git a/burrow/src/database.rs b/burrow/src/database.rs index fe9a3c7..c650d55 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -4,9 +4,11 @@ use anyhow::Result; use rusqlite::{params, Connection}; use crate::{ - control::TailnetConfig, daemon::rpc::grpc_defs::{ - Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType, + Network as RPCNetwork, + NetworkDeleteRequest, + NetworkReorderRequest, + NetworkType, }, wireguard::config::{Config, Interface, Peer}, }; @@ -122,26 +124,35 @@ pub fn dump_interface(conn: &Connection, config: &Config) -> Result<()> { pub fn get_connection(path: Option<&Path>) -> Result { let p = path.unwrap_or_else(|| std::path::Path::new(DB_PATH)); - let conn = Connection::open(p)?; - initialize_tables(&conn)?; - Ok(conn) + if !p.exists() { + let conn = Connection::open(p)?; + initialize_tables(&conn)?; + dump_interface(&conn, &Config::default())?; + return Ok(conn); + } + Ok(Connection::open(p)?) } pub fn add_network(conn: &Connection, network: &RPCNetwork) -> Result<()> { - validate_network_payload(network)?; let mut stmt = conn.prepare("INSERT INTO network (id, type, payload) VALUES (?, ?, ?)")?; stmt.execute(params![ network.id, network.r#type().as_str_name(), &network.payload ])?; + if network.r#type() == NetworkType::WireGuard { + let payload_str = String::from_utf8(network.payload.clone())?; + let wg_config = Config::from_content_fmt(&payload_str, "ini")?; + dump_interface(conn, &wg_config)?; + } Ok(()) } pub fn list_networks(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx, id")?; + let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx")?; let networks: Vec = stmt .query_map([], |row| { + println!("row: {:?}", row); let network_id: i32 = row.get(0)?; let network_type: String = row.get(1)?; let network_type = NetworkType::from_str_name(network_type.as_str()) @@ -158,19 +169,12 @@ pub fn list_networks(conn: &Connection) -> Result> { } pub fn reorder_network(conn: &Connection, req: NetworkReorderRequest) -> Result<()> { - let mut ordered_ids = ordered_network_ids(conn)?; - let Some(current_idx) = ordered_ids.iter().position(|id| *id == req.id) else { + let mut stmt = conn.prepare("UPDATE network SET idx = ? WHERE id = ?")?; + let res = stmt.execute(params![req.index, req.id])?; + if res == 0 { return Err(anyhow::anyhow!("No such network exists")); - }; - - let target_idx = usize::try_from(req.index) - .map_err(|_| anyhow::anyhow!("Network index must be non-negative"))?; - - let moved_id = ordered_ids.remove(current_idx); - let target_idx = target_idx.min(ordered_ids.len()); - ordered_ids.insert(target_idx, moved_id); - - renumber_networks(conn, &ordered_ids) + } + Ok(()) } pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<()> { @@ -179,8 +183,7 @@ pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<() if res == 0 { return Err(anyhow::anyhow!("No such network exists")); } - let ordered_ids = ordered_network_ids(conn)?; - renumber_networks(conn, &ordered_ids) + Ok(()) } fn parse_lst(s: &str) -> Vec { @@ -197,86 +200,9 @@ fn to_lst(v: &Vec) -> String { .join(",") } -fn validate_network_payload(network: &RPCNetwork) -> Result<()> { - match network.r#type() { - NetworkType::WireGuard => { - let payload_str = String::from_utf8(network.payload.clone())?; - Config::from_content_fmt(&payload_str, "ini")?; - } - NetworkType::Tailnet => { - TailnetConfig::from_slice(&network.payload)?; - } - } - Ok(()) -} - -fn ordered_network_ids(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT id FROM network ORDER BY idx, id")?; - let ids = stmt - .query_map([], |row| row.get::<_, i32>(0))? - .collect::>>()?; - Ok(ids) -} - -fn renumber_networks(conn: &Connection, ordered_ids: &[i32]) -> Result<()> { - conn.execute_batch("BEGIN IMMEDIATE")?; - let result = (|| -> Result<()> { - let mut stmt = conn.prepare("UPDATE network SET idx = ? WHERE id = ?")?; - for (idx, id) in ordered_ids.iter().enumerate() { - stmt.execute(params![idx as i32, id])?; - } - Ok(()) - })(); - - match result { - Ok(()) => { - conn.execute_batch("COMMIT")?; - Ok(()) - } - Err(err) => { - let _ = conn.execute_batch("ROLLBACK"); - Err(err) - } - } -} - #[cfg(test)] mod tests { use super::*; - use tempfile::tempdir; - - fn sample_wireguard_payload() -> Vec { - br#"[Interface] -PrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8= -Address = 10.13.13.2/24 -ListenPort = 51820 - -[Peer] -PublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM= -PresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698= -AllowedIPs = 0.0.0.0/0, 8.8.8.8/32 -Endpoint = wg.burrow.rs:51820 -"# - .to_vec() - } - - fn sample_wireguard_payload_with_address(address: &str, mtu: u16) -> Vec { - format!( - "[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0\nEndpoint = wg.burrow.rs:51820\n" - ) - .into_bytes() - } - - fn sample_tailnet_payload() -> Vec { - br#"{ - "provider":"tailscale", - "account":"default", - "identity":"apple", - "tailnet":"example.ts.net", - "hostname":"burrow-phone" -}"# - .to_vec() - } #[test] fn test_db() { @@ -287,123 +213,4 @@ Endpoint = wg.burrow.rs:51820 let loaded = load_interface(&conn, "1").unwrap(); assert_eq!(config, loaded); } - - #[test] - fn add_network_validates_payloads() { - let conn = Connection::open_in_memory().unwrap(); - initialize_tables(&conn).unwrap(); - - add_network( - &conn, - &RPCNetwork { - id: 1, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload(), - }, - ) - .unwrap(); - - add_network( - &conn, - &RPCNetwork { - id: 2, - r#type: NetworkType::Tailnet.into(), - payload: sample_tailnet_payload(), - }, - ) - .unwrap(); - - add_network( - &conn, - &RPCNetwork { - id: 3, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380), - }, - ) - .unwrap(); - - assert!(add_network( - &conn, - &RPCNetwork { - id: 4, - r#type: NetworkType::WireGuard.into(), - payload: b"not-a-config".to_vec(), - }, - ) - .is_err()); - - assert!(add_network( - &conn, - &RPCNetwork { - id: 5, - r#type: NetworkType::Tailnet.into(), - payload: b"not-a-tailnet-config".to_vec(), - }, - ) - .is_err()); - - let ids: Vec = list_networks(&conn) - .unwrap() - .into_iter() - .map(|n| n.id) - .collect(); - assert_eq!(ids, vec![1, 2, 3]); - } - - #[test] - fn reorder_and_delete_networks_keep_priority_stable() { - let conn = Connection::open_in_memory().unwrap(); - initialize_tables(&conn).unwrap(); - - for (id, address, mtu) in [ - (1, "10.42.0.2/32", 1380), - (2, "10.42.0.3/32", 1381), - (3, "10.42.0.4/32", 1382), - ] { - add_network( - &conn, - &RPCNetwork { - id, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload_with_address(address, mtu), - }, - ) - .unwrap(); - } - - reorder_network(&conn, NetworkReorderRequest { id: 3, index: 0 }).unwrap(); - let ids: Vec = list_networks(&conn) - .unwrap() - .into_iter() - .map(|n| n.id) - .collect(); - assert_eq!(ids, vec![3, 1, 2]); - - delete_network(&conn, NetworkDeleteRequest { id: 1 }).unwrap(); - let ids: Vec = list_networks(&conn) - .unwrap() - .into_iter() - .map(|n| n.id) - .collect(); - assert_eq!(ids, vec![3, 2]); - } - - #[test] - fn get_connection_does_not_seed_a_default_interface() { - let dir = tempdir().unwrap(); - let db_path = dir.path().join("burrow.sqlite3"); - - let conn = get_connection(Some(db_path.as_path())).unwrap(); - - let interface_count: i64 = conn - .query_row("SELECT COUNT(*) FROM wg_interface", [], |row| row.get(0)) - .unwrap(); - let network_count: i64 = conn - .query_row("SELECT COUNT(*) FROM network", [], |row| row.get(0)) - .unwrap(); - - assert_eq!(interface_count, 0); - assert_eq!(network_count, 0); - } } diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index 7867d18..b77ce36 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -1,6 +1,5 @@ #[cfg(any(target_os = "linux", target_vendor = "apple"))] -pub mod control; - +pub mod tor; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod wireguard; @@ -10,16 +9,12 @@ mod auth; mod daemon; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod database; -#[cfg(target_os = "linux")] -pub mod tor; pub(crate) mod tracing; -#[cfg(target_os = "linux")] -pub mod usernet; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -pub use daemon::apple::{spawn_in_process, spawn_in_process_with_paths}; +#[cfg(target_vendor = "apple")] +pub use daemon::apple::spawn_in_process; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub use daemon::{ - rpc::grpc_defs, rpc::BurrowClient, rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, - DaemonCommand, DaemonResponseData, DaemonStartOptions, + rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, DaemonCommand, DaemonResponseData, + DaemonStartOptions, }; diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 30c5960..db62a7b 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -1,20 +1,16 @@ use anyhow::Result; use clap::{Args, Parser, Subcommand}; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +mod tor; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod auth; -#[cfg(target_os = "linux")] -mod tor; -#[cfg(target_os = "linux")] -mod usernet; #[cfg(any(target_os = "linux", target_vendor = "apple"))] use daemon::{DaemonClient, DaemonCommand}; @@ -35,8 +31,8 @@ use crate::daemon::rpc::{grpc_defs::Empty, BurrowClient}; #[command( about = "Burrow is a tool for burrowing through firewalls, built by teenagers at Hack Club.", long_about = "Burrow is a 🚀 blazingly fast 🚀 tool designed to penetrate unnecessarily restrictive firewalls, providing teenagers worldwide with secure, less-filtered, and safe access to the internet! -It's being built by teenagers from Hack Club, in public! Check it out: https://git.burrow.net/hackclub/burrow -Spotted a bug? Please open an issue on the Burrow forge." +It's being built by teenagers from Hack Club, in public! Check it out: https://github.com/hackclub/burrow +Spotted a bug? Please open an issue! https://github.com/hackclub/burrow/issues/new" )] struct Cli { @@ -72,20 +68,6 @@ enum Commands { NetworkReorder(NetworkReorderArgs), /// Delete Network NetworkDelete(NetworkDeleteArgs), - /// Discover a Tailnet authority through the daemon - TailnetDiscover(TailnetDiscoverArgs), - /// Probe a Tailnet authority through the daemon - TailnetProbe(TailnetProbeArgs), - /// Send an ICMP echo probe through the active Tailnet tunnel over daemon packet streaming - TailnetPing(TailnetPingArgs), - /// Send a UDP echo probe through the active Tailnet tunnel over daemon packet streaming - TailnetUdpEcho(TailnetUdpEchoArgs), - #[cfg(target_os = "linux")] - /// Run a command in an unshared Linux namespace using a Burrow backend - Exec(ExecArgs), - #[cfg(target_os = "linux")] - /// Run a command in a Linux user namespace with Tor-backed networking - TorExec(TorExecArgs), } #[derive(Args)] @@ -118,55 +100,6 @@ struct NetworkDeleteArgs { id: i32, } -#[derive(Args)] -struct TailnetDiscoverArgs { - email: String, -} - -#[derive(Args)] -struct TailnetProbeArgs { - authority: String, -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[derive(Args)] -struct TailnetPingArgs { - remote: String, - #[arg(long, default_value = "burrow-tailnet-smoke")] - payload: String, - #[arg(long, default_value_t = 5000)] - timeout_ms: u64, -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[derive(Args)] -struct TailnetUdpEchoArgs { - remote: String, - #[arg(long, default_value = "burrow-tailnet-smoke")] - message: String, - #[arg(long, default_value_t = 5000)] - timeout_ms: u64, -} - -#[cfg(target_os = "linux")] -#[derive(Args)] -struct TorExecArgs { - payload_path: String, - #[arg(required = true, num_args = 1.., trailing_var_arg = true)] - command: Vec, -} - -#[cfg(target_os = "linux")] -#[derive(Args)] -struct ExecArgs { - #[arg(long, value_enum)] - backend: usernet::ExecBackendKind, - #[arg(long)] - payload: Option, - #[arg(required = true, num_args = 1.., trailing_var_arg = true)] - command: Vec, -} - #[cfg(any(target_os = "linux", target_vendor = "apple"))] async fn try_start() -> Result<()> { let mut client = BurrowClient::from_uds().await?; @@ -278,419 +211,6 @@ async fn try_network_delete(id: i32) -> Result<()> { Ok(()) } -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_tailnet_discover(email: &str) -> Result<()> { - let mut client = BurrowClient::from_uds().await?; - let response = client - .tailnet_client - .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() }) - .await? - .into_inner(); - println!("Tailnet Discover Response: {:?}", response); - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_tailnet_probe(authority: &str) -> Result<()> { - let mut client = BurrowClient::from_uds().await?; - let response = client - .tailnet_client - .probe(crate::daemon::rpc::grpc_defs::TailnetProbeRequest { - authority: authority.to_owned(), - }) - .await? - .into_inner(); - println!("Tailnet Probe Response: {:?}", response); - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Result<()> { - use std::net::IpAddr; - - use anyhow::Context; - use rand::Rng; - use tokio::{ - sync::mpsc, - time::{timeout, Duration}, - }; - use tokio_stream::wrappers::ReceiverStream; - - use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; - - let remote_ip: IpAddr = remote - .parse() - .with_context(|| format!("invalid remote IP address {remote}"))?; - let message = payload.as_bytes().to_vec(); - - let mut client = BurrowClient::from_uds().await?; - client.tunnel_client.tunnel_start(Empty {}).await?; - - let mut config_stream = client - .tunnel_client - .tunnel_configuration(Empty {}) - .await? - .into_inner(); - let config = config_stream - .message() - .await? - .context("tunnel configuration stream ended before yielding a config")?; - let local_ip = select_tailnet_local_ip(&config.addresses, remote_ip)?; - - let identifier = rand::thread_rng().gen::(); - let sequence = 1_u16; - let packet = build_icmp_echo_request(local_ip, remote_ip, identifier, sequence, &message)?; - - let (outbound_tx, outbound_rx) = mpsc::channel::(128); - let mut tunnel_packets = client - .tunnel_client - .tunnel_packets(ReceiverStream::new(outbound_rx)) - .await? - .into_inner(); - - outbound_tx - .send(TunnelPacket { payload: packet }) - .await - .context("failed to send ICMP echo probe into daemon packet stream")?; - log::debug!( - "tailnet ping probe queued from {local_ip} to {remote_ip} identifier={identifier} sequence={sequence}" - ); - drop(outbound_tx); - - let reply = timeout(Duration::from_millis(timeout_ms), async { - loop { - let packet = tunnel_packets - .message() - .await - .context("failed to read packet from daemon packet stream")? - .context("daemon packet stream ended before returning a reply")?; - log::debug!( - "tailnet ping received {} bytes from daemon packet stream", - packet.payload.len() - ); - if let Some(reply) = - parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)? - { - break Ok::<_, anyhow::Error>(reply); - } - } - }) - .await - .with_context(|| format!("timed out waiting for ICMP echo reply from {remote_ip}"))??; - - println!("Tailnet Ping Source: {}", reply.source); - println!("Tailnet Ping Destination: {}", reply.destination); - println!( - "Tailnet Ping Payload: {}", - String::from_utf8_lossy(&reply.payload) - ); - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> { - use std::net::SocketAddr; - - use anyhow::{bail, Context}; - use futures::{SinkExt, StreamExt}; - use netstack_smoltcp::StackBuilder; - use tokio::{ - sync::mpsc, - time::{timeout, Duration}, - }; - use tokio_stream::wrappers::ReceiverStream; - - use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; - - let remote_addr: SocketAddr = remote - .parse() - .with_context(|| format!("invalid remote socket address {remote}"))?; - - let mut client = BurrowClient::from_uds().await?; - client.tunnel_client.tunnel_start(Empty {}).await?; - - let mut config_stream = client - .tunnel_client - .tunnel_configuration(Empty {}) - .await? - .into_inner(); - let config = config_stream - .message() - .await? - .context("tunnel configuration stream ended before yielding a config")?; - let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?; - - let (stack, runner, udp_socket, _) = StackBuilder::default() - .enable_udp(true) - .enable_tcp(true) - .build() - .context("failed to build userspace UDP stack")?; - let runner = runner.context("userspace UDP stack runner unavailable")?; - let udp_socket = udp_socket.context("userspace UDP stack socket unavailable")?; - let (mut stack_sink, mut stack_stream) = stack.split(); - let (mut udp_reader, mut udp_writer) = udp_socket.split(); - - let (outbound_tx, outbound_rx) = mpsc::channel::(128); - let mut tunnel_packets = client - .tunnel_client - .tunnel_packets(ReceiverStream::new(outbound_rx)) - .await? - .into_inner(); - - let ingress_task = tokio::spawn(async move { - loop { - match tunnel_packets.message().await? { - Some(packet) => { - log::debug!( - "tailnet udp echo received {} bytes from daemon packet stream", - packet.payload.len() - ); - stack_sink - .send(packet.payload) - .await - .context("failed to feed inbound tailnet packet into userspace stack")?; - } - None => break, - } - } - Result::<()>::Ok(()) - }); - - let egress_task = tokio::spawn(async move { - while let Some(packet) = stack_stream.next().await { - let payload = packet.context("failed to read outbound packet from userspace stack")?; - log::debug!( - "tailnet udp echo sending {} bytes into daemon packet stream", - payload.len() - ); - outbound_tx - .send(TunnelPacket { payload }) - .await - .context("failed to forward outbound tailnet packet to daemon")?; - } - Result::<()>::Ok(()) - }); - - let runner_task = tokio::spawn(async move { runner.await.map_err(anyhow::Error::from) }); - - udp_writer - .send((message.as_bytes().to_vec(), local_addr, remote_addr)) - .await - .context("failed to send UDP echo probe into userspace stack")?; - log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}"); - - let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) - .await - .with_context(|| format!("timed out waiting for UDP echo from {remote_addr}"))? - .context("userspace UDP stack ended before returning a reply")?; - let (payload, reply_source, reply_destination) = response; - let response_text = String::from_utf8_lossy(&payload); - - ingress_task.abort(); - egress_task.abort(); - runner_task.abort(); - - if reply_source != remote_addr { - bail!("received UDP reply from unexpected source {reply_source}"); - } - if reply_destination != local_addr { - bail!("received UDP reply for unexpected local socket {reply_destination}"); - } - if payload != message.as_bytes() { - bail!("UDP echo payload mismatch"); - } - - println!("Tailnet UDP Echo Source: {reply_source}"); - println!("Tailnet UDP Echo Destination: {reply_destination}"); - println!("Tailnet UDP Echo Payload: {response_text}"); - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn select_tailnet_local_ip( - addresses: &[String], - remote_ip: std::net::IpAddr, -) -> Result { - use anyhow::Context; - - let family_is_v4 = remote_ip.is_ipv4(); - addresses - .iter() - .filter_map(|cidr| cidr.split('/').next()) - .filter_map(|ip| ip.parse::().ok()) - .find(|ip| ip.is_ipv4() == family_is_v4) - .with_context(|| { - format!( - "no local {} tailnet address found in daemon config {:?}", - if family_is_v4 { "IPv4" } else { "IPv6" }, - addresses - ) - }) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn select_tailnet_local_socket( - addresses: &[String], - remote_ip: std::net::IpAddr, -) -> Result { - use rand::Rng; - - let local_ip = select_tailnet_local_ip(addresses, remote_ip)?; - let port = rand::thread_rng().gen_range(40000..50000); - Ok(std::net::SocketAddr::new(local_ip, port)) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -struct IcmpEchoReply { - source: std::net::IpAddr, - destination: std::net::IpAddr, - payload: Vec, -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn build_icmp_echo_request( - source: std::net::IpAddr, - destination: std::net::IpAddr, - identifier: u16, - sequence: u16, - payload: &[u8], -) -> Result> { - use anyhow::bail; - - let (source, destination) = match (source, destination) { - (std::net::IpAddr::V4(source), std::net::IpAddr::V4(destination)) => (source, destination), - _ => bail!("tailnet ping currently supports IPv4 only"), - }; - - let mut icmp = Vec::with_capacity(8 + payload.len()); - icmp.push(8); - icmp.push(0); - icmp.extend_from_slice(&[0, 0]); - icmp.extend_from_slice(&identifier.to_be_bytes()); - icmp.extend_from_slice(&sequence.to_be_bytes()); - icmp.extend_from_slice(payload); - let icmp_checksum = internet_checksum(&icmp); - icmp[2..4].copy_from_slice(&icmp_checksum.to_be_bytes()); - - let total_len = 20 + icmp.len(); - let mut packet = Vec::with_capacity(total_len); - packet.push(0x45); - packet.push(0); - packet.extend_from_slice(&(total_len as u16).to_be_bytes()); - packet.extend_from_slice(&0u16.to_be_bytes()); - packet.extend_from_slice(&0u16.to_be_bytes()); - packet.push(64); - packet.push(1); - packet.extend_from_slice(&[0, 0]); - packet.extend_from_slice(&source.octets()); - packet.extend_from_slice(&destination.octets()); - let header_checksum = internet_checksum(&packet); - packet[10..12].copy_from_slice(&header_checksum.to_be_bytes()); - packet.extend_from_slice(&icmp); - Ok(packet) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn parse_icmp_echo_reply( - packet: &[u8], - local_ip: std::net::IpAddr, - remote_ip: std::net::IpAddr, - identifier: u16, - sequence: u16, -) -> Result> { - use anyhow::bail; - - let (local_ip, remote_ip) = match (local_ip, remote_ip) { - (std::net::IpAddr::V4(local_ip), std::net::IpAddr::V4(remote_ip)) => (local_ip, remote_ip), - _ => bail!("tailnet ping currently supports IPv4 only"), - }; - - if packet.len() < 20 { - return Ok(None); - } - let version = packet[0] >> 4; - if version != 4 { - return Ok(None); - } - let ihl = (packet[0] & 0x0f) as usize * 4; - if packet.len() < ihl + 8 { - return Ok(None); - } - if packet[9] != 1 { - return Ok(None); - } - - let source = std::net::Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]); - let destination = std::net::Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]); - if source != remote_ip || destination != local_ip { - return Ok(None); - } - - let icmp = &packet[ihl..]; - if icmp[0] != 0 || icmp[1] != 0 { - return Ok(None); - } - let reply_identifier = u16::from_be_bytes([icmp[4], icmp[5]]); - let reply_sequence = u16::from_be_bytes([icmp[6], icmp[7]]); - if reply_identifier != identifier || reply_sequence != sequence { - return Ok(None); - } - - Ok(Some(IcmpEchoReply { - source: std::net::IpAddr::V4(source), - destination: std::net::IpAddr::V4(destination), - payload: icmp[8..].to_vec(), - })) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn internet_checksum(bytes: &[u8]) -> u16 { - let mut sum = 0u32; - let mut chunks = bytes.chunks_exact(2); - for chunk in &mut chunks { - sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32; - } - if let Some(&last) = chunks.remainder().first() { - sum += (last as u32) << 8; - } - while (sum >> 16) != 0 { - sum = (sum & 0xffff) + (sum >> 16); - } - !(sum as u16) -} - -#[cfg(target_os = "linux")] -async fn try_tor_exec(payload_path: &str, command: Vec) -> Result<()> { - let exit_code = usernet::run_exec(usernet::ExecInvocation { - backend: usernet::ExecBackendKind::Tor, - payload_path: Some(payload_path.into()), - command, - }) - .await?; - if exit_code != 0 { - std::process::exit(exit_code); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -async fn try_exec( - backend: usernet::ExecBackendKind, - payload: Option, - command: Vec, -) -> Result<()> { - let exit_code = usernet::run_exec(usernet::ExecInvocation { - backend, - payload_path: payload.map(Into::into), - command, - }) - .await?; - if exit_code != 0 { - std::process::exit(exit_code); - } - Ok(()) -} - #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn handle_unexpected(res: Result) { match res { @@ -767,25 +287,6 @@ async fn main() -> Result<()> { Commands::NetworkList => try_network_list().await?, Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?, Commands::NetworkDelete(args) => try_network_delete(args.id).await?, - Commands::TailnetDiscover(args) => try_tailnet_discover(&args.email).await?, - Commands::TailnetProbe(args) => try_tailnet_probe(&args.authority).await?, - Commands::TailnetPing(args) => { - try_tailnet_ping(&args.remote, &args.payload, args.timeout_ms).await? - } - Commands::TailnetUdpEcho(args) => { - try_tailnet_udp_echo(&args.remote, &args.message, args.timeout_ms).await? - } - #[cfg(target_os = "linux")] - Commands::Exec(args) => { - try_exec( - args.backend.clone(), - args.payload.clone(), - args.command.clone(), - ) - .await? - } - #[cfg(target_os = "linux")] - Commands::TorExec(args) => try_tor_exec(&args.payload_path, args.command.clone()).await?, } Ok(()) diff --git a/burrow/src/tor/config.rs b/burrow/src/tor/config.rs index d3de9ec..c2e0bc2 100644 --- a/burrow/src/tor/config.rs +++ b/burrow/src/tor/config.rs @@ -1,14 +1,10 @@ -use std::{net::SocketAddr, path::PathBuf, str}; +use std::{net::SocketAddr, str}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Config { - #[serde(default)] - pub account: Option, - #[serde(default)] - pub identity: Option, #[serde(default)] pub address: Vec, #[serde(default)] @@ -82,68 +78,12 @@ impl Config { .with_context(|| format!("invalid system tcp listen address '{}'", config.listen)), } } - - pub fn authority(&self) -> String { - "arti://local".to_owned() - } - - pub fn account_name(&self) -> String { - self.account - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "default".to_owned()) - } - - pub fn identity_name(&self, network_id: i32) -> String { - self.identity - .clone() - .filter(|value| !value.trim().is_empty()) - .or_else(|| self.tun_name.clone()) - .unwrap_or_else(|| format!("tor-{network_id}")) - } - - pub fn runtime_dirs(&self, network_id: i32) -> (String, String) { - let authority = sanitize_path_component(&self.authority()); - let account = sanitize_path_component(&self.account_name()); - let identity = sanitize_path_component(&self.identity_name(network_id)); - ( - append_runtime_path(&self.arti.state_dir, &[&authority, &account, &identity]), - append_runtime_path(&self.arti.cache_dir, &[&authority, &account, &identity]), - ) - } } fn default_system_listen() -> String { "127.0.0.1:9040".to_string() } -fn append_runtime_path(base: &str, parts: &[&str]) -> String { - let mut path = PathBuf::from(base); - for part in parts { - path.push(part); - } - path.to_string_lossy().to_string() -} - -fn sanitize_path_component(value: &str) -> String { - let sanitized: String = value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '_' - } - }) - .collect(); - - if sanitized.is_empty() { - "default".to_owned() - } else { - sanitized - } -} - #[cfg(test)] mod tests { use super::*; @@ -160,7 +100,6 @@ mod tests { let config = Config::from_payload(payload).unwrap(); assert_eq!(config.address, vec!["100.64.0.2/32"]); assert_eq!(config.listen_addr().unwrap().to_string(), "127.0.0.1:9150"); - assert!(config.runtime_dirs(7).0.contains("arti___local")); } #[test] @@ -182,6 +121,5 @@ listen = "127.0.0.1:9140" let config = Config::from_payload(payload.as_bytes()).unwrap(); assert_eq!(config.tun_name.as_deref(), Some("burrow-tor")); assert_eq!(config.listen_addr().unwrap().to_string(), "127.0.0.1:9140"); - assert_eq!(config.identity_name(11), "burrow-tor"); } } diff --git a/burrow/src/tor/dns.rs b/burrow/src/tor/dns.rs deleted file mode 100644 index d918fc4..0000000 --- a/burrow/src/tor/dns.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::{ - net::{IpAddr, SocketAddr}, - sync::Arc, -}; - -use anyhow::{Context, Result}; -use arti_client::TorClient; -use hickory_proto::{ - op::{Message, MessageType, ResponseCode}, - rr::{rdata::A, rdata::AAAA, RData, Record, RecordType}, -}; -use tokio::{net::UdpSocket, sync::watch, task::JoinError}; -use tor_rtcompat::PreferredRuntime; -use tracing::{debug, warn}; - -const DNS_TTL_SECS: u32 = 60; - -#[derive(Debug)] -pub struct TorDnsHandle { - shutdown: watch::Sender, - task: tokio::task::JoinHandle<()>, -} - -impl TorDnsHandle { - pub async fn shutdown(self) -> Result<()> { - let _ = self.shutdown.send(true); - match self.task.await { - Ok(()) => Ok(()), - Err(err) if err.is_cancelled() => Ok(()), - Err(err) => Err(join_error(err)), - } - } -} - -pub async fn spawn( - bind_addr: SocketAddr, - tor_client: Arc>, -) -> Result { - let socket = UdpSocket::bind(bind_addr) - .await - .with_context(|| format!("failed to bind tor dns proxy on {bind_addr}"))?; - let (shutdown_tx, mut shutdown_rx) = watch::channel(false); - let task = tokio::spawn(async move { - let mut buffer = [0u8; 4096]; - loop { - tokio::select! { - changed = shutdown_rx.changed() => { - match changed { - Ok(()) if *shutdown_rx.borrow() => break, - Ok(()) => continue, - Err(_) => break, - } - } - received = socket.recv_from(&mut buffer) => { - let (len, peer_addr) = match received { - Ok(value) => value, - Err(err) => { - warn!(?err, "tor dns proxy recv failed"); - continue; - } - }; - - let response = match build_response(&buffer[..len], tor_client.as_ref()).await { - Ok(message) => message, - Err(err) => { - debug!(?err, "tor dns proxy failed to answer query"); - continue; - } - }; - - if let Err(err) = socket.send_to(&response, peer_addr).await { - warn!(?err, "tor dns proxy send failed"); - } - } - } - } - }); - - Ok(TorDnsHandle { shutdown: shutdown_tx, task }) -} - -pub(crate) async fn build_response( - packet: &[u8], - tor_client: &TorClient, -) -> Result> { - let request = Message::from_vec(packet).context("failed to parse dns packet")?; - let mut response = Message::new(); - response - .set_id(request.id()) - .set_op_code(request.op_code()) - .set_message_type(MessageType::Response) - .set_recursion_desired(request.recursion_desired()) - .set_recursion_available(true) - .set_response_code(ResponseCode::NoError); - - for query in request.queries().iter().cloned() { - response.add_query(query.clone()); - match query.query_type() { - RecordType::A | RecordType::AAAA => { - let hostname = query.name().to_utf8(); - let hostname = hostname.trim_end_matches('.'); - match tor_client.resolve(hostname).await { - Ok(addrs) => { - for addr in addrs { - if let Some(answer) = - record_for_address(query.name().clone(), query.query_type(), addr) - { - response.add_answer(answer); - } - } - } - Err(err) => { - debug!(hostname, ?err, "tor dns lookup failed"); - response.set_response_code(ResponseCode::ServFail); - } - } - } - _ => { - response.set_response_code(ResponseCode::NotImp); - } - } - } - - response.to_vec().context("failed to encode dns response") -} - -fn record_for_address( - name: hickory_proto::rr::Name, - record_type: RecordType, - addr: IpAddr, -) -> Option { - match (record_type, addr) { - (RecordType::A, IpAddr::V4(ip)) => Some(Record::from_rdata( - name, - DNS_TTL_SECS, - RData::A(A::from(ip)), - )), - (RecordType::AAAA, IpAddr::V6(ip)) => Some(Record::from_rdata( - name, - DNS_TTL_SECS, - RData::AAAA(AAAA::from(ip)), - )), - _ => None, - } -} - -fn join_error(err: JoinError) -> anyhow::Error { - anyhow::anyhow!("tor dns task failed: {err}") -} - -#[cfg(test)] -mod tests { - use super::*; - use hickory_proto::rr::Name; - use std::net::{Ipv4Addr, Ipv6Addr}; - - #[test] - fn builds_a_record_for_ipv4_answer() { - let record = record_for_address( - Name::from_ascii("example.com.").unwrap(), - RecordType::A, - IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), - ) - .unwrap(); - assert_eq!(record.record_type(), RecordType::A); - } - - #[test] - fn skips_mismatched_record_type() { - let record = record_for_address( - Name::from_ascii("example.com.").unwrap(), - RecordType::A, - IpAddr::V6(Ipv6Addr::LOCALHOST), - ); - assert!(record.is_none()); - } -} diff --git a/burrow/src/tor/exec.rs b/burrow/src/tor/exec.rs deleted file mode 100644 index 7f4317d..0000000 --- a/burrow/src/tor/exec.rs +++ /dev/null @@ -1,439 +0,0 @@ -use std::{ - ffi::{OsStr, OsString}, - fs, - net::{IpAddr, Ipv4Addr, SocketAddr}, - os::unix::process::ExitStatusExt, - path::PathBuf, - process::{Command, ExitStatus, Stdio}, - sync::Arc, - time::Duration, -}; - -use anyhow::{bail, Context, Result}; -use tokio::process::Command as TokioCommand; -use tor_rtcompat::PreferredRuntime; -use tracing::{debug, info}; - -use super::{ - bootstrap_client, - dns::{spawn as spawn_dns, TorDnsHandle}, - runtime::{spawn_with_client, TorHandle}, - Config, SystemTcpStackConfig, TcpStackConfig, -}; - -const CHILD_PREFIX_LEN: u8 = 30; -const CHILD_DNS_PORT: u16 = 53; -const LISTENER_READY_TIMEOUT: Duration = Duration::from_secs(10); -const LISTENER_READY_POLL: Duration = Duration::from_millis(100); - -pub async fn run_exec(mut config: Config, command: Vec) -> Result { - if command.is_empty() { - bail!("tor-exec requires a command to run"); - } - ensure_root()?; - ensure_host_tool("ip")?; - ensure_host_tool("iptables")?; - ensure_host_tool("unshare")?; - - let requested_listener = config.listen_addr()?; - if requested_listener.port() == 0 { - bail!("tor-exec requires a fixed listener port"); - } - - let plan = NamespacePlan::new(requested_listener.port()); - let (state_dir, cache_dir) = config.runtime_dirs(std::process::id() as i32); - config.arti.state_dir = state_dir; - config.arti.cache_dir = cache_dir; - config.tcp_stack = TcpStackConfig::System(SystemTcpStackConfig { - listen: format!("{}:{}", plan.host_ip, plan.listener_port), - }); - - let namespace = NamespaceGuard::create(&plan)?; - let tor_client = bootstrap_client(&config).await?; - let tor_handle = spawn_with_client(config, tor_client.clone()).await?; - wait_for_listener(SocketAddr::new( - IpAddr::V4(plan.host_ip), - plan.listener_port, - )) - .await?; - let dns_handle = spawn_dns( - SocketAddr::new(IpAddr::V4(plan.host_ip), CHILD_DNS_PORT), - tor_client, - ) - .await?; - - let status = namespace.run_child(&command).await; - let dns_shutdown = dns_handle.shutdown().await; - let tor_shutdown = tor_handle.shutdown().await; - - let status = status?; - dns_shutdown?; - tor_shutdown?; - child_exit_code(status) -} - -fn ensure_root() -> Result<()> { - if unsafe { libc::geteuid() } != 0 { - bail!("tor-exec currently requires root on linux"); - } - Ok(()) -} - -fn ensure_host_tool(tool: &str) -> Result<()> { - let status = Command::new("sh") - .args(["-lc", &format!("command -v {tool} >/dev/null")]) - .status() - .with_context(|| format!("failed to probe required tool '{tool}'"))?; - if !status.success() { - bail!("required host tool '{tool}' is not available"); - } - Ok(()) -} - -async fn wait_for_listener(addr: SocketAddr) -> Result<()> { - let deadline = tokio::time::Instant::now() + LISTENER_READY_TIMEOUT; - loop { - match tokio::net::TcpStream::connect(addr).await { - Ok(stream) => { - drop(stream); - return Ok(()); - } - Err(err) if tokio::time::Instant::now() < deadline => { - debug!(%addr, ?err, "waiting for tor transparent listener"); - tokio::time::sleep(LISTENER_READY_POLL).await; - } - Err(err) => return Err(err).with_context(|| format!("timed out waiting for {addr}")), - } - } -} - -fn child_exit_code(status: ExitStatus) -> Result { - if let Some(code) = status.code() { - return Ok(code); - } - if let Some(signal) = status.signal() { - return Ok(128 + signal); - } - bail!("child process terminated without an exit code"); -} - -#[derive(Debug, Clone)] -struct NamespacePlan { - netns_name: String, - host_if: String, - child_if: String, - host_ip: Ipv4Addr, - child_ip: Ipv4Addr, - listener_port: u16, -} - -impl NamespacePlan { - fn new(listener_port: u16) -> Self { - let token = std::process::id() % 10_000; - let segment = ((std::process::id() % 200) as u8) + 20; - Self { - netns_name: format!("burrow-tor-{token}"), - host_if: format!("bth{token}"), - child_if: format!("btc{token}"), - host_ip: Ipv4Addr::new(100, 90, segment, 1), - child_ip: Ipv4Addr::new(100, 90, segment, 2), - listener_port, - } - } - - fn host_cidr(&self) -> String { - format!("{}/{}", self.host_ip, CHILD_PREFIX_LEN) - } - - fn child_cidr(&self) -> String { - format!("{}/{}", self.child_ip, CHILD_PREFIX_LEN) - } -} - -struct NamespaceGuard { - plan: NamespacePlan, - resolv_conf: PathBuf, - nat_rule_installed: bool, - forward_rule_installed: bool, - netns_created: bool, - host_link_created: bool, -} - -impl NamespaceGuard { - fn create(plan: &NamespacePlan) -> Result { - let mut guard = Self { - plan: plan.clone(), - resolv_conf: write_resolv_conf(plan.host_ip)?, - nat_rule_installed: false, - forward_rule_installed: false, - netns_created: false, - host_link_created: false, - }; - - let setup = (|| -> Result<()> { - run_host_command(["ip", "netns", "add", &guard.plan.netns_name])?; - guard.netns_created = true; - - run_host_command([ - "ip", - "link", - "add", - &guard.plan.host_if, - "type", - "veth", - "peer", - "name", - &guard.plan.child_if, - ])?; - guard.host_link_created = true; - - run_host_command([ - "ip", - "addr", - "add", - &guard.plan.host_cidr(), - "dev", - &guard.plan.host_if, - ])?; - run_host_command(["ip", "link", "set", &guard.plan.host_if, "up"])?; - run_host_command([ - "ip", - "link", - "set", - &guard.plan.child_if, - "netns", - &guard.plan.netns_name, - ])?; - run_host_command([ - "ip", - "netns", - "exec", - &guard.plan.netns_name, - "ip", - "link", - "set", - "lo", - "up", - ])?; - run_host_command([ - "ip", - "netns", - "exec", - &guard.plan.netns_name, - "ip", - "addr", - "add", - &guard.plan.child_cidr(), - "dev", - &guard.plan.child_if, - ])?; - run_host_command([ - "ip", - "netns", - "exec", - &guard.plan.netns_name, - "ip", - "link", - "set", - &guard.plan.child_if, - "up", - ])?; - run_host_command([ - "ip", - "netns", - "exec", - &guard.plan.netns_name, - "ip", - "route", - "add", - "default", - "via", - &guard.plan.host_ip.to_string(), - "dev", - &guard.plan.child_if, - ])?; - run_host_command([ - "iptables", - "-t", - "nat", - "-A", - "PREROUTING", - "-i", - &guard.plan.host_if, - "-p", - "tcp", - "-j", - "DNAT", - "--to-destination", - &format!("{}:{}", guard.plan.host_ip, guard.plan.listener_port), - ])?; - guard.nat_rule_installed = true; - - run_host_command([ - "iptables", - "-A", - "FORWARD", - "-i", - &guard.plan.host_if, - "-j", - "REJECT", - ])?; - guard.forward_rule_installed = true; - Ok(()) - })(); - - if let Err(err) = setup { - guard.cleanup(); - return Err(err); - } - - Ok(guard) - } - - async fn run_child(&self, command: &[String]) -> Result { - let mut args = vec![ - OsString::from("netns"), - OsString::from("exec"), - OsString::from(&self.plan.netns_name), - OsString::from("unshare"), - OsString::from("--user"), - OsString::from("--map-root-user"), - OsString::from("--mount"), - OsString::from("--pid"), - OsString::from("--fork"), - OsString::from("--kill-child"), - OsString::from("sh"), - OsString::from("-ceu"), - OsString::from(CHILD_SCRIPT), - OsString::from("sh"), - self.resolv_conf.as_os_str().to_os_string(), - ]; - args.extend(command.iter().map(OsString::from)); - - let status = TokioCommand::new("ip") - .args(args) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .await - .context("failed to execute child in tor namespace")?; - Ok(status) - } - - fn cleanup(&mut self) { - if self.forward_rule_installed { - let _ = run_host_command([ - "iptables", - "-D", - "FORWARD", - "-i", - &self.plan.host_if, - "-j", - "REJECT", - ]); - self.forward_rule_installed = false; - } - if self.nat_rule_installed { - let _ = run_host_command([ - "iptables", - "-t", - "nat", - "-D", - "PREROUTING", - "-i", - &self.plan.host_if, - "-p", - "tcp", - "-j", - "DNAT", - "--to-destination", - &format!("{}:{}", self.plan.host_ip, self.plan.listener_port), - ]); - self.nat_rule_installed = false; - } - if self.host_link_created { - let _ = run_host_command(["ip", "link", "delete", &self.plan.host_if]); - self.host_link_created = false; - } - if self.netns_created { - let _ = run_host_command(["ip", "netns", "delete", &self.plan.netns_name]); - self.netns_created = false; - } - let _ = fs::remove_file(&self.resolv_conf); - } -} - -impl Drop for NamespaceGuard { - fn drop(&mut self) { - self.cleanup(); - } -} - -fn write_resolv_conf(nameserver: Ipv4Addr) -> Result { - let path = std::env::temp_dir().join(format!("burrow-tor-resolv-{}.conf", std::process::id())); - fs::write(&path, format!("nameserver {nameserver}\noptions ndots:1\n")) - .with_context(|| format!("failed to write {}", path.display()))?; - Ok(path) -} - -fn run_host_command(args: [&str; N]) -> Result<()> { - let (program, rest) = args - .split_first() - .expect("run_host_command requires a program and arguments"); - let status = Command::new(program) - .args(rest) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status() - .with_context(|| format!("failed to start host command {}", shell_words(&args)))?; - if status.success() { - Ok(()) - } else { - bail!("host command failed: {}", shell_words(&args)); - } -} - -fn shell_words(args: &[&str]) -> String { - args.iter() - .map(|arg| shlex_escape(arg)) - .collect::>() - .join(" ") -} - -fn shlex_escape(value: &str) -> String { - if value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || "-_./:=+".contains(ch)) - { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) - } -} - -const CHILD_SCRIPT: &str = r#" -mount -t proc proc /proc -mount --bind "$1" /etc/resolv.conf -shift -exec "$@" -"#; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn namespace_plan_uses_short_interface_names() { - let plan = NamespacePlan::new(9040); - assert!(plan.host_if.len() <= 15); - assert!(plan.child_if.len() <= 15); - } - - #[test] - fn signal_exit_code_uses_shell_convention() { - let status = ExitStatus::from_raw(libc::SIGTERM); - assert_eq!(child_exit_code(status).unwrap(), 128 + libc::SIGTERM); - } -} diff --git a/burrow/src/tor/mod.rs b/burrow/src/tor/mod.rs index 635c355..d275c7e 100644 --- a/burrow/src/tor/mod.rs +++ b/burrow/src/tor/mod.rs @@ -1,9 +1,6 @@ mod config; -pub(crate) mod dns; -mod exec; mod runtime; mod system; pub use config::{ArtiConfig, Config, SystemTcpStackConfig, TcpStackConfig}; -pub use exec::run_exec; -pub use runtime::{bootstrap_client, spawn, spawn_with_client, TorHandle}; +pub use runtime::{spawn, TorHandle}; diff --git a/burrow/src/tor/runtime.rs b/burrow/src/tor/runtime.rs index 45690ee..a7deb3c 100644 --- a/burrow/src/tor/runtime.rs +++ b/burrow/src/tor/runtime.rs @@ -7,7 +7,6 @@ use tokio::{ task::{JoinError, JoinSet}, }; use tokio_util::compat::FuturesAsyncReadCompatExt; -use tor_rtcompat::PreferredRuntime; use tracing::{debug, error, info, warn}; use super::{system::SystemTcpStackRuntime, Config, TcpStackConfig}; @@ -29,25 +28,16 @@ impl TorHandle { } } -pub async fn bootstrap_client(config: &Config) -> Result>> { +pub async fn spawn(config: Config) -> Result { let builder = TorClientConfigBuilder::from_directories(&config.arti.state_dir, &config.arti.cache_dir); let tor_config = builder.build().context("failed to build arti config")?; - let tor_client = TorClient::create_bootstrapped(tor_config) - .await - .context("failed to bootstrap arti client")?; - Ok(Arc::new(tor_client)) -} + let tor_client = Arc::new( + TorClient::create_bootstrapped(tor_config) + .await + .context("failed to bootstrap arti client")?, + ); -pub async fn spawn(config: Config) -> Result { - let tor_client = bootstrap_client(&config).await?; - spawn_with_client(config, tor_client).await -} - -pub async fn spawn_with_client( - config: Config, - tor_client: Arc>, -) -> Result { let (shutdown_tx, mut shutdown_rx) = watch::channel(false); let task = match config.tcp_stack.clone() { TcpStackConfig::System(system_config) => tokio::spawn(async move { diff --git a/burrow/src/tor/system.rs b/burrow/src/tor/system.rs index 74f8157..c049835 100644 --- a/burrow/src/tor/system.rs +++ b/burrow/src/tor/system.rs @@ -7,6 +7,8 @@ use super::SystemTcpStackConfig; pub struct SystemTcpStackRuntime { listener: TcpListener, + #[cfg(target_vendor = "apple")] + flow_tracker: AppleFlowTracker, } impl SystemTcpStackRuntime { @@ -14,7 +16,18 @@ impl SystemTcpStackRuntime { let listener = TcpListener::bind(&config.listen) .await .with_context(|| format!("failed to bind transparent listener on {}", config.listen))?; - Ok(Self { listener }) + #[cfg(target_vendor = "apple")] + let flow_tracker = AppleFlowTracker::new( + listener + .local_addr() + .expect("listener should always have a local address"), + ) + .context("failed to open /dev/pf for transparent destination lookups")?; + Ok(Self { + listener, + #[cfg(target_vendor = "apple")] + flow_tracker, + }) } pub fn local_addr(&self) -> SocketAddr { @@ -29,6 +42,9 @@ impl SystemTcpStackRuntime { .accept() .await .context("failed to accept transparent listener connection")?; + #[cfg(target_vendor = "apple")] + let original_dst = self.flow_tracker.resolve(&stream)?; + #[cfg(not(target_vendor = "apple"))] let original_dst = original_destination(&stream)?; Ok((stream, original_dst)) } @@ -65,11 +81,708 @@ fn original_destination(stream: &TcpStream) -> Result { socket_addr_from_storage(unsafe { &addr.assume_init() }, len as usize) } -#[cfg(not(target_os = "linux"))] +#[cfg(all(not(target_os = "linux"), not(target_vendor = "apple")))] fn original_destination(_stream: &TcpStream) -> Result { anyhow::bail!("system tcp stack transparent destination lookup is only implemented on linux") } +#[cfg(target_vendor = "apple")] +mod apple_pf { + use std::{ + collections::HashMap, + fs::File, + io, + mem::zeroed, + io::Read, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, + os::fd::{AsRawFd, RawFd}, + time::{Duration, Instant}, + }; + + use anyhow::{anyhow, bail, Context, Result}; + use nix::{ioctl_readwrite, libc}; + use parking_lot::Mutex; + use tokio::net::TcpStream; + + ioctl_readwrite!(pf_natlook, b'D', 23, PfiocNatlook); + + const FLOW_CACHE_LIMIT: usize = 4096; + const FLOW_CACHE_TTL: Duration = Duration::from_secs(30); + const PF_OUT: u8 = 2; + const PFLOG_RULESET_NAME_SIZE: usize = 16; + const PFLOG_DEVICE: &str = "pflog0"; + const OBSERVER_WAIT_STEPS: usize = 20; + const OBSERVER_WAIT_INTERVAL: Duration = Duration::from_millis(10); + + pub(super) struct AppleFlowTracker { + pf: File, + listener_addr: SocketAddr, + state: Mutex, + } + + impl AppleFlowTracker { + pub(super) fn new(listener_addr: SocketAddr) -> io::Result { + Ok(Self { + pf: File::options().read(true).write(true).open("/dev/pf")?, + listener_addr, + state: Mutex::new(FlowState { + cache: HashMap::new(), + observer: PacketObserver::new(listener_addr).ok(), + }), + }) + } + + pub(super) fn resolve(&self, stream: &TcpStream) -> Result { + let key = FlowKey::from_stream(stream)?; + if let Some(original_dst) = self.cached_destination(key) { + return Ok(original_dst); + } + + match lookup_pf_original_destination(self.pf.as_raw_fd(), key.peer, key.local) { + Ok(original_dst) => { + self.remember(key, original_dst); + Ok(original_dst) + } + Err(err) + if matches!( + err.raw_os_error(), + Some(code) if code == libc::EPERM || code == libc::ENOENT + ) => + { + if let Some(original_dst) = self.wait_for_observer(key) { + return Ok(original_dst); + } + match err.raw_os_error() { + Some(code) if code == libc::EPERM => Err(anyhow!( + "PF NAT lookups are denied on this macOS build and no logged redirect flow was observed for {} -> {}", + key.peer, + key.local + )), + Some(code) if code == libc::ENOENT => Err(anyhow!( + "PF did not have a redirect state for {} -> {} and no logged redirect flow was observed; ensure outbound TCP is redirected and logged before Burrow accepts it", + key.peer, + key.local + )), + _ => unreachable!(), + } + } + Err(err) => Err(err).context("DIOCNATLOOK failed"), + } + } + + fn cached_destination(&self, key: FlowKey) -> Option { + let mut state = self.state.lock(); + state.prune(); + state.drain_observer(self.listener_addr); + state.cache.get(&key).map(|entry| entry.original_dst) + } + + fn remember(&self, key: FlowKey, original_dst: SocketAddr) { + let mut state = self.state.lock(); + state.prune(); + remember_flow(&mut state.cache, key, original_dst, Instant::now()); + } + + fn wait_for_observer(&self, key: FlowKey) -> Option { + for _ in 0..OBSERVER_WAIT_STEPS { + if let Some(original_dst) = self.cached_destination(key) { + return Some(original_dst); + } + std::thread::sleep(OBSERVER_WAIT_INTERVAL); + } + None + } + } + + struct FlowState { + cache: HashMap, + observer: Option, + } + + impl FlowState { + fn prune(&mut self) { + let now = Instant::now(); + self.cache.retain(|_, entry| entry.expires_at > now); + } + + fn drain_observer(&mut self, listener_addr: SocketAddr) { + let Some(mut observer) = self.observer.take() else { + return; + }; + if observer.drain(listener_addr, &mut self.cache).is_ok() { + self.observer = Some(observer); + } + } + } + + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + struct FlowKey { + peer: SocketAddr, + local: SocketAddr, + } + + impl FlowKey { + fn from_stream(stream: &TcpStream) -> Result { + let peer = stream.peer_addr().context("failed to read transparent peer address")?; + let local = stream + .local_addr() + .context("failed to read transparent listener address")?; + match (peer, local) { + (SocketAddr::V4(_), SocketAddr::V4(_)) | (SocketAddr::V6(_), SocketAddr::V6(_)) => { + Ok(Self { peer, local }) + } + _ => bail!("transparent socket had mismatched source/destination address families"), + } + } + } + + #[derive(Clone, Copy, Debug)] + struct FlowEntry { + original_dst: SocketAddr, + expires_at: Instant, + } + + fn remember_flow( + cache: &mut HashMap, + key: FlowKey, + original_dst: SocketAddr, + now: Instant, + ) { + cache.retain(|_, entry| entry.expires_at > now); + if cache.len() >= FLOW_CACHE_LIMIT { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, entry)| entry.expires_at) + .map(|(flow_key, _)| *flow_key) + { + cache.remove(&oldest); + } + } + cache.insert( + key, + FlowEntry { + original_dst, + expires_at: now + FLOW_CACHE_TTL, + }, + ); + } + + fn lookup_pf_original_destination( + fd: RawFd, + peer: SocketAddr, + local: SocketAddr, + ) -> io::Result { + let mut request = PfiocNatlook::for_flow(peer, local) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let ioctl_result = unsafe { pf_natlook(fd, &mut request) }; + if let Err(errno) = ioctl_result { + return Err(io::Error::from(errno)); + } + request + .original_destination() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + } + + struct PacketObserver { + file: File, + buffer: Vec, + } + + impl PacketObserver { + fn new(listener_addr: SocketAddr) -> io::Result { + if listener_addr.ip().is_unspecified() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "packet observer requires an explicit listener address", + )); + } + + let file = open_bpf_device()?; + bind_bpf_to_interface(file.as_raw_fd(), PFLOG_DEVICE)?; + set_bpf_flag(file.as_raw_fd(), libc::BIOCIMMEDIATE, 1)?; + set_bpf_flag(file.as_raw_fd(), libc::BIOCSSEESENT, 1)?; + set_nonblocking(file.as_raw_fd())?; + + let mut buffer_len: libc::c_uint = 0; + ioctl_value(file.as_raw_fd(), libc::BIOCGBLEN, &mut buffer_len)?; + Ok(Self { + file, + buffer: vec![0; buffer_len as usize], + }) + } + + fn drain( + &mut self, + listener_addr: SocketAddr, + cache: &mut HashMap, + ) -> io::Result<()> { + loop { + match self.file.read(&mut self.buffer) { + Ok(0) => break, + Ok(read) => self.consume(&self.buffer[..read], listener_addr, cache), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => break, + Err(err) => return Err(err), + } + } + Ok(()) + } + + fn consume( + &self, + buffer: &[u8], + listener_addr: SocketAddr, + cache: &mut HashMap, + ) { + let mut offset = 0usize; + let now = Instant::now(); + while offset + std::mem::size_of::() <= buffer.len() { + let header = unsafe { + std::ptr::read_unaligned(buffer[offset..].as_ptr() as *const libc::bpf_hdr) + }; + let header_len = header.bh_hdrlen as usize; + let captured_len = header.bh_caplen as usize; + let packet_start = offset + header_len; + let packet_end = packet_start + captured_len; + let next_record = offset + bpf_wordalign(header_len + captured_len); + if packet_end > buffer.len() || next_record > buffer.len() { + break; + } + + if let Some((peer, original_dst)) = + parse_logged_syn(&buffer[packet_start..packet_end], listener_addr) + { + remember_flow( + cache, + FlowKey { + peer, + local: listener_addr, + }, + original_dst, + now, + ); + } + + offset = next_record; + } + } + } + + fn open_bpf_device() -> io::Result { + for index in 0..=255 { + match File::options() + .read(true) + .open(format!("/dev/bpf{index}")) + { + Ok(file) => return Ok(file), + Err(err) if err.raw_os_error() == Some(libc::EBUSY) => continue, + Err(err) => return Err(err), + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + "no free /dev/bpf devices were available", + )) + } + + fn bind_bpf_to_interface(fd: RawFd, ifname: &str) -> io::Result<()> { + let mut ifreq = unsafe { zeroed::() }; + let bytes = ifname.as_bytes(); + let max = std::cmp::min(bytes.len(), libc::IFNAMSIZ.saturating_sub(1)); + for (index, byte) in bytes.iter().take(max).enumerate() { + ifreq.ifr_name[index] = *byte as libc::c_char; + } + ioctl_value(fd, libc::BIOCSETIF, &mut ifreq) + } + + fn set_bpf_flag(fd: RawFd, request: libc::c_ulong, value: libc::c_uint) -> io::Result<()> { + let mut flag = value; + ioctl_value(fd, request, &mut flag) + } + + fn set_nonblocking(fd: RawFd) -> io::Result<()> { + let current = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if current < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFL, current | libc::O_NONBLOCK) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + fn ioctl_value(fd: RawFd, request: libc::c_ulong, value: &mut T) -> io::Result<()> { + if unsafe { libc::ioctl(fd, request, value) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + fn parse_logged_syn( + record: &[u8], + listener_addr: SocketAddr, + ) -> Option<(SocketAddr, SocketAddr)> { + let header = read_pflog_header(record)?; + if header.dir != PF_OUT { + return None; + } + let packet = record.get(header.length as usize..)?; + match header.af as i32 { + libc::AF_INET => parse_ipv4_syn(packet, listener_addr), + libc::AF_INET6 => parse_ipv6_syn(packet, listener_addr), + _ => None, + } + } + + fn parse_ipv4_syn(packet: &[u8], listener_addr: SocketAddr) -> Option<(SocketAddr, SocketAddr)> { + if !matches!(listener_addr, SocketAddr::V4(_)) || packet.len() < 20 || packet[0] >> 4 != 4 { + return None; + } + let header_len = usize::from(packet[0] & 0x0f) * 4; + if header_len < 20 || packet.len() < header_len + 20 || packet[9] != libc::IPPROTO_TCP as u8 { + return None; + } + let tcp = &packet[header_len..]; + let flags = tcp[13]; + if flags & 0x02 == 0 || flags & 0x10 != 0 { + return None; + } + let source_ip = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]); + let dest_ip = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]); + let source_port = u16::from_be_bytes([tcp[0], tcp[1]]); + let dest_port = u16::from_be_bytes([tcp[2], tcp[3]]); + Some(( + SocketAddr::V4(SocketAddrV4::new(source_ip, source_port)), + SocketAddr::V4(SocketAddrV4::new(dest_ip, dest_port)), + )) + } + + fn parse_ipv6_syn(packet: &[u8], listener_addr: SocketAddr) -> Option<(SocketAddr, SocketAddr)> { + if !matches!(listener_addr, SocketAddr::V6(_)) || packet.len() < 40 || packet[0] >> 4 != 6 { + return None; + } + if packet[6] != libc::IPPROTO_TCP as u8 || packet.len() < 60 { + return None; + } + let tcp = &packet[40..]; + let flags = tcp[13]; + if flags & 0x02 == 0 || flags & 0x10 != 0 { + return None; + } + let source_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&packet[8..24]).ok()?); + let dest_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&packet[24..40]).ok()?); + let source_port = u16::from_be_bytes([tcp[0], tcp[1]]); + let dest_port = u16::from_be_bytes([tcp[2], tcp[3]]); + Some(( + SocketAddr::V6(SocketAddrV6::new(source_ip, source_port, 0, 0)), + SocketAddr::V6(SocketAddrV6::new(dest_ip, dest_port, 0, 0)), + )) + } + + fn read_pflog_header(record: &[u8]) -> Option { + if record.len() < std::mem::size_of::() { + return None; + } + let header = + unsafe { std::ptr::read_unaligned(record.as_ptr() as *const PflogHdr) }; + if header.length as usize > record.len() || (header.length as usize) < PFLOG_REAL_HDRLEN { + return None; + } + Some(header) + } + + const fn bpf_wordalign(len: usize) -> usize { + let alignment = std::mem::size_of::(); + (len + (alignment - 1)) & !(alignment - 1) + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct PfiocNatlook { + saddr: PfAddr, + daddr: PfAddr, + rsaddr: PfAddr, + rdaddr: PfAddr, + sxport: PfStateXport, + dxport: PfStateXport, + rsxport: PfStateXport, + rdxport: PfStateXport, + af: libc::sa_family_t, + proto: u8, + proto_variant: u8, + direction: u8, + } + + impl PfiocNatlook { + fn for_flow(peer: SocketAddr, local: SocketAddr) -> Result { + let (saddr, sxport, source_af) = pf_endpoint(peer); + let (daddr, dxport, destination_af) = pf_endpoint(local); + if source_af != destination_af { + bail!("transparent flow key changed address family across redirect"); + } + Ok(Self { + saddr, + daddr, + rsaddr: PfAddr::default(), + rdaddr: PfAddr::default(), + sxport, + dxport, + rsxport: PfStateXport::default(), + rdxport: PfStateXport::default(), + af: source_af, + proto: libc::IPPROTO_TCP as u8, + proto_variant: 0, + direction: PF_OUT, + }) + } + + fn original_destination(&self) -> Result { + socket_addr_from_pf(self.af, self.rdaddr, self.rdxport) + } + } + + fn pf_endpoint(addr: SocketAddr) -> (PfAddr, PfStateXport, libc::sa_family_t) { + let port = PfStateXport { + port: u16::to_be(addr.port()), + }; + match addr { + SocketAddr::V4(addr) => ( + PfAddr::from_ipv4(*addr.ip()), + port, + libc::AF_INET as libc::sa_family_t, + ), + SocketAddr::V6(addr) => ( + PfAddr::from_ipv6(*addr.ip()), + port, + libc::AF_INET6 as libc::sa_family_t, + ), + } + } + + fn socket_addr_from_pf( + af: libc::sa_family_t, + addr: PfAddr, + port: PfStateXport, + ) -> Result { + match af as i32 { + libc::AF_INET => Ok(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::from(addr.v4_octets()), + u16::from_be(unsafe { port.port }), + ))), + libc::AF_INET6 => Ok(SocketAddr::V6(SocketAddrV6::new( + Ipv6Addr::from(addr.v6_octets()), + u16::from_be(unsafe { port.port }), + 0, + 0, + ))), + family => bail!("unsupported PF address family {family}"), + } + } + + #[repr(C)] + #[derive(Clone, Copy)] + union PfAddrRepr { + v4addr: libc::in_addr, + v6addr: libc::in6_addr, + addr8: [u8; 16], + addr16: [u16; 8], + addr32: [u32; 4], + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct PfAddr { + pfa: PfAddrRepr, + } + + impl Default for PfAddr { + fn default() -> Self { + Self { + pfa: PfAddrRepr { addr32: [0; 4] }, + } + } + } + + impl PfAddr { + fn from_ipv4(ip: Ipv4Addr) -> Self { + let mut bytes = [0u8; 16]; + bytes[..4].copy_from_slice(&ip.octets()); + Self { + pfa: PfAddrRepr { addr8: bytes }, + } + } + + fn from_ipv6(ip: Ipv6Addr) -> Self { + Self { + pfa: PfAddrRepr { + addr8: ip.octets(), + }, + } + } + + fn v4_octets(self) -> [u8; 4] { + let bytes = unsafe { self.pfa.addr8 }; + [bytes[0], bytes[1], bytes[2], bytes[3]] + } + + fn v6_octets(self) -> [u8; 16] { + unsafe { self.pfa.addr8 } + } + } + + #[repr(C)] + #[derive(Clone, Copy)] + union PfStateXport { + port: u16, + call_id: u16, + spi: u32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct PflogHdr { + length: u8, + af: libc::sa_family_t, + action: u8, + reason: u8, + ifname: [libc::c_char; libc::IFNAMSIZ], + ruleset: [libc::c_char; PFLOG_RULESET_NAME_SIZE], + rulenr: u32, + subrulenr: u32, + uid: libc::uid_t, + pid: libc::pid_t, + rule_uid: libc::uid_t, + rule_pid: libc::pid_t, + dir: u8, + pad: [u8; 3], + } + + const PFLOG_REAL_HDRLEN: usize = std::mem::offset_of!(PflogHdr, pad); + + impl Default for PfStateXport { + fn default() -> Self { + unsafe { zeroed() } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn builds_natlook_request_from_redirected_flow() { + let request = PfiocNatlook::for_flow( + "192.0.2.10:41000".parse().unwrap(), + "127.0.0.1:9040".parse().unwrap(), + ) + .unwrap(); + assert_eq!(request.af as i32, libc::AF_INET); + assert_eq!(request.proto, libc::IPPROTO_TCP as u8); + assert_eq!(request.direction, PF_OUT); + assert_eq!(request.saddr.v4_octets(), [192, 0, 2, 10]); + assert_eq!(request.daddr.v4_octets(), [127, 0, 0, 1]); + assert_eq!(u16::from_be(unsafe { request.sxport.port }), 41000); + assert_eq!(u16::from_be(unsafe { request.dxport.port }), 9040); + } + + #[test] + fn decodes_original_ipv6_destination() { + let mut request = + PfiocNatlook::for_flow("[::1]:41000".parse().unwrap(), "[::1]:9040".parse().unwrap()) + .unwrap(); + request.rdaddr = PfAddr::from_ipv6("2001:db8::42".parse().unwrap()); + request.rdxport = PfStateXport { + port: u16::to_be(443), + }; + + assert_eq!( + request.original_destination().unwrap(), + "[2001:db8::42]:443".parse::().unwrap() + ); + } + + #[test] + fn parses_logged_ipv4_syn() { + let mut record = Vec::new(); + record.extend_from_slice(&[ + PFLOG_REAL_HDRLEN as u8, + libc::AF_INET as u8, + 0, + 0, + ]); + record.extend_from_slice(&[0; libc::IFNAMSIZ]); + record.extend_from_slice(&[0; PFLOG_RULESET_NAME_SIZE]); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.push(PF_OUT); + + record.extend_from_slice(&[ + 0x45, 0, 0, 40, 0, 0, 0, 0, 64, libc::IPPROTO_TCP as u8, 0, 0, 192, 0, 2, 10, + 198, 51, 100, 42, + ]); + record.extend_from_slice(&[ + 0x9c, 0x28, 0x01, 0xbb, 0, 0, 0, 0, 0, 0, 0, 0, 0x50, 0x02, 0x20, 0, 0, 0, 0, + 0, + ]); + + assert_eq!( + parse_logged_syn(&record, "127.0.0.1:9040".parse().unwrap()), + Some(( + "192.0.2.10:39976".parse().unwrap(), + "198.51.100.42:443".parse().unwrap(), + )) + ); + } + + #[test] + fn parses_logged_ipv6_syn() { + let mut record = Vec::new(); + record.extend_from_slice(&[ + PFLOG_REAL_HDRLEN as u8, + libc::AF_INET6 as u8, + 0, + 0, + ]); + record.extend_from_slice(&[0; libc::IFNAMSIZ]); + record.extend_from_slice(&[0; PFLOG_RULESET_NAME_SIZE]); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.extend_from_slice(&0u32.to_ne_bytes()); + record.push(PF_OUT); + + let source_ip = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x10).octets(); + let dest_ip = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x42).octets(); + record.extend_from_slice(&[ + 0x60, 0, 0, 0, 0, 20, libc::IPPROTO_TCP as u8, 64, + ]); + record.extend_from_slice(&source_ip); + record.extend_from_slice(&dest_ip); + record.extend_from_slice(&[ + 0x9c, 0x28, 0x01, 0xbb, 0, 0, 0, 0, 0, 0, 0, 0, 0x50, 0x02, 0x20, 0, 0, 0, 0, + 0, + ]); + + assert_eq!( + parse_logged_syn(&record, "[::1]:9040".parse().unwrap()), + Some(( + "[2001:db8::10]:39976".parse().unwrap(), + "[2001:db8::42]:443".parse().unwrap(), + )) + ); + } + } +} + +#[cfg(target_vendor = "apple")] +use apple_pf::AppleFlowTracker; + +#[cfg(target_os = "linux")] fn socket_addr_from_storage(addr: &libc::sockaddr_storage, len: usize) -> Result { use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; @@ -120,7 +833,7 @@ mod tests { let parsed = socket_addr_from_storage(&storage, size_of::()).unwrap(); assert_eq!( parsed, - SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 9040)) + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 9040)) ); } diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 8a245ef..d48c53b 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -3,7 +3,8 @@ use std::sync::Once; use tracing::{error, info}; use tracing_subscriber::{ layer::{Layer, SubscriberExt}, - EnvFilter, Registry, + EnvFilter, + Registry, }; static TRACING: Once = Once::new(); @@ -14,55 +15,39 @@ pub fn initialize() { error!("Failed to initialize LogTracer: {}", e); } - let make_stderr = || { + #[cfg(target_os = "windows")] + let system_log = Some(tracing_subscriber::fmt::layer()); + + #[cfg(target_os = "linux")] + let system_log = match tracing_journald::layer() { + Ok(layer) => Some(layer), + Err(e) => { + if e.kind() != std::io::ErrorKind::NotFound { + error!("Failed to initialize journald: {}", e); + } + None + } + }; + + #[cfg(target_os = "macos")] + let system_log = Some(tracing_oslog::OsLogger::new( + "com.hackclub.burrow", + "tracing", + )); + + #[cfg(all(target_vendor = "apple", not(target_os = "macos")))] + let system_log = None::; + + let stderr = (console::user_attended_stderr() || system_log.is_none()).then(|| { tracing_subscriber::fmt::layer() .with_level(true) .with_writer(std::io::stderr) .with_line_number(true) .compact() .with_filter(EnvFilter::from_default_env()) - }; + }); - #[cfg(target_os = "windows")] - let subscriber = { - let system_log = Some(tracing_subscriber::fmt::layer()); - let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); - Registry::default().with(stderr).with(system_log) - }; - - #[cfg(target_os = "linux")] - let subscriber = { - let system_log = match tracing_journald::layer() { - Ok(layer) => Some(layer), - Err(e) => { - if e.kind() != std::io::ErrorKind::NotFound { - error!("Failed to initialize journald: {}", e); - } - None - } - }; - let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); - Registry::default().with(stderr).with(system_log) - }; - - #[cfg(target_os = "macos")] - let subscriber = { - // `tracing_oslog` is crashing under Tokio/h2 span churn in the host daemon on - // current macOS. Keep logging on stderr by default and allow opt-in OSLog - // only when explicitly requested for local debugging. - let enable_oslog = matches!( - std::env::var("BURROW_ENABLE_OSLOG").as_deref(), - Ok("1" | "true" | "TRUE" | "yes" | "YES") - ); - let system_log = enable_oslog.then(|| { - tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing") - }); - let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); - Registry::default().with(stderr).with(system_log) - }; - - #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] - let subscriber = Registry::default().with(Some(make_stderr())); + let subscriber = Registry::default().with(stderr).with(system_log); #[cfg(feature = "tokio-console")] let subscriber = subscriber.with( diff --git a/burrow/src/usernet/mod.rs b/burrow/src/usernet/mod.rs deleted file mode 100644 index 12de810..0000000 --- a/burrow/src/usernet/mod.rs +++ /dev/null @@ -1,935 +0,0 @@ -use std::{ - collections::HashMap, - env, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, - os::fd::{AsRawFd, FromRawFd, RawFd}, - os::unix::net::UnixStream as StdUnixStream, - os::unix::process::ExitStatusExt, - path::{Path, PathBuf}, - process::{Command as StdCommand, ExitStatus}, - str, - sync::Arc, - time::Duration, -}; - -use anyhow::{anyhow, bail, Context, Result}; -use clap::ValueEnum; -use futures::{SinkExt, StreamExt}; -use ipnetwork::IpNetwork; -use netstack_smoltcp::{ - StackBuilder, TcpListener as StackTcpListener, TcpStream as StackTcpStream, - UdpSocket as StackUdpSocket, -}; -use nix::{ - cmsg_space, - fcntl::{fcntl, FcntlArg, FdFlag}, - sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags}, -}; -use serde::{Deserialize, Serialize}; -use tokio::{ - io::copy_bidirectional, - net::{TcpStream, UdpSocket}, - process::{Child, Command}, - sync::{mpsc, Mutex, RwLock}, - task::JoinSet, -}; -use tokio_util::compat::FuturesAsyncReadCompatExt; -use tracing::{debug, warn}; -use tun::{tokio::TunInterface as TokioTunInterface, TunOptions}; - -use crate::{ - tor::{bootstrap_client, dns::build_response as build_tor_dns_response, Config as TorConfig}, - wireguard::{Config as WireGuardConfig, Interface as WireGuardInterface}, -}; - -const INNER_ENV: &str = "BURROW_USERNET_INNER"; -const INNER_CONTROL_FD_ENV: &str = "BURROW_USERNET_CONTROL_FD"; -const INNER_TUN_CONFIG_ENV: &str = "BURROW_USERNET_TUN_CONFIG"; -const DEFAULT_MTU: u32 = 1500; -const DEFAULT_TUN_V4: &str = "100.64.0.2/24"; -const DEFAULT_TUN_V6: &str = "fd00:64::2/64"; -const UDP_IDLE_TIMEOUT: Duration = Duration::from_secs(30); -const READY_ACK: &[u8; 1] = b"1"; - -#[derive(Clone, Debug, Eq, PartialEq, ValueEnum)] -pub enum ExecBackendKind { - Direct, - Tor, - Wireguard, -} - -impl ExecBackendKind { - fn cli_name(&self) -> &'static str { - match self { - Self::Direct => "direct", - Self::Tor => "tor", - Self::Wireguard => "wireguard", - } - } -} - -#[derive(Clone, Debug)] -pub struct ExecInvocation { - pub backend: ExecBackendKind, - pub payload_path: Option, - pub command: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct DirectConfig { - #[serde(default)] - pub address: Vec, - #[serde(default)] - pub dns: Vec, - #[serde(default)] - pub mtu: Option, - #[serde(default)] - pub tun_name: Option, -} - -impl DirectConfig { - pub fn from_payload(payload: &[u8]) -> Result { - if payload.is_empty() { - return Ok(Self::default()); - } - - if let Ok(config) = serde_json::from_slice(payload) { - return Ok(config); - } - - let payload = str::from_utf8(payload).context("direct payload must be valid UTF-8")?; - toml::from_str(payload).context("failed to parse direct payload as JSON or TOML") - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct TunNetworkConfig { - tun_name: String, - addresses: Vec, - mtu: u32, -} - -enum PreparedBackend { - Socket { - backend: SocketBackend, - tun_config: TunNetworkConfig, - }, - Wireguard { - config: WireGuardConfig, - tun_config: TunNetworkConfig, - }, -} - -impl PreparedBackend { - fn tun_config(&self) -> &TunNetworkConfig { - match self { - Self::Socket { tun_config, .. } => tun_config, - Self::Wireguard { tun_config, .. } => tun_config, - } - } -} - -struct NamespaceChild { - child: Child, - control: StdUnixStream, -} - -#[derive(Clone)] -enum SocketBackend { - Direct, - Tor(Arc>), -} - -#[derive(Debug)] -struct UdpReply { - payload: Vec, - source: SocketAddr, - destination: SocketAddr, -} - -#[derive(Debug, Clone, Eq, Hash, PartialEq)] -struct UdpFlowKey { - local: SocketAddr, - remote: SocketAddr, -} - -pub async fn run_exec(invocation: ExecInvocation) -> Result { - if invocation.command.is_empty() { - bail!("exec requires a command to run"); - } - - if env::var_os(INNER_ENV).is_some() { - run_inner(invocation.command).await - } else { - run_supervisor(invocation).await - } -} - -async fn run_supervisor(invocation: ExecInvocation) -> Result { - let prepared = prepare_backend(&invocation).await?; - let mut child = spawn_namespaced_child(&invocation, prepared.tun_config())?; - let tun = child.receive_tun().await?; - - match prepared { - PreparedBackend::Socket { backend, .. } => run_socket_backend(backend, tun, child).await, - PreparedBackend::Wireguard { config, .. } => { - run_wireguard_backend(config, tun, child).await - } - } -} - -async fn prepare_backend(invocation: &ExecInvocation) -> Result { - match invocation.backend { - ExecBackendKind::Direct => { - let payload = read_optional_payload(invocation.payload_path.as_deref()).await?; - let config = DirectConfig::from_payload(&payload)?; - let tun_config = socket_tun_config( - &config.address, - config.mtu, - config.tun_name.as_deref(), - "burrow-direct", - )?; - Ok(PreparedBackend::Socket { - backend: SocketBackend::Direct, - tun_config, - }) - } - ExecBackendKind::Tor => { - let payload = read_required_payload(invocation.payload_path.as_deref(), "tor").await?; - let mut config = TorConfig::from_payload(&payload)?; - let (state_dir, cache_dir) = config.runtime_dirs(std::process::id() as i32); - config.arti.state_dir = state_dir; - config.arti.cache_dir = cache_dir; - let tun_config = socket_tun_config( - &config.address, - config.mtu, - config.tun_name.as_deref(), - "burrow-tor", - )?; - let tor_client = bootstrap_client(&config).await?; - Ok(PreparedBackend::Socket { - backend: SocketBackend::Tor(tor_client), - tun_config, - }) - } - ExecBackendKind::Wireguard => { - let payload = - read_required_payload(invocation.payload_path.as_deref(), "wireguard").await?; - let config = parse_wireguard_payload(&payload, invocation.payload_path.as_deref())?; - let tun_config = wireguard_tun_config(&config)?; - Ok(PreparedBackend::Wireguard { config, tun_config }) - } - } -} - -fn spawn_namespaced_child( - invocation: &ExecInvocation, - tun_config: &TunNetworkConfig, -) -> Result { - ensure_tool("unshare")?; - ensure_tool("ip")?; - - let (parent_control, child_control) = - StdUnixStream::pair().context("failed to create namespace control socket")?; - set_inheritable(child_control.as_raw_fd())?; - - let current_exe = env::current_exe().context("failed to locate current burrow binary")?; - let mut cmd = Command::new("unshare"); - cmd.args([ - "--user", - "--map-root-user", - "--net", - "--mount", - "--pid", - "--fork", - "--kill-child", - "--mount-proc", - ]); - cmd.env(INNER_ENV, "1"); - cmd.env(INNER_CONTROL_FD_ENV, child_control.as_raw_fd().to_string()); - cmd.env( - INNER_TUN_CONFIG_ENV, - serde_json::to_string(tun_config).context("failed to encode namespace tun config")?, - ); - cmd.arg(current_exe); - cmd.arg("exec"); - cmd.args(["--backend", invocation.backend.cli_name()]); - if let Some(payload_path) = &invocation.payload_path { - cmd.arg("--payload"); - cmd.arg(payload_path); - } - cmd.arg("--"); - cmd.args(&invocation.command); - - let child = cmd - .spawn() - .context("failed to enter unshared Linux namespace")?; - drop(child_control); - - Ok(NamespaceChild { child, control: parent_control }) -} - -async fn run_inner(command: Vec) -> Result { - run_ip(["link", "set", "lo", "up"])?; - let tun_config = read_inner_tun_config()?; - let tun = open_tun_device(&tun_config)?; - configure_tun_addresses(&tun, &tun_config.addresses, tun_config.mtu)?; - let name = tun.name().context("failed to retrieve tun device name")?; - run_ip(["link", "set", "dev", &name, "up"])?; - install_default_routes(&name, &tun_config.addresses)?; - - let control_fd = env::var(INNER_CONTROL_FD_ENV) - .context("missing namespace control fd")? - .parse::() - .context("invalid namespace control fd")?; - send_tun_fd(control_fd, tun.as_raw_fd())?; - await_parent_ready(control_fd).await?; - drop(tun); - - let status = spawn_child(&command).await?; - child_exit_code(status) -} - -impl NamespaceChild { - async fn receive_tun(&mut self) -> Result { - let control = self - .control - .try_clone() - .context("failed to clone namespace control socket")?; - let fd = tokio::task::spawn_blocking(move || recv_tun_fd(&control)) - .await - .context("failed to join namespace tun receive task")??; - tokio_tun_from_fd(fd) - } - - async fn signal_ready(&self) -> Result<()> { - let mut control = self - .control - .try_clone() - .context("failed to clone namespace control socket")?; - tokio::task::spawn_blocking(move || -> Result<()> { - std::io::Write::write_all(&mut control, READY_ACK) - .context("failed to acknowledge namespace readiness")?; - Ok(()) - }) - .await - .context("failed to join namespace ready task")??; - Ok(()) - } - - async fn wait(mut self) -> Result { - self.child - .wait() - .await - .context("failed to wait for namespace child") - } -} - -async fn run_socket_backend( - backend: SocketBackend, - tun: TokioTunInterface, - child: NamespaceChild, -) -> Result { - let tun = Arc::new(tun); - let (stack, runner, udp_socket, tcp_listener) = StackBuilder::default() - .stack_buffer_size(1024) - .udp_buffer_size(1024) - .tcp_buffer_size(1024) - .enable_udp(true) - .enable_tcp(true) - .enable_icmp(true) - .build() - .context("failed to build userspace netstack")?; - let (mut stack_sink, mut stack_stream) = stack.split(); - - let mut tasks = JoinSet::new(); - if let Some(runner) = runner { - tasks.spawn(async move { runner.await.map_err(anyhow::Error::from) }); - } - - { - let tun = tun.clone(); - tasks.spawn(async move { - let mut buf = vec![0u8; 65_535]; - loop { - let len = tun - .recv(&mut buf) - .await - .context("failed to read packet from tun")?; - if len == 0 { - continue; - } - stack_sink - .send(buf[..len].to_vec()) - .await - .context("failed to send tun packet into userspace stack")?; - } - #[allow(unreachable_code)] - Result::<()>::Ok(()) - }); - } - - { - let tun = tun.clone(); - tasks.spawn(async move { - while let Some(packet) = stack_stream.next().await { - let packet = packet.context("failed to receive packet from userspace stack")?; - tun.send(&packet) - .await - .context("failed to write userspace stack packet to tun")?; - } - Result::<()>::Ok(()) - }); - } - - if let Some(tcp_listener) = tcp_listener { - let backend = backend.clone(); - tasks.spawn(async move { tcp_dispatch_loop(tcp_listener, backend).await }); - } - - if let Some(udp_socket) = udp_socket { - tasks.spawn(async move { udp_dispatch_loop(udp_socket, backend).await }); - } - - child.signal_ready().await?; - let status = child.wait().await?; - - tasks.abort_all(); - while let Some(joined) = tasks.join_next().await { - match joined { - Ok(Ok(())) => {} - Ok(Err(err)) => debug!(?err, "usernet background task exited with error"), - Err(err) if err.is_cancelled() => {} - Err(err) => debug!(?err, "usernet background task panicked"), - } - } - - child_exit_code(status) -} - -async fn run_wireguard_backend( - config: WireGuardConfig, - tun: TokioTunInterface, - child: NamespaceChild, -) -> Result { - let interface: WireGuardInterface = config.try_into()?; - interface.set_tun(tun).await; - let interface = Arc::new(interface); - let runner = { - let interface = interface.clone(); - tokio::spawn(async move { interface.run().await }) - }; - - child.signal_ready().await?; - let status = child.wait().await?; - - interface.remove_tun().await; - match runner.await { - Ok(Ok(())) => {} - Ok(Err(err)) => debug!(?err, "wireguard exec runtime exited with error"), - Err(err) if err.is_cancelled() => {} - Err(err) => debug!(?err, "wireguard exec runtime panicked"), - } - - child_exit_code(status) -} - -async fn tcp_dispatch_loop(mut listener: StackTcpListener, backend: SocketBackend) -> Result<()> { - let mut tasks = JoinSet::new(); - loop { - tokio::select! { - Some(result) = tasks.join_next(), if !tasks.is_empty() => { - match result { - Ok(Ok(())) => {} - Ok(Err(err)) => warn!(?err, "tcp bridge task failed"), - Err(err) if err.is_cancelled() => {} - Err(err) => warn!(?err, "tcp bridge task panicked"), - } - } - next = listener.next() => match next { - Some((stream, local_addr, remote_addr)) => { - debug!(%local_addr, %remote_addr, "accepted userspace tcp stream"); - let backend = backend.clone(); - tasks.spawn(async move { - bridge_tcp(backend, stream, local_addr, remote_addr).await - }); - } - None => break, - } - } - } - - tasks.abort_all(); - while let Some(result) = tasks.join_next().await { - match result { - Ok(Ok(())) => {} - Ok(Err(err)) => debug!(?err, "tcp bridge task exited during shutdown"), - Err(err) if err.is_cancelled() => {} - Err(err) => debug!(?err, "tcp bridge task panicked during shutdown"), - } - } - Ok(()) -} - -async fn bridge_tcp( - backend: SocketBackend, - mut inbound: StackTcpStream, - _local_addr: SocketAddr, - remote_addr: SocketAddr, -) -> Result<()> { - match backend { - SocketBackend::Direct => { - debug!(%remote_addr, "dialing direct outbound tcp"); - let mut outbound = TcpStream::connect(remote_addr) - .await - .with_context(|| format!("failed to connect to {remote_addr}"))?; - copy_bidirectional(&mut inbound, &mut outbound) - .await - .with_context(|| format!("failed to bridge tcp stream for {remote_addr}"))?; - } - SocketBackend::Tor(tor_client) => { - debug!(%remote_addr, "dialing tor outbound tcp"); - let tor_stream = tor_client - .connect((remote_addr.ip().to_string(), remote_addr.port())) - .await - .with_context(|| format!("failed to connect to {remote_addr} over tor"))?; - let mut tor_stream = tor_stream.compat(); - copy_bidirectional(&mut inbound, &mut tor_stream) - .await - .with_context(|| format!("failed to bridge tor stream for {remote_addr}"))?; - } - } - Ok(()) -} - -async fn udp_dispatch_loop(socket: StackUdpSocket, backend: SocketBackend) -> Result<()> { - let (mut udp_reader, mut udp_writer) = socket.split(); - let (reply_tx, mut reply_rx) = mpsc::channel::(128); - let direct_sessions = Arc::new(Mutex::new( - HashMap::>>::new(), - )); - let mut session_tasks = JoinSet::new(); - - loop { - tokio::select! { - Some(result) = session_tasks.join_next(), if !session_tasks.is_empty() => { - match result { - Ok(Ok(())) => {} - Ok(Err(err)) => warn!(?err, "udp session task failed"), - Err(err) if err.is_cancelled() => {} - Err(err) => warn!(?err, "udp session task panicked"), - } - } - maybe_reply = reply_rx.recv() => match maybe_reply { - Some(reply) => { - udp_writer - .send((reply.payload, reply.source, reply.destination)) - .await - .context("failed to write udp reply into userspace stack")?; - } - None => break, - }, - maybe_datagram = udp_reader.next() => match maybe_datagram { - Some((payload, local_addr, remote_addr)) => { - match &backend { - SocketBackend::Direct => { - dispatch_direct_udp( - payload, - local_addr, - remote_addr, - reply_tx.clone(), - direct_sessions.clone(), - &mut session_tasks, - ).await?; - } - SocketBackend::Tor(tor_client) => { - if remote_addr.port() != 53 { - debug!(%remote_addr, "dropping non-DNS UDP datagram for tor backend"); - continue; - } - let response = build_tor_dns_response(&payload, tor_client.as_ref()).await?; - reply_tx - .send(UdpReply { - payload: response, - source: remote_addr, - destination: local_addr, - }) - .await - .context("failed to enqueue tor dns response")?; - } - } - } - None => break, - } - } - } - - session_tasks.abort_all(); - while let Some(result) = session_tasks.join_next().await { - match result { - Ok(Ok(())) => {} - Ok(Err(err)) => debug!(?err, "udp session task exited during shutdown"), - Err(err) if err.is_cancelled() => {} - Err(err) => debug!(?err, "udp session task panicked during shutdown"), - } - } - Ok(()) -} - -async fn dispatch_direct_udp( - payload: Vec, - local_addr: SocketAddr, - remote_addr: SocketAddr, - reply_tx: mpsc::Sender, - sessions: Arc>>>>, - session_tasks: &mut JoinSet>, -) -> Result<()> { - let key = UdpFlowKey { - local: local_addr, - remote: remote_addr, - }; - let existing = { sessions.lock().await.get(&key).cloned() }; - if let Some(sender) = existing { - if sender.send(payload.clone()).await.is_ok() { - return Ok(()); - } - sessions.lock().await.remove(&key); - } - - let (tx, rx) = mpsc::channel::>(32); - tx.send(payload) - .await - .context("failed to enqueue outbound udp payload")?; - sessions.lock().await.insert(key.clone(), tx); - - session_tasks.spawn(async move { run_direct_udp_session(key, rx, reply_tx, sessions).await }); - Ok(()) -} - -async fn run_direct_udp_session( - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - sessions: Arc>>>>, -) -> Result<()> { - let bind_addr = match key.remote { - SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), - SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), - }; - let socket = UdpSocket::bind(bind_addr) - .await - .with_context(|| format!("failed to bind udp socket for {}", key.remote))?; - socket - .connect(key.remote) - .await - .with_context(|| format!("failed to connect udp socket to {}", key.remote))?; - - let mut buf = vec![0u8; 65_535]; - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - socket - .send(&payload) - .await - .with_context(|| format!("failed to send udp payload to {}", key.remote))?; - } - None => break, - }, - recv = tokio::time::timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { - Ok(Ok(len)) => { - reply_tx - .send(UdpReply { - payload: buf[..len].to_vec(), - source: key.remote, - destination: key.local, - }) - .await - .context("failed to enqueue inbound udp reply")?; - } - Ok(Err(err)) => return Err(err).with_context(|| format!("failed to receive udp response from {}", key.remote)), - Err(_) => break, - } - } - } - - sessions.lock().await.remove(&key); - Ok(()) -} - -fn wireguard_tun_config(config: &WireGuardConfig) -> Result { - parse_tun_config( - &config.interface.address, - config.interface.mtu, - Some("burrow-wireguard"), - ) -} - -fn socket_tun_config( - addresses: &[String], - mtu: Option, - tun_name: Option<&str>, - default_name: &str, -) -> Result { - let default_addresses; - let addresses = if addresses.is_empty() { - default_addresses = vec![DEFAULT_TUN_V4.to_string(), DEFAULT_TUN_V6.to_string()]; - default_addresses.as_slice() - } else { - addresses - }; - parse_tun_config(addresses, mtu, Some(tun_name.unwrap_or(default_name))) -} - -fn parse_tun_config( - addresses: &[String], - mtu: Option, - tun_name: Option<&str>, -) -> Result { - let addresses = addresses - .iter() - .map(|addr| { - addr.parse::() - .with_context(|| format!("invalid tunnel address '{addr}'")) - }) - .collect::>>()?; - - Ok(TunNetworkConfig { - tun_name: tun_name.unwrap_or("burrow-exec").to_string(), - addresses, - mtu: mtu.unwrap_or(DEFAULT_MTU), - }) -} - -fn open_tun_device(config: &TunNetworkConfig) -> Result { - let tun = TunOptions::new() - .name(&config.tun_name) - .no_pi(true) - .tun_excl(true) - .open() - .context("failed to create tun device")?; - Ok(tun.inner.into_inner()) -} - -fn tokio_tun_from_fd(fd: RawFd) -> Result { - let tun = unsafe { tun::TunInterface::from_raw_fd(fd) }; - TokioTunInterface::new(tun).context("failed to wrap tun fd in tokio interface") -} - -fn read_inner_tun_config() -> Result { - let raw = env::var(INNER_TUN_CONFIG_ENV).context("missing namespace tun config")?; - serde_json::from_str(&raw).context("invalid namespace tun config") -} - -fn configure_tun_addresses( - iface: &tun::TunInterface, - networks: &[IpNetwork], - mtu: u32, -) -> Result<()> { - for network in networks { - match network { - IpNetwork::V4(net) => { - iface.set_ipv4_addr(net.ip())?; - let netmask = prefix_to_netmask_v4(net.prefix()); - iface.set_netmask(netmask)?; - iface.set_broadcast_addr(broadcast_v4(net.ip(), netmask))?; - } - IpNetwork::V6(net) => iface.add_ipv6_addr(net.ip(), net.prefix())?, - } - } - iface.set_mtu(mtu as i32)?; - Ok(()) -} - -fn install_default_routes(name: &str, networks: &[IpNetwork]) -> Result<()> { - if networks - .iter() - .any(|network| matches!(network, IpNetwork::V4(_))) - { - run_ip(["route", "replace", "default", "dev", name])?; - } - if networks - .iter() - .any(|network| matches!(network, IpNetwork::V6(_))) - { - run_ip(["-6", "route", "replace", "default", "dev", name])?; - } - Ok(()) -} - -fn run_ip(args: [&str; N]) -> Result<()> { - let status = StdCommand::new("ip") - .args(args) - .status() - .context("failed to execute ip command")?; - if !status.success() { - bail!("ip {} failed with status {}", args.join(" "), status); - } - Ok(()) -} - -fn set_inheritable(fd: RawFd) -> Result<()> { - let flags = FdFlag::from_bits_truncate( - fcntl(fd, FcntlArg::F_GETFD).context("failed to query descriptor flags")?, - ); - let flags = flags & !FdFlag::FD_CLOEXEC; - fcntl(fd, FcntlArg::F_SETFD(flags)).context("failed to clear close-on-exec")?; - Ok(()) -} - -async fn await_parent_ready(control_fd: RawFd) -> Result<()> { - tokio::task::spawn_blocking(move || -> Result<()> { - let mut control = unsafe { StdUnixStream::from_raw_fd(control_fd) }; - let mut ack = [0u8; 1]; - std::io::Read::read_exact(&mut control, &mut ack) - .context("failed to read namespace ready ack")?; - if ack != *READY_ACK { - bail!("unexpected namespace ready ack"); - } - Ok(()) - }) - .await - .context("failed to join namespace ready wait task")??; - Ok(()) -} - -fn send_tun_fd(control_fd: RawFd, tun_fd: RawFd) -> Result<()> { - let buf = [0u8; 1]; - let iov = [std::io::IoSlice::new(&buf)]; - let fds = [tun_fd]; - sendmsg::<()>( - control_fd, - &iov, - &[ControlMessage::ScmRights(&fds)], - MsgFlags::empty(), - None, - ) - .context("failed to send tun fd to parent")?; - Ok(()) -} - -fn recv_tun_fd(control: &StdUnixStream) -> Result { - let mut buf = [0u8; 1]; - let mut iov = [std::io::IoSliceMut::new(&mut buf)]; - let mut cmsgspace = cmsg_space!([RawFd; 1]); - let msg = recvmsg::<()>( - control.as_raw_fd(), - &mut iov, - Some(&mut cmsgspace), - MsgFlags::empty(), - ) - .context("failed to receive tun fd from namespace child")?; - for cmsg in msg.cmsgs() { - if let ControlMessageOwned::ScmRights(fds) = cmsg { - if let Some(fd) = fds.first() { - return Ok(*fd); - } - } - } - bail!("namespace child did not send a tun fd") -} - -fn ensure_tool(tool: &str) -> Result<()> { - let status = StdCommand::new("sh") - .args(["-lc", &format!("command -v {tool} >/dev/null")]) - .status() - .with_context(|| format!("failed to probe required tool '{tool}'"))?; - if !status.success() { - bail!("required host tool '{tool}' is not available"); - } - Ok(()) -} - -async fn read_optional_payload(path: Option<&Path>) -> Result> { - match path { - Some(path) => tokio::fs::read(path) - .await - .with_context(|| format!("failed to read payload from {}", path.display())), - None => Ok(Vec::new()), - } -} - -async fn read_required_payload(path: Option<&Path>, backend: &str) -> Result> { - let path = path.ok_or_else(|| anyhow!("{backend} exec requires --payload"))?; - tokio::fs::read(path) - .await - .with_context(|| format!("failed to read payload from {}", path.display())) -} - -fn parse_wireguard_payload(payload: &[u8], path: Option<&Path>) -> Result { - let payload = str::from_utf8(payload).context("wireguard payload must be valid UTF-8")?; - if let Some(path) = path { - if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { - return WireGuardConfig::from_content_fmt(payload, ext); - } - } - - WireGuardConfig::from_toml(payload).or_else(|_| WireGuardConfig::from_ini(payload)) -} - -async fn spawn_child(command: &[String]) -> Result { - let mut cmd = Command::new(&command[0]); - if command.len() > 1 { - cmd.args(&command[1..]); - } - cmd.stdin(std::process::Stdio::inherit()); - cmd.stdout(std::process::Stdio::inherit()); - cmd.stderr(std::process::Stdio::inherit()); - cmd.kill_on_drop(true); - cmd.status() - .await - .with_context(|| format!("failed to spawn '{}'", command[0])) -} - -fn child_exit_code(status: ExitStatus) -> Result { - if let Some(code) = status.code() { - return Ok(code); - } - if let Some(signal) = status.signal() { - return Ok(128 + signal); - } - bail!("child process terminated without an exit code"); -} - -fn prefix_to_netmask_v4(prefix: u8) -> Ipv4Addr { - if prefix == 0 { - Ipv4Addr::new(0, 0, 0, 0) - } else { - let mask = (!0u32) << (32 - prefix); - Ipv4Addr::from(mask) - } -} - -fn broadcast_v4(ip: Ipv4Addr, netmask: Ipv4Addr) -> Ipv4Addr { - let ip_u32 = u32::from(ip); - let mask = u32::from(netmask); - Ipv4Addr::from(ip_u32 | !mask) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_direct_json_payload() { - let payload = br#"{"address":["10.0.0.2/24"],"mtu":1400,"tun_name":"burrow0"}"#; - let config = DirectConfig::from_payload(payload).unwrap(); - assert_eq!(config.address, vec!["10.0.0.2/24"]); - assert_eq!(config.mtu, Some(1400)); - assert_eq!(config.tun_name.as_deref(), Some("burrow0")); - } - - #[test] - fn socket_tun_config_uses_dual_stack_defaults() { - let config = socket_tun_config(&[], None, None, "burrow-test").unwrap(); - assert_eq!(config.tun_name, "burrow-test"); - assert!(config - .addresses - .iter() - .any(|network| matches!(network, IpNetwork::V4(_)))); - assert!(config - .addresses - .iter() - .any(|network| matches!(network, IpNetwork::V6(_)))); - } -} diff --git a/burrow/src/wireguard/iface.rs b/burrow/src/wireguard/iface.rs index 5b61861..321801b 100755 --- a/burrow/src/wireguard/iface.rs +++ b/burrow/src/wireguard/iface.rs @@ -148,7 +148,7 @@ impl Interface { debug!("Routing packet to {}", dst_addr); let Some(idx) = pcbs.find(dst_addr) else { - continue; + continue }; debug!("Found peer:{}", idx); diff --git a/burrow/src/wireguard/noise/handshake.rs b/burrow/src/wireguard/noise/handshake.rs index 65136bc..2ec0c6a 100755 --- a/burrow/src/wireguard/noise/handshake.rs +++ b/burrow/src/wireguard/noise/handshake.rs @@ -9,15 +9,20 @@ use std::{ use aead::{Aead, Payload}; use blake2::{ digest::{FixedOutput, KeyInit}, - Blake2s256, Blake2sMac, Digest, + Blake2s256, + Blake2sMac, + Digest, }; use chacha20poly1305::XChaCha20Poly1305; use rand_core::OsRng; use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305}; -use subtle::ConstantTimeEq; use super::{ - errors::WireGuardError, session::Session, x25519, HandshakeInit, HandshakeResponse, + errors::WireGuardError, + session::Session, + x25519, + HandshakeInit, + HandshakeResponse, PacketCookieReply, }; @@ -204,7 +209,7 @@ impl Tai64N { /// Parse a timestamp from a 12 byte u8 slice fn parse(buf: &[u8; 12]) -> Result { if buf.len() < 12 { - return Err(WireGuardError::InvalidTai64nTimestamp); + return Err(WireGuardError::InvalidTai64nTimestamp) } let (sec_bytes, nano_bytes) = buf.split_at(std::mem::size_of::()); @@ -529,14 +534,11 @@ impl Handshake { &hash, )?; - if !bool::from( - self.params - .peer_static_public - .as_bytes() - .ct_eq(&peer_static_public_decrypted), - ) { - return Err(WireGuardError::WrongKey); - } + ring::constant_time::verify_slices_are_equal( + self.params.peer_static_public.as_bytes(), + &peer_static_public_decrypted, + ) + .map_err(|_| WireGuardError::WrongKey)?; // initiator.hash = HASH(initiator.hash || msg.encrypted_static) hash = b2s_hash(&hash, packet.encrypted_static); @@ -554,22 +556,19 @@ impl Handshake { let timestamp = Tai64N::parse(×tamp)?; if !timestamp.after(&self.last_handshake_timestamp) { // Possibly a replay - return Err(WireGuardError::WrongTai64nTimestamp); + return Err(WireGuardError::WrongTai64nTimestamp) } self.last_handshake_timestamp = timestamp; // initiator.hash = HASH(initiator.hash || msg.encrypted_timestamp) hash = b2s_hash(&hash, packet.encrypted_timestamp); - self.previous = std::mem::replace( - &mut self.state, - HandshakeState::InitReceived { - chaining_key, - hash, - peer_ephemeral_public, - peer_index, - }, - ); + self.previous = std::mem::replace(&mut self.state, HandshakeState::InitReceived { + chaining_key, + hash, + peer_ephemeral_public, + peer_index, + }); self.format_handshake_response(dst) } @@ -670,7 +669,7 @@ impl Handshake { let local_index = self.cookies.index; if packet.receiver_idx != local_index { - return Err(WireGuardError::WrongIndex); + return Err(WireGuardError::WrongIndex) } // msg.encrypted_cookie = XAEAD(HASH(LABEL_COOKIE || responder.static_public), // msg.nonce, cookie, last_received_msg.mac1) @@ -726,7 +725,7 @@ impl Handshake { dst: &'a mut [u8], ) -> Result<&'a mut [u8], WireGuardError> { if dst.len() < super::HANDSHAKE_INIT_SZ { - return Err(WireGuardError::DestinationBufferTooSmall); + return Err(WireGuardError::DestinationBufferTooSmall) } let (message_type, rest) = dst.split_at_mut(4); @@ -809,7 +808,7 @@ impl Handshake { dst: &'a mut [u8], ) -> Result<(&'a mut [u8], Session), WireGuardError> { if dst.len() < super::HANDSHAKE_RESP_SZ { - return Err(WireGuardError::DestinationBufferTooSmall); + return Err(WireGuardError::DestinationBufferTooSmall) } let state = std::mem::replace(&mut self.state, HandshakeState::None); diff --git a/burrow/src/wireguard/noise/mod.rs b/burrow/src/wireguard/noise/mod.rs index 86bcc73..aa06652 100755 --- a/burrow/src/wireguard/noise/mod.rs +++ b/burrow/src/wireguard/noise/mod.rs @@ -133,9 +133,9 @@ pub enum Packet<'a> { impl Tunnel { #[inline(always)] - pub fn parse_incoming_packet(src: &[u8]) -> Result, WireGuardError> { + pub fn parse_incoming_packet(src: &[u8]) -> Result { if src.len() < 4 { - return Err(WireGuardError::InvalidPacket); + return Err(WireGuardError::InvalidPacket) } // Checks the type, as well as the reserved zero fields @@ -177,7 +177,7 @@ impl Tunnel { pub fn dst_address(packet: &[u8]) -> Option { if packet.is_empty() { - return None; + return None } match packet[0] >> 4 { @@ -201,7 +201,7 @@ impl Tunnel { pub fn src_address(packet: &[u8]) -> Option { if packet.is_empty() { - return None; + return None } match packet[0] >> 4 { @@ -296,7 +296,7 @@ impl Tunnel { self.timer_tick(TimerName::TimeLastDataPacketSent); } self.tx_bytes += src.len(); - return TunnResult::WriteToNetwork(packet); + return TunnResult::WriteToNetwork(packet) } // If there is no session, queue the packet for future retry @@ -320,7 +320,7 @@ impl Tunnel { ) -> TunnResult<'a> { if datagram.is_empty() { // Indicates a repeated call - return self.send_queued_packet(dst); + return self.send_queued_packet(dst) } let mut cookie = [0u8; COOKIE_REPLY_SZ]; @@ -331,7 +331,7 @@ impl Tunnel { Ok(packet) => packet, Err(TunnResult::WriteToNetwork(cookie)) => { dst[..cookie.len()].copy_from_slice(cookie); - return TunnResult::WriteToNetwork(&mut dst[..cookie.len()]); + return TunnResult::WriteToNetwork(&mut dst[..cookie.len()]) } Err(TunnResult::Err(e)) => return TunnResult::Err(e), _ => unreachable!(), @@ -435,7 +435,7 @@ impl Tunnel { let cur_idx = self.current; if cur_idx == new_idx { // There is nothing to do, already using this session, this is the common case - return; + return } if self.sessions[cur_idx % N_SESSIONS].is_none() || self.timers.session_timers[new_idx % N_SESSIONS] @@ -481,7 +481,7 @@ impl Tunnel { force_resend: bool, ) -> TunnResult<'a> { if self.handshake.is_in_progress() && !force_resend { - return TunnResult::Done; + return TunnResult::Done } if self.handshake.is_expired() { @@ -540,7 +540,7 @@ impl Tunnel { }; if computed_len > packet.len() { - return TunnResult::Err(WireGuardError::InvalidPacket); + return TunnResult::Err(WireGuardError::InvalidPacket) } self.timer_tick(TimerName::TimeLastDataPacketReceived); diff --git a/burrow/src/wireguard/noise/rate_limiter.rs b/burrow/src/wireguard/noise/rate_limiter.rs index e4fde02..ff19efd 100755 --- a/burrow/src/wireguard/noise/rate_limiter.rs +++ b/burrow/src/wireguard/noise/rate_limiter.rs @@ -8,13 +8,23 @@ use aead::{generic_array::GenericArray, AeadInPlace, KeyInit}; use chacha20poly1305::{Key, XChaCha20Poly1305}; use parking_lot::Mutex; use rand_core::{OsRng, RngCore}; -use subtle::ConstantTimeEq; +use ring::constant_time::verify_slices_are_equal; use super::{ handshake::{ - b2s_hash, b2s_keyed_mac_16, b2s_keyed_mac_16_2, b2s_mac_24, LABEL_COOKIE, LABEL_MAC1, + b2s_hash, + b2s_keyed_mac_16, + b2s_keyed_mac_16_2, + b2s_mac_24, + LABEL_COOKIE, + LABEL_MAC1, }, - HandshakeInit, HandshakeResponse, Packet, TunnResult, Tunnel, WireGuardError, + HandshakeInit, + HandshakeResponse, + Packet, + TunnResult, + Tunnel, + WireGuardError, }; const COOKIE_REFRESH: u64 = 128; // Use 128 and not 120 so the compiler can optimize out the division @@ -126,7 +136,7 @@ impl RateLimiter { dst: &'a mut [u8], ) -> Result<&'a mut [u8], WireGuardError> { if dst.len() < super::COOKIE_REPLY_SZ { - return Err(WireGuardError::DestinationBufferTooSmall); + return Err(WireGuardError::DestinationBufferTooSmall) } let (message_type, rest) = dst.split_at_mut(4); @@ -175,9 +185,8 @@ impl RateLimiter { let (mac1, mac2) = macs.split_at(16); let computed_mac1 = b2s_keyed_mac_16(&self.mac1_key, msg); - if !bool::from(computed_mac1[..16].ct_eq(mac1)) { - return Err(TunnResult::Err(WireGuardError::InvalidMac)); - } + verify_slices_are_equal(&computed_mac1[..16], mac1) + .map_err(|_| TunnResult::Err(WireGuardError::InvalidMac))?; if self.is_under_load() { let addr = match src_addr { @@ -189,11 +198,11 @@ impl RateLimiter { let cookie = self.current_cookie(addr); let computed_mac2 = b2s_keyed_mac_16_2(&cookie, msg, mac1); - if !bool::from(computed_mac2[..16].ct_eq(mac2)) { + if verify_slices_are_equal(&computed_mac2[..16], mac2).is_err() { let cookie_packet = self .format_cookie_reply(sender_idx, cookie, mac1, dst) .map_err(TunnResult::Err)?; - return Err(TunnResult::WriteToNetwork(cookie_packet)); + return Err(TunnResult::WriteToNetwork(cookie_packet)) } } } diff --git a/burrow/src/wireguard/noise/session.rs b/burrow/src/wireguard/noise/session.rs index 14c191b..8988728 100755 --- a/burrow/src/wireguard/noise/session.rs +++ b/burrow/src/wireguard/noise/session.rs @@ -88,11 +88,11 @@ impl ReceivingKeyCounterValidator { fn will_accept(&self, counter: u64) -> Result<(), WireGuardError> { if counter >= self.next { // As long as the counter is growing no replay took place for sure - return Ok(()); + return Ok(()) } if counter + N_BITS < self.next { // Drop if too far back - return Err(WireGuardError::InvalidCounter); + return Err(WireGuardError::InvalidCounter) } if !self.check_bit(counter) { Ok(()) @@ -107,22 +107,22 @@ impl ReceivingKeyCounterValidator { fn mark_did_receive(&mut self, counter: u64) -> Result<(), WireGuardError> { if counter + N_BITS < self.next { // Drop if too far back - return Err(WireGuardError::InvalidCounter); + return Err(WireGuardError::InvalidCounter) } if counter == self.next { // Usually the packets arrive in order, in that case we simply mark the bit and // increment the counter self.set_bit(counter); self.next += 1; - return Ok(()); + return Ok(()) } if counter < self.next { // A packet arrived out of order, check if it is valid, and mark if self.check_bit(counter) { - return Err(WireGuardError::InvalidCounter); + return Err(WireGuardError::InvalidCounter) } self.set_bit(counter); - return Ok(()); + return Ok(()) } // Packets where dropped, or maybe reordered, skip them and mark unused if counter - self.next >= N_BITS { @@ -247,7 +247,7 @@ impl Session { panic!("The destination buffer is too small"); } if packet.receiver_idx != self.receiving_index { - return Err(WireGuardError::WrongIndex); + return Err(WireGuardError::WrongIndex) } // Don't reuse counters, in case this is a replay attack we want to quickly // check the counter without running expensive decryption diff --git a/burrow/src/wireguard/noise/timers.rs b/burrow/src/wireguard/noise/timers.rs index f713e6f..1d0cf1f 100755 --- a/burrow/src/wireguard/noise/timers.rs +++ b/burrow/src/wireguard/noise/timers.rs @@ -190,7 +190,7 @@ impl Tunnel { { if self.handshake.is_expired() { - return TunnResult::Err(WireGuardError::ConnectionExpired); + return TunnResult::Err(WireGuardError::ConnectionExpired) } // Clear cookie after COOKIE_EXPIRATION_TIME @@ -206,7 +206,7 @@ impl Tunnel { tracing::error!("CONNECTION_EXPIRED(REJECT_AFTER_TIME * 3)"); self.handshake.set_expired(); self.clear_all(); - return TunnResult::Err(WireGuardError::ConnectionExpired); + return TunnResult::Err(WireGuardError::ConnectionExpired) } if let Some(time_init_sent) = self.handshake.timer() { @@ -219,7 +219,7 @@ impl Tunnel { tracing::error!("CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME)"); self.handshake.set_expired(); self.clear_all(); - return TunnResult::Err(WireGuardError::ConnectionExpired); + return TunnResult::Err(WireGuardError::ConnectionExpired) } if time_init_sent.elapsed() >= REKEY_TIMEOUT { @@ -299,11 +299,11 @@ impl Tunnel { } if handshake_initiation_required { - return self.format_handshake_initiation(dst, true); + return self.format_handshake_initiation(dst, true) } if keepalive_required { - return self.encapsulate(&[], dst); + return self.encapsulate(&[], dst) } TunnResult::Done diff --git a/burrow/src/wireguard/pcb.rs b/burrow/src/wireguard/pcb.rs index 6e5e6c0..974d84e 100755 --- a/burrow/src/wireguard/pcb.rs +++ b/burrow/src/wireguard/pcb.rs @@ -64,7 +64,7 @@ impl PeerPcb { let guard = self.socket.read().await; let Some(socket) = guard.as_ref() else { self.open_if_closed().await?; - continue; + continue }; let mut res_buf = [0; 1500]; // tracing::debug!("{} : waiting for readability on {:?}", rid, socket); @@ -72,7 +72,7 @@ impl PeerPcb { Ok(l) => l, Err(e) => { log::error!("{}: error reading from socket: {:?}", rid, e); - continue; + continue } }; let mut res_dat = &res_buf[..len]; @@ -88,7 +88,7 @@ impl PeerPcb { TunnResult::Done => break, TunnResult::Err(e) => { tracing::error!(message = "Decapsulate error", error = ?e); - break; + break } TunnResult::WriteToNetwork(packet) => { tracing::debug!("WriteToNetwork: {:?}", packet); @@ -102,29 +102,17 @@ impl PeerPcb { .await?; tracing::debug!("WriteToNetwork done"); res_dat = &[]; - continue; + continue } TunnResult::WriteToTunnelV4(packet, addr) => { tracing::debug!("WriteToTunnelV4: {:?}, {:?}", packet, addr); - tun_interface - .read() - .await - .as_ref() - .ok_or(anyhow::anyhow!("tun interface does not exist"))? - .send(packet) - .await?; - break; + tun_interface.read().await.as_ref().ok_or(anyhow::anyhow!("tun interface does not exist"))?.send(packet).await?; + break } TunnResult::WriteToTunnelV6(packet, addr) => { tracing::debug!("WriteToTunnelV6: {:?}, {:?}", packet, addr); - tun_interface - .read() - .await - .as_ref() - .ok_or(anyhow::anyhow!("tun interface does not exist"))? - .send(packet) - .await?; - break; + tun_interface.read().await.as_ref().ok_or(anyhow::anyhow!("tun interface does not exist"))?.send(packet).await?; + break } } } @@ -146,7 +134,7 @@ impl PeerPcb { let handle = self.socket.read().await; let Some(socket) = handle.as_ref() else { tracing::error!("No socket for peer"); - return Ok(()); + return Ok(()) }; tracing::debug!("Our Encapsulated packet: {:?}", packet); socket.send(packet).await?; @@ -169,7 +157,7 @@ impl PeerPcb { let handle = self.socket.read().await; let Some(socket) = handle.as_ref() else { tracing::error!("No socket for peer"); - return Ok(()); + return Ok(()) }; socket.send(packet).await?; tracing::debug!("Sent Packet for timer update"); diff --git a/contributors.nix b/contributors.nix deleted file mode 100644 index e13da91..0000000 --- a/contributors.nix +++ /dev/null @@ -1,116 +0,0 @@ -{ - groups = { - users = "burrow-users"; - admins = "burrow-admins"; - automation = "burrow-automation"; - linear = { - owners = "linear-owners"; - admins = "linear-admins"; - guests = "linear-guests"; - }; - }; - - identities = { - contact = { - displayName = "Burrow"; - canonicalEmail = "contact@burrow.net"; - isAdmin = true; - forgeAuthorized = true; - bootstrapAuthentik = true; - sshPublicKeyPath = ./nixos/keys/contact_at_burrow_net.pub; - roles = [ - "operator" - "forge-admin" - ]; - }; - - conrad = { - displayName = "Conrad Kramer"; - canonicalEmail = "conrad@burrow.net"; - isAdmin = true; - forgeAuthorized = false; - bootstrapAuthentik = true; - roles = [ - "operator" - "founder" - ]; - }; - - jett = { - displayName = "Jett"; - canonicalEmail = "jett@burrow.net"; - isAdmin = true; - forgeAuthorized = false; - forgeUnixUser = true; - bootstrapAuthentik = true; - sshPublicKeyPath = ./nixos/keys/jett_at_burrow_net.pub; - roles = [ - "member" - "operator" - "forge-admin" - ]; - }; - - davnotdev = { - displayName = "David"; - canonicalEmail = "davnotdev@burrow.net"; - isAdmin = true; - forgeAuthorized = false; - bootstrapAuthentik = true; - roles = [ - "member" - "operator" - "forge-admin" - ]; - }; - - agent = { - displayName = "Burrow Agent"; - canonicalEmail = "agent@burrow.net"; - isAdmin = false; - forgeAuthorized = true; - bootstrapAuthentik = false; - sshPublicKeyPath = ./nixos/keys/agent_at_burrow_net.pub; - roles = [ - "automation" - ]; - }; - - release = { - displayName = "Burrow Release"; - canonicalEmail = "release@burrow.net"; - isAdmin = false; - forgeAuthorized = false; - bootstrapAuthentik = false; - roles = [ - "automation" - "release" - ]; - }; - - namespace = { - displayName = "Burrow Namespace"; - canonicalEmail = "namespace@burrow.net"; - isAdmin = false; - forgeAuthorized = false; - bootstrapAuthentik = false; - roles = [ - "automation" - "namespace-runners" - ]; - }; - - ui-test = { - displayName = "Burrow UI Test"; - canonicalEmail = "ui-test@burrow.net"; - isAdmin = false; - forgeAuthorized = false; - bootstrapAuthentik = true; - authentikPasswordSecret = "burrowAuthentikUiTestPassword"; - roles = [ - "testing" - "apple-ui" - ]; - }; - }; -} diff --git a/crates/burrow-core/Cargo.toml b/crates/burrow-core/Cargo.toml deleted file mode 100644 index b6bc9fd..0000000 --- a/crates/burrow-core/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "burrow-core" -version = "0.1.0" -edition = "2021" -description = "Cross-platform Burrow protocol and application core." -license = "GPL-3.0-or-later" - -[lib] -crate-type = ["lib", "staticlib"] diff --git a/crates/burrow-core/src/lib.rs b/crates/burrow-core/src/lib.rs deleted file mode 100644 index 6cf84dd..0000000 --- a/crates/burrow-core/src/lib.rs +++ /dev/null @@ -1,61 +0,0 @@ -#![deny(missing_debug_implementations)] - -/// Version of the Rust core linked into platform shells. -pub fn version() -> &'static str { - env!("CARGO_PKG_VERSION") -} - -/// Platform family reported by the current Rust target. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PlatformFamily { - Android, - Apple, - Linux, - Windows, - Other, -} - -impl PlatformFamily { - pub fn current() -> Self { - if cfg!(target_os = "android") { - Self::Android - } else if cfg!(target_vendor = "apple") { - Self::Apple - } else if cfg!(target_os = "linux") { - Self::Linux - } else if cfg!(target_os = "windows") { - Self::Windows - } else { - Self::Other - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Android => "android", - Self::Apple => "apple", - Self::Linux => "linux", - Self::Windows => "windows", - Self::Other => "other", - } - } -} - -/// Human-readable core identity for stubs and smoke tests. -pub fn identity() -> String { - format!( - "burrow-core/{} ({})", - version(), - PlatformFamily::current().as_str() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn identity_includes_project_name() { - assert!(identity().starts_with("burrow-core/")); - } -} diff --git a/crates/burrow-mobile-ffi/Cargo.toml b/crates/burrow-mobile-ffi/Cargo.toml deleted file mode 100644 index 701c2b4..0000000 --- a/crates/burrow-mobile-ffi/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "burrow-mobile-ffi" -version = "0.1.0" -edition = "2021" -description = "Mobile FFI boundary for the Burrow Rust core." -license = "GPL-3.0-or-later" - -[lib] -crate-type = ["cdylib", "staticlib"] - -[dependencies] -burrow-core = { path = "../burrow-core" } -jni = "0.21" diff --git a/crates/burrow-mobile-ffi/src/lib.rs b/crates/burrow-mobile-ffi/src/lib.rs deleted file mode 100644 index ff75ad8..0000000 --- a/crates/burrow-mobile-ffi/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -#![deny(missing_debug_implementations)] - -use std::ffi::c_char; - -use jni::objects::JClass; -use jni::sys::jstring; -use jni::JNIEnv; - -static CORE_VERSION: &[u8] = concat!(env!("CARGO_PKG_VERSION"), "\0").as_bytes(); - -/// C ABI entrypoint for platform shells that do not use JNI. -#[no_mangle] -pub extern "C" fn burrow_core_version() -> *const c_char { - CORE_VERSION.as_ptr().cast() -} - -/// JNI entrypoint used by the Android Kotlin stub. -#[no_mangle] -pub extern "system" fn Java_net_burrow_android_BurrowCore_version( - env: JNIEnv<'_>, - _class: JClass<'_>, -) -> jstring { - match env.new_string(burrow_core::identity()) { - Ok(value) => value.into_raw(), - Err(_) => std::ptr::null_mut(), - } -} diff --git a/crates/burrow-windows-stub/Cargo.toml b/crates/burrow-windows-stub/Cargo.toml deleted file mode 100644 index 4ef859a..0000000 --- a/crates/burrow-windows-stub/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "burrow-windows-stub" -version = "0.1.0" -edition = "2021" -description = "Windows release placeholder for Burrow" -license = "GPL-3.0-or-later" - -[[bin]] -name = "burrow" -path = "src/main.rs" diff --git a/crates/burrow-windows-stub/src/main.rs b/crates/burrow-windows-stub/src/main.rs deleted file mode 100644 index fddc637..0000000 --- a/crates/burrow-windows-stub/src/main.rs +++ /dev/null @@ -1,8 +0,0 @@ -use std::process::ExitCode; - -fn main() -> ExitCode { - println!("Burrow for Windows is not ready yet."); - println!("This release stub exists so the Windows lane can exercise signing, packaging, and upload plumbing before the native client lands."); - println!("Install a Linux or Apple build for a functional Burrow client today."); - ExitCode::from(64) -} diff --git a/design/brand/Burrow.icon/Assets/burrow-ground.png b/design/brand/Burrow.icon/Assets/burrow-ground.png deleted file mode 100644 index a39c507..0000000 Binary files a/design/brand/Burrow.icon/Assets/burrow-ground.png and /dev/null differ diff --git a/design/brand/Burrow.icon/Assets/owl-body.png b/design/brand/Burrow.icon/Assets/owl-body.png deleted file mode 100644 index e335771..0000000 Binary files a/design/brand/Burrow.icon/Assets/owl-body.png and /dev/null differ diff --git a/design/brand/Burrow.icon/Assets/owl-face.png b/design/brand/Burrow.icon/Assets/owl-face.png deleted file mode 100644 index a39c507..0000000 Binary files a/design/brand/Burrow.icon/Assets/owl-face.png and /dev/null differ diff --git a/design/brand/Burrow.icon/Assets/owl-figure.png b/design/brand/Burrow.icon/Assets/owl-figure.png deleted file mode 100644 index e335771..0000000 Binary files a/design/brand/Burrow.icon/Assets/owl-figure.png and /dev/null differ diff --git a/design/brand/Burrow.icon/Assets/owl-legs.png b/design/brand/Burrow.icon/Assets/owl-legs.png deleted file mode 100644 index a39c507..0000000 Binary files a/design/brand/Burrow.icon/Assets/owl-legs.png and /dev/null differ diff --git a/design/brand/Burrow.icon/Assets/owl-spots.png b/design/brand/Burrow.icon/Assets/owl-spots.png deleted file mode 100644 index a39c507..0000000 Binary files a/design/brand/Burrow.icon/Assets/owl-spots.png and /dev/null differ diff --git a/design/brand/Burrow.icon/icon.json b/design/brand/Burrow.icon/icon.json deleted file mode 100644 index b1e196a..0000000 --- a/design/brand/Burrow.icon/icon.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "fill": { - "type": "solid", - "color": "#00000000" - }, - "layers": [ - { - "blend-mode": "normal", - "image-name": "owl-figure.png", - "name": "panel-raster", - "position": { - "scale": 1, - "translation-in-points": [ - 0, - 0 - ] - } - } - ] -} diff --git a/design/brand/README.md b/design/brand/README.md deleted file mode 100644 index 30094e3..0000000 --- a/design/brand/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Burrow Brand Source - -`design/brand/panel-variants/source-sheet.png` is the locked icon style sheet. -The six cropped PNG masters in `design/brand/panel-variants/` are the canonical -app icon source. Do not redraw these as SVG; regenerate platform outputs from -the raster panel masters. - -`Scripts/generate-brand-icons.py` owns all generated outputs: - -- `Apple/UI/Assets.xcassets/AppIcon.appiconset`: primary iOS and macOS app - icons at exact asset-catalog dimensions. -- `Apple/UI/Assets.xcassets/BurrowPanel*.appiconset`: iOS alternate app icons. -- `Apple/UI/Assets.xcassets/BurrowPanel*Preview.imageset`: in-app selector - previews. -- `design/brand/watchos/AppIcon.appiconset`: watchOS reference app icon catalog - using the selected watchOS panel. -- `Android/app/src/main/res/mipmap-*`: Android launcher PNGs. -- `store-metadata/android/icon.png`: Google Play icon. -- `burrow-gtk/data/icons/hicolor/{16,24,32,48,64,128,256,512}/apps`: Linux - hicolor PNGs. -- `design/brand/low-res`: low-resolution QA exports and preview strips. - -Platform picks: - -- iOS primary: `panel-01-guardian` -- iOS alternates: `panel-02-field`, `panel-03-burrow`, `panel-04-sentinel`, - `panel-05-stride`, `panel-06-post` -- macOS primary: `panel-05-stride` -- watchOS primary: `panel-03-burrow` -- Android primary: `panel-01-guardian` -- Linux primary: `panel-01-guardian` - -Regenerate all package assets from existing masters with: - -```bash -Scripts/generate-brand-icons.py -``` - -If the locked source sheet changes, extract fresh 1024px masters and regenerate -with: - -```bash -Scripts/generate-brand-icons.py --extract-from /path/to/source-sheet.png -``` - -Run low-resolution export QA with: - -```bash -Scripts/check-brand-icon-lowres.sh -``` - -Review these after every icon change: - -- `design/brand/panel-variants/preview-strip.png` -- `design/brand/low-res/preview-strip.png` -- `design/brand/low-res/pixel-preview-strip.png` -- `design/brand/low-res/report.csv` diff --git a/design/brand/burrow-owl-icon-master.png b/design/brand/burrow-owl-icon-master.png deleted file mode 100644 index e335771..0000000 Binary files a/design/brand/burrow-owl-icon-master.png and /dev/null differ diff --git a/design/brand/burrowing-owl-concept-v2.png b/design/brand/burrowing-owl-concept-v2.png deleted file mode 100644 index 629d4b5..0000000 Binary files a/design/brand/burrowing-owl-concept-v2.png and /dev/null differ diff --git a/design/brand/burrowing-owl-icon-flat.png b/design/brand/burrowing-owl-icon-flat.png deleted file mode 100644 index e335771..0000000 Binary files a/design/brand/burrowing-owl-icon-flat.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-1024.png b/design/brand/low-res/burrow-icon-1024.png deleted file mode 100644 index e335771..0000000 Binary files a/design/brand/low-res/burrow-icon-1024.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-128.png b/design/brand/low-res/burrow-icon-128.png deleted file mode 100644 index 54b67da..0000000 Binary files a/design/brand/low-res/burrow-icon-128.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-16.png b/design/brand/low-res/burrow-icon-16.png deleted file mode 100644 index cf418ae..0000000 Binary files a/design/brand/low-res/burrow-icon-16.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-20.png b/design/brand/low-res/burrow-icon-20.png deleted file mode 100644 index bd3cc95..0000000 Binary files a/design/brand/low-res/burrow-icon-20.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-24.png b/design/brand/low-res/burrow-icon-24.png deleted file mode 100644 index cfe8e33..0000000 Binary files a/design/brand/low-res/burrow-icon-24.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-256.png b/design/brand/low-res/burrow-icon-256.png deleted file mode 100644 index 561abcb..0000000 Binary files a/design/brand/low-res/burrow-icon-256.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-29.png b/design/brand/low-res/burrow-icon-29.png deleted file mode 100644 index 227cb6c..0000000 Binary files a/design/brand/low-res/burrow-icon-29.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-32.png b/design/brand/low-res/burrow-icon-32.png deleted file mode 100644 index 52e7a25..0000000 Binary files a/design/brand/low-res/burrow-icon-32.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-40.png b/design/brand/low-res/burrow-icon-40.png deleted file mode 100644 index c0ef687..0000000 Binary files a/design/brand/low-res/burrow-icon-40.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-48.png b/design/brand/low-res/burrow-icon-48.png deleted file mode 100644 index 68cf88b..0000000 Binary files a/design/brand/low-res/burrow-icon-48.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-64.png b/design/brand/low-res/burrow-icon-64.png deleted file mode 100644 index 46872b4..0000000 Binary files a/design/brand/low-res/burrow-icon-64.png and /dev/null differ diff --git a/design/brand/low-res/burrow-icon-80.png b/design/brand/low-res/burrow-icon-80.png deleted file mode 100644 index ecf97e9..0000000 Binary files a/design/brand/low-res/burrow-icon-80.png and /dev/null differ diff --git a/design/brand/low-res/pixel-preview-16.png b/design/brand/low-res/pixel-preview-16.png deleted file mode 100644 index 54cb3a4..0000000 Binary files a/design/brand/low-res/pixel-preview-16.png and /dev/null differ diff --git a/design/brand/low-res/pixel-preview-20.png b/design/brand/low-res/pixel-preview-20.png deleted file mode 100644 index 3064ac7..0000000 Binary files a/design/brand/low-res/pixel-preview-20.png and /dev/null differ diff --git a/design/brand/low-res/pixel-preview-24.png b/design/brand/low-res/pixel-preview-24.png deleted file mode 100644 index 139a265..0000000 Binary files a/design/brand/low-res/pixel-preview-24.png and /dev/null differ diff --git a/design/brand/low-res/pixel-preview-29.png b/design/brand/low-res/pixel-preview-29.png deleted file mode 100644 index e25f0ad..0000000 Binary files a/design/brand/low-res/pixel-preview-29.png and /dev/null differ diff --git a/design/brand/low-res/pixel-preview-32.png b/design/brand/low-res/pixel-preview-32.png deleted file mode 100644 index e997e05..0000000 Binary files a/design/brand/low-res/pixel-preview-32.png and /dev/null differ diff --git a/design/brand/low-res/pixel-preview-strip.png b/design/brand/low-res/pixel-preview-strip.png deleted file mode 100644 index 83a7060..0000000 Binary files a/design/brand/low-res/pixel-preview-strip.png and /dev/null differ diff --git a/design/brand/low-res/preview-128.png b/design/brand/low-res/preview-128.png deleted file mode 100644 index 41d405f..0000000 Binary files a/design/brand/low-res/preview-128.png and /dev/null differ diff --git a/design/brand/low-res/preview-16.png b/design/brand/low-res/preview-16.png deleted file mode 100644 index 54cb3a4..0000000 Binary files a/design/brand/low-res/preview-16.png and /dev/null differ diff --git a/design/brand/low-res/preview-20.png b/design/brand/low-res/preview-20.png deleted file mode 100644 index 3064ac7..0000000 Binary files a/design/brand/low-res/preview-20.png and /dev/null differ diff --git a/design/brand/low-res/preview-24.png b/design/brand/low-res/preview-24.png deleted file mode 100644 index 139a265..0000000 Binary files a/design/brand/low-res/preview-24.png and /dev/null differ diff --git a/design/brand/low-res/preview-256.png b/design/brand/low-res/preview-256.png deleted file mode 100644 index 474fa4d..0000000 Binary files a/design/brand/low-res/preview-256.png and /dev/null differ diff --git a/design/brand/low-res/preview-29.png b/design/brand/low-res/preview-29.png deleted file mode 100644 index e25f0ad..0000000 Binary files a/design/brand/low-res/preview-29.png and /dev/null differ diff --git a/design/brand/low-res/preview-32.png b/design/brand/low-res/preview-32.png deleted file mode 100644 index e997e05..0000000 Binary files a/design/brand/low-res/preview-32.png and /dev/null differ diff --git a/design/brand/low-res/preview-40.png b/design/brand/low-res/preview-40.png deleted file mode 100644 index 5938193..0000000 Binary files a/design/brand/low-res/preview-40.png and /dev/null differ diff --git a/design/brand/low-res/preview-48.png b/design/brand/low-res/preview-48.png deleted file mode 100644 index 940f132..0000000 Binary files a/design/brand/low-res/preview-48.png and /dev/null differ diff --git a/design/brand/low-res/preview-64.png b/design/brand/low-res/preview-64.png deleted file mode 100644 index 4f563f0..0000000 Binary files a/design/brand/low-res/preview-64.png and /dev/null differ diff --git a/design/brand/low-res/preview-80.png b/design/brand/low-res/preview-80.png deleted file mode 100644 index 4524c81..0000000 Binary files a/design/brand/low-res/preview-80.png and /dev/null differ diff --git a/design/brand/low-res/preview-strip.png b/design/brand/low-res/preview-strip.png deleted file mode 100644 index ab83302..0000000 Binary files a/design/brand/low-res/preview-strip.png and /dev/null differ diff --git a/design/brand/low-res/report.csv b/design/brand/low-res/report.csv deleted file mode 100644 index 7205aeb..0000000 --- a/design/brand/low-res/report.csv +++ /dev/null @@ -1,12 +0,0 @@ -size,colors,gray_stddev,status -16,182,0.135534,ok -20,247,0.142914,ok -24,325,0.157326,ok -29,436,0.156951,ok -32,504,0.164219,ok -40,691,0.173161,ok -48,868,0.179509,ok -64,1287,0.190366,ok -80,1701,0.198425,ok -128,3115,0.210773,ok -256,6728,0.212284,ok diff --git a/design/brand/panel-variants/README.md b/design/brand/panel-variants/README.md deleted file mode 100644 index ca368bf..0000000 --- a/design/brand/panel-variants/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Burrow Raster Panel Icon Variants - -The six PNG masters here are cropped from `source-sheet.png` and are the -locked app icon source. Do not redraw them; regenerate package assets from -these rasters. - -| Variant | Use | Source | -| --- | --- | --- | -| `panel-01-guardian` | iOS primary, Android primary, Linux primary | Guardian, top-left panel | -| `panel-02-field` | iOS alternate | Field, top-middle panel | -| `panel-03-burrow` | iOS alternate, watchOS primary | Burrow, top-right panel | -| `panel-04-sentinel` | iOS alternate | Sentinel, bottom-left panel | -| `panel-05-stride` | iOS alternate, macOS primary | Stride, bottom-middle panel | -| `panel-06-post` | iOS alternate | Post, bottom-right panel | - -`preview-strip.png` shows the six raster masters side by side. diff --git a/design/brand/panel-variants/panel-01-guardian-preview.png b/design/brand/panel-variants/panel-01-guardian-preview.png deleted file mode 100644 index 561abcb..0000000 Binary files a/design/brand/panel-variants/panel-01-guardian-preview.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-01-guardian.png b/design/brand/panel-variants/panel-01-guardian.png deleted file mode 100644 index e335771..0000000 Binary files a/design/brand/panel-variants/panel-01-guardian.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-02-field-preview.png b/design/brand/panel-variants/panel-02-field-preview.png deleted file mode 100644 index f6d9feb..0000000 Binary files a/design/brand/panel-variants/panel-02-field-preview.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-02-field.png b/design/brand/panel-variants/panel-02-field.png deleted file mode 100644 index 278575e..0000000 Binary files a/design/brand/panel-variants/panel-02-field.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-03-burrow-preview.png b/design/brand/panel-variants/panel-03-burrow-preview.png deleted file mode 100644 index 76d432e..0000000 Binary files a/design/brand/panel-variants/panel-03-burrow-preview.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-03-burrow.png b/design/brand/panel-variants/panel-03-burrow.png deleted file mode 100644 index 6f40b0a..0000000 Binary files a/design/brand/panel-variants/panel-03-burrow.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-04-sentinel-preview.png b/design/brand/panel-variants/panel-04-sentinel-preview.png deleted file mode 100644 index 36b526c..0000000 Binary files a/design/brand/panel-variants/panel-04-sentinel-preview.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-04-sentinel.png b/design/brand/panel-variants/panel-04-sentinel.png deleted file mode 100644 index 4925f9f..0000000 Binary files a/design/brand/panel-variants/panel-04-sentinel.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-05-stride-preview.png b/design/brand/panel-variants/panel-05-stride-preview.png deleted file mode 100644 index d919e9c..0000000 Binary files a/design/brand/panel-variants/panel-05-stride-preview.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-05-stride.png b/design/brand/panel-variants/panel-05-stride.png deleted file mode 100644 index 065e8b6..0000000 Binary files a/design/brand/panel-variants/panel-05-stride.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-06-post-preview.png b/design/brand/panel-variants/panel-06-post-preview.png deleted file mode 100644 index 7c63df0..0000000 Binary files a/design/brand/panel-variants/panel-06-post-preview.png and /dev/null differ diff --git a/design/brand/panel-variants/panel-06-post.png b/design/brand/panel-variants/panel-06-post.png deleted file mode 100644 index 54336b2..0000000 Binary files a/design/brand/panel-variants/panel-06-post.png and /dev/null differ diff --git a/design/brand/panel-variants/preview-strip.png b/design/brand/panel-variants/preview-strip.png deleted file mode 100644 index 8ecc2ff..0000000 Binary files a/design/brand/panel-variants/preview-strip.png and /dev/null differ diff --git a/design/brand/panel-variants/source-sheet.png b/design/brand/panel-variants/source-sheet.png deleted file mode 100644 index a3b6f7f..0000000 Binary files a/design/brand/panel-variants/source-sheet.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/Contents.json b/design/brand/watchos/AppIcon.appiconset/Contents.json deleted file mode 100644 index e3f5ccd..0000000 --- a/design/brand/watchos/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "images": [ - { - "filename": "watch-panel-03-48.png", - "idiom": "watch", - "scale": "2x", - "size": "24x24", - "role": "notificationCenter", - "subtype": "38mm" - }, - { - "filename": "watch-panel-03-55.png", - "idiom": "watch", - "scale": "2x", - "size": "27.5x27.5", - "role": "notificationCenter", - "subtype": "42mm" - }, - { - "filename": "watch-panel-03-66.png", - "idiom": "watch", - "scale": "2x", - "size": "33x33", - "role": "notificationCenter", - "subtype": "45mm" - }, - { - "filename": "watch-panel-03-58.png", - "idiom": "watch", - "scale": "2x", - "size": "29x29", - "role": "companionSettings" - }, - { - "filename": "watch-panel-03-87.png", - "idiom": "watch", - "scale": "3x", - "size": "29x29", - "role": "companionSettings" - }, - { - "filename": "watch-panel-03-80.png", - "idiom": "watch", - "scale": "2x", - "size": "40x40", - "role": "appLauncher", - "subtype": "38mm" - }, - { - "filename": "watch-panel-03-88.png", - "idiom": "watch", - "scale": "2x", - "size": "44x44", - "role": "appLauncher", - "subtype": "40mm" - }, - { - "filename": "watch-panel-03-92.png", - "idiom": "watch", - "scale": "2x", - "size": "46x46", - "role": "appLauncher", - "subtype": "41mm" - }, - { - "filename": "watch-panel-03-100.png", - "idiom": "watch", - "scale": "2x", - "size": "50x50", - "role": "appLauncher", - "subtype": "44mm" - }, - { - "filename": "watch-panel-03-102.png", - "idiom": "watch", - "scale": "2x", - "size": "51x51", - "role": "appLauncher", - "subtype": "45mm" - }, - { - "filename": "watch-panel-03-108.png", - "idiom": "watch", - "scale": "2x", - "size": "54x54", - "role": "appLauncher", - "subtype": "49mm" - }, - { - "filename": "watch-panel-03-172.png", - "idiom": "watch", - "scale": "2x", - "size": "86x86", - "role": "quickLook", - "subtype": "38mm" - }, - { - "filename": "watch-panel-03-196.png", - "idiom": "watch", - "scale": "2x", - "size": "98x98", - "role": "quickLook", - "subtype": "42mm" - }, - { - "filename": "watch-panel-03-216.png", - "idiom": "watch", - "scale": "2x", - "size": "108x108", - "role": "quickLook", - "subtype": "44mm" - }, - { - "filename": "watch-panel-03-234.png", - "idiom": "watch", - "scale": "2x", - "size": "117x117", - "role": "quickLook", - "subtype": "45mm" - }, - { - "filename": "watch-panel-03-258.png", - "idiom": "watch", - "scale": "2x", - "size": "129x129", - "role": "quickLook", - "subtype": "49mm" - }, - { - "filename": "watch-panel-03-1024.png", - "idiom": "watch-marketing", - "scale": "1x", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-100.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-100.png deleted file mode 100644 index 8d72c1b..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-100.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-102.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-102.png deleted file mode 100644 index d5ac27c..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-102.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-1024.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-1024.png deleted file mode 100644 index 6f40b0a..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-1024.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-108.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-108.png deleted file mode 100644 index c9613d1..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-108.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-172.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-172.png deleted file mode 100644 index 995ec8b..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-172.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-196.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-196.png deleted file mode 100644 index 676a117..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-196.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-216.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-216.png deleted file mode 100644 index 90e53cb..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-216.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-234.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-234.png deleted file mode 100644 index 9fb18c5..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-234.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-258.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-258.png deleted file mode 100644 index 9eca1be..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-258.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-48.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-48.png deleted file mode 100644 index 6eb1304..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-48.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-55.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-55.png deleted file mode 100644 index 147f62c..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-55.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-58.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-58.png deleted file mode 100644 index 1efe951..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-58.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-66.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-66.png deleted file mode 100644 index 32d7b01..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-66.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-80.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-80.png deleted file mode 100644 index 37337e7..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-80.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-87.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-87.png deleted file mode 100644 index e37171c..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-87.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-88.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-88.png deleted file mode 100644 index 5c8da72..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-88.png and /dev/null differ diff --git a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-92.png b/design/brand/watchos/AppIcon.appiconset/watch-panel-03-92.png deleted file mode 100644 index 74b9a60..0000000 Binary files a/design/brand/watchos/AppIcon.appiconset/watch-panel-03-92.png and /dev/null differ diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md deleted file mode 100644 index 7ef29f5..0000000 --- a/docs/DISTRIBUTION.md +++ /dev/null @@ -1,135 +0,0 @@ -# Distribution - -Burrow distribution is split by platform authority. - -## Apple - -macOS, iOS, and visionOS builds use the Apple app plus NetworkExtension packet -tunnel provider. App Store and TestFlight upload lanes stay separate from build -lanes so signing, export, metadata, and external distribution are visible. - -Developer ID signing is moving to a KMS-backed key path. The -`apple-developer-id-application` key in the Burrow `burrow-identity` Google KMS -key ring is non-exportable and can produce a standard Apple CSR through -`Scripts/apple/google-kms-csr.py` or the manual Forgejo -`apple-developer-id-kms-csr.yml` workflow. The active G2 Developer ID -Application certificate is `9JKN6HXBHC`, stored as public material at -`Apple/Certificates/developer-id-application-9JKN6HXBHC.cer`, and expires on -`2031-06-08`. - -App Store iOS signing uses the same non-exportable model. The -`apple-ios-distribution` key in the Burrow identity key ring produced the active -`IOS_DISTRIBUTION` certificate `3G42677598`, stored as public material at -`Apple/Certificates/ios-distribution-3G42677598.cer`, and expires on -`2027-06-07`. App Store Connect certificate creation can be driven by -`Scripts/apple/create-asc-certificate.mjs` once a KMS-backed CSR has been -generated. - -When `BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID` is set, provisioning-profile sync -pins iOS App Store profiles to that certificate. Existing App Store profiles -with the same name are deleted and recreated if Apple reports that they contain -a different distribution certificate. - -Sparkle appcast signing keeps the `sparkle-ed25519` Google KMS key available -for payloads small enough for direct KMS Ed25519 signing, but normal macOS -release archives are larger than Google KMS accepts for direct Ed25519 -messages. Those archives are signed directly from the decrypted release -`SPARKLE_EDDSA_KEY_PATH` seed so Sparkle receives the standard -full-archive EdDSA signature it verifies at update time. macOS release builds -embed public EdDSA key -`uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=` and point Sparkle metadata at -`https://releases.burrow.net/sparkle/appcast.xml`. The appcast signer runs only -when `BURROW_SPARKLE_SIGN_WITH_KMS=true` so unsigned local validation artifacts -do not require Google credentials. - -This is not equivalent to the existing `.p12` keychain import path. The current -Xcode packaging lane can still use exportable signing assets, while the -KMS-backed Apple certificates require a follow-up signer integration that can -ask Google KMS to produce code signatures without exporting the private key. - -## Android - -Android starts with a Kotlin shell and Rust core FFI stub. Full VPN support must -use Android `VpnService`, which means Play Store builds need the VPN service -declaration, clear in-app disclosure, and data-safety review before upload. - -## Linux - -The GTK app is the desktop UI. For VPN operation, it should talk to a host -daemon or privileged helper that owns TUN, routes, DNS, and firewall state. - -Flatpak can package the GUI, but it should not be treated as the privileged data -plane. The preferred Linux shape is: - -- native packages install `burrowd`, the systemd unit/socket, D-Bus policy, and - polkit policy -- the Flatpak GUI talks to the host service over a narrowly named D-Bus API or a - constrained local daemon socket -- the daemon performs privileged operations only after polkit authorizes the - user action - -A Flatpak build can either: - -- connect to a system/user Burrow daemon over a constrained local API -- ask a host-installed helper to start or configure the daemon through a - reviewed D-Bus policy -- stay as a configuration/status UI when no helper is installed - -It should not silently install native packages. PackageKit can be an optional -bootstrap path on distros where the native Burrow daemon package is available, -PackageKit is installed, and the Flatpak has a narrow -`org.freedesktop.PackageKit` system-bus permission. That path is a convenience, -not the baseline, because it is distro-dependent and still requires native -package trust and polkit authorization. - -The native repository plan is now: - -- `packages.burrow.net/apt` for Debian-family packages -- `packages.burrow.net/rpm//` for RPM-family packages -- `packages.burrow.net/arch/burrow/` for pacman binary packages -- `aur.archlinux.org/burrow-git.git` for the Arch source-build package -- a Flatpak repository descriptor for the GTK GUI, paired with the native daemon -- the NixOS flake/module for declarative NixOS installs - -Package repository signing is split by repository format. Google Cloud KMS owns -non-exportable RSA signing keys for APT, RPM, pacman, Flatpak, and AUR source -surfaces. The current repository builder signs APT `Release`, RPM `repomd.xml`, -and pacman database/package sidecar signatures through the KMS OpenPGP bridge. -Embedded RPM package signatures and Flatpak OSTree summary signing still need a -dedicated implementation pass before those channels are promoted beyond -staging. - -Release artifacts and signed repository trees publish through repo-owned storage -wrappers. Garage is the required S3-compatible primary target; GCS remains the -first backup target through buckets owned by `infra/releases`: -`burrow-net-releases` for build artifacts and `burrow-net-packages` for package -repositories. CI authenticates with Authentik-backed Google Workload Identity -Federation before using Google KMS or `gcloud storage`; no Google -service-account JSON key or rclone transport is part of the release path. - -An AppImage may be useful as a portable GUI on distributions without current -Flatpak support, but it has the same daemon boundary as Flatpak. NixOS should -prefer the flake/module path so daemon, policy, and service activation are -declarative. - -## F-Droid and Play Store - -F-Droid should build from source with reproducible metadata and no proprietary -Play dependencies in the free flavor. Play Store can use the same Rust core but -requires the Play policy path for `VpnService`. - -## Flatpak - -Flatpak distribution is useful for desktop reach, but the app sandbox does not -grant route, DNS, or TUN authority by itself. Burrow should publish Flatpak only -with clear daemon/helper requirements until a real desktop smoke test proves the -full flow. - -Flatpak can start child processes inside its sandbox and can request background -or autostart permission for the Flatpak app. That is not enough for the VPN data -plane. Starting the host daemon belongs to one of these paths: - -- systemd socket or D-Bus activation installed by the native package -- explicit D-Bus call to a host helper with narrow permissions and polkit -- PackageKit-assisted native package install where supported -- manual native package install instructions opened from the GUI 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/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index e36bbad..764c219 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -75,7 +75,7 @@ rustup toolchain install stable-msvc 1. Clone the repository: ``` -git clone https://git.burrow.net/hackclub/burrow.git +git clone git@github.com:hackclub/burrow.git ``` 2. Open the `burrow` folder in Visual Studio Code: @@ -98,14 +98,10 @@ code burrow You can run burrow on the command line with cargo: ``` -sudo -E cargo run -- start +cargo run ``` -Creating the tunnel requires elevated privileges. Regular checks and tests can run without `sudo`: - -``` -cargo test --workspace --all-features -``` +Cargo will ask for your password because burrow needs permission in order to create a tunnel. diff --git a/docs/GTK_APP.md b/docs/GTK_APP.md index a22f8fc..ef73d2b 100644 --- a/docs/GTK_APP.md +++ b/docs/GTK_APP.md @@ -1,10 +1,7 @@ # Linux GTK App Getting Started -Currently, the GTK App can be built as a binary or as an AppImage. The Flatpak -lane is a GUI packaging target until Burrow has a verified host daemon/helper -path for TUN, routes, DNS, and firewall state. The intended Flatpak behavior is -to discover and talk to a native Burrow daemon installed by DEB, RPM, or the -NixOS flake/module, not to own privileged routing itself. +Currently, the GTK App can be built as a binary or as an AppImage. +Note that the flatpak version can compile but will not run properly! ## Dependencies @@ -18,7 +15,7 @@ NixOS flake/module, not to own privileged routing itself. 1. Install build dependencies ``` - sudo apt install -y clang meson cmake pkg-config libssl-dev libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils + sudo apt install -y clang meson cmake pkg-config libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils ``` 2. Install flatpak builder (Optional) @@ -41,7 +38,7 @@ NixOS flake/module, not to own privileged routing itself. 1. Install build dependencies ``` - sudo dnf install -y clang ninja-build cmake meson openssl-devel gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib + sudo dnf install -y clang ninja-build cmake meson gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib ``` 2. Install flatpak builder (Optional) @@ -64,7 +61,7 @@ NixOS flake/module, not to own privileged routing itself. 1. Install build dependencies ``` - sudo xbps-install -Sy gcc clang meson cmake pkg-config openssl-devel gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib + sudo xbps-install -Sy gcc clang meson cmake pkg-config gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib ``` 2. Install flatpak builder (Optional) @@ -91,12 +88,6 @@ flatpak install --user \ ## Building -With Nix, enter the focused GTK shell before running the Meson build: - -```bash -nix develop .#gtk -``` -
General @@ -148,16 +139,6 @@ nix develop .#gtk ## Running -The GTK app mirrors the Apple home surface: a Burrow header, Networks carousel, -Accounts section, Tunnel action, and the same add flows for WireGuard, Tor, and -Tailnet. It talks to the daemon over the same gRPC API used by Apple clients for -network storage, tunnel state, Tailnet discovery, authority probing, browser -sign-in, and Tailnet payloads. - -On Linux the GTK app first looks for a daemon on the configured gRPC socket. If -none is reachable, it starts an embedded user-scoped daemon with a socket under -`XDG_RUNTIME_DIR` and a database under `XDG_DATA_HOME` before refreshing the UI. -
General diff --git a/docs/NIX_CACHE.md b/docs/NIX_CACHE.md deleted file mode 100644 index 96862c3..0000000 --- a/docs/NIX_CACHE.md +++ /dev/null @@ -1,88 +0,0 @@ -# Burrow Nix Cache - -`nix.burrow.net` is the Burrow Nix binary cache surface for forge jobs and -workers. - -The host layout is: - -- Garage stores objects on the forge host's Garage data volume. -- Attic serves the Nix binary cache API at `nix.burrow.net`. -- Attic stores NAR chunks in the Garage `attic` bucket through the local S3 API. -- Workers consume `https://nix.burrow.net/burrow` as a substituter once the - cache public key is configured. - -Garage is not a multi-backend object sync manager. It can replicate across -Garage nodes, but it does not write the same object to GCS and another provider -as independent storage backends. Multi-cloud backup should be a separate mirror -or export job from Garage buckets to GCS and later to additional providers. - -## Host Bootstrap - -`services.burrow.garage` creates a host-local environment file at -`/var/lib/burrow/garage/env`. The file is not stored in Git or the Nix store. -It contains: - -- Garage RPC secret -- Garage access keys for Attic, release artifacts, package repositories, and - read-only backups -- Attic JWT signing secret -- AWS-compatible variables used by Attic to talk to Garage - -`burrow-garage-bootstrap.service` creates the initial single-node Garage layout, -imports the generated keys, and creates the `attic`, `burrow-releases`, and -`burrow-packages` buckets. It grants the backup key read-only access to those -three buckets so CI can mirror them without receiving owner/write access to -Garage. - -`services.burrow.nixCache` enables Attic, points it at Garage, exposes it through -Caddy, creates a public `burrow` cache, and writes two host-local tokens: - -- `/var/lib/burrow/nix-cache/admin-token`: bootstrap/admin recovery token -- `/var/lib/burrow/nix-cache/ci-push-token`: scoped pull/push token for the - `burrow` cache - -## Worker Configuration - -`Scripts/ci/ensure-nix.sh` uses the Burrow cache only when -`BURROW_NIX_CACHE_PUBLIC_KEY` is present: - -```sh -BURROW_NIX_CACHE_URL=https://nix.burrow.net/burrow -BURROW_NIX_CACHE_PUBLIC_KEY= -``` - -Without the public key, CI falls back to `cache.nixos.org` only. This avoids -configuring an unsigned or untrusted substituter. - -After deployment, get the public key from the host: - -```sh -attic cache info local:burrow -``` - -Then set `BURROW_NIX_CACHE_PUBLIC_KEY` in Forgejo variables. - -Seal the scoped push token from `/var/lib/burrow/nix-cache/ci-push-token` into -Forgejo/OpenBao as `BURROW_NIX_CACHE_PUSH_TOKEN`. The -`Cache: Publish Nix` workflow runs `Scripts/ci/publish-nix-cache.sh`, builds the -selected flake outputs, and pushes their closures to Attic. It skips cleanly -when the push token is absent unless `BURROW_NIX_CACHE_REQUIRED=true`. - -## Backups - -The Garage `attic` bucket is backed up to the private GCS bucket -`burrow-net-nix-cache`. Release artifacts and package repositories are backed up -to their public GCS buckets. The scheduled `Backup: Garage Storage` workflow -runs `Scripts/ci/backup-garage-to-gcs.sh` with: - -```sh -BURROW_GARAGE_ENDPOINT=https://objects.burrow.net -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -BURROW_NIX_CACHE_GCS_BUCKET=burrow-net-nix-cache -``` - -The workflow authenticates to Google through Authentik-backed WIF and uses -`gcloud storage rsync`. It does not use rclone or Google service-account JSON -keys. Destination deletes are disabled by default; set -`BURROW_GARAGE_BACKUP_DELETE=true` only for an intentional prune run. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md deleted file mode 100644 index 04a17b3..0000000 --- a/docs/OBSERVABILITY.md +++ /dev/null @@ -1,60 +0,0 @@ -# Burrow Observability - -The forge host imports `services.burrow.observability`, which wires the first -production observability spine: - -- Prometheus on `127.0.0.1:9090` -- OpenTelemetry collector OTLP gRPC on `127.0.0.1:4317` -- OpenTelemetry collector OTLP HTTP on `127.0.0.1:4318` -- collector Prometheus export on `127.0.0.1:9464` -- Jaeger all-in-one on local ports only -- Grafana at `graphs.burrow.net`, backed by Authentik SSO - -Grafana datasources are provisioned by NixOS: - -- `prometheus` points at local Prometheus -- `jaeger` points at the local Jaeger query API - -OpenTofu owns checked-in dashboards under `services/grafana/dashboards/`: - -- `burrow-overview.json` -- `headscale.json` -- `observability.json` - -## Metrics - -Prometheus scrapes: - -- Prometheus self metrics -- node exporter -- systemd exporter -- Grafana `/metrics` -- Headscale `/metrics` on `127.0.0.1:9098` -- OpenTelemetry collector metrics -- Jaeger admin metrics -- Garage admin metrics when `services.burrow.garage.enable = true` -- the optional Tailscale exporter when its OAuth environment file is configured - -Headscale and Tailscale are treated as the same Tailnet family in dashboards. -Headscale exposes a local Prometheus listener through -`services.burrow.headscale.enableMetrics`. Tailscale SaaS metrics require the -NixOS Tailscale exporter and an OAuth environment file; the module leaves that -disabled until the credential is provisioned. - -## Traces - -Burrow services that emit OpenTelemetry traces should use: - -```sh -OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317 -OTEL_EXPORTER_OTLP_PROTOCOL=grpc -OTEL_SERVICE_NAME=burrow -``` - -The collector exports traces to Jaeger. Jaeger is intentionally local-only for -now; Grafana can query it server-side through the provisioned datasource. - -Headscale and Tailscale currently enter the spine through Prometheus metrics and -systemd state. If an upstream Tailnet component starts emitting OTLP, point it -at the same local collector endpoint rather than exposing Jaeger or Prometheus -directly. diff --git a/docs/PLATFORM_SERVICES.md b/docs/PLATFORM_SERVICES.md deleted file mode 100644 index b4d1304..0000000 --- a/docs/PLATFORM_SERVICES.md +++ /dev/null @@ -1,99 +0,0 @@ -# Platform Services - -Burrow's next platform layer is split into service boundaries that can be -enabled independently after their security and rollback checks pass. - -## Identity and KMS - -`infra/identity` owns Google Cloud KMS and Workload Identity Federation -resources for the Burrow Google project: - -- project id: `project-88c23ce9-918a-470a-b33` -- project number: `416198671487` -- key ring: `burrow-identity` -- intended keys: Authentik signing, SAML CA signing, release signing - -The WIF path is for Authentik-backed Forgejo runners. Runners should exchange an -OIDC token for a short-lived Google credential instead of carrying static -service-account JSON. - -Release storage uses provider-neutral upload wrappers. Garage is the required -S3-compatible primary target. GCS is the first backup target and remains the -compatibility source for current -Sparkle/store follow-on jobs: - -- release artifacts: `burrow-net-releases` -- signed package repositories: `burrow-net-packages` -- private Nix cache backup: `burrow-net-nix-cache` - -Forgejo jobs authenticate to the same Google project through Authentik-backed -WIF, then use `gcloud storage` for upload/download and Google KMS for release -and repository signatures. No rclone transport or Google service-account JSON -key is part of the release path. - -`services.burrow.garage` is enabled by the forge host. It provides the host -activation point for Garage API, static-web, and admin listeners, and -bootstraps the initial single-node layout plus the `attic`, `burrow-releases`, -and `burrow-packages` buckets. It creates separate owner/write keys for Attic, -release artifacts, and package repositories, plus a read-only backup key for -the scheduled Garage-to-GCS mirror. - -Garage is now the primary object-storage surface for release/package uploads and -the Nix cache. It uses host-local storage for object data and metadata; it does -not fan out writes to multiple object-store backends. Multi-cloud failover -should be implemented as explicit Garage bucket mirrors/backups, with GCS as the -first backup target. - -## Nix Cache - -`nix.burrow.net` is the Burrow Nix binary cache surface. The forge host runs -Attic behind Caddy and stores cache chunks in the Garage `attic` bucket through -Garage's local S3 API. Workers can use `https://nix.burrow.net/burrow` once the -cache public key is published as `BURROW_NIX_CACHE_PUBLIC_KEY` in Forgejo. The -host bootstrap also creates `/var/lib/burrow/nix-cache/ci-push-token`; seal it -as `BURROW_NIX_CACHE_PUSH_TOKEN` before enabling broad cache publication. - -## Observability - -`graphs.burrow.net` is backed by Grafana with Authentik SSO. The forge host now -imports `services.burrow.observability`, which starts local Prometheus, -OpenTelemetry collector, and Jaeger services. NixOS provisions Grafana -datasources; OpenTofu manages checked-in dashboards for the Burrow overview, -Headscale, and the broader Prometheus/OpenTelemetry/Jaeger spine. - -Jaeger is local-only at first and is queried through Grafana. Headscale metrics -are scraped locally. Tailscale SaaS metrics are optional until the OAuth -credential file exists. - -## OpenBao - -`vault.burrow.net` is the planned OpenBao surface. The repository now has: - -- `infra/openbao` for KMS seal wrapping and OpenBao auth/policy resources -- `services.burrow.openbao` as a disabled-by-default NixOS switch point -- `Scripts/openbao-tofu.sh` for stack-local OpenTofu operations - -OpenBao should not be enabled until the KMS seal key, bootstrap token handling, -backup/restore procedure, and Authentik roles are verified. - -## Jitsi Meet - -`meet.burrow.net` is the planned Jitsi Meet surface. The repository now has -`services.burrow.jitsi` as a disabled-by-default NixOS switch point. - -Jitsi is not just an HTTP service. The rollout needs DNS, TLS, XMPP/prosody -state, and an explicit UDP media-port decision before activation. - -## Mail and Webmail - -`inbox.burrow.net` is the intended webmail surface. Stalwart is the planned mail -server, but this flake does not currently expose the expected Stalwart NixOS -option. Forward Email remains the current production mail path until the -Stalwart module name, ports, DKIM rotation, backup target, spam policy, and -migration steps are pinned in a follow-up BEP update. - -## MCP Hub - -The MCP hub should be extracted to `compatible.systems/burrow/mcp-hub`. Until -that repository exists, `services/mcp-hub/` records the extraction boundary and -tool inventory. diff --git a/docs/PROTOCOL_ROADMAP.md b/docs/PROTOCOL_ROADMAP.md index 37c7228..6bfde42 100644 --- a/docs/PROTOCOL_ROADMAP.md +++ b/docs/PROTOCOL_ROADMAP.md @@ -3,7 +3,7 @@ Burrow currently has two tunnel paths in-tree: - a WireGuard data plane -- a Tor-backed userspace TCP path +- a mesh transport built on `iroh` What it does not have yet is a transport-neutral control plane that can honestly claim full MASQUE `CONNECT-IP` or full Tailscale-style negotiation parity. This repository now contains the beginnings of that layer: diff --git a/docs/TOR.md b/docs/TOR.md new file mode 100644 index 0000000..81b8a1a --- /dev/null +++ b/docs/TOR.md @@ -0,0 +1,41 @@ +# Tor Transport + +Burrow now has a `Tor` network type that boots an in-process [Arti](https://gitlab.torproject.org/tpo/core/arti) client and exposes a transparent TCP listener for outbound stream forwarding. + +The first implementation is intentionally narrow: + +- `tcp_stack.kind = "system"` is the only supported TCP stack backend. +- transparent destination recovery uses Linux `SO_ORIGINAL_DST` and macOS PF lookups. +- on macOS, Burrow first tries PF `DIOCNATLOOK`, then falls back to a `pflog0` observer backed by an in-memory flow cache keyed by the redirected socket tuple. +- Burrow does not yet install firewall redirect rules for you. +- traffic reaches Arti only if the host already redirects outbound TCP flows to Burrow's local listener. +- the macOS observer fallback only works when the redirect rule is logged to `pflog0` and Burrow listens on an explicit local address such as `127.0.0.1:9040`. +- destination handling is IP-and-port based, so this does not yet capture DNS or `.onion` names before local resolution. +- Burrow still does not install loop-avoidance rules for Arti's own relay connections, so redirect rules must exempt those flows externally for now. + +## Payload format + +`Network.payload` can be JSON or TOML. + +```json +{ + "address": ["100.64.0.2/32"], + "tun_name": "burrow-tor", + "mtu": 1400, + "arti": { + "state_dir": "/var/lib/burrow/arti/state", + "cache_dir": "/var/cache/burrow/arti" + }, + "tcp_stack": { + "kind": "system", + "listen": "127.0.0.1:9040" + } +} +``` + +## Next steps + +- teach Burrow to program and tear down redirect rules safely. +- add loop-avoidance for Arti's own relay connections before enabling automatic redirect. +- add DNS capture or hostname-aware forwarding for `.onion` and other unresolved destinations. +- add alternate pure-Rust TCP stack backends behind the same `tcp_stack` enum. diff --git a/docs/WIREGUARD_LINEAGE.md b/docs/WIREGUARD_LINEAGE.md index 63e8839..15ca67a 100644 --- a/docs/WIREGUARD_LINEAGE.md +++ b/docs/WIREGUARD_LINEAGE.md @@ -15,7 +15,7 @@ Burrow does not embed BoringTun unchanged. - The original device layer was replaced with Burrow-specific interface and peer control blocks in `burrow/src/wireguard/iface.rs` and `burrow/src/wireguard/pcb.rs`. - Configuration handling was rewritten around Burrow's own INI parser and config model in `burrow/src/wireguard/config.rs`. - The daemon now resolves the active runtime from the database-backed network list rather than from a single static WireGuard payload. -- Burrow added its own runtime switching path so WireGuard can share one daemon lifecycle with the rest of the managed runtime system. +- Burrow added its own runtime switching path so WireGuard and mesh transports can share one daemon lifecycle. ## What Was Improved @@ -23,7 +23,7 @@ The lifted code has been tightened further in-repo. - Deprecated constant-time comparisons were replaced with `subtle`. - Network ordering and runtime selection are now deterministic and test-covered. -- The Burrow runtime can swap between WireGuard configurations without restarting the daemon process itself. +- The Burrow runtime can swap between WireGuard and mesh-backed networks without restarting the daemon process itself. ## Why This Matters diff --git a/evolution/README.md b/evolution/README.md index 794b1fe..e55a347 100644 --- a/evolution/README.md +++ b/evolution/README.md @@ -58,17 +58,3 @@ evolution/ ``` Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly. - -## BEP Helper - -Use the `bep` helper under `Scripts/` to browse or list proposals: - -- `Scripts/bep` opens a quick browser for `evolution/`. -- `Scripts/bep list --status Draft` lists proposals by status. -- `Scripts/bep open BEP-0005` opens a proposal in `$EDITOR`. - -Validate proposal metadata with: - -```bash -python3 Scripts/check-bep-metadata.py -``` diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md deleted file mode 100644 index a34a609..0000000 --- a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md +++ /dev/null @@ -1,81 +0,0 @@ -# `BEP-0005` - Daemon IPC and Apple Boundary - -```text -Status: Draft -Proposal: BEP-0005 -Authors: gpt-5.4 -Coordinator: gpt-5.4 -Reviewers: Pending -Constitution Sections: II, III, IV, V -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow should formalize one Apple/runtime boundary: Apple clients speak only to the daemon over gRPC on the app-group Unix socket, and the daemon owns all external control-plane, helper-process, and runtime coordination work. This prevents UI code from accreting side HTTP paths or ad hoc control-plane integrations that bypass the system Burrow is supposed to own. - -## Motivation - -- The current Tailnet work already showed the failure mode: Swift UI code started reaching around the daemon boundary to talk to helper HTTP endpoints directly. -- Apple-specific process ownership is easy to blur between the app, the network extension, and helper daemons unless the contract is explicit. -- If Burrow wants a durable multi-runtime architecture, the daemon must remain the only orchestration boundary between clients and control/data-plane behavior. - -## Detailed Design - -- Apple UI and Apple support libraries may call only daemon gRPC methods over the declared Burrow Unix socket. -- Direct Swift calls to external control-plane HTTP APIs, localhost helper HTTP servers, or runtime-specific subprocesses are forbidden. -- The daemon is responsible for: - - discovery of Tailnet authorities and related metadata - - control-plane session setup and tracking - - login/session lifecycle brokering - - runtime start/stop/reconcile - - translating helper or bridge processes into stable daemon RPCs -- `burrow/src/control/` owns transport-neutral control-plane semantics such as discovery, authority normalization, and request/response shaping. -- Apple UI owns presentation only: - - forms - - local state - - presenting returned auth URLs or statuses - - surfacing daemon availability and errors -- Any new Apple-facing runtime capability requires a daemon RPC first. - -## Security and Operational Considerations - -- Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code. -- The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized. -- Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly. -- Non-Apple presentation clients should follow the same daemon-first lifecycle pattern: connect to a managed daemon when present, or start a user-scoped embedded daemon before issuing RPCs, without adding platform-local control-plane paths. - -## Contributor Playbook - -- Before adding a new Apple-side workflow, identify the daemon RPC that should own it. -- If the RPC does not exist, add the protocol shape in `proto/burrow.proto`, implement it in the daemon, and only then wire Swift UI. -- Verify that no Swift UI or support code calls external control-plane HTTP endpoints directly. -- For Tailnet and similar flows, test: - - daemon unavailable behavior - - successful RPC path - - error propagation through the UI -- Keep Linux GTK and Apple clients visually and functionally aligned around the same daemon-backed home surface: Networks, Accounts, Tunnel, and add flows should remain corresponding views over the daemon API. - -## Alternatives Considered - -- Let Apple UI call control-plane endpoints directly for convenience. Rejected because it creates parallel orchestration paths and breaks the daemon contract. -- Allow one-off exceptions for login helpers. Rejected because those exceptions become the architecture. - -## Impact on Other Work - -- Governs the Tailnet refactor and future Apple runtime work. -- Governs Linux GTK daemon startup parity where the same daemon API is reused from a user-scoped presentation process. -- Interacts with BEP-0002 control-plane bootstrap and BEP-0003 transport refactoring. - -## Decision - -Pending. - -## References - -- `Apple/UI/` -- `Apple/Core/` -- `Apple/NetworkExtension/` -- `burrow/src/daemon/` -- `burrow/src/control/` diff --git a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md deleted file mode 100644 index 36458ef..0000000 --- a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md +++ /dev/null @@ -1,74 +0,0 @@ -# `BEP-0006` - Tailnet Authority-First Control Plane - -```text -Status: Draft -Proposal: BEP-0006 -Authors: gpt-5.4 -Coordinator: gpt-5.4 -Reviewers: Pending -Constitution Sections: I, II, IV, V -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a distinct user-facing protocol. Burrow’s config and UI should therefore be authority-first rather than provider-first. - -## Motivation - -- Splitting Tailscale and Headscale into separate user-facing providers causes fake architectural divergence. -- Discovery already naturally returns an authority and optional issuer; that is the stable contract users actually need. -- Future managed or enterprise deployments should fit the same model without requiring another protocol picker. - -## Detailed Design - -- Tailnet configuration is centered on: - - account - - identity - - authority/login server URL - - optional tailnet name - - optional hostname - - auth method/material -- User-facing surfaces should not force a protocol choice between Tailscale and Headscale. -- Provider inference may remain internal metadata for compatibility and diagnostics: - - default managed Tailscale authority - - custom self-hosted authority - - Burrow-owned authority when explicitly applicable -- Discovery returns authority and related metadata; editing the authority is the mechanism that moves a configuration from managed default to custom control server. -- The daemon and control layer own provider inference; the UI should primarily present “Tailnet” plus the selected authority. -- Platform clients consume the same daemon gRPC surface for Tailnet discovery, authority probing, browser sign-in, and saved network payloads. macOS/iOS SwiftUI and Linux GTK may differ in presentation and local credential stores, but neither should introduce a second control-plane path. - -## Security and Operational Considerations - -- Authority-first config reduces UI complexity and makes misconfiguration easier to reason about. -- Provider-specific assumptions must not leak into packet or control-plane semantics unless the authority actually requires them. -- Auth material must remain authority-scoped and identity-scoped in daemon storage. - -## Contributor Playbook - -- Remove provider pickers from Tailnet UI unless a concrete protocol difference requires one. -- Store the authority explicitly in payloads and infer provider internally only when needed. -- Keep Linux GTK and Apple clients at functional parity by routing Tailnet add/discover/probe/login through `TailnetControl` and `Networks` RPCs instead of platform-local HTTP or legacy JSON daemon commands. -- Prefer tests that validate authority normalization and discovery behavior over UI-provider branching. - -## Alternatives Considered - -- Keep separate user-facing providers for Tailscale and Headscale. Rejected because it models deployment shape as protocol shape. -- Collapse all control planes into one opaque Burrow provider. Rejected because the authority still matters operationally and diagnostically. - -## Impact on Other Work - -- Refines BEP-0002’s Tailscale-shaped control-plane work. -- Constrains the Tailnet Apple and Linux GTK refactors plus future daemon control-plane storage. - -## Decision - -Pending. - -## References - -- `burrow/src/control/` -- `Apple/UI/Networks/` -- `burrow-gtk/src/` -- `proto/burrow.proto` diff --git a/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md b/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md deleted file mode 100644 index 1fde0fb..0000000 --- a/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md +++ /dev/null @@ -1,73 +0,0 @@ -# `BEP-0007` - Identity Registry and Operator Bootstrap - -```text -Status: Draft -Proposal: BEP-0007 -Authors: gpt-5.4 -Coordinator: gpt-5.4 -Reviewers: Pending -Constitution Sections: II, III, IV, V -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow should maintain one canonical registry for project identities, aliases, bootstrap users, SSH keys, and admin-group mappings. Forgejo, Authentik, and related bootstrap configuration should derive from that registry instead of hardcoding overlapping identity facts in multiple modules. - -## Motivation - -- Burrow currently hardcodes operator and admin/bootstrap user facts directly in host configuration. -- Multi-account and self-hosted identity are becoming core architecture, not incidental infra details. -- A single registry reduces drift across Forgejo, Authentik, Headscale, SSH authorization, and future control-plane bootstrap. - -## Detailed Design - -- Add a root-level identity registry (`contributors.nix`) as the canonical source of truth for: - - usernames - - display names - - canonical emails - - external source emails or aliases - - admin scope - - bootstrap eligibility - - forge authorized SSH keys - - named roles -- Consume that registry from host configuration for: - - Forgejo authorized keys - - Forgejo bootstrap admin defaults - - Authentik bootstrap users - - Burrow user/admin group names -- Future work may derive contributor docs, OIDC bootstrap, and additional runtime configuration from the same registry. - -## Security and Operational Considerations - -- Identity drift is a security bug when it affects admin groups, bootstrap accounts, or SSH authorization. -- The registry stores metadata only; secrets remain in agenix or other declared secret paths. -- Changes to the registry should receive explicit review because they affect access and governance. - -## Contributor Playbook - -- Edit `contributors.nix` first when changing operator, admin, alias, or bootstrap identity state. -- Derive runtime configuration from the registry instead of duplicating the same facts elsewhere. -- Keep secret references separate from identity metadata. - -## Alternatives Considered - -- Continue hardcoding users in module options. Rejected because drift is inevitable once Forgejo, Authentik, and Headscale all depend on the same identities. -- Create separate per-service user lists. Rejected because it duplicates governance facts and weakens review. - -## Impact on Other Work - -- Supports forge auth, Authentik group sync, and future multi-account Burrow control-plane work. -- Creates the basis for stronger contributor and operator provenance later. - -## Decision - -Pending. - -## References - -- `contributors.nix` -- `nixos/hosts/burrow-forge/default.nix` -- `nixos/modules/burrow-authentik.nix` -- `nixos/modules/burrow-forge.nix` diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md deleted file mode 100644 index 0ce03a6..0000000 --- a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md +++ /dev/null @@ -1,169 +0,0 @@ -# `BEP-0008` - Authentik-Backed Team Chat and Workspace Identity - -```text -Status: Draft -Proposal: BEP-0008 -Authors: gpt-5.4 -Coordinator: gpt-5.4 -Reviewers: Pending -Constitution Sections: II, III, V -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow should add a self-hosted team chat surface at `chat.burrow.net` and -continue the project-wide move toward Authentik as the identity authority for -external work systems. The immediate targets are a self-hosted Zulip -deployment rooted in Authentik SAML, a Linear SAML configuration when the -workspace plan supports it, and a 1Password Unlock-with-SSO deployment rooted -in the same Authentik-backed OIDC authority. - -This keeps Burrow's day-to-day coordination surfaces aligned with the same -admin groups, canonical users, and secret-handling model already used for -Forgejo, Headscale, and Tailscale. It also avoids fragmenting login state -across vendor-native Google auth flows when Burrow already operates an IdP. - -## Motivation - -- Forge, Tailnet, operator identity, and Tailscale custom OIDC are already - rooted in Authentik. Team chat, work tracking, and password-manager access - should not become separate authority islands. -- Zulip provides a self-hosted chat system under Burrow's control, which fits - the constitution better than adding another hosted chat dependency. -- Linear remains a SaaS dependency, but its workspace access should still be - derived from Burrow-managed identities and domains when the vendor plan - exposes SAML configuration. -- 1Password Business is another external work surface where Burrow-controlled - identities are preferable to vendor-native Google-only auth. Its current - vendor flow is OIDC-based Unlock with SSO rather than SAML, so the proposal - needs to preserve protocol accuracy instead of flattening everything into - one SAML bucket. -- Burrow already has a canonical public identity registry and a secret-backed - external-email alias map. Reusing that structure is lower-risk than - inventing per-app user bootstrap logic. - -## Detailed Design - -- Add a Burrow-managed Zulip workload on the forge host at `chat.burrow.net`. - The deployment should be repo-owned and rebuildable from Nix, even if the - runtime uses vendor-supported container images internally. -- Prefer host-managed NixOS services for Zulip's stateful dependencies - (PostgreSQL, Redis, RabbitMQ, memcached, backups) so Burrow owns the - operational surface directly rather than composing a container-side service - mesh. -- Zulip should authenticate through Authentik SAML rather than local passwords - as the primary path. Initial bootstrap may still keep an operational escape - hatch while the deployment is being validated. -- Add Authentik-managed SAML applications for: - - Zulip at `chat.burrow.net` - - Linear using Burrow's claimed domains and Authentik metadata -- Add an Authentik-managed SCIM backchannel for Linear so Burrow can push - role groups declaratively instead of hand-maintaining workspace roles. -- Add an Authentik-managed OIDC application for 1Password Business under the - Burrow team sign-in address. -- Treat Zulip and Linear as downstream applications of the same identity - authority, and treat 1Password as part of that same authority even though - its vendor protocol is OIDC rather than SAML. The source of truth remains: - - public identities and admin intent in `contributors.nix` - - private alias mappings and external accounts in agenix-encrypted secrets -- Keep app-specific configuration in dedicated reconciliation code or module - options instead of hand-edited UI state. -- Prefer service-specific reconciliation over ad hoc manual setup so rebuilds - and host replacement converge automatically. -- When Burrow wants an external-user launcher surface in Authentik, configure - the brand's `default_application` explicitly instead of relying on - `/if/user/`, which otherwise remains internal-user-only. -- Derive Linear SCIM role groups from Burrow's canonical identity metadata. - If Burrow-wide admin intent says a user is an operator/admin, the repo-owned - configuration should map that intent onto the Linear push group without a - second manual roster. -- Model 1Password according to the vendor's actual integration contract: - - OIDC Authorization Code Flow with PKCE - - public client rather than a confidential client - - no Burrow-side dependence on a stored client secret unless the vendor flow - changes - -## Security and Operational Considerations - -- Do not store external personal email mappings in public registry files. - Public tree data may include Burrow usernames and canonical `@burrow.net` - addresses, but external aliases must stay in encrypted secrets. -- Zulip internal service credentials, Django secret material, and any mail - credentials must have explicit storage and rotation paths. -- Linear SAML must not become Burrow's only admin recovery path. At least one - owner login path outside the enforced SAML flow should remain available until - rollout is proven. -- Linear SCIM group push should be role-scoped and explicit. Burrow should - avoid blanket ownership mapping unless that intent is recorded in the repo. -- 1Password Owners cannot be forced onto Unlock with SSO during initial setup. - Burrow should preserve the owner recovery path and treat OIDC rollout as a - scoped migration for non-owner users first. -- If Zulip is deployed without production-grade outbound email at first, that - limitation must be documented and treated as an operational constraint, not a - hidden assumption. -- Rollback should be straightforward: - - disable or stop the Zulip module - - remove the Authentik SAML apps - - remove the Authentik OIDC app used for 1Password if necessary - - leave the underlying Burrow identities unchanged - -## Contributor Playbook - -- Define the app and identity intent in the repository before modifying the - forge host. -- Add or update Nix modules so `burrow-forge` can rebuild Zulip and the - corresponding Authentik SAML configuration from the tree. -- Verify: - - `chat.burrow.net` serves a working Zulip login surface - - Authentik exposes working metadata for Zulip and Linear -- Authentik exposes a working OIDC issuer for 1Password - - users in Burrow admin groups receive the expected access on first login - - external Burrow users landing on `auth.burrow.net` reach the intended - app launcher target instead of the internal-only Authentik user interface -- Record concrete evidence for: - - host deployment generation - - Authentik reconciliation success - - Zulip login success - - Linear SAML configuration state - - 1Password Unlock with SSO configuration state - -## Alternatives Considered - -- Use Zulip Cloud instead of self-hosting. Rejected because the ask is to host - chat under `chat.burrow.net`, and Burrow already operates a forge host with a - self-managed identity plane. -- Keep Linear on Google-native login. Rejected because it leaves Burrow work - access outside the project's operator and group model. -- Treat 1Password as a SAML app for consistency. Rejected because the live - vendor flow is OIDC and Burrow should not pretend otherwise in repo-owned - infrastructure. -- Add per-app manual Authentik configuration without repository automation. - Rejected because it violates Burrow's infrastructure-in-repo commitment. - -## Impact on Other Work - -- Extends Burrow's Authentik role from control-plane identity into team-work - surfaces. -- Introduces a persistent chat workload on the forge host, with resource and - monitoring implications. -- Creates a likely follow-up for SCIM or richer group synchronization if Linear - or Zulip role mapping needs to become fully declarative later. -- Adds a second OIDC relying party beyond Forgejo, Headscale, and Tailscale, - which raises the importance of keeping Burrow's Authentik scope mappings and - redirect handling consistent across applications. - -## Decision - -Pending. - -## References - -- `CONSTITUTION.md` -- `contributors.nix` -- `evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md` -- Authentik docs: SAML provider and metadata endpoints -- Zulip docs: SAML authentication and docker deployment -- Linear docs: SAML and access control -- 1Password docs: Unlock with SSO using OpenID Connect diff --git a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md deleted file mode 100644 index 4d8f5c9..0000000 --- a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md +++ /dev/null @@ -1,374 +0,0 @@ -# `BEP-0009` - Release Infrastructure and Store Upload Pipeline - -```text -Status: Draft -Proposal: BEP-0009 -Authors: gpt-5.5 -Coordinator: gpt-5.5 -Reviewers: Pending -Constitution Sections: II, III, IV, V -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow needs a release pipeline that behaves like the project infrastructure it is replacing: release eligibility is detected from forge build tags, build artifacts are produced per platform, upload lanes are separate from build lanes, and store credentials are explicit automation inputs rather than hidden local state. - -The first implementation creates the release spine now: `release-if-needed` dispatches a build when HEAD has not been tagged as released, `build-release` packages current artifacts on Namespace-backed runners, and `publish-store-uploads` performs guarded upload steps for App Store Connect, Sparkle, Microsoft Store, and storage-backed public channels after Authentik-backed Google WIF authentication. - -The same change also makes the forge itself the release authority: release tags prefer the Burrow Forgejo remote, dispatch helpers talk to Forgejo directly with a live-host fallback, runner identity is discoverable from persistent host state, repository mirror sync is disabled for the canonical Burrow repository, `nix.burrow.net` provides a Burrow-owned Nix cache for workers, and Grafana observability is bootstrapped with Nix-owned Prometheus/OpenTelemetry/Jaeger service state plus OpenTofu-owned dashboards. - -## Motivation - -- Burrow already controls its forge and runner infrastructure, but the release path only produced a Linux CLI tarball. -- Apple, Linux, Sparkle, and future Windows outputs need the same build-number and artifact layout so release evidence is inspectable. -- The Namespace account should be part of the live runner surface, including macOS and Windows lanes, instead of sitting idle behind unused configuration. -- Authentication and store credentials should be named and checked at lane boundaries so dedicated accounts can be added without rewriting workflows. - -## Detailed Design - -- Use lightweight `builds/` tags as the authoritative release watermark. -- Keep `Scripts/version.sh status` as the release-needed check: - - `clean` means HEAD is exactly the latest authoritative build tag. - - `dirty` means the release workflow should create a new build number. -- Split workflows: - - `.forgejo/workflows/release-if-needed.yml` checks release status on a - schedule and on pushes to `main`, then dispatches `build-release.yml` when - HEAD is unreleased. - - `.forgejo/workflows/build-release.yml` builds and stages artifacts, and - defaults App Store Connect upload handoff on unless the dispatch explicitly - disables it. - - `.forgejo/workflows/publish-store-uploads.yml` uploads staged artifacts to - external stores and public channels, with App Store Connect upload enabled - by default for manual dispatches and release handoffs. -- Stage artifacts under `dist/builds///`. -- Publish generic release assets to a Forgejo release named `Build `. -- Upload release artifacts through `Scripts/ci/upload-release-storage.sh`. The wrapper requires Garage as the primary S3-compatible target by default, then mirrors to the `burrow-net-releases` GCS backup bucket unless `BURROW_RELEASE_GCS_BACKUP=false`. -- Publish signed package repository trees through `Scripts/ci/upload-package-storage.sh`. The wrapper requires Garage as the primary S3-compatible target by default, then mirrors to the `burrow-net-packages` GCS backup bucket unless `BURROW_PACKAGE_GCS_BACKUP=false`. -- Publish selected Nix closures through `.forgejo/workflows/publish-nix-cache.yml` and `Scripts/ci/publish-nix-cache.sh`. The workflow logs into `nix.burrow.net` with the scoped `BURROW_NIX_CACHE_PUSH_TOKEN` generated by host bootstrap and skips when the token is absent unless explicitly required. -- Mirror Garage buckets through `.forgejo/workflows/backup-garage-storage.yml` and `Scripts/ci/backup-garage-to-gcs.sh`. The scheduled workflow uses the host-generated Garage read-only backup key, Authentik-backed Google WIF, and `gcloud storage rsync` to back up `burrow-releases`, `burrow-packages`, and `attic` to GCS. -- Build Apple artifacts on `namespace-profile-macos-large` through Bazel wrapper targets: - - `//bazel/apple:release_ios_stamp` - - `//bazel/apple:release_macos_stamp` - These targets wrap the existing Xcode project and give the workflow one stable entrypoint that shares the same Bazel/Nix cache setup while Burrow decides whether deeper `rules_apple` targets are worth the migration. -- Use Namespace cache setup before Bazel/Nix work. Prefer the Namespace Bazel remote cache when `nsc` is authenticated and fall back to a local disk/repository cache when remote setup is unavailable. - Cache setup is optional release acceleration: Bazel-only cache steps must not - install filesystem-cache helpers such as `spacectl`, and Namespace cache - probes must be timeout-bounded so Apple builds can continue without the - remote cache. Apple workflows should call the fail-open cache helper directly - instead of entering the full Nix development shell just to decide whether a - cache is available. macOS runners should use the local Bazel disk/repository - cache path and skip the `nsc` remote-cache auth probe, which is optional and - can block without GNU timeout support. -- The Nix CI shell must not pin Apple builds to a Bazel major that disagrees - with `.bazelversion`. Prefer Bazelisk or a Bazel 9 package so the release - lane uses the repository-declared Bazel 9.1.0 toolchain. -- Moving tarball URLs are not acceptable for release-critical Nix inputs. The - forge worker lockfile must resolve nixpkgs through a fixed Git revision so - fresh workers do not fail when a branch tarball no longer matches a stored - NAR hash. -- The CI shell should prefer currently supported Node runtimes when nixpkgs - marks an older major insecure. Release workers must fail on product build - errors, not on avoidable toolchain end-of-life policy checks. -- Bazel repository and disk caches must live outside the checked-out repository - on macOS runners. Bazel 9 rejects a repository contents cache inside the main - repo because it can create spurious build graph failures. -- Bazel Apple genrules must explicitly pass the Nix CI shell tool path and - `PROTOC` into the action environment. Xcode build phases run with a reduced - path, and the Rust Network Extension build depends on `protoc`. -- Forgejo release workflows must use the artifact action generation supported - by the deployed forge. The v4 artifact actions assume GitHub's newer artifact - service and fail on this Forgejo install before downstream signing/upload - jobs can consume the staged Apple artifacts. -- The release secret decrypt action must be self-contained after the Nix - bootstrap step. Linux Namespace signing jobs install Nix at runtime, so the - bootstrap writes Nix profile directories to `GITHUB_PATH`, the decrypt action - resolves Nix from those profile paths, and the action builds `.#age` before - falling back to `.#agenix`. The decrypt step must not require Python before - the Nix CI shell is active. -- Authentik-backed Google WIF must exchange tokens against Authentik's OAuth - token endpoint, not a provider-slug child path. The helper should prefer an - explicit `BURROW_AUTHENTIK_WIF_TOKEN_ENDPOINT`, then OIDC discovery, then the - Authentik `/application/o/token/` fallback derived from the configured issuer. -- The Google WIF Authentik application is a machine-to-machine - client-credentials provider. It must not default to a human group policy - binding, because the token exchange has no interactive user and Authentik will - reject the grant before Google WIF can validate the issued JWT. -- Forgejo should store the Authentik WIF credential as the client-credentials - `client_secret` form value. For service-account authentication, that value is - `base64(":")`; the helper must - send it as form data, not HTTP Basic auth. -- Produce unsigned Apple validation artifacts when signing is absent, but require signing for an App Store requested iOS release and for a Sparkle tester release. Uploadable artifacts are produced only after provisioning profiles can be synced from App Store Connect credentials and the relevant Apple certificate is proven to match the selected Google KMS key. -- Resolve release credentials through age/agenix before signed Apple lanes run. Forgejo secrets should carry only the runner age identity fallback and the Authentik WIF client secret until OpenBao can mint the runner token directly. App Store Connect keys and signing certificates live as sealed files under `secrets/`. -- Persist the forge runner SSH key as `/var/lib/forgejo-runner-agent/age_keystore` and export `BURROW_RUNNER_AGE_IDENTITY_PATH` so jobs can resolve agenix identities without embedding long-lived material in every workflow. -- Add `Scripts/forgejo-dispatch.sh` and `Scripts/forgejo-dispatch-via-host.sh`: - - local dispatch uses an explicit token, local intake token, or the sealed forgejo-nsc dispatcher config when available; - - host fallback SSHes to the live forge and reads the agenix-materialized dispatcher config there; - - neither path relies on a GitHub remote or GitHub workflow API. -- Default build tag reads and pushes to the Burrow Forgejo remote when that remote exists. `BURROW_VERSION_REMOTE` remains the override for unusual migrations. -- Keep the canonical Forgejo repo as an ordinary source repository, not a mirror. The host config disables new mirror creation and removes mirror/push-mirror rows for the canonical Burrow repository at activation time. -- Add `.forgejo/workflows/infra-opentofu.yml` and `Scripts/grafana-tofu.sh` for Grafana API-managed folders and dashboards. NixOS owns the Grafana service, SSO, reverse proxy, datasources, Prometheus, OpenTelemetry collector, Jaeger, secrets, and boot-time configuration; OpenTofu owns API-created dashboard objects. -- Sync provisioning profiles from App Store Connect in CI using the decrypted API key: - - iOS App Store profiles for `net.burrow.app` and `net.burrow.app.network`. - - macOS Developer ID profiles for the same bundle identifiers. - Provisioning profile `.age` files are not part of the normal release path. -- Add a non-exportable Google KMS RSA-2048/SHA-256 key named - `apple-developer-id-application` in the Burrow identity key ring. The manual - `apple-developer-id-kms-csr.yml` workflow uses Authentik-backed Google WIF to - generate a standard Developer ID Application CSR without exporting private key - material. -- Treat Developer ID certificate issuance as a Developer website or Xcode step - until Apple documents App Store Connect API creation for Developer ID - certificates. The returned `.cer` is public release material; the private key - remains in Google KMS and must not be converted into `.p12` form. -- The first KMS-backed G2 Developer ID Application certificate is - `9JKN6HXBHC`, stored at - `Apple/Certificates/developer-id-application-9JKN6HXBHC.cer`, and expires on - `2031-06-08`. -- Add a non-exportable Google KMS RSA-2048/SHA-256 key named - `apple-ios-distribution` in the Burrow identity key ring. The KMS-backed CSR - can be submitted through the App Store Connect API with - `Scripts/apple/create-asc-certificate.mjs`. -- The first KMS-backed iOS Distribution certificate is `3G42677598`, stored at - `Apple/Certificates/ios-distribution-3G42677598.cer`, and expires on - `2027-06-07`. -- When `BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID` is set, provisioning-profile - sync must verify App Store profiles reference that certificate. Stale profiles - with the same name may be deleted and recreated so CI does not silently sign - against an older distribution identity. -- Apple release signing uses a staged KMS flow because Google publishes the - Cloud KMS PKCS#11 library for Linux release runners, not macOS runners: - - macOS Namespace builders compile unsigned iOS device and macOS app bundles - with the existing Xcode project. - - Linux release runners download those staged artifacts, authenticate to - Google through Authentik WIF, verify the Apple `.cer` public key matches the - intended Google KMS key version, embed the synced provisioning profiles, and - sign the app/extension bundles with the patched `rcodesign` PKCS#11 path. - - The signed iOS IPA is uploaded to App Store Connect, and the signed macOS - ZIP is used as the Sparkle tester artifact. -- Add a Google KMS key named `sparkle-ed25519` for small Sparkle signing probes - and future signing-service work. It uses `EC_SIGN_ED25519` with software - protection level because Google KMS rejects Ed25519 for HSM protection. Google - KMS also rejects normal macOS release archives as direct Ed25519 messages, so - the tester pipeline signs full-size Sparkle archives directly from the - decrypted `SPARKLE_EDDSA_KEY_PATH` release seed until a compatible KMS-backed - Sparkle signing service is introduced. macOS builds embed public EdDSA key - `uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=`. -- Use Namespace labels for platform lanes: - - `namespace-profile-linux-medium` - - `namespace-profile-macos-large` - - `namespace-profile-windows-large` -- Use dedicated Apple distribution labels for the focused tester-distribution - workflow so stale generic Namespace jobs cannot consume Apple release runners: - - `namespace-profile-linux-medium-apple-v2` - - `namespace-profile-macos-large-apple-v2` - Label revisions are allowed when earlier accepted workflows have stale - queued jobs; the autoscaler should serve only the current focused label set. -- Configure macOS Namespace targets with the Compute API CPU-by-memory shape - format. `12x28` is the Burrow large macOS baseline; platform-prefixed values - such as `macos/arm64:8x16` are not accepted by the macOS launcher. -- Treat macOS shape fallback as part of the release lane rather than as a - best-effort debug path. When large shapes are quota-constrained, the - dispatcher may try smaller accepted shapes, but every fallback must have a - bounded create timeout and additional shapes must reuse the final timeout - policy instead of crashing the dispatcher. -- Keep the shared Linux runner work directory out of macOS bootstraps. The - dispatcher normalizes macOS runners to `/tmp/forgejo-runner` when the shared - config points at Linux state paths such as `/var/lib/forgejo-runner`. -- Do not destroy Namespace Linux runners until `nsc describe` shows a real - terminal container state or tombstone. Empty describe payloads mean the - environment is still starting, not that the one-job runner has finished. -- Keep the autoscaler-to-dispatcher HTTP timeout longer than the runner TTL. - Dispatch requests intentionally block until the one-job runner exits, so short - HTTP client timeouts cancel macOS provisioning mid-bootstrap. -- Run forge Namespace services with the repo-owned `nsc` package and keep it - current with the token format expected by Namespace. `nsc 0.0.520` is the - first required baseline for revokable token-file auth. The dispatcher and - autoscaler systemd units must also use the repo-built packages so runner fixes - ship with Burrow deploys instead of staying pinned to the module input. -- Render the sealed Namespace runner token with `nsc token create --token_file` - instead of `nsc auth generate-dev-token`. The dev-token output is for direct - API/registry access, while the dispatcher needs the JSON token-file format - consumed by `nsc run`. -- Add a Rust `burrow-windows-stub` binary so the Windows lane can validate packaging and upload mechanics before native Windows networking support is implemented. -- Treat Sparkle as a macOS public-channel upload lane: - - Generate staged appcast content from the signed macOS artifact when present. - - Publish `sparkle//`, `sparkle/appcast.xml`, and `sparkle/default/` pointers from `publish-store-uploads`. - - The macOS app embeds Sparkle and consumes the `https://releases.burrow.net/sparkle/appcast.xml` feed. -- Treat TestFlight distribution as a retryable post-upload step. Apple build - processing can exceed 30 minutes after a successful IPA upload, so - `apple-distribute-testers` and `publish-store-uploads` wait up to 7200 - seconds by default and allow Forgejo variable - `TESTFLIGHT_WAIT_PROCESSING_TIMEOUT_SECONDS` to tune that ceiling without a - repo change. TestFlight wait jobs must use `namespace-profile-linux-testflight` - or `namespace-profile-macos-large-testflight`; the generic Linux and Apple - macOS profiles expire before the 7200-second processing window. The - autoscaler-to-dispatcher timeout must stay above those runner TTLs because - dispatch requests block until the one-job runner exits. `publish-store-uploads` - has a separate no-`needs` `distribute-ios-testflight` retry job for - `distribute_testflight=true` and `upload_app_store=false`, so skipped - upload/signing jobs cannot suppress distribution of an already-uploaded build. - `distribute-testflight` is the narrower one-job retry workflow when only App - Store Connect processing and tester assignment need to be retried. -- Internal TestFlight distribution must not submit the build for external beta - review. The internal lane waits for processing, sets export-compliance - metadata, uses Fastlane `skip_submission:true`, and relies on App Store - Connect's configured internal tester group availability. External TestFlight - distribution requires explicit groups plus beta-review metadata before it can - be enabled. -- Add `infra/releases` as the OpenTofu boundary for the GCS release and package repository backup buckets. Garage is the required S3-compatible primary distribution target after host bootstrap. `infra/identity` remains the boundary for Google KMS signing keys, the Authentik WIF pool/provider, and the runner service account. -- Serve `releases.burrow.net` and `packages.burrow.net` from the forge Caddy - edge as stable Burrow domains backed by the GCS release and package buckets. - Appcast enclosure URLs and package repository metadata must use those Burrow - domains instead of provider-specific object-storage URLs. - -## Security and Operational Considerations - -- Store credentials are consumed only by signed build or upload jobs, not by ordinary build/test jobs. -- GCS backup and Google KMS access must use Authentik-backed Workload Identity Federation against project `project-88c23ce9-918a-470a-b33`; workflows must not use Google service-account JSON keys. -- Garage uploads use S3-compatible access keys scoped to release/package buckets. They are required by default; GCS is a backup mirror, not the success fallback. -- Garage backup uses a separate read-only key spanning the release, package, and Attic buckets. That key may be sealed into Forgejo/OpenBao for backup jobs, but it must not be reused for release/package writes. -- Attic cache publishing uses a scoped CI push token for the `burrow` cache instead of the bootstrap admin token. -- rclone is not part of the release path. Multi-cloud failover should be added through provider-specific or S3-compatible wrappers with explicit credentials and evidence. -- Required upload credentials are checked explicitly after `./.forgejo/actions/decrypt-release-secrets` materializes them from age: - - `secrets/apple/appstore-connect.env.age`: `ASC_API_KEY_ID`, `ASC_API_ISSUER_ID`, and base64-encoded `.p8` key material. - - KMS-backed Apple signing must not use `.p12` private-key material. The - legacy `secrets/apple/distribution-signing.env.age` path remains accepted - only for emergency keychain signing and should not be used for the normal - tester release lane. - - Sparkle appcasts are signed with Google KMS by default; the legacy - `secrets/apple/sparkle.env.age` path remains accepted only for emergency - local EdDSA signing. - - future Microsoft Partner Center credentials -- The temporary CI keychain password is always the distribution certificate password; there is no separate keychain-password release secret. -- KMS-backed Apple certificates do not satisfy the existing Xcode/keychain - import path. Shipping with these certificates requires a signer that can - delegate Apple code-signing operations to Google KMS. -- iOS KMS signing must filter provisioning-profile entitlements through the - checked-in iOS entitlement templates before signing. App Store Connect - validates the bundle signature, not just the profile, and rejects profile-only - capabilities such as system extensions or unsupported NetworkExtension values - when they leak into an iOS app or extension signature. -- The App target must keep `Assets.xcassets` in its Resources phase. Xcode - derives the `CFBundleIcons` metadata and standalone App Store icon PNGs from - that resource membership during iOS builds. -- App Store Connect upload steps must fail on `altool` validation output as - well as non-zero process exits. Apple can emit a textual upload failure before - the workflow reaches TestFlight processing, and downstream tester waits must - not run unless the IPA upload was actually accepted. -- If the first key ring or Developer ID key is bootstrapped before the - OpenTofu remote state backend is available locally, the live resources must be - imported into `infra/identity` before enabling managed identity applies. -- Dedicated store accounts should be sealed with agenix before enabling always-on uploads. The runner only needs an age identity such as `/var/lib/forgejo-runner-agent/age_keystore`; `AGE_FORGE_SSH_KEY` is a fallback bootstrap secret, not a place for release material. -- The canonical identity registry reserves `release@burrow.net` for release automation and `namespace@burrow.net` for Namespace runner capacity credentials; secrets stay outside `contributors.nix`. -- Grafana starts with a sealed admin password and a pending OIDC client secret. Local admin login remains available until the real OIDC secret is rotated and the Authentik reconciliation service creates the Grafana provider. -- OpenTofu apply/import modes require an explicit confirmation and remote state. Validation may run without remote state. -- Build tags are pushed only after artifacts have been assembled for the build number. -- App Store Connect upload and internal/private tester lanes may default on only - after dedicated credentials and export metadata are verified. This default - applies to `release-if-needed`, `build-release`, `publish-store-uploads`, and - `apple-distribute-testers`. External TestFlight distribution and public - app-store rollout must stay explicit. - -## Contributor Playbook - -1. Check whether a release is needed: - ```bash - Scripts/version.sh status - ``` -2. Build local artifacts: - ```bash - BUILD_NUMBER=local Scripts/ci/build-release-artifacts.sh all - ``` -3. Build Apple release validation artifacts on macOS: - ```bash - BUILD_NUMBER=local bazel build //bazel/apple:release_ios_stamp //bazel/apple:release_macos_stamp - ``` -4. Verify the Windows stub still builds: - ```bash - cargo test --locked -p burrow-windows-stub - ``` -5. Validate BEP metadata: - ```bash - python3 Scripts/check-bep-metadata.py - ``` -6. Before turning on an upload lane, seal the dedicated service-account secret and run: - ```bash - Scripts/seal-release-secrets.sh --source-dir intake/release - Scripts/ci/check-release-config.sh app-store - Scripts/ci/check-release-config.sh apple-signing - Scripts/ci/check-release-config.sh gcs - ``` -7. Treat successful Forgejo release asset publication and successful store upload jobs as separate evidence points. -8. Validate Grafana OpenTofu syntax locally: - ```bash - Scripts/grafana-tofu.sh init -backend=false - Scripts/grafana-tofu.sh validate - ``` -9. Validate release storage OpenTofu syntax locally: - ```bash - Scripts/releases-tofu.sh init -backend=false - Scripts/releases-tofu.sh validate - ``` -10. Validate release storage wrapper syntax: - ```bash - bash -n Scripts/ci/upload-release-storage.sh Scripts/ci/upload-package-storage.sh Scripts/ci/backup-garage-to-gcs.sh Scripts/ci/publish-nix-cache.sh - ``` -11. Dispatch a Forgejo workflow without GitHub: - ```bash - Scripts/forgejo-dispatch.sh build-release.yml --ref main - ``` -12. After deploying the forge host, seal: - ```text - BURROW_NIX_CACHE_PUSH_TOKEN from /var/lib/burrow/nix-cache/ci-push-token - BURROW_GARAGE_BACKUP_ACCESS_KEY_ID from /var/lib/burrow/garage/env - BURROW_GARAGE_BACKUP_SECRET_ACCESS_KEY from /var/lib/burrow/garage/env - ``` - -## Alternatives Considered - -- Keep one monolithic release workflow. Rejected because build failures, upload failures, and credential gaps become hard to distinguish. -- Wait for native Windows support before adding a Windows lane. Rejected because packaging and Microsoft Store authentication can be validated with a Rust stub first. -- Add Sparkle to the Apple app in the same change. Rejected because Sparkle changes the client update trust boundary and should be reviewed separately from release plumbing. - -## Impact on Other Work - -- Future Apple release work can fill in signed archive/export details without changing release numbering. -- Future Windows work can replace the stub package with the native client while preserving upload shape. -- Identity and credential follow-up should derive long-lived automation accounts from `contributors.nix` where they represent Burrow-operated identities. - -## Decision - -Pending. - -## References - -- `CONSTITUTION.md` -- `contributors.nix` -- `.forgejo/workflows/release-if-needed.yml` -- `.forgejo/workflows/build-release.yml` -- `.forgejo/workflows/publish-store-uploads.yml` -- `Scripts/version.sh` -- `Scripts/ci/nscloud-cache.sh` -- `Scripts/ci/ensure-nix.sh` -- `Scripts/ci/package-apple-artifacts.sh` -- `Scripts/ci/upload-release-storage.sh` -- `Scripts/ci/upload-package-storage.sh` -- `Scripts/ci/publish-nix-cache.sh` -- `Scripts/ci/backup-garage-to-gcs.sh` -- `bazel/apple/BUILD.bazel` -- `Scripts/forgejo-dispatch.sh` -- `Scripts/forgejo-dispatch-via-host.sh` -- `Scripts/grafana-tofu.sh` -- `Scripts/releases-tofu.sh` -- `.forgejo/workflows/infra-opentofu.yml` -- `.forgejo/workflows/publish-nix-cache.yml` -- `.forgejo/workflows/backup-garage-storage.yml` -- `infra/grafana/` -- `infra/releases/` -- `nixos/modules/burrow-garage.nix` -- `nixos/modules/burrow-observability.nix` -- `services/grafana/dashboards/burrow-overview.json` -- `services/grafana/dashboards/headscale.json` -- `services/grafana/dashboards/observability.json` diff --git a/evolution/proposals/BEP-0010-platform-services-core-and-distribution.md b/evolution/proposals/BEP-0010-platform-services-core-and-distribution.md deleted file mode 100644 index 211554a..0000000 --- a/evolution/proposals/BEP-0010-platform-services-core-and-distribution.md +++ /dev/null @@ -1,332 +0,0 @@ -# `BEP-0010` - Platform Services, Core, and Distribution - -```text -Status: Draft -Proposal: BEP-0010 -Authors: gpt-5.5 -Coordinator: gpt-5.5 -Reviewers: Pending -Constitution Sections: II, III, IV, V -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow should grow from a forge-hosted VPN project into a coherent platform: a -KMS-backed identity and signing plane, OpenBao-backed secret distribution, -private meeting and mail services, an extracted MCP hub for operator and agent -workflows, and app builds for Apple, Linux, Android, and later store channels. - -The first implementation is deliberately scaffolding-heavy. It adds OpenTofu -boundaries for Google KMS, Workload Identity Federation, GCS release backup -storage, and OpenBao; enables Garage-backed object storage and a Burrow-owned -Nix cache at `nix.burrow.net`; adds -NixOS wiring for Grafana, Prometheus, OpenTelemetry, and Jaeger; adds -disabled-by-default NixOS service switch points for `vault.burrow.net` and -`meet.burrow.net`; introduces a small cross-platform Rust core and Android -Kotlin stub; and records the distribution model before stores or public -production services are enabled. - -## Motivation - -- The forge now controls source and release automation, but release signing, - SAML signing, and runner cloud access still need a stronger non-exportable key - story. -- Secrets are still primarily age-managed files. That remains a good bootstrap - path, but long-running services and agents need short-lived, attributable - runtime access. -- Burrow needs mobile and desktop clients that share protocol logic instead of - repeatedly embedding platform-local behavior. -- The GTK Flatpak can be a useful desktop shell, but the actual VPN data plane - cannot be assumed to work inside an unprivileged Flatpak sandbox. -- App store, F-Droid, Flatpak, and Play Store distribution should be designed as - separate release surfaces with explicit signing, policy, and metadata gates. - -## Detailed Design - -- Add `infra/identity` as the OpenTofu boundary for Google Cloud identity keys - and Authentik-backed Workload Identity Federation: - - Google project id: `project-88c23ce9-918a-470a-b33`. - - Google project number: `416198671487`. - - KMS key ring: `burrow-identity`. - - non-exportable signing keys for Authentik signing, SAML CA signing, and - release signing. - - a Forgejo runner service account that can be assumed only through the - configured Authentik WIF provider and IAM bindings. Machine-to-machine - runner tokens are scoped by the Authentik WIF client ID - `google-wif.burrow.net`, with `burrow-automation` kept as the human/group - policy boundary. -- Add `infra/releases` as the OpenTofu boundary for Google Cloud - Storage-backed release backups: - - `burrow-net-releases` backs up build artifacts and Sparkle public channels. - - `burrow-net-packages` backs up signed APT, RPM, pacman, Flatpak, and Arch - repository trees. - - `burrow-net-nix-cache` backs up the Garage `attic` bucket and remains - private by default. - - Garage is the required S3-compatible primary target for release and package - uploads. - - `services.burrow.garage` is enabled by the forge host and bootstraps the - single-node layout, the `attic`, `burrow-releases`, and `burrow-packages` - buckets, scoped owner/write S3 access keys, and a read-only backup key from - a host-local environment file. - - Forgejo jobs authenticate with Authentik-backed WIF before using `gcloud - storage` for backup and Google KMS for signing; rclone and Google - service-account JSON keys are not part of the release path. -- Add `services.burrow.nixCache` for `nix.burrow.net`: - - Attic serves `https://nix.burrow.net/burrow` as the public Burrow cache. - - Attic stores chunks in the Garage `attic` bucket through the local S3 API. - - Workers use the cache only after `BURROW_NIX_CACHE_PUBLIC_KEY` is published - in Forgejo variables. - - Push access uses `/var/lib/burrow/nix-cache/ci-push-token`, sealed into - Forgejo/OpenBao as `BURROW_NIX_CACHE_PUSH_TOKEN`, instead of the host-local - bootstrap admin token. - - `.forgejo/workflows/publish-nix-cache.yml` builds selected flake outputs and - publishes their closures to Attic. -- Add `services.burrow.observability` as the forge host observability spine: - - Prometheus scrapes local system, Grafana, Headscale, OpenTelemetry - collector, and Jaeger metrics. - - Headscale exposes local metrics on `127.0.0.1:9098`. - - Tailscale SaaS metrics can be enabled later through the Prometheus - Tailscale exporter once the OAuth environment file is provisioned. - - Burrow backend/frontend processes emit OTLP to the local collector at - `127.0.0.1:4317` or `127.0.0.1:4318`. - - The collector exports traces to local Jaeger, while Grafana queries Jaeger - server-side through a provisioned datasource. -- Add `infra/openbao` as the OpenTofu boundary for OpenBao: - - Google KMS seal wrapping. - - an `age/` KV v2 mount for migrated runtime secrets. - - Authentik OIDC roles for administrators and automation. - - AppRole policy scaffolding for machine consumers. -- Add NixOS modules under `services.burrow.openbao` and - `services.burrow.jitsi`: - - both are imported by the forge host but disabled by default. - - DNS, reverse proxy, KMS seal, storage, and UDP/video bridge requirements are - visible in repo-owned options before activation. - - live enablement requires a separate deploy with secrets, DNS, and rollback - evidence. -- Keep webmail as a second mail phase: - - `inbox.burrow.net` is the intended webmail surface. - - Stalwart is the planned mail server, but the current Nixpkgs module set in - this flake does not expose the expected `services.stalwart-mail` option. - - Forward Email remains the current production mail path until a Stalwart BEP - revision names the exact NixOS module, ports, spam policy, backup target, - DKIM rotation, and migration plan. -- Extract an MCP hub to a dedicated compatible.systems repository: - - target repository: `compatible.systems/burrow/mcp-hub`. - - this repository keeps `services/mcp-hub/` as the extraction plan and - bootstrap manifest until the external repo exists. - - MCP tools should cover OpenBao, Authentik, KMS/WIF, Forgejo runners, Jitsi, - mail, release signing, and Burrow agent identity. -- Create a `burrow-core` Rust crate as the cross-platform protocol/application - core boundary: - - no direct TUN, NetworkExtension, VpnService, GTK, SwiftUI, or desktop UI - dependencies. - - platform shells link it through a thin FFI crate or native Rust API. - - target platforms include Linux x86_64/aarch64/riscv64, Android, macOS, iOS, - visionOS, and future Windows. -- Add an Android stub: - - Kotlin app shell in `Android/app`. - - Rust JNI/FFI crate in `crates/burrow-mobile-ffi`. - - the stub loads the Rust core and displays its version. - - full VPN support must use Android `VpnService`; it must not bypass platform - disclosure and consent flows. -- Keep Bazel as the stable build orchestration entrypoint: - - platform lanes expose wrapper targets under `bazel/`. - - Bazel actions may call repo-owned scripts, Cargo, Gradle, Xcode, Meson, and - OpenTofu while the project migrates deeper native rules selectively. - - Namespace cache setup remains the fast path for remote Bazel and local disk - caches. -- Use a shared Burrow owl identity across app shells: - - the mark uses a grounded Burrowing Owl silhouette: compact face, warm brown - body, small cream markings, and long pale legs visible in front of the - burrow rim. - - `design/brand/Burrow.icon` is the Icon Composer source and is intentionally - kept to three broad raster layers: burrow ground, owl figure, and front - legs. - - `design/brand/burrow-owl-icon-master.png` is the generated raster master for - Apple app icon outputs. - - Apple asset catalogs derive all `AppIcon` PNG sizes from that master and - use a restrained warm brown/gold accent color. - - GTK keeps a simple vector owl/burrow icon and symbolic icon so Linux - desktops do not inherit the old blue gear-style mark. - - Brown/black stays the core palette, with lighter brown, orange, or gold used - only as accents for eyes, beak, highlights, and platform tint. -- For Linux distribution, split the GUI package from the privileged daemon: - - DEB, RPM, pacman, AUR, and the NixOS flake/module install `burrowd`, - systemd units, D-Bus policy, and polkit policy. - - APT, RPM, and pacman repository metadata is generated from release package - artifacts and signed through Google KMS-backed OpenPGP signatures. - - Repository trees publish through the storage wrapper: Garage first when - configured, then GCS backup after the runner obtains a short-lived Google - credential from Authentik-backed WIF. - - AUR is a source-build git repository, with `burrow-git` tracked in this - repo before publication to `aur.archlinux.org`. - - Flatpak and AppImage packages are GUI-first shells that connect to the host - daemon/helper. - - Flatpak may request background/autostart permission for the GUI, but it - must not be responsible for the privileged VPN data plane. - - PackageKit may be used only as an optional native-package bootstrap on - supported distros, with narrow D-Bus permission and polkit authorization. - -## Security and Operational Considerations - -- Google KMS keys are non-exportable and have `prevent_destroy` enabled. -- Workload Identity Federation is scoped by issuer, audience, mapped - attributes, and service-account IAM bindings. Runners should not receive - static Google credentials. -- OpenBao bootstrap tokens and AppRole SecretIDs must never enter OpenTofu - state. OpenTofu manages mounts, policies, roles, and cloud KMS objects only. -- OpenBao should start behind `vault.burrow.net` only after: - - KMS seal key exists and decrypt permission is limited to the service - identity. - - root/admin token bootstrap is recorded out of band. - - backup/restore and seal/unseal drills are documented. -- Jitsi should start behind `meet.burrow.net` only after: - - HTTPS and XMPP domains resolve. - - UDP media ports are intentionally opened. - - Authentik guest/member policy is decided. -- Flatpak cannot be the privileged VPN backend by itself. A Flatpak GUI may talk - to a host daemon or privileged helper, but opening TUN devices, installing - routes, and owning system DNS/routing policy belong outside the sandbox. -- Host service bootstrap should be auditable. A Flatpak may discover the daemon, - open native install instructions, or request a host helper through a narrow - D-Bus name. It must not require broad system-bus access, host filesystem - access, or `flatpak-spawn --host` as the normal production path. -- Mobile VPN support uses platform VPN APIs: - - Android: `VpnService` with Play policy disclosure for Play Store builds. - - iOS and visionOS: NetworkExtension packet tunnel provider. -- Release signing, SAML CA signing, and Authentik signing should remain separate - keys even when they share a KMS key ring. -- Package repository signing should stay split by repository format. APT, RPM, - pacman, Flatpak, and AUR source surfaces each get a dedicated KMS key so a - compromised publisher path does not automatically sign every Linux channel. -- Observability ingress is local-first. Prometheus, the OpenTelemetry collector, - and Jaeger bind to loopback; Grafana is the authenticated public surface. -- Jaeger should not be exposed publicly until an Authentik-protected route or - equivalent access boundary is added. -- GCS is a backup target, not the storage abstraction. Future multi-cloud - failover should add explicit provider mirrors or S3-compatible endpoints - behind the storage wrappers. -- Garage needs real host storage for object data and metadata. It replicates - across Garage nodes, but it does not manage multiple external storage - backends. GCS and later providers should be explicit mirror/backup jobs. -- `.forgejo/workflows/backup-garage-storage.yml` is the first explicit mirror. - It uses a read-only Garage key and Authentik-backed WIF to copy Garage bucket - contents to GCS without rclone or static Google service-account keys. - -## Contributor Playbook - -1. Validate BEP metadata: - ```bash - python3 Scripts/check-bep-metadata.py - ``` -2. Check the Android/Rust stub: - ```bash - cargo check --locked -p burrow-mobile-ffi - bazel build //bazel/android:check_stub_stamp - ``` -3. Validate identity OpenTofu syntax: - ```bash - Scripts/identity-tofu.sh init -backend=false - Scripts/identity-tofu.sh validate - ``` -4. Validate release storage OpenTofu syntax: - ```bash - Scripts/releases-tofu.sh init -backend=false - Scripts/releases-tofu.sh validate - ``` -5. Validate observability Nix wiring and dashboard JSON: - ```bash - nix eval .#nixosConfigurations.burrow-forge.config.services.prometheus.scrapeConfigs - jq empty services/grafana/dashboards/headscale.json services/grafana/dashboards/observability.json - ``` -6. Validate the Nix cache module wiring: - ```bash - nix eval .#nixosConfigurations.burrow-forge.config.services.atticd.settings.storage --json - ``` -7. Validate cache publishing and Garage backup wrapper syntax: - ```bash - bash -n Scripts/ci/publish-nix-cache.sh Scripts/ci/backup-garage-to-gcs.sh - ``` -8. Validate OpenBao OpenTofu syntax: - ```bash - Scripts/openbao-tofu.sh init -backend=false - Scripts/openbao-tofu.sh validate - ``` -9. Before enabling `vault.burrow.net`, create or import the Google KMS seal key, - configure OpenBao bootstrap access, and record a restore test. -10. Before enabling `meet.burrow.net`, verify DNS, TLS, UDP reachability, and - Authentik access policy. -11. Before distributing Flatpak as more than a GUI, prove the host daemon/helper - path on a real Linux desktop. -12. Before Play Store distribution, complete the `VpnService` declaration and - data-safety review. -13. Before relying on PackageKit bootstrap, test Fedora, Debian/Ubuntu, and an - immutable/atomic desktop separately. PackageKit support is not universal and - should degrade to native install instructions. -14. Before promoting package repositories to stable, run the repository - publisher, import the generated public key on a clean VM for each package - family, and install Burrow from that repository rather than from the loose - artifact. - -## Alternatives Considered - -- Enable OpenBao and Jitsi immediately on the forge host. Rejected because both - change public ingress and security boundaries before secrets, DNS, and - rollback evidence are complete. -- Store Google service-account JSON in Forgejo secrets. Rejected because WIF - gives the runner an attributable short-lived credential path without static - cloud keys. -- Put Android directly on the existing daemon crate. Rejected for now because - the daemon carries OS-specific TUN and service assumptions. A small - cross-platform core gives mobile a safer first boundary. -- Treat Flatpak as the complete Linux VPN package. Rejected because routing, - TUN, and DNS changes need host authority. -- Use broad Flatpak host escape permissions to fork the daemon directly. - Rejected because that defeats the value of Flatpak and makes review harder - than shipping a small native daemon package with explicit polkit policy. -- Use one package signing key for every Linux repository. Rejected because - format-specific keys give cleaner revocation and narrower IAM grants. - -## Impact on Other Work - -- BEP-0009 release work gains identity and app-distribution follow-up lanes. -- BEP-0005 and BEP-0006 remain authoritative for daemon/client boundaries. -- BEP-0004 remains authoritative for current hosted mail until a Stalwart - revision supersedes it. -- MCP extraction should not move forge runner code until the external - compatible.systems repository exists and import paths are stable. - -## Decision - -Pending. - -## References - -- `CONSTITUTION.md` -- `contributors.nix` -- `evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md` -- `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md` -- `evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md` -- `evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md` -- `infra/identity/` -- `infra/releases/` -- `infra/openbao/` -- `docs/OBSERVABILITY.md` -- `docs/NIX_CACHE.md` -- `.forgejo/workflows/publish-nix-cache.yml` -- `.forgejo/workflows/backup-garage-storage.yml` -- `Scripts/ci/publish-nix-cache.sh` -- `Scripts/ci/backup-garage-to-gcs.sh` -- `package/repositories/` -- `nixos/modules/burrow-garage.nix` -- `nixos/modules/burrow-nix-cache.nix` -- `nixos/modules/burrow-observability.nix` -- `nixos/modules/burrow-openbao.nix` -- `nixos/modules/burrow-jitsi.nix` -- `crates/burrow-core/` -- `crates/burrow-mobile-ffi/` -- `Android/` -- `bazel/android/` -- `services/mcp-hub/` diff --git a/evolution/proposals/BEP-0011-panel-icon-family-and-platform-assets.md b/evolution/proposals/BEP-0011-panel-icon-family-and-platform-assets.md deleted file mode 100644 index a8ed094..0000000 --- a/evolution/proposals/BEP-0011-panel-icon-family-and-platform-assets.md +++ /dev/null @@ -1,87 +0,0 @@ -# `BEP-0011` - Panel Icon Family and Platform Assets - -```text -Status: Implemented -Proposal: BEP-0011 -Authors: gpt-5.4 -Coordinator: gpt-5.4 -Reviewers: Pending -Constitution Sections: II, V -Implementation PRs: Pending -Decision Date: 2026-06-07 -``` - -## Summary - -Burrow uses a locked six-panel burrowing owl icon family as the product icon -language. The supplied panel sheet and its six cropped PNG masters are the -canonical source; platform-specific PNGs are generated from those rasters. - -## Motivation - -- Burrow needs one recognizable icon language across iOS, macOS, watchOS, - Android, and Linux packaging. -- The iOS app should expose the six panel variants as local alternate app icons. -- Platform packages need exact launcher dimensions without hand-edited raster - drift. - -## Detailed Design - -- `Scripts/generate-brand-icons.py` owns the raster crop and platform exports. -- `design/brand/panel-variants/source-sheet.png` stores the locked panel sheet. -- The generator writes six 1024px PNG masters under - `design/brand/panel-variants/` when run with `--extract-from`. -- iOS uses `panel-01-guardian` as the primary icon and exposes the other five - panel variants through native alternate app icon asset sets. -- macOS uses `panel-05-stride` as the primary app icon. -- watchOS uses `panel-03-burrow` as the primary app icon; exact watch icon - sizes live in `design/brand/watchos/AppIcon.appiconset` until a watch target - owns them directly. -- Android launcher mipmaps and Linux hicolor PNGs are generated from the same - raster master as iOS primary. -- The Apple UI calls only `UIApplication.setAlternateIconName`; no daemon, - control-plane, or network path is added for icon selection. - -## Security and Operational Considerations - -- Icon selection is local presentation state and does not touch auth, routing, - daemon IPC, or account material. -- Generated rasters are deterministic from checked-in raster masters. -- The rollback path is to revert the generator and regenerate assets. - -## Contributor Playbook - -- Keep icon geometry in the raster panel masters; do not redraw it as SVG. -- Edit `Scripts/generate-brand-icons.py` only for crop boxes, package sizes, or - platform picks. -- Run `Scripts/generate-brand-icons.py`. -- Review `design/brand/panel-variants/preview-strip.png`. -- Review `design/brand/low-res/preview-strip.png` and - `design/brand/low-res/pixel-preview-strip.png`. -- Run `Scripts/check-brand-icon-lowres.sh`. -- Build the Apple app and Android app after asset changes. - -## Alternatives Considered - -- Keep a single app icon. Rejected because the six panel variants are all valid - product directions and iOS has a native alternate icon API. -- Redraw the sheet as SVG. Rejected because the supplied raster panel already - carries the desired style, texture, and proportions. -- Let the daemon choose app icons. Rejected because icon selection is local - presentation behavior and should not cross the daemon boundary. - -## Impact on Other Work - -- Store metadata and packaging jobs should use generated platform assets. -- Future design changes should preserve the raster panel source and generated - package outputs. - -## Decision - -Implemented on 2026-06-07. - -## References - -- `Scripts/generate-brand-icons.py` -- `design/brand/panel-variants/` -- `Apple/UI/BurrowView.swift` diff --git a/flake.lock b/flake.lock index e2ce1a1..6f7f20c 100644 --- a/flake.lock +++ b/flake.lock @@ -12,15 +12,12 @@ "locked": { "lastModified": 1770165109, "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", - "owner": "ryantm", - "repo": "agenix", - "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", - "type": "github" + "type": "tarball", + "url": "https://codeload.github.com/ryantm/agenix/tar.gz/main" }, "original": { - "owner": "ryantm", - "repo": "agenix", - "type": "github" + "type": "tarball", + "url": "https://codeload.github.com/ryantm/agenix/tar.gz/main" } }, "darwin": { @@ -52,8 +49,8 @@ ] }, "locked": { - "lastModified": 1780290312, - "narHash": "sha256-eTAlX0CwgB84Ts3GaBd944A3DRXVMzgA0EqroZBISUo=", + "lastModified": 1773506317, + "narHash": "sha256-qWKbLUJpavIpvOdX1fhHYm0WGerytFHRoh9lVck6Bh0=", "type": "tarball", "url": "https://codeload.github.com/nix-community/disko/tar.gz/master" }, @@ -113,41 +110,14 @@ }, "nixpkgs": { "locked": { - "lastModified": 1780243769, - "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", - "type": "github" + "lastModified": 1773389992, + "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "type": "tarball", + "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" }, "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nsc-autoscaler": { - "inputs": { - "flake-utils": [ - "flake-utils" - ], - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1775221037, - "narHash": "sha256-tv6Y3cqn76PEyZpSMMItVW96KKIboovBWTOv5Lt7PXg=", - "ref": "refs/heads/main", - "rev": "2c485752fde28ec3be2f228b571d1906f4bcf917", - "revCount": 10, - "type": "git", - "url": "https://compatible.systems/conrad/nsc-autoscaler.git" - }, - "original": { - "type": "git", - "url": "https://compatible.systems/conrad/nsc-autoscaler.git" + "type": "tarball", + "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" } }, "root": { @@ -156,8 +126,7 @@ "disko": "disko", "flake-utils": "flake-utils", "hcloud-upload-image-src": "hcloud-upload-image-src", - "nixpkgs": "nixpkgs", - "nsc-autoscaler": "nsc-autoscaler" + "nixpkgs": "nixpkgs" } }, "systems": { diff --git a/flake.nix b/flake.nix index bdaa590..ed59619 100644 --- a/flake.nix +++ b/flake.nix @@ -2,28 +2,23 @@ description = "Burrow development shell and forge host configuration"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + nixpkgs.url = "tarball+https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable"; flake-utils.url = "tarball+https://codeload.github.com/numtide/flake-utils/tar.gz/main"; agenix = { - url = "github:ryantm/agenix"; + url = "tarball+https://codeload.github.com/ryantm/agenix/tar.gz/main"; inputs.nixpkgs.follows = "nixpkgs"; }; disko = { url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master"; inputs.nixpkgs.follows = "nixpkgs"; }; - nsc-autoscaler = { - url = "git+https://compatible.systems/conrad/nsc-autoscaler.git"; - inputs.nixpkgs.follows = "nixpkgs"; - inputs.flake-utils.follows = "flake-utils"; - }; hcloud-upload-image-src = { url = "tarball+https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0"; flake = false; }; }; - outputs = { self, nixpkgs, flake-utils, agenix, disko, nsc-autoscaler, hcloud-upload-image-src }: + outputs = { self, nixpkgs, flake-utils, agenix, disko, hcloud-upload-image-src }: let supportedSystems = [ "x86_64-linux" @@ -38,161 +33,28 @@ inherit system; }; lib = pkgs.lib; - bazelPkg = - if lib.hasAttr "bazelisk" pkgs then - pkgs.bazelisk - else if lib.hasAttr "bazel_9" pkgs then - pkgs.bazel_9 - else if lib.hasAttr "bazel" pkgs then - pkgs.bazel - else - pkgs.bazel_7; - buildifierPkg = - if lib.hasAttr "buildifier" pkgs then - pkgs.buildifier - else - pkgs.bazel-buildtools; - nodejsPkg = - if lib.hasAttr "nodejs_22" pkgs then - pkgs.nodejs_22 - else if lib.hasAttr "nodejs_24" pkgs then - pkgs.nodejs_24 - else if lib.hasAttr "nodejs" pkgs then - pkgs.nodejs - else - pkgs.nodejs_20; - python3WithCrypto = pkgs.python3.withPackages (pythonPackages: [ - pythonPackages.cryptography - ]); - optionalPackage = name: lib.optional (lib.hasAttr name pkgs) (lib.getAttr name pkgs); - googleKmsPkcs11Package = - if pkgs.stdenv.hostPlatform.isx86_64 && pkgs.stdenv.isLinux then - let - version = "1.9"; - in - pkgs.stdenv.mkDerivation { - pname = "google-kms-pkcs11"; - inherit version; - src = pkgs.fetchurl { - url = "https://github.com/GoogleCloudPlatform/kms-integrations/releases/download/pkcs11-v${version}/libkmsp11-${version}-linux-amd64.tar.gz"; - sha256 = "sha256-U5f5ZQc81dHixD5RSPEC8xp8g1kC+X60HHycDxuHakA="; - }; - nativeBuildInputs = [ pkgs.autoPatchelfHook ]; - buildInputs = [ pkgs.stdenv.cc.cc.lib ]; - installPhase = '' - runHook preInstall - install -Dm755 libkmsp11.so "$out/lib/libkmsp11.so" - install -Dm644 kmsp11.h "$out/include/kmsp11.h" - install -Dm644 LICENSE "$out/share/licenses/google-kms-pkcs11/LICENSE" - mkdir -p "$out/bin" - cat > "$out/bin/google-kms-pkcs11-module" < dashboard.url } -} - -output "grafana_folder_uids" { - description = "Grafana folder UIDs managed by this stack." - value = { for name, folder in grafana_folder.folders : name => folder.uid } -} diff --git a/infra/grafana/providers.tf b/infra/grafana/providers.tf deleted file mode 100644 index 6dc01f6..0000000 --- a/infra/grafana/providers.tf +++ /dev/null @@ -1,4 +0,0 @@ -provider "grafana" { - url = var.grafana_url - auth = var.grafana_auth -} diff --git a/infra/grafana/terraform.tfvars.example b/infra/grafana/terraform.tfvars.example deleted file mode 100644 index 27dbab5..0000000 --- a/infra/grafana/terraform.tfvars.example +++ /dev/null @@ -1,18 +0,0 @@ -grafana_url = "https://graphs.burrow.net" - -manage_grafana_dashboards = true - -grafana_folders = { - burrow = { - title = "Burrow" - uid = "burrow" - } -} - -grafana_dashboards = { - burrow-overview = { - path = "../../services/grafana/dashboards/burrow-overview.json" - folder = "burrow" - overwrite = true - } -} diff --git a/infra/grafana/variables.tf b/infra/grafana/variables.tf deleted file mode 100644 index 585ad0e..0000000 --- a/infra/grafana/variables.tf +++ /dev/null @@ -1,56 +0,0 @@ -variable "grafana_url" { - description = "Grafana base URL." - type = string - default = "https://graphs.burrow.net" -} - -variable "grafana_auth" { - description = "Grafana API token or basic auth in username:password form. Prefer GRAFANA_AUTH in CI." - type = string - default = null - sensitive = true -} - -variable "manage_grafana_dashboards" { - description = "Manage Grafana folders and dashboards through the Grafana API." - type = bool - default = false -} - -variable "grafana_folders" { - description = "Grafana folders to create before dashboards." - type = map(object({ - title = string - uid = optional(string) - })) - default = { - burrow = { - title = "Burrow" - uid = "burrow" - } - } -} - -variable "grafana_dashboards" { - description = "Grafana dashboards to manage from checked-in JSON." - type = map(object({ - path = string - folder = optional(string) - message = optional(string, "Managed by Burrow OpenTofu") - overwrite = optional(bool, true) - })) - default = { - burrow-overview = { - path = "../../services/grafana/dashboards/burrow-overview.json" - folder = "burrow" - } - burrow-headscale = { - path = "../../services/grafana/dashboards/headscale.json" - folder = "burrow" - } - burrow-observability = { - path = "../../services/grafana/dashboards/observability.json" - folder = "burrow" - } - } -} diff --git a/infra/grafana/versions.tf b/infra/grafana/versions.tf deleted file mode 100644 index 1186143..0000000 --- a/infra/grafana/versions.tf +++ /dev/null @@ -1,12 +0,0 @@ -terraform { - required_version = ">= 1.8.0" - - required_providers { - grafana = { - source = "grafana/grafana" - version = ">= 4.36.0, < 5.0" - } - } - - backend "s3" {} -} diff --git a/infra/identity/.gitignore b/infra/identity/.gitignore deleted file mode 100644 index b12b9ed..0000000 --- a/infra/identity/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.terraform/ -backend.hcl -terraform.tfstate -terraform.tfstate.backup -*.tfplan diff --git a/infra/identity/.terraform.lock.hcl b/infra/identity/.terraform.lock.hcl deleted file mode 100644 index 0a51a6e..0000000 --- a/infra/identity/.terraform.lock.hcl +++ /dev/null @@ -1,20 +0,0 @@ -# This file is maintained automatically by "tofu init". -# Manual edits may be lost in future updates. - -provider "registry.opentofu.org/hashicorp/google" { - version = "6.50.0" - constraints = ">= 5.40.0, < 7.0.0" - hashes = [ - "h1:MAAe4zFFdqS9M5rpmJK/vKgdb6ZMD/s/0Xd97yTDipA=", - "zh:1d4695f807d998f11fcdcfa174766287b82a8093513af857bcdad2d81c642480", - "zh:3173ac5df0294624d113812e49e2a55714aff7db617488168cecdf4168df9e29", - "zh:34d2b3d44c23bd6354fc4ab5917b302872ea1ab8de107034567f955b1717fa5b", - "zh:3a77f3cc2f3664cd5aaeeef4d044e6ec1695a079588fffec3ca03953664e5f04", - "zh:6b444e4b629ea8dc8cb112a39dde098dc5584d26d6de4177558f556a9a226696", - "zh:96545c8cd4d3a57069c5d1799eab5aedd887e16d98b5559a195f6d2c2d9bc674", - "zh:ba464caafde95ee16671d6b5ec90f053ed77a9d06c567456db6efd9160fa3165", - "zh:d876938e5b0d3f57a984d9be72467995f87fef6569968623415dc51d9f54d30b", - "zh:dfd908d873e314ab807d0abc9cfd42d2611cd06dc1b9ec719ebdbb738e8e68d6", - "zh:f9f16819a7738d564afd45fd169ba61004ec4e4e7089d2a4950cb8895be1fe1f", - ] -} diff --git a/infra/identity/README.md b/infra/identity/README.md deleted file mode 100644 index b774578..0000000 --- a/infra/identity/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# Burrow Identity KMS and WIF - -This OpenTofu stack owns Google Cloud KMS and Workload Identity Federation -resources for Burrow identity and release automation. - -It may manage: - -- the `burrow-identity` Google KMS key ring -- non-exportable KMS signing keys for Authentik, the SAML CA, and release - signing -- dedicated non-exportable KMS signing keys for APT, RPM, pacman, Flatpak, and - AUR source release surfaces -- a non-exportable KMS signing key for the Apple Developer ID Application CSR - path (`apple-developer-id-application`) -- a non-exportable KMS signing key for the Apple iOS Distribution CSR path - (`apple-ios-distribution`) -- a non-exportable software KMS signing key for Sparkle Ed25519 appcast - signatures (`sparkle-ed25519`) -- an Authentik-backed WIF pool/provider for Forgejo runners -- the runner service account and its narrow IAM binding for the Authentik WIF - audience/client value `google-wif.burrow.net`; human/group grants are opt-in - -It must not manage: - -- Authentik private signing key material -- OpenBao bootstrap tokens -- store credentials or service-account JSON keys - -Forgejo CI should authenticate with `Scripts/ci/google-wif-auth.sh`. That helper -uses Authentik to obtain an OIDC token and lets Google WIF impersonate -`burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com` -in project `project-88c23ce9-918a-470a-b33`. - -## Apple Developer ID CSR - -`Scripts/apple/google-kms-csr.py` builds a standard PKCS#10 CSR using the public -key from `projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/apple-developer-id-application` -and asks Google KMS to sign the CSR payload. The private key remains -non-exportable in KMS. - -Forgejo can generate the Apple-uploadable CSR with: - -```sh -Scripts/forgejo-dispatch.sh apple-developer-id-kms-csr.yml --ref main -``` - -Apple currently documents Developer ID certificate creation through the -Developer website or Xcode. After downloading the returned `.cer`, keep it with -release artifacts; do not convert this key path into an exportable `.p12`. - -The active certificate created from this path is Apple certificate -`9JKN6HXBHC`, stored at -`Apple/Certificates/developer-id-application-9JKN6HXBHC.cer`. Its public key -matches KMS key version `apple-developer-id-application/cryptoKeyVersions/1`. - -## Apple iOS Distribution CSR - -The iOS App Store distribution certificate uses the same CSR builder with the -`apple-ios-distribution` KMS key: - -```sh -Scripts/apple/google-kms-csr.py \ - --kms-key apple-ios-distribution \ - --common-name "Burrow iOS Distribution" \ - --output ios-distribution.certSigningRequest -``` - -The App Store Connect API can create the certificate from that CSR: - -```sh -Scripts/apple/create-asc-certificate.mjs \ - --certificate-type IOS_DISTRIBUTION \ - --csr-file ios-distribution.certSigningRequest \ - --out-dir Apple/Certificates -``` - -The active certificate created from this path is Apple certificate -`3G42677598`, stored at `Apple/Certificates/ios-distribution-3G42677598.cer`. -Its public key matches KMS key version -`apple-ios-distribution/cryptoKeyVersions/1`. - -## Sparkle Appcast Signing - -Sparkle appcasts keep `sparkle-ed25519`, a Google KMS key with algorithm -`EC_SIGN_ED25519` and software protection level, for small signing probes and -future signing-service work. Google KMS rejects Ed25519 at HSM protection level -and also rejects full-size macOS release archives as direct Ed25519 messages. -The tester pipeline therefore signs normal Sparkle archives directly from the -decrypted `SPARKLE_EDDSA_KEY_PATH` release seed so the appcast contains the -standard full-archive EdDSA signature Sparkle verifies. - -The public EdDSA key embedded in macOS release builds is: - -```text -uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30= -``` - -The appcast signer is: - -```sh -Scripts/sparkle/sign-appcast-kms.py \ - --appcast dist/builds//sparkle//appcast.xml \ - --artifact-dir dist/builds//sparkle/ -``` - -## Run - -```sh -Scripts/identity-tofu.sh init -backend-config=backend.hcl -Scripts/identity-tofu.sh plan -var-file=terraform.tfvars -``` - -For local syntax work without remote state: - -```sh -Scripts/identity-tofu.sh init -backend=false -Scripts/identity-tofu.sh validate -``` - -Start with `manage_google = false`. Import existing resources before enabling -management, then turn on one resource group at a time. - -## Bootstrap Imports - -The first live Apple Developer ID pass may need these resources imported before -an `identity` apply can own them: - -```sh -Scripts/identity-tofu.sh import 'google_kms_key_ring.identity[0]' \ - projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity - -Scripts/identity-tofu.sh import 'google_kms_crypto_key.signing["apple_developer_id_application"]' \ - projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/apple-developer-id-application - -Scripts/identity-tofu.sh import 'google_kms_crypto_key.signing["apple_ios_distribution"]' \ - projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/apple-ios-distribution - -Scripts/identity-tofu.sh import 'google_kms_crypto_key.signing["sparkle_ed25519"]' \ - projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/sparkle-ed25519 - -Scripts/identity-tofu.sh import 'google_service_account.runner[0]' \ - projects/project-88c23ce9-918a-470a-b33/serviceAccounts/burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com -``` - -Run imports with the same `manage_google`, key-ring, and service-account -variables that will be used for apply. Key-level IAM bindings should be imported -after the signing keys and service account exist. diff --git a/infra/identity/backend.hcl.example b/infra/identity/backend.hcl.example deleted file mode 100644 index 60350e3..0000000 --- a/infra/identity/backend.hcl.example +++ /dev/null @@ -1,4 +0,0 @@ -bucket = "burrow-opentofu-state" -key = "identity/identity.tfstate" -region = "us-west-2" -encrypt = true diff --git a/infra/identity/google.tf b/infra/identity/google.tf deleted file mode 100644 index a429401..0000000 --- a/infra/identity/google.tf +++ /dev/null @@ -1,172 +0,0 @@ -locals { - google_required_services = toset([ - "cloudkms.googleapis.com", - "cloudresourcemanager.googleapis.com", - "iam.googleapis.com", - "iamcredentials.googleapis.com", - "sts.googleapis.com", - ]) - - google_existing_kms_key_ring_id = var.google_kms_key_ring_id != "" ? var.google_kms_key_ring_id : "projects/${var.google_project_id}/locations/${var.google_kms_key_ring_location}/keyRings/${var.google_kms_key_ring_name}" - - google_kms_key_ring_id = var.manage_google_kms_key_ring ? ( - google_kms_key_ring.identity[0].id - ) : local.google_existing_kms_key_ring_id - - google_runner_service_account_email = var.manage_google_runner_service_account ? ( - google_service_account.runner[0].email - ) : var.google_runner_service_account_email - - google_runner_wif_group_members = var.google_wif_runner_group != "" ? ( - toset([ - "principalSet://iam.googleapis.com/projects/${var.google_project_number}/locations/global/workloadIdentityPools/${var.google_wif_pool_id}/group/${var.google_wif_runner_group}" - ]) - ) : ( - toset([]) - ) - - google_runner_wif_client_members = var.google_wif_runner_client_id != "" ? ( - toset([ - "principalSet://iam.googleapis.com/projects/${var.google_project_number}/locations/global/workloadIdentityPools/${var.google_wif_pool_id}/attribute.burrow_client_id/${var.google_wif_runner_client_id}" - ]) - ) : ( - toset([]) - ) - - google_runner_wif_members = setunion( - local.google_runner_wif_group_members, - local.google_runner_wif_client_members, - var.google_wif_runner_group == "" && var.google_wif_runner_client_id == "" ? toset([ - "principalSet://iam.googleapis.com/projects/${var.google_project_number}/locations/global/workloadIdentityPools/${var.google_wif_pool_id}/*" - ]) : toset([]) - ) - - google_signer_members = setunion( - var.google_release_signer_members, - toset(["serviceAccount:${local.google_runner_service_account_email}"]), - ) - - google_signer_grants = var.manage_google ? { - for grant in flatten([ - for key_name in keys(google_kms_crypto_key.signing) : [ - for member in local.google_signer_members : { - id = "${key_name}|${member}" - key_name = key_name - member = member - } - ] - ]) : grant.id => grant - } : {} -} - -resource "google_project_service" "identity" { - for_each = var.manage_google ? local.google_required_services : toset([]) - - project = var.google_project_id - service = each.value - disable_on_destroy = false -} - -resource "google_kms_key_ring" "identity" { - count = var.manage_google && var.manage_google_kms_key_ring ? 1 : 0 - - project = var.google_project_id - location = var.google_kms_key_ring_location - name = var.google_kms_key_ring_name - - depends_on = [google_project_service.identity] -} - -resource "google_kms_crypto_key" "signing" { - for_each = var.manage_google && local.google_kms_key_ring_id != "" ? var.google_signing_keys : {} - - name = each.value.name - key_ring = local.google_kms_key_ring_id - purpose = "ASYMMETRIC_SIGN" - labels = merge( - { - project = "burrow" - component = "identity" - managed_by = "opentofu" - }, - var.tags, - try(each.value.labels, {}), - ) - - version_template { - algorithm = try(each.value.algorithm, "RSA_SIGN_PKCS1_2048_SHA256") - protection_level = try(each.value.protection_level, "HSM") - } - - lifecycle { - prevent_destroy = true - } -} - -resource "google_service_account" "runner" { - count = var.manage_google && var.manage_google_runner_service_account ? 1 : 0 - - project = var.google_project_id - account_id = var.google_runner_service_account_id - display_name = "Burrow Forgejo Runner" - description = "Authentik-backed WIF service account for Burrow Forgejo runners." - - depends_on = [google_project_service.identity] -} - -resource "google_iam_workload_identity_pool" "burrow" { - count = var.manage_google ? 1 : 0 - - project = var.google_project_id - workload_identity_pool_id = var.google_wif_pool_id - display_name = "Burrow Authentik" - description = "Authentik-issued identities for Burrow automation." - disabled = false - - depends_on = [google_project_service.identity] -} - -resource "google_iam_workload_identity_pool_provider" "forgejo_runners" { - count = var.manage_google ? 1 : 0 - - project = var.google_project_id - workload_identity_pool_id = google_iam_workload_identity_pool.burrow[0].workload_identity_pool_id - workload_identity_pool_provider_id = var.google_wif_provider_id - display_name = "Burrow Forgejo Runners" - description = "Authentik OIDC provider for Burrow Forgejo runner cloud access." - disabled = false - - attribute_mapping = { - "google.subject" = "assertion.sub" - "google.groups" = "assertion.groups" - "attribute.burrow_subject" = "assertion.sub" - "attribute.burrow_username" = "assertion.preferred_username" - "attribute.burrow_email" = "assertion.email" - "attribute.burrow_client_id" = "assertion.aud" - } - - attribute_condition = var.google_wif_attribute_condition - - oidc { - issuer_uri = var.google_wif_issuer_uri - allowed_audiences = var.google_wif_allowed_audiences - } -} - -resource "google_service_account_iam_member" "runner_wif" { - for_each = var.manage_google ? local.google_runner_wif_members : toset([]) - - service_account_id = var.manage_google_runner_service_account ? ( - google_service_account.runner[0].name - ) : "projects/${var.google_project_id}/serviceAccounts/${local.google_runner_service_account_email}" - role = "roles/iam.workloadIdentityUser" - member = each.value -} - -resource "google_kms_crypto_key_iam_member" "signer" { - for_each = local.google_signer_grants - - crypto_key_id = google_kms_crypto_key.signing[each.value.key_name].id - role = "roles/cloudkms.signerVerifier" - member = each.value.member -} diff --git a/infra/identity/outputs.tf b/infra/identity/outputs.tf deleted file mode 100644 index 2a0b9a0..0000000 --- a/infra/identity/outputs.tf +++ /dev/null @@ -1,19 +0,0 @@ -output "google_kms_key_ring_id" { - description = "Google KMS key ring id used by the identity stack." - value = local.google_kms_key_ring_id -} - -output "google_runner_service_account_email" { - description = "Forgejo runner service account email." - value = local.google_runner_service_account_email -} - -output "google_wif_pool_name" { - description = "Google WIF pool resource name when managed." - value = try(google_iam_workload_identity_pool.burrow[0].name, null) -} - -output "google_signing_key_ids" { - description = "Managed Google KMS signing key ids." - value = { for name, key in google_kms_crypto_key.signing : name => key.id } -} diff --git a/infra/identity/providers.tf b/infra/identity/providers.tf deleted file mode 100644 index 5343c2e..0000000 --- a/infra/identity/providers.tf +++ /dev/null @@ -1,3 +0,0 @@ -provider "google" { - project = var.google_project_id -} diff --git a/infra/identity/terraform.tfvars.example b/infra/identity/terraform.tfvars.example deleted file mode 100644 index 7411302..0000000 --- a/infra/identity/terraform.tfvars.example +++ /dev/null @@ -1,15 +0,0 @@ -manage_google = false - -google_project_id = "project-88c23ce9-918a-470a-b33" -google_project_number = "416198671487" - -# Import the key ring before setting this true if it already exists. -manage_google_kms_key_ring = false - -# Create this only after the Authentik WIF provider design is final. -manage_google_runner_service_account = false - -google_wif_issuer_uri = "https://auth.burrow.net/application/o/google-cloud/" -google_wif_allowed_audiences = ["google-wif.burrow.net"] -google_wif_runner_group = "" -google_wif_runner_client_id = "google-wif.burrow.net" diff --git a/infra/identity/variables.tf b/infra/identity/variables.tf deleted file mode 100644 index 68fad72..0000000 --- a/infra/identity/variables.tf +++ /dev/null @@ -1,201 +0,0 @@ -variable "manage_google" { - description = "Manage Google Cloud identity and WIF resources." - type = bool - default = false -} - -variable "google_project_id" { - description = "Google Cloud project id for Burrow identity resources." - type = string - default = "project-88c23ce9-918a-470a-b33" -} - -variable "google_project_number" { - description = "Google Cloud project number for IAM principal URIs." - type = string - default = "416198671487" -} - -variable "google_kms_key_ring_name" { - description = "Google KMS key ring for identity and release signing keys." - type = string - default = "burrow-identity" -} - -variable "google_kms_key_ring_location" { - description = "Google KMS key ring location." - type = string - default = "global" -} - -variable "google_kms_key_ring_id" { - description = "Existing Google KMS key ring id. Leave blank to derive it from project, location, and name." - type = string - default = "" -} - -variable "manage_google_kms_key_ring" { - description = "Create/manage the Google KMS key ring. Import existing rings before enabling this." - type = bool - default = false -} - -variable "google_signing_keys" { - description = "Non-exportable Burrow signing keys to create in the identity key ring." - type = map(object({ - name = string - algorithm = optional(string, "RSA_SIGN_PKCS1_2048_SHA256") - protection_level = optional(string, "HSM") - labels = optional(map(string), {}) - })) - default = { - authentik = { - name = "authentik-signing" - labels = { - key_target = "authentik" - } - } - saml_ca = { - name = "saml-ca" - labels = { - key_target = "saml-ca" - } - } - release = { - name = "release-signing" - labels = { - key_target = "release-signing" - } - } - package_apt_repository = { - name = "package-apt-repository" - labels = { - key_target = "package-apt-repository" - repo_format = "apt" - } - } - package_rpm_repository = { - name = "package-rpm-repository" - labels = { - key_target = "package-rpm-repository" - repo_format = "rpm" - } - } - package_pacman_repository = { - name = "package-pacman-repository" - labels = { - key_target = "package-pacman-repository" - repo_format = "pacman" - } - } - package_flatpak_repository = { - name = "package-flatpak-repository" - labels = { - key_target = "package-flatpak-repository" - repo_format = "flatpak" - } - } - package_aur_source = { - name = "package-aur-source" - labels = { - key_target = "package-aur-source" - repo_format = "aur" - } - } - apple_developer_id_application = { - name = "apple-developer-id-application" - labels = { - key_target = "apple-developer-id-application" - apple_cert_type = "developer-id-application" - } - } - apple_ios_distribution = { - name = "apple-ios-distribution" - labels = { - key_target = "apple-ios-distribution" - apple_cert_type = "ios-distribution" - } - } - sparkle_ed25519 = { - name = "sparkle-ed25519" - algorithm = "EC_SIGN_ED25519" - protection_level = "SOFTWARE" - labels = { - key_target = "sparkle-ed25519" - release_surface = "sparkle" - } - } - } -} - -variable "google_release_signer_members" { - description = "Additional IAM members allowed to sign/verify with Burrow release and package repository keys." - type = set(string) - default = [] -} - -variable "manage_google_runner_service_account" { - description = "Create/manage the Forgejo runner service account for WIF." - type = bool - default = false -} - -variable "google_runner_service_account_id" { - description = "Google service account id for Authentik-backed Forgejo runners." - type = string - default = "burrow-forgejo-runner" -} - -variable "google_runner_service_account_email" { - description = "Existing runner service account email when not managed here." - type = string - default = "burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com" -} - -variable "google_wif_pool_id" { - description = "Workload Identity Federation pool id for Burrow automation." - type = string - default = "burrow-authentik" -} - -variable "google_wif_provider_id" { - description = "Workload Identity Federation provider id for Authentik-issued OIDC tokens." - type = string - default = "forgejo-runners" -} - -variable "google_wif_issuer_uri" { - description = "Authentik OIDC issuer used by Google WIF." - type = string - default = "https://auth.burrow.net/application/o/google-cloud/" -} - -variable "google_wif_allowed_audiences" { - description = "Allowed audiences for Authentik-issued WIF tokens." - type = list(string) - default = ["google-wif.burrow.net"] -} - -variable "google_wif_runner_group" { - description = "Google groups principal allowed to assume the runner service account. Leave empty for machine-to-machine client credentials." - type = string - default = "" -} - -variable "google_wif_runner_client_id" { - description = "Mapped Authentik OIDC audience/client value allowed to assume the runner service account for machine-to-machine CI. Empty disables this grant." - type = string - default = "google-wif.burrow.net" -} - -variable "google_wif_attribute_condition" { - description = "CEL condition applied to Authentik-issued WIF tokens." - type = string - default = "assertion.iss == 'https://auth.burrow.net/application/o/google-cloud/'" -} - -variable "tags" { - description = "Additional labels applied to supported Google resources." - type = map(string) - default = {} -} diff --git a/infra/identity/versions.tf b/infra/identity/versions.tf deleted file mode 100644 index 2b6a05d..0000000 --- a/infra/identity/versions.tf +++ /dev/null @@ -1,12 +0,0 @@ -terraform { - required_version = ">= 1.8.0" - - required_providers { - google = { - source = "hashicorp/google" - version = ">= 5.40.0, < 7.0" - } - } - - backend "s3" {} -} diff --git a/infra/openbao/.gitignore b/infra/openbao/.gitignore deleted file mode 100644 index b12b9ed..0000000 --- a/infra/openbao/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.terraform/ -backend.hcl -terraform.tfstate -terraform.tfstate.backup -*.tfplan diff --git a/infra/openbao/.terraform.lock.hcl b/infra/openbao/.terraform.lock.hcl deleted file mode 100644 index ded7beb..0000000 --- a/infra/openbao/.terraform.lock.hcl +++ /dev/null @@ -1,43 +0,0 @@ -# This file is maintained automatically by "tofu init". -# Manual edits may be lost in future updates. - -provider "registry.opentofu.org/hashicorp/google" { - version = "6.50.0" - constraints = ">= 5.40.0, < 7.0.0" - hashes = [ - "h1:MAAe4zFFdqS9M5rpmJK/vKgdb6ZMD/s/0Xd97yTDipA=", - "zh:1d4695f807d998f11fcdcfa174766287b82a8093513af857bcdad2d81c642480", - "zh:3173ac5df0294624d113812e49e2a55714aff7db617488168cecdf4168df9e29", - "zh:34d2b3d44c23bd6354fc4ab5917b302872ea1ab8de107034567f955b1717fa5b", - "zh:3a77f3cc2f3664cd5aaeeef4d044e6ec1695a079588fffec3ca03953664e5f04", - "zh:6b444e4b629ea8dc8cb112a39dde098dc5584d26d6de4177558f556a9a226696", - "zh:96545c8cd4d3a57069c5d1799eab5aedd887e16d98b5559a195f6d2c2d9bc674", - "zh:ba464caafde95ee16671d6b5ec90f053ed77a9d06c567456db6efd9160fa3165", - "zh:d876938e5b0d3f57a984d9be72467995f87fef6569968623415dc51d9f54d30b", - "zh:dfd908d873e314ab807d0abc9cfd42d2611cd06dc1b9ec719ebdbb738e8e68d6", - "zh:f9f16819a7738d564afd45fd169ba61004ec4e4e7089d2a4950cb8895be1fe1f", - ] -} - -provider "registry.opentofu.org/hashicorp/vault" { - version = "5.9.0" - constraints = ">= 4.5.0, < 6.0.0" - hashes = [ - "h1:9U4AT36IqNYXfQMR7nf+oywulVKYua473W4NmuwVb7M=", - "zh:03cb46fab606872e3fc06e27f8d14b911b7a9781d0f32af335c7287a9dbe4f4f", - "zh:1e69677b09329b009a8791f9cbb556790fb4dbddc1360f953345259892dc6c5b", - "zh:4e5a3dc82e70d88f5671968b7f2217163815be259d0b26dfcc9a897409c491fa", - "zh:51b82b358fe52099ef29cae62e2040ae0224ceea40a7dfdf0b69de90fb0233a8", - "zh:60ec3477fbe712b3269eec2e4d9d5921e2e68d0861d77e11e91bea088aab27bf", - "zh:64e58a3e3fd61005b54d71eee792b8576ab42ea4cd711515043a3ac0d8bc7919", - "zh:69142fb83aff31e27f5b3d9e7afb7594013b63e490cfcef8678fd0d724609359", - "zh:6f2e58ba6bd41c4d9e0e752e55eb7d86104f9d786cf57fa8dc960c61b574f9ce", - "zh:73390b7cb186310e5aaf4a1baaa639ab0302a8da4db130b4e28ebced3ba103fe", - "zh:a2214b3fda93cfed89ba1a7d714fe8b015176927605b215365978efdaae1c6db", - "zh:b275ee0f0a63765364d2e6979e3a11464d2c2ce6d3ce9bd15b3f503aea6f12b0", - "zh:bb2cd553b1c589a0f6f5b0575e1b176a9d91c1b20c6375d1afd63bb2a47fd014", - "zh:bc38e48ae5cdb8219fef326366f46e6a25e7f77f9bbcd9bdc80c7cb960de81ed", - "zh:e54af55cf667759a400af07b927b5034f1733e6527f9352b84001205561dcf9e", - "zh:f617688ef4568c0de8a28d5e1fa3714fb2eeab9f8cf7bcf20af42a1a511844be", - ] -} diff --git a/infra/openbao/README.md b/infra/openbao/README.md deleted file mode 100644 index bca0089..0000000 --- a/infra/openbao/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Burrow OpenBao Control Plane - -This OpenTofu stack manages non-secret infrastructure around -`vault.burrow.net`. - -It may manage: - -- Google KMS seal-wrapping keys for OpenBao -- the OpenBao `age/` KV v2 mount used for migrated runtime secrets -- OpenBao policies and Authentik OIDC roles -- AppRole auth scaffolding for machine consumers - -It must not manage: - -- secret values -- root tokens -- AppRole SecretIDs -- release-signing key material - -## Run - -```sh -Scripts/openbao-tofu.sh init -backend-config=backend.hcl -Scripts/openbao-tofu.sh plan -var-file=terraform.tfvars -``` - -For local syntax work: - -```sh -Scripts/openbao-tofu.sh init -backend=false -Scripts/openbao-tofu.sh validate -``` - -The Vault/OpenBao provider reads `VAULT_TOKEN` from the environment. Keep -`manage_openbao_config = false` until `vault.burrow.net` is initialized and an -operator token is available out of band. diff --git a/infra/openbao/backend.hcl.example b/infra/openbao/backend.hcl.example deleted file mode 100644 index 53c6b7d..0000000 --- a/infra/openbao/backend.hcl.example +++ /dev/null @@ -1,4 +0,0 @@ -bucket = "burrow-opentofu-state" -key = "openbao/openbao.tfstate" -region = "us-west-2" -encrypt = true diff --git a/infra/openbao/google.tf b/infra/openbao/google.tf deleted file mode 100644 index 0b34f7c..0000000 --- a/infra/openbao/google.tf +++ /dev/null @@ -1,65 +0,0 @@ -locals { - google_required_services = toset([ - "cloudkms.googleapis.com", - "cloudresourcemanager.googleapis.com", - "iam.googleapis.com", - ]) - - google_existing_kms_key_ring_id = var.google_kms_key_ring_id != "" ? var.google_kms_key_ring_id : "projects/${var.google_project_id}/locations/${var.google_kms_key_ring_location}/keyRings/${var.google_kms_key_ring_name}" - - google_kms_key_ring_id = var.manage_google_kms_key_ring ? ( - google_kms_key_ring.openbao[0].id - ) : local.google_existing_kms_key_ring_id -} - -resource "google_project_service" "openbao" { - for_each = var.manage_google ? local.google_required_services : toset([]) - - project = var.google_project_id - service = each.value - disable_on_destroy = false -} - -resource "google_kms_key_ring" "openbao" { - count = var.manage_google && var.manage_google_kms_key_ring ? 1 : 0 - - project = var.google_project_id - location = var.google_kms_key_ring_location - name = var.google_kms_key_ring_name - - depends_on = [google_project_service.openbao] -} - -resource "google_kms_crypto_key" "openbao_seal" { - count = var.manage_google && local.google_kms_key_ring_id != "" ? 1 : 0 - - name = var.google_openbao_seal_key_name - key_ring = local.google_kms_key_ring_id - purpose = "ENCRYPT_DECRYPT" - labels = merge( - { - project = "burrow" - component = "openbao" - managed_by = "opentofu" - key_target = "seal" - }, - var.tags, - ) - - version_template { - algorithm = "GOOGLE_SYMMETRIC_ENCRYPTION" - protection_level = "HSM" - } - - lifecycle { - prevent_destroy = true - } -} - -resource "google_kms_crypto_key_iam_member" "openbao_seal" { - for_each = var.manage_google && local.google_kms_key_ring_id != "" ? var.google_openbao_seal_members : toset([]) - - crypto_key_id = google_kms_crypto_key.openbao_seal[0].id - role = "roles/cloudkms.cryptoKeyEncrypterDecrypter" - member = each.value -} diff --git a/infra/openbao/openbao.tf b/infra/openbao/openbao.tf deleted file mode 100644 index 619f50e..0000000 --- a/infra/openbao/openbao.tf +++ /dev/null @@ -1,125 +0,0 @@ -resource "vault_mount" "age" { - count = var.manage_openbao_config ? 1 : 0 - - path = var.age_mount_path - type = "kv-v2" - description = "Migrated Burrow age-managed runtime secrets." - - options = { - version = "2" - } -} - -resource "vault_policy" "admin" { - count = var.manage_openbao_config ? 1 : 0 - - name = "burrow-openbao-admin" - - policy = <<-EOT - path "*" { - capabilities = ["create", "read", "update", "delete", "list", "sudo"] - } - EOT -} - -resource "vault_policy" "runtime_read" { - count = var.manage_openbao_config ? 1 : 0 - - name = "burrow-runtime-read" - - policy = <<-EOT - path "${var.age_mount_path}/data/*" { - capabilities = ["read"] - } - - path "${var.age_mount_path}/metadata/*" { - capabilities = ["list", "read"] - } - EOT -} - -resource "vault_auth_backend" "approle" { - count = var.manage_openbao_config ? 1 : 0 - - path = var.approle_auth_path - type = "approle" - description = "Machine auth for Burrow runtime secret consumers." - - tune { - token_type = "default-service" - } -} - -resource "vault_approle_auth_backend_role" "runtime" { - count = var.manage_openbao_config ? 1 : 0 - - backend = vault_auth_backend.approle[0].path - role_name = var.runtime_approle_role_name - bind_secret_id = true - - token_policies = [vault_policy.runtime_read[0].name] - token_ttl = var.runtime_approle_token_ttl_seconds - token_max_ttl = var.runtime_approle_token_max_ttl_seconds - token_num_uses = 0 - - secret_id_ttl = var.runtime_approle_secret_id_ttl_seconds - secret_id_num_uses = var.runtime_approle_secret_id_num_uses -} - -resource "vault_jwt_auth_backend" "authentik" { - count = var.manage_openbao_config ? 1 : 0 - - path = "oidc" - type = "oidc" - oidc_discovery_url = var.authentik_issuer - bound_issuer = var.authentik_issuer - oidc_client_id = var.authentik_client_id - default_role = "burrow-admin" - - tune { - listing_visibility = "unauth" - token_type = "default-service" - } -} - -resource "vault_jwt_auth_backend_role" "admin" { - count = var.manage_openbao_config ? 1 : 0 - - backend = vault_jwt_auth_backend.authentik[0].path - role_name = "burrow-admin" - role_type = "oidc" - user_claim = "email" - - allowed_redirect_uris = [ - "https://vault.burrow.net/ui/vault/auth/oidc/oidc/callback", - "http://localhost:8250/oidc/callback", - ] - - bound_audiences = [var.authentik_client_id] - bound_claims = { - groups = var.authentik_admin_group - } - oidc_scopes = ["openid", "profile", "email", "groups"] - token_policies = [vault_policy.admin[0].name] -} - -resource "vault_jwt_auth_backend_role" "runtime" { - count = var.manage_openbao_config ? 1 : 0 - - backend = vault_jwt_auth_backend.authentik[0].path - role_name = "burrow-runtime-read" - role_type = "oidc" - user_claim = "email" - - allowed_redirect_uris = [ - "https://vault.burrow.net/ui/vault/auth/oidc/oidc/callback", - "http://localhost:8250/oidc/callback", - ] - - bound_audiences = [var.authentik_client_id] - bound_claims = { - groups = var.authentik_runtime_group - } - oidc_scopes = ["openid", "profile", "email", "groups"] - token_policies = [vault_policy.runtime_read[0].name] -} diff --git a/infra/openbao/outputs.tf b/infra/openbao/outputs.tf deleted file mode 100644 index 8474957..0000000 --- a/infra/openbao/outputs.tf +++ /dev/null @@ -1,14 +0,0 @@ -output "google_kms_key_ring_id" { - description = "Google KMS key ring id used by the OpenBao stack." - value = local.google_kms_key_ring_id -} - -output "google_openbao_seal_key_id" { - description = "Google KMS seal key id when managed." - value = try(google_kms_crypto_key.openbao_seal[0].id, null) -} - -output "openbao_age_mount_path" { - description = "OpenBao KV v2 mount path for migrated age secrets." - value = var.age_mount_path -} diff --git a/infra/openbao/providers.tf b/infra/openbao/providers.tf deleted file mode 100644 index 7e485d4..0000000 --- a/infra/openbao/providers.tf +++ /dev/null @@ -1,8 +0,0 @@ -provider "google" { - project = var.google_project_id -} - -provider "vault" { - address = var.openbao_address - skip_tls_verify = var.openbao_skip_tls_verify -} diff --git a/infra/openbao/terraform.tfvars.example b/infra/openbao/terraform.tfvars.example deleted file mode 100644 index ae1a4e1..0000000 --- a/infra/openbao/terraform.tfvars.example +++ /dev/null @@ -1,8 +0,0 @@ -manage_google = false -manage_openbao_config = false -google_project_id = "project-88c23ce9-918a-470a-b33" -openbao_address = "https://vault.burrow.net" -authentik_issuer = "https://auth.burrow.net/application/o/openbao/" -authentik_client_id = "vault.burrow.net" -authentik_admin_group = "burrow-admins" -authentik_runtime_group = "burrow-automation" diff --git a/infra/openbao/variables.tf b/infra/openbao/variables.tf deleted file mode 100644 index 011eaa7..0000000 --- a/infra/openbao/variables.tf +++ /dev/null @@ -1,137 +0,0 @@ -variable "openbao_address" { - description = "OpenBao API address. The Vault provider reads the token from VAULT_TOKEN." - type = string - default = "https://vault.burrow.net" -} - -variable "openbao_skip_tls_verify" { - description = "Skip TLS verification for local bootstrap only." - type = bool - default = false -} - -variable "age_mount_path" { - description = "KV v2 mount path for migrated age-managed runtime secrets." - type = string - default = "age" -} - -variable "manage_openbao_config" { - description = "Manage OpenBao mounts, policies, and Authentik OIDC auth. Requires VAULT_TOKEN." - type = bool - default = false -} - -variable "authentik_issuer" { - description = "Authentik OpenBao OIDC issuer." - type = string - default = "https://auth.burrow.net/application/o/openbao/" -} - -variable "authentik_client_id" { - description = "Public Authentik OpenBao OIDC client id." - type = string - default = "vault.burrow.net" -} - -variable "authentik_admin_group" { - description = "Authentik group allowed to administer OpenBao." - type = string - default = "burrow-admins" -} - -variable "authentik_runtime_group" { - description = "Authentik group allowed to read runtime secrets." - type = string - default = "burrow-automation" -} - -variable "approle_auth_path" { - description = "OpenBao AppRole auth mount path for machine consumers." - type = string - default = "approle" -} - -variable "runtime_approle_role_name" { - description = "AppRole role name used by Burrow runtime secret consumers." - type = string - default = "burrow-runtime" -} - -variable "runtime_approle_token_ttl_seconds" { - description = "Token TTL for runtime AppRole login." - type = number - default = 3600 -} - -variable "runtime_approle_token_max_ttl_seconds" { - description = "Maximum token TTL for runtime AppRole login." - type = number - default = 7200 -} - -variable "runtime_approle_secret_id_ttl_seconds" { - description = "SecretID TTL for runtime AppRole. Zero means no expiry; rotate manually." - type = number - default = 0 -} - -variable "runtime_approle_secret_id_num_uses" { - description = "SecretID use limit for runtime AppRole. Zero means unlimited uses; rotate manually." - type = number - default = 0 -} - -variable "manage_google" { - description = "Manage Google Cloud KMS resources for OpenBao seal wrapping." - type = bool - default = false -} - -variable "google_project_id" { - description = "Google Cloud project id for OpenBao seal wrapping." - type = string - default = "project-88c23ce9-918a-470a-b33" -} - -variable "manage_google_kms_key_ring" { - description = "Create/manage the Google KMS key ring. Existing rings should be imported before enabling this." - type = bool - default = false -} - -variable "google_kms_key_ring_name" { - description = "Google KMS key ring for OpenBao seal wrapping." - type = string - default = "burrow-identity" -} - -variable "google_kms_key_ring_location" { - description = "Google KMS key ring location." - type = string - default = "global" -} - -variable "google_kms_key_ring_id" { - description = "Existing Google KMS key ring id." - type = string - default = "" -} - -variable "google_openbao_seal_key_name" { - description = "Google KMS symmetric key used for OpenBao seal wrapping." - type = string - default = "openbao-seal" -} - -variable "google_openbao_seal_members" { - description = "IAM members allowed to encrypt/decrypt with the OpenBao seal key." - type = set(string) - default = [] -} - -variable "tags" { - description = "Additional labels applied to supported Google resources." - type = map(string) - default = {} -} diff --git a/infra/openbao/versions.tf b/infra/openbao/versions.tf deleted file mode 100644 index 9de0722..0000000 --- a/infra/openbao/versions.tf +++ /dev/null @@ -1,16 +0,0 @@ -terraform { - required_version = ">= 1.8.0" - - required_providers { - google = { - source = "hashicorp/google" - version = ">= 5.40.0, < 7.0" - } - vault = { - source = "hashicorp/vault" - version = ">= 4.5.0, < 6.0" - } - } - - backend "s3" {} -} diff --git a/infra/releases/.gitignore b/infra/releases/.gitignore deleted file mode 100644 index 493f814..0000000 --- a/infra/releases/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.terraform/ -*.tfstate -*.tfstate.* -backend.hcl -terraform.tfvars diff --git a/infra/releases/.terraform.lock.hcl b/infra/releases/.terraform.lock.hcl deleted file mode 100644 index 0a51a6e..0000000 --- a/infra/releases/.terraform.lock.hcl +++ /dev/null @@ -1,20 +0,0 @@ -# This file is maintained automatically by "tofu init". -# Manual edits may be lost in future updates. - -provider "registry.opentofu.org/hashicorp/google" { - version = "6.50.0" - constraints = ">= 5.40.0, < 7.0.0" - hashes = [ - "h1:MAAe4zFFdqS9M5rpmJK/vKgdb6ZMD/s/0Xd97yTDipA=", - "zh:1d4695f807d998f11fcdcfa174766287b82a8093513af857bcdad2d81c642480", - "zh:3173ac5df0294624d113812e49e2a55714aff7db617488168cecdf4168df9e29", - "zh:34d2b3d44c23bd6354fc4ab5917b302872ea1ab8de107034567f955b1717fa5b", - "zh:3a77f3cc2f3664cd5aaeeef4d044e6ec1695a079588fffec3ca03953664e5f04", - "zh:6b444e4b629ea8dc8cb112a39dde098dc5584d26d6de4177558f556a9a226696", - "zh:96545c8cd4d3a57069c5d1799eab5aedd887e16d98b5559a195f6d2c2d9bc674", - "zh:ba464caafde95ee16671d6b5ec90f053ed77a9d06c567456db6efd9160fa3165", - "zh:d876938e5b0d3f57a984d9be72467995f87fef6569968623415dc51d9f54d30b", - "zh:dfd908d873e314ab807d0abc9cfd42d2611cd06dc1b9ec719ebdbb738e8e68d6", - "zh:f9f16819a7738d564afd45fd169ba61004ec4e4e7089d2a4950cb8895be1fe1f", - ] -} diff --git a/infra/releases/README.md b/infra/releases/README.md deleted file mode 100644 index 7d88b13..0000000 --- a/infra/releases/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Burrow Release Storage - -This OpenTofu stack owns Google Cloud Storage buckets used as Burrow's first -release-storage backup target. - -Garage is the required S3-compatible primary target. CI uses repo-owned storage -wrappers: they upload to Garage first, then mirror to GCS unless the backup is -explicitly disabled. - -The host activation point is `services.burrow.garage`, enabled by the forge -host. It creates the initial single-node Garage layout and buckets from -host-local runtime secrets. - -It may manage: - -- the release artifact bucket served by `releases.burrow.net` -- the signed package repository bucket served by `packages.burrow.net` -- the private Nix cache backup bucket for `nix.burrow.net` -- public object-read IAM for download buckets -- object-admin IAM for the Burrow Forgejo runner service account - -It must not manage: - -- Google service-account JSON keys -- release signing keys -- package repository metadata -- DNS or CDN cutover - -GCS access is keyless. Forgejo jobs run `Scripts/ci/google-wif-auth.sh` to -trade an Authentik-issued OIDC token for the Google runner service account in -project `project-88c23ce9-918a-470a-b33`, then use `gcloud storage` against the -managed backup buckets. The normal release path does not use rclone or Google -service-account JSON keys. - -The Nix cache backup bucket defaults to private. Attic remains the public cache -surface at `nix.burrow.net`; GCS is only a backup target for the Garage `attic` -bucket. - -## Run - -```sh -Scripts/releases-tofu.sh init -backend-config=backend.hcl -Scripts/releases-tofu.sh plan -var-file=terraform.tfvars -``` - -For local syntax work without remote state: - -```sh -Scripts/releases-tofu.sh init -backend=false -Scripts/releases-tofu.sh validate -``` - -Start with `manage_google = false`. Import existing buckets before enabling -management if they already exist. diff --git a/infra/releases/backend.hcl.example b/infra/releases/backend.hcl.example deleted file mode 100644 index 6f144bd..0000000 --- a/infra/releases/backend.hcl.example +++ /dev/null @@ -1,3 +0,0 @@ -bucket = "burrow-opentofu-state" -key = "releases/releases.tfstate" -region = "us-west-2" diff --git a/infra/releases/outputs.tf b/infra/releases/outputs.tf deleted file mode 100644 index ebb4611..0000000 --- a/infra/releases/outputs.tf +++ /dev/null @@ -1,29 +0,0 @@ -output "google_release_bucket_name" { - description = "GCS bucket used for release artifacts." - value = try(google_storage_bucket.release_storage["releases"].name, var.google_release_bucket_name) -} - -output "google_package_bucket_name" { - description = "GCS bucket used for signed package repositories." - value = try(google_storage_bucket.release_storage["packages"].name, var.google_package_bucket_name) -} - -output "google_nix_cache_bucket_name" { - description = "GCS bucket used for Nix cache backups." - value = try(google_storage_bucket.release_storage["nix-cache"].name, var.google_nix_cache_bucket_name) -} - -output "google_release_bucket_uri" { - description = "GCS URI for release artifacts." - value = "gs://${try(google_storage_bucket.release_storage["releases"].name, var.google_release_bucket_name)}" -} - -output "google_package_bucket_uri" { - description = "GCS URI for signed package repositories." - value = "gs://${try(google_storage_bucket.release_storage["packages"].name, var.google_package_bucket_name)}" -} - -output "google_nix_cache_bucket_uri" { - description = "GCS URI for Nix cache backups." - value = "gs://${try(google_storage_bucket.release_storage["nix-cache"].name, var.google_nix_cache_bucket_name)}" -} diff --git a/infra/releases/providers.tf b/infra/releases/providers.tf deleted file mode 100644 index 5343c2e..0000000 --- a/infra/releases/providers.tf +++ /dev/null @@ -1,3 +0,0 @@ -provider "google" { - project = var.google_project_id -} diff --git a/infra/releases/storage.tf b/infra/releases/storage.tf deleted file mode 100644 index f31f84c..0000000 --- a/infra/releases/storage.tf +++ /dev/null @@ -1,116 +0,0 @@ -locals { - google_required_services = toset([ - "storage.googleapis.com", - ]) - - release_storage_buckets = { - releases = { - name = var.google_release_bucket_name - description = "Burrow release artifacts" - public = var.google_release_public_read - } - packages = { - name = var.google_package_bucket_name - description = "Burrow signed package repositories" - public = var.google_package_public_read - } - nix-cache = { - name = var.google_nix_cache_bucket_name - description = "Burrow Nix cache backups" - public = var.google_nix_cache_public_read - } - } - - release_storage_writer_members = setunion( - var.google_release_extra_writer_members, - toset(["serviceAccount:${var.google_runner_service_account_email}"]), - ) - - release_storage_writer_grants = var.manage_google ? { - for grant in flatten([ - for bucket_key in keys(local.release_storage_buckets) : [ - for member in local.release_storage_writer_members : { - id = "${bucket_key}|${member}" - bucket_key = bucket_key - member = member - } - ] - ]) : grant.id => grant - } : {} - - release_storage_public_grants = var.manage_google ? { - for bucket_key, bucket in local.release_storage_buckets : bucket_key => bucket - if bucket.public - } : {} -} - -resource "google_project_service" "release_storage" { - for_each = var.manage_google ? local.google_required_services : toset([]) - - project = var.google_project_id - service = each.value - disable_on_destroy = false -} - -resource "google_storage_bucket" "release_storage" { - for_each = var.manage_google ? local.release_storage_buckets : {} - - project = var.google_project_id - name = each.value.name - location = var.google_storage_location - uniform_bucket_level_access = true - public_access_prevention = each.value.public ? "inherited" : "enforced" - force_destroy = false - - labels = merge( - { - project = "burrow" - component = "release-storage" - managed_by = "opentofu" - bucket = each.key - }, - var.tags, - ) - - versioning { - enabled = true - } - - cors { - origin = var.google_storage_cors_origins - method = ["GET", "HEAD", "OPTIONS"] - response_header = ["Content-Type", "Content-Length", "ETag", "Cache-Control"] - max_age_seconds = 3600 - } - - lifecycle_rule { - condition { - age = 30 - with_state = "ARCHIVED" - num_newer_versions = 5 - matches_storage_class = ["STANDARD"] - } - - action { - type = "Delete" - } - } - - depends_on = [google_project_service.release_storage] -} - -resource "google_storage_bucket_iam_member" "release_storage_public_viewer" { - for_each = local.release_storage_public_grants - - bucket = google_storage_bucket.release_storage[each.key].name - role = "roles/storage.objectViewer" - member = "allUsers" -} - -resource "google_storage_bucket_iam_member" "release_storage_writer" { - for_each = local.release_storage_writer_grants - - bucket = google_storage_bucket.release_storage[each.value.bucket_key].name - role = "roles/storage.objectAdmin" - member = each.value.member -} diff --git a/infra/releases/terraform.tfvars.example b/infra/releases/terraform.tfvars.example deleted file mode 100644 index 59bd1e3..0000000 --- a/infra/releases/terraform.tfvars.example +++ /dev/null @@ -1,12 +0,0 @@ -manage_google = false - -google_project_id = "project-88c23ce9-918a-470a-b33" -google_storage_location = "US" -google_runner_service_account_email = "burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com" -google_release_bucket_name = "burrow-net-releases" -google_package_bucket_name = "burrow-net-packages" -google_nix_cache_bucket_name = "burrow-net-nix-cache" -google_release_public_read = true -google_package_public_read = true -google_nix_cache_public_read = false -google_release_extra_writer_members = [] diff --git a/infra/releases/variables.tf b/infra/releases/variables.tf deleted file mode 100644 index c1c8d4a..0000000 --- a/infra/releases/variables.tf +++ /dev/null @@ -1,81 +0,0 @@ -variable "manage_google" { - description = "Manage Google Cloud release storage resources." - type = bool - default = false -} - -variable "google_project_id" { - description = "Google Cloud project id for Burrow release storage." - type = string - default = "project-88c23ce9-918a-470a-b33" -} - -variable "google_storage_location" { - description = "Google Cloud Storage bucket location for release, package, and cache backup buckets." - type = string - default = "US" -} - -variable "google_runner_service_account_email" { - description = "Forgejo runner service account email allowed to publish release, package, and cache backup objects." - type = string - default = "burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com" -} - -variable "google_release_bucket_name" { - description = "GCS bucket for release artifacts served by releases.burrow.net." - type = string - default = "burrow-net-releases" -} - -variable "google_package_bucket_name" { - description = "GCS bucket for signed package repositories served by packages.burrow.net." - type = string - default = "burrow-net-packages" -} - -variable "google_nix_cache_bucket_name" { - description = "GCS backup bucket for the Garage-backed Burrow Nix cache." - type = string - default = "burrow-net-nix-cache" -} - -variable "google_release_public_read" { - description = "Grant allUsers objectViewer on the release artifact bucket." - type = bool - default = true -} - -variable "google_package_public_read" { - description = "Grant allUsers objectViewer on the package repository bucket." - type = bool - default = true -} - -variable "google_nix_cache_public_read" { - description = "Grant allUsers objectViewer on the Nix cache backup bucket." - type = bool - default = false -} - -variable "google_release_extra_writer_members" { - description = "Additional IAM members allowed to write release, repository, and cache backup objects." - type = set(string) - default = [] -} - -variable "google_storage_cors_origins" { - description = "Allowed origins for public release/package downloads." - type = list(string) - default = [ - "https://burrow.net", - "https://releases.burrow.net", - "https://packages.burrow.net", - ] -} - -variable "tags" { - description = "Additional labels applied to supported Google resources." - type = map(string) - default = {} -} diff --git a/infra/releases/versions.tf b/infra/releases/versions.tf deleted file mode 100644 index 2b6a05d..0000000 --- a/infra/releases/versions.tf +++ /dev/null @@ -1,12 +0,0 @@ -terraform { - required_version = ">= 1.8.0" - - required_providers { - google = { - source = "hashicorp/google" - version = ">= 5.40.0, < 7.0" - } - } - - backend "s3" {} -} diff --git a/nixos/README.md b/nixos/README.md index c37a75b..ebdb2dc 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -2,59 +2,48 @@ This directory contains the Burrow forge host definition and the Hetzner bootstrap shape for `burrow-forge`. -Mail hosting is intentionally not part of this NixOS host in the current plan. Burrow's first mail path is Forward Email with Burrow-owned custom S3 backups; see [`docs/FORWARDEMAIL.md`](../docs/FORWARDEMAIL.md). `inbox.burrow.net` remains the planned webmail surface for the later Stalwart phase. +Mail hosting is intentionally not part of this NixOS host in the current plan. Burrow's first mail path is Forward Email with Burrow-owned custom S3 backups; see [`docs/FORWARDEMAIL.md`](../docs/FORWARDEMAIL.md). ## Files - `hosts/burrow-forge/default.nix`: host entrypoint - `modules/burrow-forge.nix`: Forgejo, Caddy, PostgreSQL, and admin bootstrap module - `modules/burrow-forge-runner.nix`: Forgejo Actions runner and agent identity bootstrap -- upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input -- `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes -- `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC -- `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets +- `modules/burrow-forgejo-nsc.nix`: Namespace-backed ephemeral Forgejo runner services - `hetzner-cloud-config.yaml`: desired Hetzner host shape - `keys/contact_at_burrow_net.pub`: initial operator SSH public key - `keys/agent_at_burrow_net.pub`: automation SSH public key - `../Scripts/hetzner-forge.sh`: Hetzner inventory and replace workflow - `../Scripts/nsc-build-and-upload-image.sh`: temporary Namespace builder -> raw image -> Hetzner snapshot -- `../Scripts/bootstrap-forge-intake.sh`: copy the Forgejo bootstrap password and agent SSH key into `/var/lib/burrow/intake/` -- `../Scripts/check-forge-host.sh`: verify Forgejo, Caddy, the local runner, optional NSC services, and optional Tailnet services after boot -- `../Scripts/grafana-tofu.sh`: manage Grafana API-created folders and dashboards with OpenTofu +- `../Scripts/bootstrap-forge-intake.sh`: legacy intake bootstrap helper; current forge runtime secrets should live in `../secrets/forgejo/*.age` +- `../Scripts/check-forge-host.sh`: verify Forgejo, Caddy, the local runner, and optional NSC services after boot - `../Scripts/cloudflare-upsert-a-record.sh`: upsert DNS-only Cloudflare `A` records for Burrow host cutovers - `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host -- `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists -- `../Scripts/seal-forgejo-nsc-secrets.sh`: encrypt forgejo-nsc runtime inputs into the agenix secrets consumed by `burrow-forge` +- `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler bootstrap inputs and ensure the default Forgejo scope exists +- `../secrets/forgejo/*.age`: authoritative encrypted forge admin password, agent SSH key, and Namespace runtime configs for the forge host ## Intended Flow 1. Build and upload the raw NixOS image with `Scripts/hetzner-forge.sh build-image` or `Scripts/nsc-build-and-upload-image.sh`. 2. Recreate `burrow-forge` from the latest labeled snapshot with `Scripts/hetzner-forge.sh recreate-from-image --yes`. -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. +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 to refresh `intake/forgejo_nsc_token.txt`, `intake/forgejo_nsc_dispatcher.yaml`, and `intake/forgejo_nsc_autoscaler.yaml`. -7. Run `Scripts/seal-forgejo-nsc-secrets.sh` to encrypt those runtime inputs into the agenix secrets used by `burrow-forge`. -8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, `secrets/infra/grafana-admin-password.age`, `secrets/infra/grafana-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`, `graphs.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. -10. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. -11. Rotate `secrets/infra/grafana-oidc-client-secret.age` from its pending placeholder, deploy, and let `burrow-authentik-grafana-oidc.service` reconcile the Authentik client. -12. Validate Grafana API-managed dashboards with `Scripts/grafana-tofu.sh init -backend=false` and `Scripts/grafana-tofu.sh validate`; use `.forgejo/workflows/infra-opentofu.yml` for planned dashboard apply/import. -13. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. -14. Keep `services.burrow.openbao` and `services.burrow.jitsi` disabled until their DNS, secret, rollback, and live-service checks in BEP-0010 are complete. +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`. ## Current Constraints -- `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, Grafana, and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host. +- `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. - 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`; `graphs.burrow.net` should be pointed there before Grafana public cutover + - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21` - HTTP redirects to HTTPS on all three names - `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/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index a4f71f8..0ce7964 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -1,79 +1,4 @@ -{ config, lib, pkgs, self, ... }: - -let - contributors = import ../../../contributors.nix; - identities = contributors.identities; - linearGroups = contributors.groups.linear; - stripNewline = value: lib.replaceStrings [ "\n" ] [ "" ] value; - authentikPasswordSecretPath = identity: - if identity ? authentikPasswordSecret - then config.age.secrets.${identity.authentikPasswordSecret}.path - else null; - bootstrapUsers = lib.mapAttrsToList - ( - username: identity: { - inherit username; - name = identity.displayName; - email = identity.canonicalEmail; - isAdmin = identity.isAdmin or false; - groups = lib.optionals (identity.isAdmin or false) [ linearGroups.owners ]; - passwordFile = authentikPasswordSecretPath identity; - } - ) - (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); - headscaleBootstrapUsers = lib.mapAttrsToList - ( - username: identity: { - name = username; - displayName = identity.displayName; - email = identity.canonicalEmail; - } - ) - (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); - forgeUnixUsernames = - builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeUnixUser or false) identities); - forgeUnixUsers = lib.genAttrs forgeUnixUsernames (username: - let - identity = identities.${username}; - sshKeys = lib.optional (identity ? sshPublicKeyPath) (stripNewline (builtins.readFile identity.sshPublicKeyPath)); - in - { - isNormalUser = true; - createHome = true; - home = "/home/${username}"; - shell = pkgs.bashInteractive; - extraGroups = lib.optional (identity.isAdmin or false) "wheel"; - openssh.authorizedKeys.keys = sshKeys; - }); - forgeUnixAdminUsernames = - builtins.attrNames (lib.filterAttrs (_: identity: (identity.forgeUnixUser or false) && (identity.isAdmin or false)) identities); - forgeAuthorizedKeys = map - (username: builtins.readFile identities.${username}.sshPublicKeyPath) - (builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeAuthorized or false) identities)); - forgejoNscMaterializeTokenCache = '' - install -d -m 700 -o forgejo-nsc -g forgejo-nsc /var/lib/forgejo-nsc/.config/ns - ${pkgs.python3}/bin/python3 - <<'PY' -import json -from pathlib import Path - -raw = Path("/var/lib/forgejo-nsc/nsc.token").read_text(encoding="utf-8").strip() -if not raw: - raise SystemExit("empty Namespace token") -if raw.startswith("{"): - data = json.loads(raw) - if "bearer_token" not in data and "session_token" not in data: - raise SystemExit("Namespace token JSON must contain bearer_token or session_token") -else: - data = {"session_token": raw} -Path("/var/lib/forgejo-nsc/.config/ns/token.json").write_text( - json.dumps(data) + "\n", - encoding="utf-8", -) -PY - chown forgejo-nsc:forgejo-nsc /var/lib/forgejo-nsc/.config/ns/token.json - chmod 600 /var/lib/forgejo-nsc/.config/ns/token.json - ''; -in +{ config, self, ... }: { imports = [ @@ -82,14 +7,6 @@ in self.nixosModules.burrow-forge self.nixosModules.burrow-forge-runner self.nixosModules.burrow-forgejo-nsc - self.nixosModules.burrow-authentik - self.nixosModules.burrow-garage - self.nixosModules.burrow-headscale - self.nixosModules.burrow-nix-cache - self.nixosModules.burrow-observability - self.nixosModules.burrow-zulip - self.nixosModules.burrow-openbao - self.nixosModules.burrow-jitsi ]; system.stateVersion = "24.11"; @@ -101,316 +18,64 @@ in "flakes" ]; - users.users = forgeUnixUsers; - - security.sudo.extraRules = lib.map (username: { - users = [ username ]; - commands = [ - { - command = "ALL"; - options = [ "NOPASSWD" ]; - } - ]; - }) forgeUnixAdminUsernames; - - environment.systemPackages = lib.optionals config.services.forgejo-nsc.enable [ - self.packages.${pkgs.stdenv.hostPlatform.system}.nsc - ]; - - age.identityPaths = [ "/var/lib/agenix/agenix.key" ]; - age.secrets.burrowAuthentikEnv = { - file = ../../../secrets/infra/authentik.env.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowHeadscaleOidcClientSecret = { - file = ../../../secrets/infra/headscale-oidc-client-secret.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowForgejoOidcClientSecret = { - file = ../../../secrets/infra/forgejo-oidc-client-secret.age; - owner = "forgejo"; - group = "forgejo"; - mode = "0440"; - }; - age.secrets.burrowGrafanaAdminPassword = { - file = ../../../secrets/infra/grafana-admin-password.age; - owner = "grafana"; - group = "grafana"; - mode = "0400"; - }; - age.secrets.burrowGrafanaSecretKey = { - file = ../../../secrets/infra/grafana-secret-key.age; - owner = "grafana"; - group = "grafana"; - mode = "0400"; - }; - age.secrets.burrowGrafanaOidcClientSecret = { - file = ../../../secrets/infra/grafana-oidc-client-secret.age; - owner = "grafana"; - group = "grafana"; - mode = "0400"; - }; - age.secrets.burrowTailscaleOidcClientSecret = { - file = ../../../secrets/infra/tailscale-oidc-client-secret.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowLinearScimToken = { - file = ../../../secrets/infra/linear-scim-token.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowAuthentikGoogleClientId = { - file = ../../../secrets/infra/authentik-google-client-id.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowAuthentikGoogleClientSecret = { - file = ../../../secrets/infra/authentik-google-client-secret.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowAuthentikGoogleAccountMap = { - file = ../../../secrets/infra/authentik-google-account-map.json.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowAuthentikGoogleWifClientSecret = { - file = ../../../secrets/infra/authentik-google-wif-client-secret.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowAuthentikUiTestPassword = { - file = ../../../secrets/infra/authentik-ui-test-password.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowForgejoNscToken = { - file = ../../../secrets/infra/forgejo-nsc-token.age; - owner = "forgejo-nsc"; - group = "forgejo-nsc"; - mode = "0400"; - }; - age.secrets.burrowForgejoNscDispatcherConfig = { - file = ../../../secrets/infra/forgejo-nsc-dispatcher-config.age; - owner = "forgejo-nsc"; - group = "forgejo-nsc"; - mode = "0400"; - }; - age.secrets.burrowForgejoNscAutoscalerConfig = { - file = ../../../secrets/infra/forgejo-nsc-autoscaler-config.age; - owner = "forgejo-nsc"; - group = "forgejo-nsc"; - mode = "0400"; - }; - - age.secrets.burrowZulipPostgresPassword = { - file = ../../../secrets/infra/zulip-postgres-password.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - - age.secrets.burrowZulipRabbitmqPassword = { - file = ../../../secrets/infra/zulip-rabbitmq-password.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - - age.secrets.burrowZulipRedisPassword = { - file = ../../../secrets/infra/zulip-redis-password.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - - age.secrets.burrowZulipSecretKey = { - file = ../../../secrets/infra/zulip-secret-key.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - - networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net graphs.burrow.net vault.burrow.net meet.burrow.net inbox.burrow.net nsc-autoscaler.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net graphs.burrow.net vault.burrow.net meet.burrow.net inbox.burrow.net nsc-autoscaler.burrow.net - ''; - services.burrow.forge = { enable = true; - contactEmail = identities.contact.canonicalEmail; - adminUsername = "contact"; - adminEmail = identities.contact.canonicalEmail; - adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; - oidcAdminGroup = contributors.groups.admins; - oidcRestrictedGroup = contributors.groups.users; - oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; - authorizedKeys = forgeAuthorizedKeys; + adminPasswordFile = config.age.secrets.forgejoAdminPassword.path; + authorizedKeys = [ + (builtins.readFile ../../keys/contact_at_burrow_net.pub) + (builtins.readFile ../../keys/agent_at_burrow_net.pub) + ]; }; services.burrow.forgeRunner = { enable = true; - sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519"; - labels = [ - "self-hosted" - "linux" - "x86_64" - "burrow-forge" - ]; + sshPrivateKeyFile = config.age.secrets.forgejoAgentSshKey.path; }; - services.forgejo-nsc = { + age.secrets.forgejoAdminPassword = { + file = ../../../secrets/forgejo/admin-password.age; + mode = "0400"; + owner = "forgejo"; + group = "forgejo"; + }; + + age.secrets.forgejoAgentSshKey = { + file = ../../../secrets/forgejo/agent-ssh-key.age; + mode = "0400"; + owner = "root"; + group = "root"; + }; + + age.secrets.forgejoNscToken = { + file = ../../../secrets/forgejo/nsc-token.age; + mode = "0400"; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + }; + + age.secrets.forgejoNscDispatcherConfig = { + file = ../../../secrets/forgejo/nsc-dispatcher-config.age; + mode = "0400"; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + }; + + age.secrets.forgejoNscAutoscalerConfig = { + file = ../../../secrets/forgejo/nsc-autoscaler-config.age; + mode = "0400"; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + }; + + services.burrow.forgejoNsc = { enable = true; - nscPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.nsc; - nscTokenFile = config.age.secrets.burrowForgejoNscToken.path; + nscTokenFile = config.age.secrets.forgejoNscToken.path; dispatcher = { - package = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-dispatcher; - configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path; + configFile = config.age.secrets.forgejoNscDispatcherConfig.path; }; autoscaler = { enable = true; - package = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-autoscaler; - configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.path; + configFile = config.age.secrets.forgejoNscAutoscalerConfig.path; }; }; - - systemd.services.forgejo-nsc-dispatcher.preStart = lib.mkAfter forgejoNscMaterializeTokenCache; - systemd.services.forgejo-nsc-autoscaler.preStart = lib.mkAfter forgejoNscMaterializeTokenCache; - - services.burrow.authentik = { - enable = true; - envFile = config.age.secrets.burrowAuthentikEnv.path; - forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; - grafanaClientSecretFile = config.age.secrets.burrowGrafanaOidcClientSecret.path; - grafanaAccessGroupName = contributors.groups.users; - headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; - tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; - defaultExternalApplicationSlug = "tailscale"; - googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; - googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; - googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; - googleWifClientSecretFile = config.age.secrets.burrowAuthentikGoogleWifClientSecret.path; - googleLoginMode = "redirect"; - userGroupName = contributors.groups.users; - adminGroupName = contributors.groups.admins; - extraGroupNames = [ contributors.groups.automation ]; - tailscaleAccessGroupName = contributors.groups.users; - bootstrapUsers = bootstrapUsers; - linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; - linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; - linearDefaultRelayState = "https://linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; - linearScimUrl = "https://api.linear.app/auth/scim/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; - linearScimTokenFile = config.age.secrets.burrowLinearScimToken.path; - linearScimUserIdentifier = "email"; - linearOwnerGroupName = linearGroups.owners; - linearAdminGroupName = linearGroups.admins; - linearGuestGroupName = linearGroups.guests; - zulipAccessGroupName = contributors.groups.users; - }; - - services.grafana = { - enable = true; - settings = { - server = { - domain = "graphs.burrow.net"; - root_url = "https://graphs.burrow.net/"; - http_addr = "127.0.0.1"; - http_port = 3001; - enforce_domain = true; - }; - analytics = { - reporting_enabled = false; - check_for_updates = false; - check_for_plugin_updates = false; - }; - security = { - admin_user = "admin"; - admin_password = "$__file{${config.age.secrets.burrowGrafanaAdminPassword.path}}"; - secret_key = "$__file{${config.age.secrets.burrowGrafanaSecretKey.path}}"; - cookie_secure = true; - cookie_samesite = "strict"; - disable_gravatar = true; - }; - users = { - allow_sign_up = false; - auto_assign_org = true; - auto_assign_org_role = "Viewer"; - }; - auth = { - disable_login_form = false; - oauth_auto_login = false; - }; - "auth.generic_oauth" = { - enabled = true; - name = "burrow.net"; - allow_sign_up = true; - auto_login = true; - client_id = "graphs.burrow.net"; - client_secret = "$__file{${config.age.secrets.burrowGrafanaOidcClientSecret.path}}"; - scopes = "openid profile email groups"; - auth_url = "https://auth.burrow.net/application/o/grafana/authorize/"; - token_url = "https://auth.burrow.net/application/o/grafana/token/"; - api_url = "https://auth.burrow.net/application/o/grafana/userinfo/"; - role_attribute_path = "contains(groups[*], 'burrow-admins') && 'Admin' || 'Viewer'"; - }; - }; - }; - - services.caddy.virtualHosts."graphs.burrow.net".extraConfig = '' - encode gzip zstd - reverse_proxy 127.0.0.1:3001 - ''; - - services.caddy.virtualHosts."releases.burrow.net".extraConfig = '' - encode gzip zstd - rewrite * /burrow-net-releases{uri} - reverse_proxy https://storage.googleapis.com { - header_up Host storage.googleapis.com - } - ''; - - services.caddy.virtualHosts."packages.burrow.net".extraConfig = '' - encode gzip zstd - rewrite * /burrow-net-packages{uri} - reverse_proxy https://storage.googleapis.com { - header_up Host storage.googleapis.com - } - ''; - - services.burrow.headscale = { - enable = true; - oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; - bootstrapUsers = headscaleBootstrapUsers; - }; - - services.burrow.garage = { - enable = true; - enableApiProxy = true; - enableWebProxy = true; - }; - - services.burrow.nixCache.enable = true; - - services.burrow.observability.enable = true; - - services.burrow.zulip = { - enable = true; - administratorEmail = identities.contact.canonicalEmail; - postgresPasswordFile = config.age.secrets.burrowZulipPostgresPassword.path; - rabbitmqPasswordFile = config.age.secrets.burrowZulipRabbitmqPassword.path; - redisPasswordFile = config.age.secrets.burrowZulipRedisPassword.path; - secretKeyFile = config.age.secrets.burrowZulipSecretKey.path; - }; } diff --git a/nixos/keys/jett_at_burrow_net.pub b/nixos/keys/jett_at_burrow_net.pub deleted file mode 100644 index 36c85ee..0000000 --- a/nixos/keys/jett_at_burrow_net.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMe960j6TC869F6RvElpICxlBauIT3E0uLyy0m7n70ZC diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix deleted file mode 100644 index a96ece8..0000000 --- a/nixos/modules/burrow-authentik.nix +++ /dev/null @@ -1,1243 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.burrow.authentik; - runtimeDir = "/run/burrow-authentik"; - envFile = "${runtimeDir}/authentik.env"; - blueprintDir = "${runtimeDir}/blueprints"; - blueprintFile = "${blueprintDir}/burrow-authentik.yaml"; - postgresVolume = "burrow-authentik-postgresql:/var/lib/postgresql/data"; - dataVolume = "burrow-authentik-data:/data"; - directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; - forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; - grafanaOidcSyncScript = ../../Scripts/authentik-sync-grafana-oidc.sh; - googleWifOidcSyncScript = ../../Scripts/authentik-sync-google-wif-oidc.sh; - tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; - onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; - zulipSamlSyncScript = ../../Scripts/authentik-sync-zulip-saml.sh; - linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; - linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh; - googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; - tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; - authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' - version: 1 - metadata: - name: Burrow Authentik - labels: - blueprints.goauthentik.io/description: Minimal Burrow Authentik applications - entries: - - model: authentik_providers_oauth2.scopemapping - id: burrow-oidc-email - identifiers: - name: Burrow OIDC Email - attrs: - name: Burrow OIDC Email - scope_name: email - description: Verified email mapping for Burrow - expression: | - return { - "email": request.user.email, - "email_verified": True, - } - - - model: authentik_providers_oauth2.scopemapping - id: burrow-oidc-groups - identifiers: - name: Burrow OIDC Groups - attrs: - name: Burrow OIDC Groups - scope_name: groups - description: Group membership mapping for Burrow - expression: | - return { - "groups": [group.name for group in request.user.ak_groups.all()], - } - - - model: authentik_providers_oauth2.oauth2provider - id: burrow-oidc-provider-ts - identifiers: - name: Burrow Tailnet - attrs: - authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] - invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] - issuer_mode: per_provider - slug: ${cfg.headscaleProviderSlug} - client_type: confidential - client_id: ${cfg.headscaleDomain} - client_secret: !Env [AUTHENTIK_BURROW_TS_CLIENT_SECRET, ""] - include_claims_in_id_token: true - redirect_uris: - - matching_mode: strict - url: https://${cfg.headscaleDomain}/oidc/callback - property_mappings: - - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] - - !KeyOf burrow-oidc-email - - !KeyOf burrow-oidc-groups - - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] - signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] - - - model: authentik_core.application - identifiers: - slug: ${cfg.headscaleProviderSlug} - attrs: - name: Burrow Tailnet - slug: ${cfg.headscaleProviderSlug} - provider: !KeyOf burrow-oidc-provider-ts - meta_launch_url: https://${cfg.headscaleDomain}/ - ''; -in -{ - options.services.burrow.authentik = { - enable = lib.mkEnableOption "the Burrow Authentik identity provider"; - - domain = lib.mkOption { - type = lib.types.str; - default = "auth.burrow.net"; - description = "Public Authentik domain."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 9002; - description = "Local Authentik HTTP listen port."; - }; - - image = lib.mkOption { - type = lib.types.str; - default = "ghcr.io/goauthentik/server:2026.2.1"; - description = "Authentik container image reference."; - }; - - envFile = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/intake/authentik.env"; - description = "Host-local Authentik bootstrap environment file."; - }; - - headscaleDomain = lib.mkOption { - type = lib.types.str; - default = "ts.burrow.net"; - description = "Headscale public domain used for the bundled OIDC client."; - }; - - headscaleProviderSlug = lib.mkOption { - type = lib.types.str; - default = "ts"; - description = "Authentik provider slug for Headscale."; - }; - - forgejoDomain = lib.mkOption { - type = lib.types.str; - default = "git.burrow.net"; - description = "Forgejo public domain used for the bundled OIDC client."; - }; - - forgejoProviderSlug = lib.mkOption { - type = lib.types.str; - default = "git"; - description = "Authentik application slug for Forgejo."; - }; - - grafanaDomain = lib.mkOption { - type = lib.types.str; - default = "graphs.burrow.net"; - description = "Grafana public domain used for the bundled OIDC client."; - }; - - grafanaProviderSlug = lib.mkOption { - type = lib.types.str; - default = "grafana"; - description = "Authentik application slug for Grafana."; - }; - - grafanaClientId = lib.mkOption { - type = lib.types.str; - default = "graphs.burrow.net"; - description = "Client ID Authentik should present to Grafana."; - }; - - grafanaClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local file containing the Authentik Grafana OIDC client secret."; - }; - - grafanaAccessGroupName = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Authentik group allowed to launch Grafana."; - }; - - googleWifProviderSlug = lib.mkOption { - type = lib.types.str; - default = "google-cloud"; - description = "Authentik application slug for Google Cloud Workload Identity Federation."; - }; - - googleWifClientId = lib.mkOption { - type = lib.types.str; - default = "google-wif.burrow.net"; - description = "Client ID Authentik should present to Google Cloud WIF consumers."; - }; - - googleWifClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local file containing the Authentik Google WIF client-credentials secret."; - }; - - googleWifAccessGroupName = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional Authentik group allowed to launch the Google WIF application. Leave null for client-credentials token exchange."; - }; - - tailscaleProviderSlug = lib.mkOption { - type = lib.types.str; - default = "tailscale"; - description = "Authentik application slug for Tailscale custom OIDC sign-in."; - }; - - tailscaleClientId = lib.mkOption { - type = lib.types.str; - default = "tailscale.burrow.net"; - description = "Client ID Authentik should present to Tailscale."; - }; - - tailscaleClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local file containing the Authentik Tailscale OIDC client secret."; - }; - - tailscaleAccessGroupName = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Authentik group that should be allowed to launch the Tailscale application."; - }; - - defaultExternalApplicationSlug = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Authentik application slug that external users should land on instead of /if/user/."; - }; - - onePasswordDomain = lib.mkOption { - type = lib.types.str; - default = "burrow-team.1password.com"; - description = "1Password team sign-in domain used for Burrow Unlock with SSO."; - }; - - onePasswordProviderSlug = lib.mkOption { - type = lib.types.str; - default = "onepassword"; - description = "Authentik application slug for 1Password Unlock with SSO."; - }; - - onePasswordClientId = lib.mkOption { - type = lib.types.str; - default = "1password.burrow.net"; - description = "Public OIDC client ID Authentik should present to 1Password."; - }; - - onePasswordRedirectUris = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ - "https://burrow-team.1password.com/sso/oidc/redirect/" - "onepassword://sso/oidc/redirect" - ]; - description = "Allowed 1Password OIDC redirect URIs."; - }; - - linearProviderSlug = lib.mkOption { - type = lib.types.str; - default = "linear"; - description = "Authentik application slug for Linear SAML."; - }; - - zulipDomain = lib.mkOption { - type = lib.types.str; - default = "chat.burrow.net"; - description = "Public Zulip domain exposed through Authentik SAML."; - }; - - zulipProviderSlug = lib.mkOption { - type = lib.types.str; - default = "zulip"; - description = "Authentik application slug for Zulip SAML."; - }; - - zulipAcsUrl = lib.mkOption { - type = lib.types.str; - default = "https://${config.services.burrow.authentik.zulipDomain}/complete/saml/"; - description = "Zulip SAML ACS URL."; - }; - - zulipAudience = lib.mkOption { - type = lib.types.str; - default = "https://${config.services.burrow.authentik.zulipDomain}"; - description = "Zulip SAML audience/entity identifier."; - }; - - zulipLaunchUrl = lib.mkOption { - type = lib.types.str; - default = "https://${config.services.burrow.authentik.zulipDomain}/"; - description = "Zulip URL exposed in Authentik."; - }; - - zulipAccessGroupName = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Authentik group allowed to launch Zulip from Burrow SSO surfaces."; - }; - - linearAcsUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Linear SAML ACS URL."; - }; - - linearAudience = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Linear SAML audience/entity identifier."; - }; - - linearLaunchUrl = lib.mkOption { - type = lib.types.str; - default = "https://linear.app/burrownet"; - description = "Linear workspace URL exposed in Authentik."; - }; - - linearDefaultRelayState = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional Linear relay state or login URL for IdP-initiated launches."; - }; - - linearScimUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Linear SCIM base connector URL."; - }; - - linearScimTokenFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local file containing the Linear SCIM bearer token."; - }; - - linearScimUserIdentifier = lib.mkOption { - type = lib.types.str; - default = "email"; - description = "Linear SCIM unique identifier field for users."; - }; - - linearOwnerGroupName = lib.mkOption { - type = lib.types.str; - default = "linear-owners"; - description = "Authentik group name that should map to Linear owners."; - }; - - linearAdminGroupName = lib.mkOption { - type = lib.types.str; - default = "linear-admins"; - description = "Authentik group name that should map to Linear admins."; - }; - - linearGuestGroupName = lib.mkOption { - type = lib.types.str; - default = "linear-guests"; - description = "Authentik group name that should map to Linear guests."; - }; - - forgejoClientId = lib.mkOption { - type = lib.types.str; - default = "git.burrow.net"; - description = "Client ID Authentik should present to Forgejo."; - }; - - forgejoClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local file containing the Authentik Forgejo OIDC client secret."; - }; - - headscaleClientSecretFile = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; - description = "Host-local file containing the Authentik Headscale OIDC client secret."; - }; - - googleClientIDFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local file containing the Google OAuth client ID for the Authentik source."; - }; - - googleClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local file containing the Google OAuth client secret for the Authentik source."; - }; - - googleAccountMapFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional host-local JSON file mapping external Google accounts onto Burrow Authentik users."; - }; - - googleSourceSlug = lib.mkOption { - type = lib.types.str; - default = "google"; - description = "Authentik OAuth source slug used for Google login."; - }; - - googleLoginMode = lib.mkOption { - type = lib.types.enum [ - "promoted" - "redirect" - ]; - default = "redirect"; - description = "Identification-stage behavior for the Google Authentik source."; - }; - - headscaleAuthenticationFlowSlug = lib.mkOption { - type = lib.types.str; - default = "burrow-tailnet-authentication"; - description = "Authentik authentication flow slug used for Burrow Tailnet sign-in."; - }; - - headscaleAuthenticationFlowName = lib.mkOption { - type = lib.types.str; - default = "Burrow Tailnet Authentication"; - description = "Authentik authentication flow name used for Burrow Tailnet sign-in."; - }; - - headscaleIdentificationStageName = lib.mkOption { - type = lib.types.str; - default = "burrow-tailnet-identification-stage"; - description = "Authentik identification stage used for Burrow Tailnet sign-in."; - }; - - headscalePasswordStageName = lib.mkOption { - type = lib.types.str; - default = "burrow-tailnet-password-stage"; - description = "Authentik password stage used for Burrow Tailnet sign-in."; - }; - - headscaleUserLoginStageName = lib.mkOption { - type = lib.types.str; - default = "burrow-tailnet-user-login-stage"; - description = "Authentik user-login stage used for Burrow Tailnet sign-in."; - }; - - userGroupName = lib.mkOption { - type = lib.types.str; - default = "burrow-users"; - description = "Authentik group granted baseline Burrow access."; - }; - - adminGroupName = lib.mkOption { - type = lib.types.str; - default = "burrow-admins"; - description = "Authentik group granted Burrow administrator access."; - }; - - extraGroupNames = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = "Additional Authentik groups that should exist even if no bootstrap user currently belongs to them."; - }; - - bootstrapUsers = lib.mkOption { - type = with lib.types; listOf (submodule { - options = { - username = lib.mkOption { - type = str; - description = "Authentik username."; - }; - name = lib.mkOption { - type = str; - description = "Display name for the user."; - }; - email = lib.mkOption { - type = str; - description = "Canonical email stored in Authentik."; - }; - sourceEmail = lib.mkOption { - type = nullOr str; - default = null; - description = "External Google account email that should map onto this Authentik user."; - }; - groups = lib.mkOption { - type = listOf str; - default = [ ]; - description = "Additional Authentik groups for this user."; - }; - isAdmin = lib.mkOption { - type = bool; - default = false; - description = "Whether this user should be in the Burrow admin group."; - }; - passwordFile = lib.mkOption { - type = nullOr str; - default = null; - description = "Optional host-local file containing a bootstrap password for this user."; - }; - }; - }); - default = [ ]; - description = "Declarative Burrow users to create in Authentik."; - }; - }; - - config = lib.mkIf cfg.enable { - virtualisation.podman.enable = true; - - systemd.tmpfiles.rules = [ - "d ${runtimeDir} 0750 root root -" - "d ${blueprintDir} 0750 root root -" - ]; - - systemd.services.burrow-authentik-runtime = { - description = "Render the Burrow Authentik runtime environment"; - before = [ - "podman-burrow-authentik-postgresql.service" - "podman-burrow-authentik-server.service" - "podman-burrow-authentik-worker.service" - ]; - wantedBy = [ - "podman-burrow-authentik-postgresql.service" - "podman-burrow-authentik-server.service" - "podman-burrow-authentik-worker.service" - ]; - after = lib.optionals config.services.burrow.headscale.enable [ - "burrow-headscale-client-secret.service" - ]; - wants = lib.optionals config.services.burrow.headscale.enable [ - "burrow-headscale-client-secret.service" - ]; - path = [ pkgs.coreutils ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - RemainAfterExit = true; - }; - script = '' - set -euo pipefail - - if [ ! -s ${lib.escapeShellArg cfg.envFile} ]; then - echo "Authentik env file missing: ${cfg.envFile}" >&2 - exit 1 - fi - - if [ ! -s ${lib.escapeShellArg cfg.headscaleClientSecretFile} ]; then - echo "Headscale client secret missing: ${cfg.headscaleClientSecretFile}" >&2 - exit 1 - fi - - ${lib.optionalString (cfg.forgejoClientSecretFile != null) '' - if [ ! -s ${lib.escapeShellArg cfg.forgejoClientSecretFile} ]; then - echo "Forgejo client secret missing: ${cfg.forgejoClientSecretFile}" >&2 - exit 1 - fi - ''} - - ${lib.optionalString (cfg.tailscaleClientSecretFile != null) '' - if [ ! -s ${lib.escapeShellArg cfg.tailscaleClientSecretFile} ]; then - echo "Tailscale client secret missing: ${cfg.tailscaleClientSecretFile}" >&2 - exit 1 - fi - ''} - - ${lib.optionalString (cfg.googleWifClientSecretFile != null) '' - if [ ! -s ${lib.escapeShellArg cfg.googleWifClientSecretFile} ]; then - echo "Google WIF client secret missing: ${cfg.googleWifClientSecretFile}" >&2 - exit 1 - fi - ''} - - install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir} - install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} - - source ${lib.escapeShellArg cfg.envFile} - - read_secret() { - tr -d '\r\n' < "$1" - } - - cat > ${envFile} </dev/null; then - exit 0 - fi - sleep 2 - done - - echo "Authentik did not become ready on ${cfg.domain}" >&2 - exit 1 - ''; - }; - - systemd.services.burrow-authentik-google-source = lib.mkIf ( - cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null - ) { - description = "Reconcile the Burrow Authentik Google OAuth source"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - googleSourceSyncScript - cfg.envFile - cfg.googleClientIDFile - cfg.googleClientSecretFile - ] ++ lib.optional (cfg.googleAccountMapFile != null) cfg.googleAccountMapFile; - 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_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug} - export AUTHENTIK_GOOGLE_LOGIN_MODE=${lib.escapeShellArg cfg.googleLoginMode} - export AUTHENTIK_GOOGLE_USER_MATCHING_MODE=email_link - export AUTHENTIK_GOOGLE_CLIENT_ID="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientIDFile})" - export AUTHENTIK_GOOGLE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientSecretFile})" - if [ -n ${lib.escapeShellArg (cfg.googleAccountMapFile or "")} ]; then - export AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON="$(tr -d '\n' < ${lib.escapeShellArg (cfg.googleAccountMapFile or "/dev/null")})" - else - export AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON='${builtins.toJSON (map (user: { - source_email = user.sourceEmail; - username = user.username; - email = user.email; - name = user.name; - }) (lib.filter (user: user.sourceEmail != null) cfg.bootstrapUsers))}' - fi - - ${pkgs.bash}/bin/bash ${googleSourceSyncScript} - ''; - }; - - systemd.services.burrow-authentik-directory = lib.mkIf (cfg.bootstrapUsers != [ ] || cfg.extraGroupNames != [ ]) { - description = "Reconcile Burrow Authentik users and groups"; - after = - [ - "burrow-authentik-ready.service" - "network-online.target" - ] - ++ lib.optionals (cfg.forgejoClientSecretFile != null) [ "burrow-authentik-forgejo-oidc.service" ]; - wants = - [ - "burrow-authentik-ready.service" - "network-online.target" - ] - ++ lib.optionals (cfg.forgejoClientSecretFile != null) [ "burrow-authentik-forgejo-oidc.service" ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - directorySyncScript - cfg.envFile - ] ++ lib.concatMap (user: lib.optional (user.passwordFile != null) user.passwordFile) cfg.bootstrapUsers; - 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_BURROW_USERS_GROUP=${lib.escapeShellArg cfg.userGroupName} - export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName} - export AUTHENTIK_BURROW_EXTRA_GROUPS_JSON='${builtins.toJSON cfg.extraGroupNames}' - export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} - export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: { - inherit (user) username name email isAdmin passwordFile; - groups = user.groups; - }) cfg.bootstrapUsers)}' - - ${pkgs.bash}/bin/bash ${directorySyncScript} - ''; - }; - - systemd.services.burrow-authentik-tailnet-auth-flow = { - description = "Reconcile the Burrow Tailnet authentication flow"; - after = - [ - "burrow-authentik-ready.service" - "network-online.target" - ] - ++ lib.optionals ( - cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null - ) [ "burrow-authentik-google-source.service" ]; - wants = - [ - "burrow-authentik-ready.service" - "network-online.target" - ] - ++ lib.optionals ( - cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null - ) [ "burrow-authentik-google-source.service" ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - tailnetAuthFlowSyncScript - cfg.envFile - ]; - 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_TAILNET_PROVIDER_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} - export AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON='["${cfg.headscaleProviderSlug}","${cfg.tailscaleProviderSlug}"]' - export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME=${lib.escapeShellArg cfg.headscaleAuthenticationFlowName} - export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG=${lib.escapeShellArg cfg.headscaleAuthenticationFlowSlug} - export AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME=${lib.escapeShellArg cfg.headscaleIdentificationStageName} - export AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME=${lib.escapeShellArg cfg.headscalePasswordStageName} - export AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME=${lib.escapeShellArg cfg.headscaleUserLoginStageName} - export AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug} - - ${pkgs.bash}/bin/bash ${tailnetAuthFlowSyncScript} - ''; - }; - - systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { - description = "Reconcile the Burrow Authentik Forgejo OIDC application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - forgejoOidcSyncScript - cfg.envFile - cfg.forgejoClientSecretFile - ]; - 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_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} - export AUTHENTIK_FORGEJO_APPLICATION_NAME=burrow.net - export AUTHENTIK_FORGEJO_PROVIDER_NAME=burrow.net - export AUTHENTIK_FORGEJO_CLIENT_ID=${lib.escapeShellArg cfg.forgejoClientId} - export AUTHENTIK_FORGEJO_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.forgejoClientSecretFile})" - export AUTHENTIK_FORGEJO_LAUNCH_URL=https://${cfg.forgejoDomain}/ - export AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON='["https://${cfg.forgejoDomain}/user/oauth2/burrow.net/callback","https://${cfg.forgejoDomain}/user/oauth2/authentik/callback"]' - - ${pkgs.bash}/bin/bash ${forgejoOidcSyncScript} - ''; - }; - - systemd.services.burrow-authentik-grafana-oidc = lib.mkIf (cfg.grafanaClientSecretFile != null) { - description = "Reconcile the Burrow Authentik Grafana OIDC application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - grafanaOidcSyncScript - cfg.envFile - cfg.grafanaClientSecretFile - ]; - 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_GRAFANA_APPLICATION_SLUG=${lib.escapeShellArg cfg.grafanaProviderSlug} - export AUTHENTIK_GRAFANA_APPLICATION_NAME="Burrow Grafana" - export AUTHENTIK_GRAFANA_PROVIDER_NAME="Burrow Grafana" - export AUTHENTIK_GRAFANA_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} - export AUTHENTIK_GRAFANA_CLIENT_ID=${lib.escapeShellArg cfg.grafanaClientId} - export AUTHENTIK_GRAFANA_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.grafanaClientSecretFile})" - export AUTHENTIK_GRAFANA_LAUNCH_URL=https://${cfg.grafanaDomain}/ - export AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON='["https://${cfg.grafanaDomain}/login/generic_oauth"]' - ${lib.optionalString (cfg.grafanaAccessGroupName != null) '' - export AUTHENTIK_GRAFANA_ACCESS_GROUP=${lib.escapeShellArg cfg.grafanaAccessGroupName} - ''} - - ${pkgs.bash}/bin/bash ${grafanaOidcSyncScript} - ''; - }; - - systemd.services.burrow-authentik-google-wif-oidc = lib.mkIf (cfg.googleWifClientSecretFile != null) { - description = "Reconcile the Burrow Authentik Google WIF OIDC application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - googleWifOidcSyncScript - cfg.envFile - cfg.googleWifClientSecretFile - ]; - 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_GOOGLE_WIF_APPLICATION_SLUG=${lib.escapeShellArg cfg.googleWifProviderSlug} - export AUTHENTIK_GOOGLE_WIF_APPLICATION_NAME="Google Cloud WIF" - export AUTHENTIK_GOOGLE_WIF_PROVIDER_NAME="Google Cloud WIF" - export AUTHENTIK_GOOGLE_WIF_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} - export AUTHENTIK_GOOGLE_WIF_CLIENT_ID=${lib.escapeShellArg cfg.googleWifClientId} - export AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleWifClientSecretFile})" - export AUTHENTIK_GOOGLE_WIF_LAUNCH_URL=https://console.cloud.google.com/ - export AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON='[]' - ${lib.optionalString (cfg.googleWifAccessGroupName != null) '' - export AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP=${lib.escapeShellArg cfg.googleWifAccessGroupName} - ''} - - ${pkgs.bash}/bin/bash ${googleWifOidcSyncScript} - ''; - }; - - systemd.services.burrow-authentik-tailscale-oidc = lib.mkIf (cfg.tailscaleClientSecretFile != null) { - description = "Reconcile the Burrow Authentik Tailscale OIDC application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - tailscaleOidcSyncScript - cfg.envFile - cfg.tailscaleClientSecretFile - ]; - 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_TAILSCALE_APPLICATION_SLUG=${lib.escapeShellArg cfg.tailscaleProviderSlug} - export AUTHENTIK_TAILSCALE_APPLICATION_NAME=Tailscale - export AUTHENTIK_TAILSCALE_PROVIDER_NAME=Tailscale - export AUTHENTIK_TAILSCALE_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} - export AUTHENTIK_TAILSCALE_CLIENT_ID=${lib.escapeShellArg cfg.tailscaleClientId} - export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})" - export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc - export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]' - ${lib.optionalString (cfg.tailscaleAccessGroupName != null) '' - export AUTHENTIK_TAILSCALE_ACCESS_GROUP=${lib.escapeShellArg cfg.tailscaleAccessGroupName} - ''} - ${lib.optionalString (cfg.defaultExternalApplicationSlug != null) '' - export AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.defaultExternalApplicationSlug} - ''} - - ${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript} - ''; - }; - - systemd.services.burrow-authentik-1password-oidc = { - description = "Reconcile the Burrow Authentik 1Password OIDC application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - onePasswordOidcSyncScript - cfg.envFile - ]; - 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_ONEPASSWORD_APPLICATION_SLUG=${lib.escapeShellArg cfg.onePasswordProviderSlug} - export AUTHENTIK_ONEPASSWORD_APPLICATION_NAME=1Password - export AUTHENTIK_ONEPASSWORD_PROVIDER_NAME=1Password - export AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} - export AUTHENTIK_ONEPASSWORD_CLIENT_ID=${lib.escapeShellArg cfg.onePasswordClientId} - export AUTHENTIK_ONEPASSWORD_LAUNCH_URL=https://${cfg.onePasswordDomain}/ - export AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON='${builtins.toJSON cfg.onePasswordRedirectUris}' - - ${pkgs.bash}/bin/bash ${onePasswordOidcSyncScript} - ''; - }; - - systemd.services.burrow-authentik-zulip-saml = { - description = "Reconcile the Burrow Authentik Zulip SAML application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - zulipSamlSyncScript - cfg.envFile - ]; - 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_ZULIP_APPLICATION_SLUG=${lib.escapeShellArg cfg.zulipProviderSlug} - export AUTHENTIK_ZULIP_APPLICATION_NAME=Zulip - export AUTHENTIK_ZULIP_PROVIDER_NAME=Zulip - export AUTHENTIK_ZULIP_ACS_URL=${lib.escapeShellArg cfg.zulipAcsUrl} - export AUTHENTIK_ZULIP_AUDIENCE=${lib.escapeShellArg cfg.zulipAudience} - export AUTHENTIK_ZULIP_LAUNCH_URL=${lib.escapeShellArg cfg.zulipLaunchUrl} - ${lib.optionalString (cfg.zulipAccessGroupName != null) '' - export AUTHENTIK_ZULIP_ACCESS_GROUP=${lib.escapeShellArg cfg.zulipAccessGroupName} - ''} - export AUTHENTIK_ZULIP_ADMIN_GROUP=${lib.escapeShellArg cfg.adminGroupName} - - ${pkgs.bash}/bin/bash ${zulipSamlSyncScript} - ''; - }; - - systemd.services.burrow-authentik-linear-saml = lib.mkIf ( - cfg.linearAcsUrl != null && cfg.linearAudience != null - ) { - description = "Reconcile the Burrow Authentik Linear SAML application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - linearSamlSyncScript - cfg.envFile - ]; - 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_LINEAR_APPLICATION_SLUG=${lib.escapeShellArg cfg.linearProviderSlug} - export AUTHENTIK_LINEAR_APPLICATION_NAME=Linear - export AUTHENTIK_LINEAR_PROVIDER_NAME=Linear - export AUTHENTIK_LINEAR_ACS_URL=${lib.escapeShellArg cfg.linearAcsUrl} - export AUTHENTIK_LINEAR_AUDIENCE=${lib.escapeShellArg cfg.linearAudience} - export AUTHENTIK_LINEAR_LAUNCH_URL=${lib.escapeShellArg cfg.linearLaunchUrl} - ${lib.optionalString (cfg.linearDefaultRelayState != null) '' - export AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE=${lib.escapeShellArg cfg.linearDefaultRelayState} - ''} - - ${pkgs.bash}/bin/bash ${linearSamlSyncScript} - ''; - }; - - systemd.services.burrow-authentik-linear-scim = lib.mkIf ( - cfg.linearScimUrl != null && cfg.linearScimTokenFile != null - ) { - description = "Reconcile the Burrow Authentik Linear SCIM provider"; - after = [ - "burrow-authentik-ready.service" - "burrow-authentik-directory.service" - "burrow-authentik-linear-saml.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "burrow-authentik-directory.service" - "burrow-authentik-linear-saml.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - linearScimSyncScript - cfg.envFile - cfg.linearScimTokenFile - ]; - 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_LINEAR_APPLICATION_SLUG=${lib.escapeShellArg cfg.linearProviderSlug} - export AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME="Linear SCIM" - export AUTHENTIK_LINEAR_SCIM_URL=${lib.escapeShellArg cfg.linearScimUrl} - export AUTHENTIK_LINEAR_SCIM_TOKEN_FILE=${lib.escapeShellArg cfg.linearScimTokenFile} - export AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER=${lib.escapeShellArg cfg.linearScimUserIdentifier} - export AUTHENTIK_LINEAR_OWNER_GROUP=${lib.escapeShellArg cfg.linearOwnerGroupName} - export AUTHENTIK_LINEAR_ADMIN_GROUP=${lib.escapeShellArg cfg.linearAdminGroupName} - export AUTHENTIK_LINEAR_GUEST_GROUP=${lib.escapeShellArg cfg.linearGuestGroupName} - - ${pkgs.bash}/bin/bash ${linearScimSyncScript} - ''; - }; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' - encode gzip zstd - reverse_proxy 127.0.0.1:${toString cfg.port} - ''; - }; -} diff --git a/nixos/modules/burrow-forge-runner.nix b/nixos/modules/burrow-forge-runner.nix index b5e9d72..1e183d2 100644 --- a/nixos/modules/burrow-forge-runner.nix +++ b/nixos/modules/burrow-forge-runner.nix @@ -5,11 +5,8 @@ let runnerPkg = pkgs.forgejo-runner; stateDir = cfg.stateDir; runnerFile = "${stateDir}/.runner"; - registrationFingerprintFile = "${stateDir}/.runner-registration-fingerprint"; configFile = "${stateDir}/runner.yaml"; - ageIdentityFile = "${stateDir}/age_keystore"; labelsCsv = lib.concatStringsSep "," (map (label: "${label}:host") cfg.labels); - registrationFingerprint = builtins.hashString "sha256" "${cfg.instanceUrl}\n${cfg.name}\n${labelsCsv}"; sshPrivateKeyFile = cfg.sshPrivateKeyFile or ""; in { @@ -144,17 +141,6 @@ EOF chown ${cfg.user}:${cfg.group} ${configFile} chmod 0640 ${configFile} - expected_fingerprint=${lib.escapeShellArg registrationFingerprint} - if [ -s ${runnerFile} ]; then - current_fingerprint="" - if [ -s ${registrationFingerprintFile} ]; then - current_fingerprint="$(tr -d '\r\n' < ${registrationFingerprintFile})" - fi - if [ "${"$"}current_fingerprint" != "${"$"}expected_fingerprint" ]; then - rm -f ${runnerFile} ${registrationFingerprintFile} - fi - fi - install -d -m 0700 -o ${cfg.user} -g ${cfg.group} ${stateDir}/.ssh ${pkgs.util-linux}/bin/runuser -u ${cfg.user} -- \ ${pkgs.git}/bin/git config --global user.name ${lib.escapeShellArg cfg.gitUserName} @@ -165,9 +151,6 @@ EOF install -m 0600 -o ${cfg.user} -g ${cfg.group} \ ${lib.escapeShellArg sshPrivateKeyFile} \ ${stateDir}/.ssh/id_ed25519 - install -m 0600 -o ${cfg.user} -g ${cfg.group} \ - ${lib.escapeShellArg sshPrivateKeyFile} \ - ${ageIdentityFile} cat > ${stateDir}/.ssh/config < ${registrationFingerprintFile} - chown ${cfg.user}:${cfg.group} ${registrationFingerprintFile} - chmod 0640 ${registrationFingerprintFile} fi ''; }; @@ -212,13 +191,6 @@ EOF User = cfg.user; Group = cfg.group; WorkingDirectory = stateDir; - Environment = [ - "BURROW_RUNNER_REGISTRATION_FINGERPRINT=${registrationFingerprint}" - "BURROW_RUNNER_AGE_IDENTITY_PATH=${ageIdentityFile}" - "FORGEJO_RUNNER_HOME=${stateDir}" - "RUNNER_HOME=${stateDir}" - "HOME=${stateDir}" - ]; Restart = "on-failure"; RestartSec = 2; ExecStart = pkgs.writeShellScript "burrow-forgejo-runner" '' diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index 947e642..e02475f 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -10,7 +10,6 @@ let forgejoAdminArgs = "--config ${lib.escapeShellArg forgejoConfigFile} --work-path ${lib.escapeShellArg forgejoWorkPath} --custom-path ${lib.escapeShellArg forgejoCustomPath}"; homeRepoPath = "/${cfg.homeOwner}/${cfg.homeRepo}"; homeRepoUrl = "https://${cfg.gitDomain}${homeRepoPath}"; - sqlLiteral = value: "'${lib.replaceStrings [ "'" ] [ "''" ] value}'"; in { options.services.burrow.forge = { @@ -69,77 +68,6 @@ in description = "Host-local path to the plaintext bootstrap password file for the initial Forgejo admin."; }; - oidcDisplayName = lib.mkOption { - type = lib.types.str; - default = "burrow.net"; - description = "Login button label for the Forgejo OIDC provider."; - }; - - oidcClientId = lib.mkOption { - type = lib.types.str; - default = "git.burrow.net"; - description = "OIDC client ID that Forgejo should use against Authentik."; - }; - - oidcClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Host-local path to the Forgejo OIDC client secret."; - }; - - oidcDiscoveryUrl = lib.mkOption { - type = lib.types.str; - default = "https://auth.burrow.net/application/o/git/.well-known/openid-configuration"; - description = "OpenID Connect discovery URL for the Forgejo login source."; - }; - - oidcScopes = lib.mkOption { - type = with lib.types; listOf str; - default = [ - "openid" - "profile" - "email" - "groups" - ]; - description = "OIDC scopes requested from Authentik."; - }; - - oidcGroupClaimName = lib.mkOption { - type = lib.types.str; - default = "groups"; - description = "OIDC claim name that carries group membership."; - }; - - oidcAdminGroup = lib.mkOption { - type = lib.types.str; - default = "burrow-admins"; - description = "OIDC group that should grant Forgejo admin access."; - }; - - oidcRestrictedGroup = lib.mkOption { - type = lib.types.str; - default = "burrow-users"; - description = "OIDC group that is required to log into Forgejo."; - }; - - oidcAutoRegistration = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether Forgejo should automatically create users for new OIDC sign-ins."; - }; - - oidcAccountLinking = lib.mkOption { - type = lib.types.enum [ "disabled" "login" "auto" ]; - default = "auto"; - description = "How Forgejo should link existing local accounts for OIDC sign-ins."; - }; - - oidcUsernameSource = lib.mkOption { - type = lib.types.enum [ "userid" "nickname" "email" ]; - default = "email"; - description = "Which OIDC claim Forgejo should use to derive usernames for auto-registration."; - }; - authorizedKeys = lib.mkOption { type = with lib.types; listOf str; default = [ ]; @@ -204,9 +132,6 @@ in service = { DISABLE_REGISTRATION = true; - ENABLE_INTERNAL_SIGNIN = false; - ENABLE_BASIC_AUTHENTICATION = false; - SHOW_REGISTRATION_BUTTON = false; REQUIRE_SIGNIN_VIEW = false; DEFAULT_ALLOW_CREATE_ORGANIZATION = false; ENABLE_NOTIFY_MAIL = false; @@ -223,13 +148,6 @@ in ENABLE_OPENID_SIGNUP = false; }; - oauth2_client = { - OPENID_CONNECT_SCOPES = lib.concatStringsSep " " (lib.subtractLists [ "openid" ] cfg.oidcScopes); - ENABLE_AUTO_REGISTRATION = cfg.oidcAutoRegistration; - ACCOUNT_LINKING = cfg.oidcAccountLinking; - USERNAME = cfg.oidcUsernameSource; - }; - actions = { ENABLED = true; }; @@ -237,7 +155,6 @@ in repository = { DEFAULT_BRANCH = "main"; ENABLE_PUSH_CREATE_USER = false; - DISABLE_MIRRORS = true; }; ui = { @@ -258,22 +175,13 @@ in reverse_proxy 127.0.0.1:${toString config.services.forgejo.settings.server.HTTP_PORT} ''; "${cfg.siteDomain}".extraConfig = '' - encode gzip zstd - @oidcConfig path /.well-known/openid-configuration - redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/.well-known/openid-configuration 308 - @tailnetConfig path /.well-known/burrow-tailnet - header @tailnetConfig Content-Type application/json - respond @tailnetConfig "{\"domain\":\"${cfg.siteDomain}\",\"provider\":\"headscale\",\"authority\":\"https://${config.services.burrow.headscale.domain}\",\"oidc_issuer\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/\"}" 200 - @webfinger path /.well-known/webfinger - header @webfinger Content-Type application/jrd+json - respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 @root path / redir @root ${homeRepoUrl} 308 respond 404 ''; } // lib.optionalAttrs ( - config.services.forgejo-nsc.enable && config.services.forgejo-nsc.autoscaler.enable + config.services.burrow.forgejoNsc.enable && config.services.burrow.forgejoNsc.autoscaler.enable ) { "${cfg.nscAutoscalerDomain}".extraConfig = '' encode gzip zstd @@ -335,195 +243,5 @@ in fi ''; }; - - systemd.services.burrow-forgejo-repository-policy = { - description = "Apply Burrow Forgejo repository authority policy"; - after = [ - "forgejo.service" - "postgresql.service" - ]; - requires = [ - "forgejo.service" - "postgresql.service" - ]; - wantedBy = [ "multi-user.target" ]; - path = [ - pkgs.postgresql - ]; - serviceConfig = { - Type = "oneshot"; - User = forgejoCfg.user; - Group = forgejoCfg.group; - WorkingDirectory = forgejoCfg.stateDir; - }; - script = '' - set -euo pipefail - - ready=0 - for attempt in $(seq 1 60); do - if ${pkgs.postgresql}/bin/psql -h /run/postgresql -U forgejo forgejo -tAc \ - "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='repository';" \ - | grep -q 1; then - ready=1 - break - fi - sleep 1 - done - - if [ "$ready" -ne 1 ]; then - echo "Forgejo repository table did not become ready" >&2 - exit 1 - fi - - ${pkgs.postgresql}/bin/psql -v ON_ERROR_STOP=1 \ - -h /run/postgresql -U forgejo forgejo <<'SQL' - DO $$ - DECLARE - repo_ids BIGINT[]; - BEGIN - SELECT array_agg(r.id) - INTO repo_ids - FROM repository r - JOIN "user" u ON u.id = r.owner_id - WHERE u.lower_name = lower(${sqlLiteral cfg.homeOwner}) - AND r.lower_name = lower(${sqlLiteral cfg.homeRepo}); - - IF repo_ids IS NULL THEN - RETURN; - END IF; - - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'repository' - AND column_name = 'is_mirror' - ) THEN - UPDATE repository SET is_mirror = FALSE WHERE id = ANY(repo_ids); - END IF; - - IF to_regclass('public.mirror') IS NOT NULL THEN - DELETE FROM mirror WHERE repo_id = ANY(repo_ids); - END IF; - - IF to_regclass('public.push_mirror') IS NOT NULL THEN - DELETE FROM push_mirror WHERE repo_id = ANY(repo_ids); - END IF; - END $$; - SQL - ''; - }; - - systemd.services.burrow-forgejo-oidc-bootstrap = lib.mkIf (cfg.oidcClientSecretFile != null) { - description = "Seed the Burrow Forgejo OIDC login source"; - after = [ - "forgejo.service" - "postgresql.service" - ] ++ lib.optionals config.services.burrow.authentik.enable [ - "burrow-authentik-ready.service" - ]; - wants = lib.optionals config.services.burrow.authentik.enable [ - "burrow-authentik-ready.service" - ]; - requires = [ - "forgejo.service" - "postgresql.service" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - cfg.oidcClientSecretFile - ]; - path = [ - pkgs.coreutils - pkgs.gnugrep - pkgs.jq - pkgs.postgresql - ]; - serviceConfig = { - Type = "oneshot"; - User = forgejoCfg.user; - Group = forgejoCfg.group; - WorkingDirectory = forgejoCfg.stateDir; - }; - script = '' - set -euo pipefail - - if [ ! -s ${lib.escapeShellArg cfg.oidcClientSecretFile} ]; then - echo "Forgejo OIDC client secret missing: ${cfg.oidcClientSecretFile}" >&2 - exit 1 - fi - - ready=0 - for attempt in $(seq 1 60); do - if ${pkgs.postgresql}/bin/psql -h /run/postgresql -U forgejo forgejo -tAc \ - "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='login_source';" \ - | grep -q 1; then - ready=1 - break - fi - sleep 1 - done - - if [ "$ready" -ne 1 ]; then - echo "Forgejo login_source table did not become ready" >&2 - exit 1 - fi - - oidc_secret="$(${pkgs.coreutils}/bin/tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" - if [ -z "$oidc_secret" ]; then - echo "Forgejo OIDC client secret is empty" >&2 - exit 1 - fi - - cfg_json="$(${pkgs.jq}/bin/jq -nc \ - --arg client_id ${lib.escapeShellArg cfg.oidcClientId} \ - --arg client_secret "$oidc_secret" \ - --arg discovery_url ${lib.escapeShellArg cfg.oidcDiscoveryUrl} \ - --argjson scopes '${builtins.toJSON cfg.oidcScopes}' \ - --arg group_claim_name ${lib.escapeShellArg cfg.oidcGroupClaimName} \ - --arg admin_group ${lib.escapeShellArg cfg.oidcAdminGroup} \ - --arg restricted_group ${lib.escapeShellArg cfg.oidcRestrictedGroup} \ - '{ - Provider: "openidConnect", - ClientID: $client_id, - ClientSecret: $client_secret, - OpenIDConnectAutoDiscoveryURL: $discovery_url, - CustomURLMapping: null, - IconURL: "", - Scopes: $scopes, - AttributeSSHPublicKey: "", - RequiredClaimName: "", - RequiredClaimValue: "", - GroupClaimName: $group_claim_name, - AdminGroup: $admin_group, - GroupTeamMap: "", - GroupTeamMapRemoval: false, - RestrictedGroup: $restricted_group - }')" - - ${pkgs.postgresql}/bin/psql -v ON_ERROR_STOP=1 \ - -h /run/postgresql -U forgejo forgejo \ - -v oidc_name=${lib.escapeShellArg cfg.oidcDisplayName} \ - -v cfg_json="$cfg_json" <<'SQL' - INSERT INTO login_source ( - type, name, is_active, is_sync_enabled, cfg, created_unix, updated_unix - ) VALUES ( - 6, - :'oidc_name', - TRUE, - FALSE, - :'cfg_json', - EXTRACT(EPOCH FROM NOW())::BIGINT, - EXTRACT(EPOCH FROM NOW())::BIGINT - ) - ON CONFLICT (name) DO UPDATE SET - type = EXCLUDED.type, - is_active = TRUE, - is_sync_enabled = FALSE, - cfg = EXCLUDED.cfg, - updated_unix = EXCLUDED.updated_unix; - SQL - ''; - }; }; } diff --git a/nixos/modules/burrow-forgejo-nsc.nix b/nixos/modules/burrow-forgejo-nsc.nix new file mode 100644 index 0000000..e05b2ae --- /dev/null +++ b/nixos/modules/burrow-forgejo-nsc.nix @@ -0,0 +1,296 @@ +{ config, lib, pkgs, self, ... }: + +let + inherit (lib) + mkEnableOption + mkIf + mkOption + types + mkAfter + mkDefault + optional + optionalAttrs + optionalString + ; + + cfg = config.services.burrow.forgejoNsc; + dispatcherRuntimeConfig = "${cfg.stateDir}/dispatcher.yaml"; + autoscalerRuntimeConfig = "${cfg.stateDir}/autoscaler.yaml"; + + pendingCheck = configPath: pkgs.writeShellScript "forgejo-nsc-check-pending" '' + set -euo pipefail + if ${pkgs.gnugrep}/bin/grep -q 'PENDING-' '${configPath}'; then + echo "forgejo-nsc config still contains placeholder values (PENDING-); update ${configPath} before starting." >&2 + exit 1 + fi + ''; + + nscTokenPath = "${cfg.stateDir}/nsc.token"; + tokenSync = optionalString (cfg.nscTokenFile != null) '' + install -m 600 ${lib.escapeShellArg cfg.nscTokenFile} ${lib.escapeShellArg nscTokenPath} + chown ${cfg.user}:${cfg.group} ${nscTokenPath} + chmod 600 ${nscTokenPath} + ''; + dispatcherConfigSync = optionalString (cfg.dispatcher.configFile != null) '' + install -m 400 ${lib.escapeShellArg cfg.dispatcher.configFile} ${lib.escapeShellArg dispatcherRuntimeConfig} + chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg dispatcherRuntimeConfig} + chmod 400 ${lib.escapeShellArg dispatcherRuntimeConfig} + ''; + autoscalerConfigSync = optionalString (cfg.autoscaler.configFile != null) '' + install -m 400 ${lib.escapeShellArg cfg.autoscaler.configFile} ${lib.escapeShellArg autoscalerRuntimeConfig} + chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg autoscalerRuntimeConfig} + chmod 400 ${lib.escapeShellArg autoscalerRuntimeConfig} + ''; + + dispatcherEnv = + cfg.extraEnv + // optionalAttrs (cfg.nscTokenFile != null) { NSC_TOKEN_FILE = nscTokenPath; } + // optionalAttrs (cfg.nscTokenSpecFile != null) { NSC_TOKEN_SPEC_FILE = cfg.nscTokenSpecFile; } + // optionalAttrs (cfg.nscEndpoint != null) { NSC_ENDPOINT = cfg.nscEndpoint; }; +in { + options.services.burrow.forgejoNsc = { + enable = mkEnableOption "Forgejo Namespace Cloud runner dispatcher"; + + user = mkOption { + type = types.str; + default = "forgejo-nsc"; + description = "System user that runs the forgejo-nsc services."; + }; + + group = mkOption { + type = types.str; + default = "forgejo-nsc"; + description = "System group for the forgejo-nsc services."; + }; + + stateDir = mkOption { + type = types.str; + default = "/var/lib/forgejo-nsc"; + description = "State directory for the dispatcher/autoscaler."; + }; + + nscTokenFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Optional NSC token file (exported as NSC_TOKEN_FILE)."; + }; + + nscTokenSpecFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Optional NSC token spec file (exported as NSC_TOKEN_SPEC_FILE)."; + }; + + nscEndpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Optional NSC endpoint override (exported as NSC_ENDPOINT)."; + }; + + extraEnv = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Extra environment variables injected into the services."; + }; + + nscPackage = mkOption { + type = types.nullOr types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.nsc or null; + description = "Optional nsc CLI package added to the service PATH."; + }; + + dispatcher = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable the forgejo-nsc dispatcher service."; + }; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-dispatcher; + description = "Package providing the forgejo-nsc dispatcher binary."; + }; + + configFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Host-local YAML config file for the dispatcher."; + }; + + allowPending = mkOption { + type = types.bool; + default = false; + description = "Allow placeholder values (PENDING-) in the dispatcher config."; + }; + }; + + autoscaler = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable the forgejo-nsc autoscaler service."; + }; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-autoscaler; + description = "Package providing the forgejo-nsc autoscaler binary."; + }; + + configFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Host-local YAML config file for the autoscaler."; + }; + + allowPending = mkOption { + type = types.bool; + default = false; + description = "Allow placeholder values (PENDING-) in the autoscaler config."; + }; + }; + + pruneRunners = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable periodic pruning of stale Forgejo action runners."; + }; + + ttlSeconds = mkOption { + type = types.ints.positive; + default = 3600; + description = "Age threshold in seconds before offline runners are marked deleted."; + }; + + onBootSec = mkOption { + type = types.str; + default = "15m"; + description = "How long after boot to wait before the first prune run."; + }; + + onUnitActiveSec = mkOption { + type = types.str; + default = "1h"; + description = "How often to rerun stale runner pruning."; + }; + + randomizedDelaySec = mkOption { + type = types.str; + default = "10m"; + description = "Randomized delay applied to the prune timer."; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = (!cfg.dispatcher.enable) || cfg.dispatcher.configFile != null; + message = "services.burrow.forgejoNsc.dispatcher.configFile must be set when the dispatcher is enabled."; + } + { + assertion = (!cfg.autoscaler.enable) || cfg.autoscaler.configFile != null; + message = "services.burrow.forgejoNsc.autoscaler.configFile must be set when the autoscaler is enabled."; + } + ]; + + users.groups.${cfg.group} = { }; + users.users.${cfg.user} = { + uid = mkDefault 2011; + isSystemUser = true; + group = cfg.group; + description = "Forgejo Namespace Cloud runner services"; + home = cfg.stateDir; + createHome = true; + shell = pkgs.bashInteractive; + }; + + systemd.tmpfiles.rules = mkAfter [ + "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.forgejo-nsc-dispatcher = mkIf cfg.dispatcher.enable { + description = "Forgejo Namespace Cloud dispatcher"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + unitConfig.ConditionPathExists = + optional (cfg.dispatcher.configFile != null) cfg.dispatcher.configFile + ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ExecStart = "${cfg.dispatcher.package}/bin/forgejo-nsc-dispatcher --config ${dispatcherRuntimeConfig}"; + Restart = "on-failure"; + RestartSec = 5; + }; + path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; + environment = dispatcherEnv; + preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ + (optionalString (!cfg.dispatcher.allowPending) (pendingCheck cfg.dispatcher.configFile)) + dispatcherConfigSync + tokenSync + ]); + }; + + systemd.services.forgejo-nsc-autoscaler = mkIf cfg.autoscaler.enable { + description = "Forgejo Namespace Cloud autoscaler"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "forgejo-nsc-dispatcher.service" ]; + wants = [ "network-online.target" ]; + unitConfig.ConditionPathExists = + optional (cfg.autoscaler.configFile != null) cfg.autoscaler.configFile + ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ExecStart = "${cfg.autoscaler.package}/bin/forgejo-nsc-autoscaler --config ${autoscalerRuntimeConfig}"; + Restart = "on-failure"; + RestartSec = 5; + }; + path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; + environment = dispatcherEnv; + preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ + (optionalString (!cfg.autoscaler.allowPending) (pendingCheck cfg.autoscaler.configFile)) + autoscalerConfigSync + tokenSync + ]); + }; + + systemd.services.forgejo-prune-runners = mkIf cfg.pruneRunners.enable { + description = "Prune offline Forgejo action runners"; + after = [ "forgejo.service" ]; + requires = [ "forgejo.service" ]; + serviceConfig = { + Type = "oneshot"; + User = "forgejo"; + Group = "forgejo"; + }; + environment = { + FORGEJO_PRUNE_DB = "1"; + FORGEJO_RUNNER_TTL_SEC = toString cfg.pruneRunners.ttlSeconds; + }; + path = [ pkgs.python3 pkgs.postgresql ]; + script = '' + ${pkgs.python3}/bin/python3 ${self}/Scripts/forgejo-prune-runners.py + ''; + }; + + systemd.timers.forgejo-prune-runners = mkIf cfg.pruneRunners.enable { + description = "Periodic Forgejo runner cleanup"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = cfg.pruneRunners.onBootSec; + OnUnitActiveSec = cfg.pruneRunners.onUnitActiveSec; + RandomizedDelaySec = cfg.pruneRunners.randomizedDelaySec; + Unit = "forgejo-prune-runners.service"; + }; + }; + }; +} diff --git a/nixos/modules/burrow-garage.nix b/nixos/modules/burrow-garage.nix deleted file mode 100644 index 993d5ba..0000000 --- a/nixos/modules/burrow-garage.nix +++ /dev/null @@ -1,284 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.burrow.garage; -in -{ - options.services.burrow.garage = { - enable = lib.mkEnableOption "Burrow Garage S3-compatible object storage"; - - environmentFile = lib.mkOption { - type = lib.types.path; - default = "/var/lib/burrow/garage/env"; - description = "Host-local environment file containing Garage and S3 bootstrap secrets."; - }; - - region = lib.mkOption { - type = lib.types.str; - default = "garage"; - description = "S3 region name advertised by Garage."; - }; - - apiListenAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Address used by the Garage S3 API listener."; - }; - - apiPort = lib.mkOption { - type = lib.types.port; - default = 3900; - description = "Port used by the Garage S3 API listener."; - }; - - webListenAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Address used by the Garage static-web listener."; - }; - - webPort = lib.mkOption { - type = lib.types.port; - default = 3902; - description = "Port used by the Garage static-web listener."; - }; - - adminListenAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Address used by the Garage admin listener."; - }; - - adminPort = lib.mkOption { - type = lib.types.port; - default = 3903; - description = "Port used by the Garage admin listener."; - }; - - zone = lib.mkOption { - type = lib.types.str; - default = "burrow-forge"; - description = "Garage layout zone assigned to the forge host."; - }; - - layoutCapacity = lib.mkOption { - type = lib.types.str; - default = "100GB"; - description = "Initial Garage layout capacity assigned to the forge host."; - }; - - s3RootDomain = lib.mkOption { - type = lib.types.str; - default = "s3.burrow.net"; - description = "Wildcard root domain used for bucket-style S3 API requests."; - }; - - webRootDomain = lib.mkOption { - type = lib.types.str; - default = "web.burrow.net"; - description = "Wildcard root domain used for bucket static website requests."; - }; - - apiDomain = lib.mkOption { - type = lib.types.str; - default = "objects.burrow.net"; - description = "Public S3 API reverse-proxy domain."; - }; - - enableApiProxy = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Expose the Garage S3 API through Caddy."; - }; - - enableWebProxy = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Expose the Garage static-web listener through Caddy wildcard routes."; - }; - - atticBucket = lib.mkOption { - type = lib.types.str; - default = "attic"; - description = "Garage bucket used by the Burrow Nix cache."; - }; - - releaseBucket = lib.mkOption { - type = lib.types.str; - default = "burrow-releases"; - description = "Garage bucket used for release artifacts."; - }; - - packageBucket = lib.mkOption { - type = lib.types.str; - default = "burrow-packages"; - description = "Garage bucket used for signed package repositories."; - }; - }; - - config = lib.mkIf cfg.enable { - services.garage = { - enable = true; - package = pkgs.garage; - environmentFile = cfg.environmentFile; - settings = { - metadata_dir = "/var/lib/garage/meta"; - data_dir = "/var/lib/garage/data"; - rpc_bind_addr = "127.0.0.1:3901"; - rpc_public_addr = "127.0.0.1:3901"; - replication_factor = 1; - s3_api = { - s3_region = cfg.region; - api_bind_addr = "${cfg.apiListenAddress}:${toString cfg.apiPort}"; - root_domain = ".${cfg.s3RootDomain}"; - }; - s3_web = { - bind_addr = "${cfg.webListenAddress}:${toString cfg.webPort}"; - root_domain = ".${cfg.webRootDomain}"; - index = "index.html"; - }; - admin.api_bind_addr = "${cfg.adminListenAddress}:${toString cfg.adminPort}"; - }; - }; - - systemd.services.burrow-garage-env = { - description = "Create Burrow Garage bootstrap environment"; - before = [ "garage.service" ]; - wantedBy = [ "garage.service" ]; - requiredBy = [ "garage.service" ]; - path = [ - pkgs.coreutils - pkgs.gnugrep - pkgs.openssl - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - RemainAfterExit = true; - }; - script = '' - set -euo pipefail - - install -d -m 0700 "$(dirname ${lib.escapeShellArg cfg.environmentFile})" - touch ${lib.escapeShellArg cfg.environmentFile} - chmod 0600 ${lib.escapeShellArg cfg.environmentFile} - chown root:root ${lib.escapeShellArg cfg.environmentFile} - - has_var() { - grep -q "^$1=" ${lib.escapeShellArg cfg.environmentFile} - } - - add_var() { - local name="$1" - local value="$2" - if ! has_var "$name"; then - printf '%s=%s\n' "$name" "$value" >> ${lib.escapeShellArg cfg.environmentFile} - fi - } - - add_var GARAGE_RPC_SECRET "$(${pkgs.openssl}/bin/openssl rand -hex 32)" - add_var GARAGE_ATTIC_ACCESS_KEY_ID "GK$(${pkgs.openssl}/bin/openssl rand -hex 12)" - add_var GARAGE_ATTIC_SECRET_ACCESS_KEY "$(${pkgs.openssl}/bin/openssl rand -hex 32)" - add_var GARAGE_RELEASE_ACCESS_KEY_ID "GK$(${pkgs.openssl}/bin/openssl rand -hex 12)" - add_var GARAGE_RELEASE_SECRET_ACCESS_KEY "$(${pkgs.openssl}/bin/openssl rand -hex 32)" - add_var GARAGE_PACKAGE_ACCESS_KEY_ID "GK$(${pkgs.openssl}/bin/openssl rand -hex 12)" - add_var GARAGE_PACKAGE_SECRET_ACCESS_KEY "$(${pkgs.openssl}/bin/openssl rand -hex 32)" - add_var GARAGE_BACKUP_ACCESS_KEY_ID "GK$(${pkgs.openssl}/bin/openssl rand -hex 12)" - add_var GARAGE_BACKUP_SECRET_ACCESS_KEY "$(${pkgs.openssl}/bin/openssl rand -hex 32)" - add_var ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64 "$(${pkgs.openssl}/bin/openssl genrsa -traditional 4096 | ${pkgs.coreutils}/bin/base64 -w0)" - - . ${lib.escapeShellArg cfg.environmentFile} - add_var AWS_ACCESS_KEY_ID "$GARAGE_ATTIC_ACCESS_KEY_ID" - add_var AWS_SECRET_ACCESS_KEY "$GARAGE_ATTIC_SECRET_ACCESS_KEY" - ''; - }; - - systemd.services.burrow-garage-bootstrap = { - description = "Bootstrap Burrow Garage layout, keys, and buckets"; - after = [ "garage.service" ]; - requires = [ "garage.service" ]; - wantedBy = [ "multi-user.target" ]; - path = [ - config.services.garage.package - pkgs.coreutils - pkgs.gawk - pkgs.gnugrep - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - }; - script = '' - set -euo pipefail - set -a - . ${lib.escapeShellArg cfg.environmentFile} - set +a - - for _ in $(seq 1 60); do - if garage status >/dev/null 2>&1; then - break - fi - sleep 1 - done - - node_id="$(garage status | tail -n1 | awk '{ print $1 }')" - if [ -n "$node_id" ]; then - garage layout assign -c ${lib.escapeShellArg cfg.layoutCapacity} -z ${lib.escapeShellArg cfg.zone} "$node_id" >/dev/null 2>&1 || true - garage layout apply --version 1 >/dev/null 2>&1 || true - fi - - ensure_key() { - local key_id="$1" - local secret="$2" - if ! garage key info "$key_id" >/dev/null 2>&1; then - garage key import "$key_id" "$secret" --yes >/dev/null - fi - } - - ensure_bucket() { - local bucket="$1" - local key_id="$2" - if ! garage bucket info "$bucket" >/dev/null 2>&1; then - garage bucket create "$bucket" >/dev/null - fi - garage bucket allow --read --write --owner "$bucket" --key "$key_id" >/dev/null - } - - ensure_bucket_reader() { - local bucket="$1" - local key_id="$2" - garage bucket allow --read "$bucket" --key "$key_id" >/dev/null - } - - ensure_key "$GARAGE_ATTIC_ACCESS_KEY_ID" "$GARAGE_ATTIC_SECRET_ACCESS_KEY" - ensure_key "$GARAGE_RELEASE_ACCESS_KEY_ID" "$GARAGE_RELEASE_SECRET_ACCESS_KEY" - ensure_key "$GARAGE_PACKAGE_ACCESS_KEY_ID" "$GARAGE_PACKAGE_SECRET_ACCESS_KEY" - ensure_key "$GARAGE_BACKUP_ACCESS_KEY_ID" "$GARAGE_BACKUP_SECRET_ACCESS_KEY" - - ensure_bucket ${lib.escapeShellArg cfg.atticBucket} "$GARAGE_ATTIC_ACCESS_KEY_ID" - ensure_bucket ${lib.escapeShellArg cfg.releaseBucket} "$GARAGE_RELEASE_ACCESS_KEY_ID" - ensure_bucket ${lib.escapeShellArg cfg.packageBucket} "$GARAGE_PACKAGE_ACCESS_KEY_ID" - ensure_bucket_reader ${lib.escapeShellArg cfg.atticBucket} "$GARAGE_BACKUP_ACCESS_KEY_ID" - ensure_bucket_reader ${lib.escapeShellArg cfg.releaseBucket} "$GARAGE_BACKUP_ACCESS_KEY_ID" - ensure_bucket_reader ${lib.escapeShellArg cfg.packageBucket} "$GARAGE_BACKUP_ACCESS_KEY_ID" - ''; - }; - - services.caddy.virtualHosts = lib.mkMerge [ - (lib.mkIf cfg.enableApiProxy { - "${cfg.apiDomain}".extraConfig = '' - encode gzip zstd - reverse_proxy ${cfg.apiListenAddress}:${toString cfg.apiPort} - ''; - }) - (lib.mkIf cfg.enableWebProxy { - "*.${cfg.webRootDomain}".extraConfig = '' - encode gzip zstd - reverse_proxy ${cfg.webListenAddress}:${toString cfg.webPort} - ''; - }) - ]; - }; -} diff --git a/nixos/modules/burrow-headscale-policy.hujson b/nixos/modules/burrow-headscale-policy.hujson deleted file mode 100644 index 8f0bcd2..0000000 --- a/nixos/modules/burrow-headscale-policy.hujson +++ /dev/null @@ -1,11 +0,0 @@ -{ - // Bootstrap with a simple allow-all policy; Burrow-specific lane segmentation - // can be layered on once the control plane is live. - "acls": [ - { - "action": "accept", - "src": ["*"], - "dst": ["*:*"], - }, - ], -} diff --git a/nixos/modules/burrow-headscale.nix b/nixos/modules/burrow-headscale.nix deleted file mode 100644 index b79fe48..0000000 --- a/nixos/modules/burrow-headscale.nix +++ /dev/null @@ -1,247 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.burrow.headscale; - policyFile = ./burrow-headscale-policy.hujson; -in -{ - options.services.burrow.headscale = { - enable = lib.mkEnableOption "the Burrow Headscale control plane"; - - domain = lib.mkOption { - type = lib.types.str; - default = "ts.burrow.net"; - description = "Public Headscale control-plane domain."; - }; - - tailDomain = lib.mkOption { - type = lib.types.str; - default = "tail.burrow.net"; - description = "MagicDNS suffix served by Headscale."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 8413; - description = "Local Headscale listen port."; - }; - - enableMetrics = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Expose Headscale Prometheus metrics on the local host."; - }; - - metricsListenAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Address used for the Headscale metrics listener."; - }; - - metricsPort = lib.mkOption { - type = lib.types.port; - default = 9098; - description = "Port used for the Headscale metrics listener."; - }; - - oidcIssuer = lib.mkOption { - type = lib.types.str; - default = "https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/"; - description = "OIDC issuer URL used by Headscale."; - }; - - oidcClientSecretFile = lib.mkOption { - type = lib.types.str; - default = config.services.burrow.authentik.headscaleClientSecretFile; - description = "Host-local file containing the OIDC client secret used by Headscale."; - }; - - bootstrapUsers = lib.mkOption { - type = with lib.types; listOf (submodule { - options = { - name = lib.mkOption { - type = str; - description = "Headscale username."; - }; - displayName = lib.mkOption { - type = str; - description = "Friendly display name."; - }; - email = lib.mkOption { - type = str; - description = "User email address."; - }; - }; - }); - default = [ - { - name = "contact"; - displayName = "Burrow"; - email = "contact@burrow.net"; - } - { - name = "conrad"; - displayName = "Conrad"; - email = "conrad@burrow.net"; - } - { - name = "agent"; - displayName = "Agent"; - email = "agent@burrow.net"; - } - { - name = "infra"; - displayName = "Infrastructure"; - email = "infra@burrow.net"; - } - ]; - description = "Users to create or reconcile inside Headscale."; - }; - }; - - config = lib.mkIf cfg.enable { - environment.systemPackages = [ pkgs.headscale ]; - - systemd.services.burrow-headscale-client-secret = { - description = "Ensure the Burrow Headscale OIDC client secret exists"; - before = - [ "headscale.service" ] - ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-runtime.service" ]; - wantedBy = - [ "headscale.service" ] - ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-runtime.service" ]; - path = [ - pkgs.coreutils - pkgs.openssl - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - RemainAfterExit = true; - }; - script = '' - set -euo pipefail - - install -d -m 0755 /var/lib/burrow/intake - - if [ ! -s ${lib.escapeShellArg cfg.oidcClientSecretFile} ]; then - umask 077 - ${pkgs.openssl}/bin/openssl rand -base64 48 > ${lib.escapeShellArg cfg.oidcClientSecretFile} - chown root:root ${lib.escapeShellArg cfg.oidcClientSecretFile} - chmod 0400 ${lib.escapeShellArg cfg.oidcClientSecretFile} - fi - ''; - }; - - services.headscale = { - enable = true; - address = "127.0.0.1"; - port = cfg.port; - settings = { - server_url = "https://${cfg.domain}"; - dns = { - magic_dns = true; - base_domain = cfg.tailDomain; - nameservers.global = [ - "1.1.1.1" - "1.0.0.1" - "2606:4700:4700::1111" - "2606:4700:4700::1001" - ]; - search_domains = [ cfg.tailDomain ]; - }; - database.sqlite.write_ahead_log = true; - log.level = "info"; - policy = { - mode = "file"; - path = policyFile; - }; - oidc = { - only_start_if_oidc_is_available = true; - issuer = cfg.oidcIssuer; - client_id = cfg.domain; - client_secret_path = "\${CREDENTIALS_DIRECTORY}/oidc_client_secret"; - scope = [ - "openid" - "profile" - "email" - ]; - pkce = { - enabled = true; - method = "S256"; - }; - }; - } // lib.optionalAttrs cfg.enableMetrics { - metrics_listen_addr = "${cfg.metricsListenAddress}:${toString cfg.metricsPort}"; - }; - }; - - systemd.services.headscale = { - after = - [ "burrow-headscale-client-secret.service" ] - ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-ready.service" ]; - wants = - [ "burrow-headscale-client-secret.service" ] - ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-ready.service" ]; - requires = - [ "burrow-headscale-client-secret.service" ] - ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-ready.service" ]; - serviceConfig.LoadCredential = [ - "oidc_client_secret:${cfg.oidcClientSecretFile}" - ]; - }; - - systemd.services.headscale-bootstrap = { - description = "Bootstrap Burrow Headscale users"; - after = [ "headscale.service" ]; - requires = [ "headscale.service" ]; - wantedBy = [ "multi-user.target" ]; - path = [ - pkgs.coreutils - pkgs.headscale - pkgs.jq - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - }; - script = '' - set -euo pipefail - - list_users() { - local users_json - users_json="$(${pkgs.headscale}/bin/headscale users list -o json)" - printf '%s\n' "$users_json" | ${pkgs.jq}/bin/jq -c 'if type == "array" then . else [] end' - } - - ensure_user() { - local name="$1" - local display_name="$2" - local email="$3" - if list_users | ${pkgs.jq}/bin/jq -e --arg name "$name" 'map(select(.name == $name)) | length > 0' >/dev/null; then - return 0 - fi - ${pkgs.headscale}/bin/headscale users create "$name" --display-name "$display_name" --email "$email" >/dev/null - } - - for _ in $(seq 1 60); do - if list_users >/dev/null 2>&1; then - break - fi - sleep 1 - done - - ${lib.concatMapStringsSep "\n" (user: '' - ensure_user ${lib.escapeShellArg user.name} ${lib.escapeShellArg user.displayName} ${lib.escapeShellArg user.email} - '') cfg.bootstrapUsers} - ''; - }; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' - encode gzip zstd - reverse_proxy 127.0.0.1:${toString cfg.port} - ''; - }; -} diff --git a/nixos/modules/burrow-jitsi.nix b/nixos/modules/burrow-jitsi.nix deleted file mode 100644 index 6041632..0000000 --- a/nixos/modules/burrow-jitsi.nix +++ /dev/null @@ -1,45 +0,0 @@ -{ config, lib, ... }: - -let - cfg = config.services.burrow.jitsi; -in -{ - options.services.burrow.jitsi = { - enable = lib.mkEnableOption "the Burrow Jitsi Meet deployment"; - - domain = lib.mkOption { - type = lib.types.str; - default = "meet.burrow.net"; - description = "Public Jitsi Meet domain."; - }; - - videobridgeUdpPort = lib.mkOption { - type = lib.types.port; - default = 10000; - description = "Public UDP port used by Jitsi Videobridge media traffic."; - }; - - enableHttpProxy = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Expose Jitsi through the repo-owned Caddy reverse proxy."; - }; - }; - - config = lib.mkIf cfg.enable { - services.jitsi-meet = { - enable = true; - hostName = cfg.domain; - nginx.enable = false; - }; - - services.jitsi-videobridge.openFirewall = true; - - networking.firewall.allowedUDPPorts = [ cfg.videobridgeUdpPort ]; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = lib.mkIf cfg.enableHttpProxy '' - encode gzip zstd - reverse_proxy 127.0.0.1:5280 - ''; - }; -} diff --git a/nixos/modules/burrow-nix-cache.nix b/nixos/modules/burrow-nix-cache.nix deleted file mode 100644 index 420d3b9..0000000 --- a/nixos/modules/burrow-nix-cache.nix +++ /dev/null @@ -1,163 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.burrow.nixCache; - garageCfg = config.services.burrow.garage; -in -{ - options.services.burrow.nixCache = { - enable = lib.mkEnableOption "Burrow Nix binary cache"; - - domain = lib.mkOption { - type = lib.types.str; - default = "nix.burrow.net"; - description = "Public domain for the Burrow Nix binary cache."; - }; - - listenAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Attic listen address."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 8080; - description = "Attic listen port."; - }; - - cacheName = lib.mkOption { - type = lib.types.str; - default = "burrow"; - description = "Default public Attic cache name."; - }; - - environmentFile = lib.mkOption { - type = lib.types.path; - default = garageCfg.environmentFile; - description = "Environment file containing Attic JWT and Garage S3 credentials."; - }; - - adminTokenFile = lib.mkOption { - type = lib.types.path; - default = "/var/lib/burrow/nix-cache/admin-token"; - description = "Host-local file containing the generated Attic admin token."; - }; - - ciPushTokenFile = lib.mkOption { - type = lib.types.path; - default = "/var/lib/burrow/nix-cache/ci-push-token"; - description = "Host-local file containing the generated Attic CI push token."; - }; - - ciPushTokenValidity = lib.mkOption { - type = lib.types.str; - default = "1y"; - description = "Validity duration for the generated Attic CI push token."; - }; - }; - - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = garageCfg.enable; - message = "services.burrow.nixCache requires services.burrow.garage.enable = true."; - } - ]; - - services.atticd = { - enable = true; - environmentFile = cfg.environmentFile; - settings = { - listen = "${cfg.listenAddress}:${toString cfg.port}"; - allowed-hosts = [ cfg.domain ]; - api-endpoint = "https://${cfg.domain}/"; - require-proof-of-possession = true; - storage = { - type = "s3"; - bucket = garageCfg.atticBucket; - region = garageCfg.region; - endpoint = "http://${garageCfg.apiListenAddress}:${toString garageCfg.apiPort}"; - }; - }; - }; - - environment.systemPackages = [ - pkgs.attic-client - ]; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' - encode gzip zstd - reverse_proxy ${cfg.listenAddress}:${toString cfg.port} - ''; - - systemd.services.atticd = { - after = [ "burrow-garage-bootstrap.service" ]; - wants = [ "burrow-garage-bootstrap.service" ]; - requires = [ "burrow-garage-bootstrap.service" ]; - }; - - systemd.services.burrow-nix-cache-bootstrap = { - description = "Bootstrap Burrow Nix binary cache"; - after = [ "atticd.service" ]; - requires = [ "atticd.service" ]; - wantedBy = [ "multi-user.target" ]; - path = [ - pkgs.attic-client - pkgs.attic-server - pkgs.coreutils - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - }; - script = '' - set -euo pipefail - - install -d -m 0700 "$(dirname ${lib.escapeShellArg cfg.adminTokenFile})" - install -d -m 0700 "$(dirname ${lib.escapeShellArg cfg.ciPushTokenFile})" - - for _ in $(seq 1 60); do - if (: > /dev/tcp/${cfg.listenAddress}/${toString cfg.port}) >/dev/null 2>&1; then - break - fi - sleep 1 - done - - if [ ! -s ${lib.escapeShellArg cfg.adminTokenFile} ]; then - umask 077 - atticadm make-token \ - --sub burrow-nix-cache-bootstrap \ - --validity 10y \ - --create-cache '*' \ - --pull '*' \ - --push '*' \ - --delete '*' \ - --configure-cache '*' \ - --configure-cache-retention '*' \ - > ${lib.escapeShellArg cfg.adminTokenFile} - fi - - token="$(cat ${lib.escapeShellArg cfg.adminTokenFile})" - export HOME=/var/lib/burrow/nix-cache - install -d -m 0700 "$HOME" - - attic login local http://${cfg.listenAddress}:${toString cfg.port} "$token" --set-default - if ! attic cache info ${lib.escapeShellArg "local:${cfg.cacheName}"} >/dev/null 2>&1; then - attic cache create --public --priority 39 ${lib.escapeShellArg "local:${cfg.cacheName}"} - fi - - if [ ! -s ${lib.escapeShellArg cfg.ciPushTokenFile} ]; then - umask 077 - atticadm make-token \ - --sub burrow-ci-cache-publisher \ - --validity ${lib.escapeShellArg cfg.ciPushTokenValidity} \ - --pull ${lib.escapeShellArg cfg.cacheName} \ - --push ${lib.escapeShellArg cfg.cacheName} \ - > ${lib.escapeShellArg cfg.ciPushTokenFile} - fi - ''; - }; - }; -} diff --git a/nixos/modules/burrow-observability.nix b/nixos/modules/burrow-observability.nix deleted file mode 100644 index 090c2ee..0000000 --- a/nixos/modules/burrow-observability.nix +++ /dev/null @@ -1,302 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.burrow.observability; - otelCollectorPackage = - if builtins.hasAttr "opentelemetry-collector-contrib" pkgs then - pkgs."opentelemetry-collector-contrib" - else - pkgs.opentelemetry-collector; - garageCfg = config.services.burrow.garage; - headscaleCfg = config.services.burrow.headscale; - grafanaHttpPort = config.services.grafana.settings.server.http_port or 3000; - grafanaHttpAddress = config.services.grafana.settings.server.http_addr or "127.0.0.1"; - tailscaleExporterEnabled = cfg.tailscaleExporterEnvironmentFile != null; -in -{ - options.services.burrow.observability = { - enable = lib.mkEnableOption "Burrow Prometheus, OpenTelemetry, Jaeger, and Grafana wiring"; - - prometheusListenAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Address used by the local Prometheus server."; - }; - - prometheusPort = lib.mkOption { - type = lib.types.port; - default = 9090; - description = "Port used by the local Prometheus server."; - }; - - otelGrpcEndpoint = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1:4317"; - description = "Local OTLP gRPC endpoint accepted by the OpenTelemetry collector."; - }; - - otelHttpEndpoint = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1:4318"; - description = "Local OTLP HTTP endpoint accepted by the OpenTelemetry collector."; - }; - - otelPrometheusEndpoint = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1:9464"; - description = "Prometheus endpoint where the OpenTelemetry collector exports its own metrics."; - }; - - jaegerImage = lib.mkOption { - type = lib.types.str; - default = "docker.io/jaegertracing/all-in-one:1.57"; - description = "OCI image used for the local Jaeger all-in-one service."; - }; - - jaegerUiPort = lib.mkOption { - type = lib.types.port; - default = 16686; - description = "Local Jaeger UI and query API port."; - }; - - jaegerOtlpGrpcPort = lib.mkOption { - type = lib.types.port; - default = 14317; - description = "Local remapped OTLP gRPC port forwarded to Jaeger."; - }; - - jaegerOtlpHttpPort = lib.mkOption { - type = lib.types.port; - default = 14318; - description = "Local remapped OTLP HTTP port forwarded to Jaeger."; - }; - - jaegerAdminPort = lib.mkOption { - type = lib.types.port; - default = 14269; - description = "Local Jaeger admin and metrics port."; - }; - - tailscaleExporterEnvironmentFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Optional environment file for the Prometheus Tailscale exporter OAuth credentials."; - }; - }; - - config = lib.mkIf cfg.enable (lib.mkMerge [ - { - virtualisation.podman.enable = true; - - services.grafana = { - settings = { - metrics = { - enabled = true; - disable_total_stats = true; - }; - }; - provision = { - enable = true; - datasources.settings = { - apiVersion = 1; - prune = true; - datasources = [ - { - name = "Prometheus"; - uid = "prometheus"; - type = "prometheus"; - access = "proxy"; - url = "http://${cfg.prometheusListenAddress}:${toString cfg.prometheusPort}"; - isDefault = true; - jsonData = { - timeInterval = "15s"; - httpMethod = "POST"; - }; - } - { - name = "Jaeger"; - uid = "jaeger"; - type = "jaeger"; - access = "proxy"; - url = "http://127.0.0.1:${toString cfg.jaegerUiPort}"; - } - ]; - }; - }; - }; - - services.prometheus = { - enable = true; - listenAddress = cfg.prometheusListenAddress; - port = cfg.prometheusPort; - retentionTime = "30d"; - checkConfig = "syntax-only"; - exporters = { - node = { - enable = true; - listenAddress = "127.0.0.1"; - }; - systemd = { - enable = true; - listenAddress = "127.0.0.1"; - }; - }; - scrapeConfigs = [ - { - job_name = "prometheus"; - static_configs = [ - { targets = [ "${cfg.prometheusListenAddress}:${toString cfg.prometheusPort}" ]; } - ]; - } - { - job_name = "node"; - static_configs = [ - { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.node.port}" ]; } - ]; - } - { - job_name = "systemd"; - static_configs = [ - { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.systemd.port}" ]; } - ]; - } - { - job_name = "grafana"; - metrics_path = "/metrics"; - static_configs = [ - { targets = [ "${grafanaHttpAddress}:${toString grafanaHttpPort}" ]; } - ]; - } - { - job_name = "headscale"; - metrics_path = "/metrics"; - static_configs = [ - { targets = [ "${headscaleCfg.metricsListenAddress}:${toString headscaleCfg.metricsPort}" ]; } - ]; - } - { - job_name = "otel-collector"; - static_configs = [ - { targets = [ cfg.otelPrometheusEndpoint ]; } - ]; - } - { - job_name = "jaeger"; - metrics_path = "/metrics"; - static_configs = [ - { targets = [ "127.0.0.1:${toString cfg.jaegerAdminPort}" ]; } - ]; - } - ] ++ lib.optionals garageCfg.enable [ - { - job_name = "garage"; - metrics_path = "/metrics"; - static_configs = [ - { targets = [ "${garageCfg.adminListenAddress}:${toString garageCfg.adminPort}" ]; } - ]; - } - ] ++ lib.optionals tailscaleExporterEnabled [ - { - job_name = "tailscale"; - static_configs = [ - { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.tailscale.port}" ]; } - ]; - } - ]; - }; - - services.opentelemetry-collector = { - enable = true; - package = otelCollectorPackage; - settings = { - receivers = { - otlp = { - protocols = { - grpc.endpoint = cfg.otelGrpcEndpoint; - http.endpoint = cfg.otelHttpEndpoint; - }; - }; - }; - processors = { - resource.attributes = [ - { - key = "service.namespace"; - value = "burrow"; - action = "upsert"; - } - { - key = "deployment.environment"; - value = config.networking.hostName; - action = "upsert"; - } - ]; - batch = { }; - }; - exporters = { - "otlp/jaeger" = { - endpoint = "127.0.0.1:${toString cfg.jaegerOtlpGrpcPort}"; - tls.insecure = true; - }; - prometheus.endpoint = cfg.otelPrometheusEndpoint; - }; - service.pipelines = { - traces = { - receivers = [ "otlp" ]; - processors = [ - "resource" - "batch" - ]; - exporters = [ "otlp/jaeger" ]; - }; - metrics = { - receivers = [ "otlp" ]; - processors = [ - "resource" - "batch" - ]; - exporters = [ "prometheus" ]; - }; - }; - }; - }; - - virtualisation.oci-containers.containers.burrow-jaeger = { - image = cfg.jaegerImage; - autoStart = true; - environment = { - COLLECTOR_OTLP_ENABLED = "true"; - SPAN_STORAGE_TYPE = "badger"; - BADGER_EPHEMERAL = "false"; - BADGER_DIRECTORY_VALUE = "/badger/data"; - BADGER_DIRECTORY_KEY = "/badger/key"; - }; - volumes = [ - "burrow-jaeger-badger:/badger" - ]; - ports = [ - "127.0.0.1:${toString cfg.jaegerUiPort}:16686" - "127.0.0.1:${toString cfg.jaegerOtlpGrpcPort}:4317" - "127.0.0.1:${toString cfg.jaegerOtlpHttpPort}:4318" - "127.0.0.1:${toString cfg.jaegerAdminPort}:14269" - ]; - extraOptions = [ "--pull=always" ]; - }; - - systemd.services.opentelemetry-collector.after = [ "podman-burrow-jaeger.service" ]; - systemd.services.opentelemetry-collector.wants = [ "podman-burrow-jaeger.service" ]; - - environment.sessionVariables = { - OTEL_EXPORTER_OTLP_ENDPOINT = "http://${cfg.otelGrpcEndpoint}"; - OTEL_EXPORTER_OTLP_PROTOCOL = "grpc"; - }; - } - - (lib.mkIf tailscaleExporterEnabled { - services.prometheus.exporters.tailscale = { - enable = true; - listenAddress = "127.0.0.1"; - environmentFile = cfg.tailscaleExporterEnvironmentFile; - }; - }) - ]); -} diff --git a/nixos/modules/burrow-openbao.nix b/nixos/modules/burrow-openbao.nix deleted file mode 100644 index 82f7718..0000000 --- a/nixos/modules/burrow-openbao.nix +++ /dev/null @@ -1,101 +0,0 @@ -{ config, lib, ... }: - -let - cfg = config.services.burrow.openbao; -in -{ - options.services.burrow.openbao = { - enable = lib.mkEnableOption "the Burrow OpenBao deployment"; - - domain = lib.mkOption { - type = lib.types.str; - default = "vault.burrow.net"; - description = "Public OpenBao domain."; - }; - - listenAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Local OpenBao listener address."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 8200; - description = "Local OpenBao API port."; - }; - - clusterPort = lib.mkOption { - type = lib.types.port; - default = 8201; - description = "Local OpenBao cluster port."; - }; - - dataDir = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/openbao"; - description = "Persistent OpenBao state directory."; - }; - - googleKmsSeal = { - enable = lib.mkEnableOption "Google Cloud KMS seal wrapping"; - - project = lib.mkOption { - type = lib.types.str; - default = "project-88c23ce9-918a-470a-b33"; - description = "Google Cloud project containing the seal key."; - }; - - location = lib.mkOption { - type = lib.types.str; - default = "global"; - description = "Google Cloud KMS key ring location."; - }; - - keyRing = lib.mkOption { - type = lib.types.str; - default = "burrow-identity"; - description = "Google Cloud KMS key ring name."; - }; - - cryptoKey = lib.mkOption { - type = lib.types.str; - default = "openbao-seal"; - description = "Google Cloud KMS crypto key name used for seal wrapping."; - }; - }; - }; - - config = lib.mkIf cfg.enable { - systemd.tmpfiles.rules = [ - "d ${cfg.dataDir} 0700 openbao openbao - -" - "d ${cfg.dataDir}/data 0700 openbao openbao - -" - ]; - - services.openbao = { - enable = true; - settings = { - ui = true; - api_addr = "https://${cfg.domain}"; - cluster_addr = "http://${cfg.listenAddress}:${toString cfg.clusterPort}"; - storage.file.path = "${cfg.dataDir}/data"; - listener.tcp = { - address = "${cfg.listenAddress}:${toString cfg.port}"; - tls_disable = true; - }; - } // lib.optionalAttrs cfg.googleKmsSeal.enable { - seal.gcpckms = { - project = cfg.googleKmsSeal.project; - region = cfg.googleKmsSeal.location; - key_ring = cfg.googleKmsSeal.keyRing; - crypto_key = cfg.googleKmsSeal.cryptoKey; - }; - }; - }; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' - encode gzip zstd - reverse_proxy ${cfg.listenAddress}:${toString cfg.port} - ''; - }; -} diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix deleted file mode 100644 index 9670694..0000000 --- a/nixos/modules/burrow-zulip.nix +++ /dev/null @@ -1,587 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.burrow.zulip; - realmSignupDomain = - let - parts = lib.splitString "@" cfg.administratorEmail; - in - if builtins.length parts == 2 then builtins.elemAt parts 1 else cfg.domain; - yamlFormat = pkgs.formats.yaml { }; - composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" { - services = { - zulip = { - image = "ghcr.io/zulip/zulip-server:11.6-1"; - restart = "unless-stopped"; - network_mode = "host"; - secrets = [ - "zulip__postgres_password" - "zulip__rabbitmq_password" - "zulip__redis_password" - "zulip__secret_key" - "zulip__email_password" - ]; - environment = { - SETTING_REMOTE_POSTGRES_HOST = "127.0.0.1"; - SETTING_MEMCACHED_LOCATION = "127.0.0.1:11211"; - SETTING_RABBITMQ_HOST = "127.0.0.1"; - SETTING_REDIS_HOST = "127.0.0.1"; - }; - volumes = [ "${cfg.dataDir}/data:/data:rw" ]; - ulimits.nofile = { - soft = 1000000; - hard = 1048576; - }; - }; - }; - }; -in -{ - options.services.burrow.zulip = { - enable = lib.mkEnableOption "the Burrow Zulip deployment"; - - domain = lib.mkOption { - type = lib.types.str; - default = "chat.burrow.net"; - description = "Public Zulip domain."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 18090; - description = "Local loopback port Caddy should proxy to."; - }; - - dataDir = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/zulip"; - description = "Host directory storing Zulip compose state and generated runtime files."; - }; - - administratorEmail = lib.mkOption { - type = lib.types.str; - default = "contact@burrow.net"; - description = "Operational Zulip administrator email."; - }; - - realmName = lib.mkOption { - type = lib.types.str; - default = "Burrow"; - description = "Initial Zulip organization name for single-tenant bootstrap."; - }; - - realmOwnerName = lib.mkOption { - type = lib.types.str; - default = "Burrow"; - description = "Display name used for the initial Zulip organization owner."; - }; - - authentikDomain = lib.mkOption { - type = lib.types.str; - default = config.services.burrow.authentik.domain; - description = "Authentik domain Zulip should trust as its SAML IdP."; - }; - - authentikProviderSlug = lib.mkOption { - type = lib.types.str; - default = config.services.burrow.authentik.zulipProviderSlug; - description = "Authentik SAML application slug used for Zulip."; - }; - - postgresPasswordFile = lib.mkOption { - type = lib.types.str; - description = "File containing the Zulip PostgreSQL password."; - }; - - rabbitmqPasswordFile = lib.mkOption { - type = lib.types.str; - description = "File containing the Zulip RabbitMQ password."; - }; - - redisPasswordFile = lib.mkOption { - type = lib.types.str; - description = "File containing the Zulip Redis password."; - }; - - secretKeyFile = lib.mkOption { - type = lib.types.str; - description = "File containing the Zulip Django secret key."; - }; - }; - - config = lib.mkIf cfg.enable { - environment.systemPackages = [ - pkgs.podman - pkgs.podman-compose - ]; - - services.postgresql = { - ensureDatabases = [ "zulip" ]; - ensureUsers = [ - { - name = "zulip"; - ensureDBOwnership = true; - } - ]; - settings = { - listen_addresses = lib.mkDefault "127.0.0.1"; - password_encryption = lib.mkDefault "scram-sha-256"; - }; - authentication = lib.mkAfter '' - host zulip zulip 127.0.0.1/32 scram-sha-256 - ''; - }; - - services.postgresqlBackup = { - enable = true; - backupAll = false; - databases = [ "zulip" ]; - }; - - services.memcached = { - enable = true; - listen = "127.0.0.1"; - port = 11211; - extraOptions = [ "-U 0" ]; - }; - - services.redis.servers.zulip = { - enable = true; - bind = "127.0.0.1"; - port = 6379; - requirePassFile = cfg.redisPasswordFile; - }; - - services.rabbitmq = { - enable = true; - listenAddress = "127.0.0.1"; - port = 5672; - }; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' - encode gzip zstd - reverse_proxy 127.0.0.1:${toString cfg.port} - ''; - - systemd.tmpfiles.rules = [ - "d ${cfg.dataDir} 0755 root root - -" - "d ${cfg.dataDir}/data 0755 root root - -" - "d ${cfg.dataDir}/data/logs 0755 root root - -" - "d ${cfg.dataDir}/data/logs/emails 0755 root root - -" - "d ${cfg.dataDir}/data/secrets 0700 root root - -" - "d ${cfg.dataDir}/secrets 0700 root root - -" - "d ${cfg.dataDir}/logs 0755 root root - -" - ]; - - systemd.services.burrow-zulip-postgres-bootstrap = { - description = "Bootstrap PostgreSQL role for Burrow Zulip"; - after = [ "postgresql.service" ]; - wants = [ "postgresql.service" ]; - requiredBy = [ "burrow-zulip.service" ]; - before = [ "burrow-zulip.service" ]; - path = [ - config.services.postgresql.package - pkgs.bash - pkgs.coreutils - pkgs.python3 - pkgs.util-linux - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - }; - script = '' - set -euo pipefail - - db_password="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.postgresPasswordFile})" - db_password_sql="$(printf '%s' "$db_password" | python3 -c "import sys; print(sys.stdin.read().replace(chr(39), chr(39) * 2), end=\"\")")" - setup_sql="$(mktemp)" - trap 'rm -f "$setup_sql"' EXIT - - cat > "$setup_sql" < ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} - chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} - - metadata_xml="$(${pkgs.curl}/bin/curl -fsSL https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" - saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c ' -import xml.etree.ElementTree as ET, sys -xml = sys.stdin.read() -root = ET.fromstring(xml) -ns = {"ds": "http://www.w3.org/2000/09/xmldsig#"} -node = root.find(".//ds:X509Certificate", ns) -if node is None or not (node.text or "").strip(): - raise SystemExit("missing X509 certificate in Authentik metadata") -print((node.text or "").strip()) -')" - - cat > ${lib.escapeShellArg "${cfg.dataDir}/compose.override.yaml"} < "$zulip_data_dir/secrets/bootstrap-owner-password" - fi - chown 1000:1000 "$zulip_data_dir/secrets/bootstrap-owner-password" - chmod 0600 "$zulip_data_dir/secrets/bootstrap-owner-password" - } - - wait_for_zulip_supervisor() { - local attempts=0 - while ! podman exec burrow-zulip_zulip_1 supervisorctl status >/dev/null 2>&1; do - attempts=$((attempts + 1)) - if [ "$attempts" -ge 90 ]; then - echo "error: Zulip supervisor did not become ready" >&2 - exit 1 - fi - sleep 2 - done - } - - patch_uwsgi_scheme_handling() { - wait_for_zulip_supervisor - podman exec burrow-zulip_zulip_1 bash -lc "cat > /etc/nginx/zulip-include/trusted-proto <<'EOF' -map \$remote_addr \$trusted_x_forwarded_proto { - default \$scheme; - 127.0.0.1 \$http_x_forwarded_proto; - ::1 \$http_x_forwarded_proto; - 172.31.1.1 \$http_x_forwarded_proto; -} -map \$remote_addr \$trusted_x_forwarded_for { - default \"\"; - 127.0.0.1 \$http_x_forwarded_for; - ::1 \$http_x_forwarded_for; - 172.31.1.1 \$http_x_forwarded_for; -} -map \$remote_addr \$x_proxy_misconfiguration { - default \"\"; -} -EOF -cat > /etc/nginx/uwsgi_params <<'EOF' -uwsgi_param QUERY_STRING \$query_string; -uwsgi_param REQUEST_METHOD \$request_method; -uwsgi_param CONTENT_TYPE \$content_type; -uwsgi_param CONTENT_LENGTH \$content_length; -uwsgi_param REQUEST_URI \$request_uri; -uwsgi_param PATH_INFO \$document_uri; -uwsgi_param DOCUMENT_ROOT \$document_root; -uwsgi_param SERVER_PROTOCOL \$server_protocol; -uwsgi_param REQUEST_SCHEME \$trusted_x_forwarded_proto; -uwsgi_param HTTPS on; -uwsgi_param REMOTE_ADDR \$remote_addr; -uwsgi_param REMOTE_PORT \$remote_port; -uwsgi_param SERVER_ADDR \$server_addr; -uwsgi_param SERVER_PORT \$server_port; -uwsgi_param SERVER_NAME \$server_name; -uwsgi_param HTTP_X_REAL_IP \$remote_addr; -uwsgi_param HTTP_X_FORWARDED_PROTO \$trusted_x_forwarded_proto; -uwsgi_param HTTP_X_FORWARDED_SSL \"\"; -uwsgi_param HTTP_X_PROXY_MISCONFIGURATION \$x_proxy_misconfiguration; - -# This value is the default, and is provided for explicitness; it must -# be longer than the configured 55s harakiri timeout in uwsgi -uwsgi_read_timeout 60s; - -uwsgi_pass django; -EOF -supervisorctl restart nginx zulip-django >/dev/null" - } - - bootstrap_realm_if_needed() { - wait_for_zulip_supervisor - local realm_exists - - realm_exists="$( - podman exec burrow-zulip_zulip_1 bash -lc \ - "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ - | awk '$NF == "https://${cfg.domain}" { print "yes" }' - )" - - if [ -n "$realm_exists" ]; then - return 0 - fi - - local realm_name=${lib.escapeShellArg cfg.realmName} - local admin_email=${lib.escapeShellArg cfg.administratorEmail} - local owner_name=${lib.escapeShellArg cfg.realmOwnerName} - local create_realm_cmd - - printf -v create_realm_cmd '%q ' \ - /home/zulip/deployments/current/manage.py \ - create_realm \ - --string-id= \ - --password-file /data/secrets/bootstrap-owner-password \ - --automated \ - "$realm_name" \ - "$admin_email" \ - "$owner_name" - - podman exec burrow-zulip_zulip_1 su zulip -c "$create_realm_cmd" - } - - reconcile_realm_policy() { - wait_for_zulip_supervisor - local realm_id - realm_id="$( - podman exec burrow-zulip_zulip_1 bash -lc \ - "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ - | awk '$NF == "https://${cfg.domain}" { print $1 }' - )" - - podman exec burrow-zulip_zulip_1 su zulip -c \ - "/home/zulip/deployments/current/manage.py realm_domain --op add -r $realm_id ${realmSignupDomain} --allow-subdomains --automated" \ - >/dev/null 2>&1 || true - - podman exec burrow-zulip_zulip_1 su zulip -c \ - "/home/zulip/deployments/current/manage.py shell -c 'from zerver.models import Realm; realm = Realm.objects.get(id=$realm_id); realm.invite_required = False; realm.save(update_fields=[\"invite_required\"])'" - } - - if [ ! -e .initialized ]; then - compose pull - compose run --rm -T zulip app:init - touch .initialized - fi - - ensure_zulip_data_layout - compose up -d zulip - bootstrap_realm_if_needed - reconcile_realm_policy - patch_uwsgi_scheme_handling - ''; - }; - }; -} diff --git a/package/arch/README.md b/package/arch/README.md deleted file mode 100644 index 71567cd..0000000 --- a/package/arch/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Arch and AUR - -Burrow has two Arch-family distribution surfaces: - -- `package/arch/aur/burrow-git` is the source-build AUR package repository. -- `package/repositories/pacman/pacman.conf` describes the binary pacman - repository at `packages.burrow.net`. - -Publish `burrow-git` to the AUR as its own git repository. Regenerate -`.SRCINFO` from the same `PKGBUILD` before pushing an AUR update. - -The binary pacman repository is built by -`Scripts/package/build-repositories.sh arch` and signs both packages and the -repository database sidecars through the `package-pacman-repository` Google KMS -key. diff --git a/package/arch/aur/burrow-git/.SRCINFO b/package/arch/aur/burrow-git/.SRCINFO deleted file mode 100644 index 34cc320..0000000 --- a/package/arch/aur/burrow-git/.SRCINFO +++ /dev/null @@ -1,21 +0,0 @@ -pkgbase = burrow-git - pkgdesc = Burrow VPN daemon and CLI - pkgver = 0.1.r0.g0000000 - pkgrel = 1 - url = https://git.burrow.net/hackclub/burrow - install = burrow.install - arch = x86_64 - arch = aarch64 - license = GPL-3.0-or-later - makedepends = cargo - makedepends = git - makedepends = protobuf - depends = openssl - depends = sqlite - depends = systemd - provides = burrow - conflicts = burrow - source = git+https://git.burrow.net/hackclub/burrow.git - sha256sums = SKIP - -pkgname = burrow-git diff --git a/package/arch/aur/burrow-git/PKGBUILD b/package/arch/aur/burrow-git/PKGBUILD deleted file mode 100644 index 92a7555..0000000 --- a/package/arch/aur/burrow-git/PKGBUILD +++ /dev/null @@ -1,35 +0,0 @@ -# Maintainer: Burrow Release Automation - -pkgname=burrow-git -pkgver=0.1.r0.g0000000 -pkgrel=1 -pkgdesc="Burrow VPN daemon and CLI" -arch=("x86_64" "aarch64") -url="https://git.burrow.net/hackclub/burrow" -license=("GPL-3.0-or-later") -depends=("openssl" "sqlite" "systemd") -makedepends=("cargo" "git" "protobuf") -provides=("burrow") -conflicts=("burrow") -source=("git+https://git.burrow.net/hackclub/burrow.git") -sha256sums=("SKIP") -install="burrow.install" - -pkgver() { - cd burrow - printf "0.1.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" -} - -build() { - cd burrow - cargo build --locked --release -p burrow --bin burrow -} - -package() { - cd burrow - install -Dm755 target/release/burrow "${pkgdir}/usr/bin/burrow" - install -Dm644 systemd/burrow.service "${pkgdir}/usr/lib/systemd/system/burrow.service" - install -Dm644 systemd/burrow.socket "${pkgdir}/usr/lib/systemd/system/burrow.socket" - install -Dm644 README.md "${pkgdir}/usr/share/doc/burrow/README.md" - install -Dm644 LICENSE.md "${pkgdir}/usr/share/licenses/burrow/LICENSE.md" -} diff --git a/package/arch/aur/burrow-git/burrow.install b/package/arch/aur/burrow-git/burrow.install deleted file mode 100644 index 355547d..0000000 --- a/package/arch/aur/burrow-git/burrow.install +++ /dev/null @@ -1,16 +0,0 @@ -post_install() { - systemctl daemon-reload - echo "Enable Burrow with: systemctl enable --now burrow.socket" -} - -post_upgrade() { - systemctl daemon-reload -} - -pre_remove() { - systemctl disable --now burrow.socket >/dev/null 2>&1 || true -} - -post_remove() { - systemctl daemon-reload -} diff --git a/package/repositories/README.md b/package/repositories/README.md deleted file mode 100644 index bc227fe..0000000 --- a/package/repositories/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Burrow Native Package Repositories - -Burrow publishes native daemon packages through repository formats that Linux -desktops already understand: - -- APT repository for Debian and Ubuntu -- RPM repository for Fedora, RHEL-family systems, and openSUSE-family systems -- pacman repository for Arch-family systems -- AUR source package repository for Arch users who prefer local builds -- Flatpak repository descriptor for the GTK GUI, with the daemon still supplied - by a native package -- NixOS flake/module for NixOS - -APT, RPM, and pacman repository metadata is signed with OpenPGP signatures -produced by `Scripts/package/google-kms-openpgp.py`. The OpenPGP public keys are -derived from the Google Cloud KMS public keys, while the private RSA signing -operation happens inside Google Cloud KMS. Each repository format has its own -KMS key: - -- `package-apt-repository` -- `package-rpm-repository` -- `package-pacman-repository` -- `package-flatpak-repository` -- `package-aur-source` - -Build repository metadata from package artifacts with: - -```sh -BURROW_PACKAGE_INPUT_DIR=dist/packages \ -BURROW_APT_REPO_KMS_PUBLIC_KEY_PEM=.kms/apt.pem \ -BURROW_RPM_REPO_KMS_PUBLIC_KEY_PEM=.kms/rpm.pem \ -BURROW_PACMAN_REPO_KMS_PUBLIC_KEY_PEM=.kms/pacman.pem \ -Scripts/package/build-repositories.sh all -``` - -The builder writes repository trees under `publish/repositories` by default: - -- `apt/dists//...` -- `rpm///...` -- `arch/burrow//...` -- `keys/*.asc` - -The Forgejo workflow `.forgejo/workflows/publish-package-repositories.yml` -builds Linux package artifacts, exports KMS public keys, signs repository -metadata through Google KMS, uploads the repository tree as a workflow artifact, -and publishes the tree to the package GCS bucket when `publish_gcs` is enabled. -The workflow authenticates to the Burrow Google project through Authentik-backed -Workload Identity Federation before it calls Google KMS or writes objects. - -PackageKit is an optional install frontend. It is not the repository format and -it is not available everywhere. The Flatpak GUI should try PackageKit only as a -convenience path, then degrade to native repository setup instructions. diff --git a/package/repositories/apt/burrow.sources b/package/repositories/apt/burrow.sources deleted file mode 100644 index 99da324..0000000 --- a/package/repositories/apt/burrow.sources +++ /dev/null @@ -1,6 +0,0 @@ -Types: deb -URIs: https://packages.burrow.net/apt -Suites: stable -Components: main -Architectures: amd64 arm64 -Signed-By: /usr/share/keyrings/burrow-package-apt-repository.gpg diff --git a/package/repositories/flatpak/burrow.flatpakrepo.in b/package/repositories/flatpak/burrow.flatpakrepo.in deleted file mode 100644 index a813194..0000000 --- a/package/repositories/flatpak/burrow.flatpakrepo.in +++ /dev/null @@ -1,8 +0,0 @@ -[Flatpak Repo] -Title=Burrow -Url=https://packages.burrow.net/flatpak -Homepage=https://burrow.net -Comment=Burrow GTK desktop UI -Description=Burrow GTK desktop UI. VPN operation requires the native Burrow daemon package on the host. -Icon=https://burrow.net/icon.png -GPGKey=@BURROW_FLATPAK_GPG_KEY_BASE64@ diff --git a/package/repositories/packagekit/README.md b/package/repositories/packagekit/README.md deleted file mode 100644 index aad13c5..0000000 --- a/package/repositories/packagekit/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# PackageKit Bootstrap - -PackageKit is the closest cross-distro install prompt for mutable Linux -desktops. Burrow treats it as an optional bootstrap path: - -```sh -pkcon install burrow -``` - -This works only when the host distro has PackageKit, the Burrow native -repository is configured, and polkit authorizes the action. The Flatpak GUI may -offer this path through a narrow `org.freedesktop.PackageKit` permission, but it -must also support direct native-repository instructions. - -The command fallback helper checks `apt-get`, `dnf`, `zypper`, `pacman`, `apk`, -`xbps-install`, `eopkg`, `emerge`, `swupd`, and `nix` after PackageKit. diff --git a/package/repositories/pacman/pacman.conf b/package/repositories/pacman/pacman.conf deleted file mode 100644 index 4c206bc..0000000 --- a/package/repositories/pacman/pacman.conf +++ /dev/null @@ -1,3 +0,0 @@ -[burrow] -Server = https://packages.burrow.net/arch/burrow/$arch -SigLevel = Required DatabaseOptional diff --git a/package/repositories/rpm/burrow.repo b/package/repositories/rpm/burrow.repo deleted file mode 100644 index 481aa37..0000000 --- a/package/repositories/rpm/burrow.repo +++ /dev/null @@ -1,7 +0,0 @@ -[burrow] -name=Burrow -baseurl=https://packages.burrow.net/rpm/stable/$basearch -enabled=1 -gpgcheck=1 -repo_gpgcheck=1 -gpgkey=https://packages.burrow.net/keys/burrow-package-rpm-repository.asc diff --git a/patches/rcodesign-pkcs11-tool.patch b/patches/rcodesign-pkcs11-tool.patch deleted file mode 100644 index c1fe36a..0000000 --- a/patches/rcodesign-pkcs11-tool.patch +++ /dev/null @@ -1,522 +0,0 @@ ---- a/apple-codesign/src/cli/certificate_source.rs -+++ b/apple-codesign/src/cli/certificate_source.rs -@@ -16,0 +17 @@ -+ bytes::Bytes, -@@ -19,0 +21 @@ -+ signature::Signer, -@@ -21,2 +23,10 @@ -- std::path::PathBuf, -- x509_certificate::CapturedX509Certificate, -+ std::{ -+ path::PathBuf, -+ process::Command, -+ sync::atomic::{AtomicU64, Ordering}, -+ }, -+ x509_certificate::{ -+ CapturedX509Certificate, KeyAlgorithm, Sign, Signature, SignatureAlgorithm, -+ X509CertificateError, -+ }, -+ zeroize::Zeroizing, -@@ -36,0 +47,2 @@ -+static PKCS11_SIGN_COUNTER: AtomicU64 = AtomicU64::new(0); -+ -@@ -204,0 +214,262 @@ -+#[derive(Clone, Debug)] -+struct Pkcs11PrivateKey { -+ tool: String, -+ module: PathBuf, -+ slot_id: Option, -+ token_label: Option, -+ key_id: Option, -+ key_label: Option, -+ pin: String, -+ cert: CapturedX509Certificate, -+} -+ -+impl signature::Signer for Pkcs11PrivateKey { -+ fn try_sign(&self, message: &[u8]) -> Result { -+ match self.cert.key_algorithm() { -+ Some(KeyAlgorithm::Rsa) => {} -+ Some(algorithm) => { -+ return Err(signature::Error::from_source(format!( -+ "PKCS#11 signing currently supports RSA certificates, not {algorithm:?}" -+ ))); -+ } -+ None => { -+ return Err(signature::Error::from_source( -+ "failed to resolve PKCS#11 certificate key algorithm", -+ )); -+ } -+ } -+ -+ let sequence = PKCS11_SIGN_COUNTER.fetch_add(1, Ordering::Relaxed); -+ let base = std::env::temp_dir().join(format!( -+ "rcodesign-pkcs11-{}-{sequence}", -+ std::process::id() -+ )); -+ let input_path = base.with_extension("in"); -+ let output_path = base.with_extension("sig"); -+ let openssl_config_path = base.with_extension("openssl.cnf"); -+ let provider_debug_path = base.with_extension("pkcs11-provider.log"); -+ -+ std::fs::write(&input_path, message).map_err(signature::Error::from_source)?; -+ -+ let provider_override = -+ std::env::var("BURROW_APPLE_PKCS11_SIGN_WITH_OPENSSL_PROVIDER") -+ .or_else(|_| std::env::var("BURROW_PKCS11_SIGN_WITH_OPENSSL_PROVIDER")) -+ .ok() -+ .and_then(|value| { -+ if value.trim().is_empty() { -+ None -+ } else { -+ Some(matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")) -+ } -+ }); -+ let use_openssl_provider = provider_override.unwrap_or_else(|| { -+ std::env::var("BURROW_APPLE_PKCS11_BACKEND") -+ .or_else(|_| std::env::var("BURROW_PKCS11_BACKEND")) -+ .map(|value| value == "gcp-kms") -+ .unwrap_or(false) -+ || self.module.to_string_lossy().contains("kmsp11") -+ || std::env::var("BURROW_GCP_KMS_PKCS11_CONFIG").is_ok() -+ || std::env::var("KMS_PKCS11_CONFIG").is_ok() -+ }); -+ -+ if use_openssl_provider { -+ let token_label = self.token_label.as_ref().ok_or_else(|| { -+ signature::Error::from_source( -+ "OpenSSL PKCS#11 provider signing requires --pkcs11-token-label", -+ ) -+ })?; -+ let mut key_uri = format!("pkcs11:token={token_label}"); -+ if let Some(label) = &self.key_label { -+ key_uri.push_str(";object="); -+ key_uri.push_str(label); -+ } else if let Some(id) = &self.key_id { -+ key_uri.push_str(";id="); -+ key_uri.push_str(id); -+ } else { -+ return Err(signature::Error::from_source( -+ "OpenSSL PKCS#11 provider signing requires --pkcs11-key-label or --pkcs11-key-id", -+ )); -+ } -+ key_uri.push_str(";type=private"); -+ -+ let pin_path = base.with_extension("pin"); -+ std::fs::write(&pin_path, &self.pin).map_err(signature::Error::from_source)?; -+ -+ let module_path = self.module.to_string_lossy(); -+ let kms_config = std::env::var("KMS_PKCS11_CONFIG") -+ .or_else(|_| std::env::var("BURROW_GCP_KMS_PKCS11_CONFIG")) -+ .ok(); -+ let needs_kms_config = module_path.contains("kmsp11") || kms_config.is_some(); -+ if needs_kms_config { -+ let config_path = kms_config.as_ref().ok_or_else(|| { -+ signature::Error::from_source( -+ "OpenSSL PKCS#11 provider signing with Google KMS requires KMS_PKCS11_CONFIG", -+ ) -+ })?; -+ let metadata = std::fs::metadata(config_path).map_err(|err| { -+ signature::Error::from_source(format!( -+ "OpenSSL PKCS#11 provider signing could not read KMS_PKCS11_CONFIG at {config_path}: {err}" -+ )) -+ })?; -+ if !metadata.is_file() || metadata.len() == 0 { -+ return Err(signature::Error::from_source(format!( -+ "OpenSSL PKCS#11 provider signing requires a non-empty KMS_PKCS11_CONFIG file at {config_path}" -+ ))); -+ } -+ } -+ -+ let openssl_modules = std::env::var("BURROW_OPENSSL_MODULES") -+ .or_else(|_| std::env::var("OPENSSL_MODULES")) -+ .ok() -+ .filter(|value| !value.trim().is_empty()); -+ if let Some(modules) = &openssl_modules { -+ let provider_extension = if cfg!(target_os = "macos") { -+ "dylib" -+ } else { -+ "so" -+ }; -+ let provider_module = PathBuf::from(modules) -+ .join(format!("pkcs11.{provider_extension}")); -+ if provider_module.is_file() { -+ let config = format!( -+ "HOME = .\nopenssl_conf = openssl_init\n\n[openssl_init]\nproviders = provider_sect\n\n[provider_sect]\ndefault = default_sect\npkcs11 = pkcs11_sect\n\n[default_sect]\nactivate = 1\n\n[pkcs11_sect]\nmodule = {}\npkcs11-module-path = {}\npkcs11-module-token-pin = file:{}\npkcs11-module-cache-pins = cache\npkcs11-module-quirks = no-operation-state no-allowed-mechanisms\nactivate = 1\n", -+ provider_module.display(), -+ self.module.display(), -+ pin_path.display(), -+ ); -+ std::fs::write(&openssl_config_path, config) -+ .map_err(signature::Error::from_source)?; -+ } -+ } -+ -+ let openssl = std::env::var("BURROW_OPENSSL").unwrap_or_else(|_| "openssl".into()); -+ let mut command = Command::new(openssl); -+ command -+ .env("PKCS11_PROVIDER_MODULE", &self.module) -+ .arg("dgst") -+ .arg("-provider") -+ .arg("pkcs11") -+ .arg("-provider") -+ .arg("default") -+ .arg("-sha256") -+ .arg("-sign") -+ .arg(&key_uri) -+ .arg("-passin") -+ .arg(format!("file:{}", pin_path.display())) -+ .arg("-out") -+ .arg(&output_path) -+ .arg(&input_path); -+ if let Some(config_path) = kms_config { -+ command -+ .env("KMS_PKCS11_CONFIG", &config_path) -+ .env("BURROW_GCP_KMS_PKCS11_CONFIG", &config_path); -+ } -+ if let Ok(credentials_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") { -+ command.env("GOOGLE_APPLICATION_CREDENTIALS", credentials_path); -+ } -+ if let Some(modules) = openssl_modules { -+ command.env("OPENSSL_MODULES", modules); -+ } -+ if openssl_config_path.is_file() { -+ command.env("OPENSSL_CONF", &openssl_config_path); -+ } -+ if let Ok(debug) = std::env::var("BURROW_PKCS11_PROVIDER_DEBUG") -+ .or_else(|_| std::env::var("PKCS11_PROVIDER_DEBUG")) -+ { -+ command.env("PKCS11_PROVIDER_DEBUG", debug); -+ } else { -+ command.env( -+ "PKCS11_PROVIDER_DEBUG", -+ format!("file:{},level:1", provider_debug_path.display()), -+ ); -+ } -+ -+ let output = command.output().map_err(signature::Error::from_source)?; -+ let _ = std::fs::remove_file(&input_path); -+ let _ = std::fs::remove_file(&pin_path); -+ let _ = std::fs::remove_file(&openssl_config_path); -+ -+ if !output.status.success() { -+ let _ = std::fs::remove_file(&output_path); -+ let provider_debug = std::fs::read_to_string(&provider_debug_path) -+ .ok() -+ .filter(|value| !value.trim().is_empty()) -+ .map(|value| { -+ let tail = value -+ .lines() -+ .rev() -+ .take(80) -+ .collect::>() -+ .into_iter() -+ .rev() -+ .collect::>() -+ .join("\n"); -+ format!("\nPKCS#11 provider debug tail:\n{tail}\n") -+ }) -+ .unwrap_or_default(); -+ let _ = std::fs::remove_file(&provider_debug_path); -+ return Err(signature::Error::from_source(format!( -+ "OpenSSL PKCS#11 provider signing failed with status {}: {}{}{}", -+ output.status, -+ String::from_utf8_lossy(&output.stdout), -+ String::from_utf8_lossy(&output.stderr), -+ provider_debug -+ ))); -+ } -+ let _ = std::fs::remove_file(&provider_debug_path); -+ } else { -+ let mut command = Command::new(&self.tool); -+ command -+ .arg("--module") -+ .arg(&self.module) -+ .arg("--login") -+ .arg("--pin") -+ .arg(&self.pin) -+ .arg("--sign") -+ .arg("--mechanism") -+ .arg("SHA256-RSA-PKCS") -+ .arg("--input-file") -+ .arg(&input_path) -+ .arg("--output-file") -+ .arg(&output_path); -+ -+ if let Some(slot_id) = &self.slot_id { -+ command.arg("--slot").arg(slot_id); -+ } -+ if let Some(token_label) = &self.token_label { -+ command.arg("--token-label").arg(token_label); -+ } -+ if let Some(id) = &self.key_id { -+ command.arg("--id").arg(id); -+ } -+ if let Some(label) = &self.key_label { -+ command.arg("--label").arg(label); -+ } -+ -+ let output = command.output().map_err(signature::Error::from_source)?; -+ -+ let _ = std::fs::remove_file(&input_path); -+ -+ if !output.status.success() { -+ let _ = std::fs::remove_file(&output_path); -+ return Err(signature::Error::from_source(format!( -+ "pkcs11-tool signing failed with status {}: {}{}", -+ output.status, -+ String::from_utf8_lossy(&output.stdout), -+ String::from_utf8_lossy(&output.stderr) -+ ))); -+ } -+ } -+ -+ let signature = std::fs::read(&output_path).map_err(signature::Error::from_source)?; -+ let _ = std::fs::remove_file(&output_path); -+ -+ if signature.is_empty() { -+ return Err(signature::Error::from_source( -+ "pkcs11-tool produced an empty signature", -+ )); -+ } -+ -+ Ok(signature.into()) -+ } -+} -@@ -204,0 +384,214 @@ -+impl Sign for Pkcs11PrivateKey { -+ fn sign(&self, message: &[u8]) -> Result<(Vec, SignatureAlgorithm), X509CertificateError> { -+ let algorithm = self.signature_algorithm()?; -+ -+ Ok((self.try_sign(message)?.into(), algorithm)) -+ } -+ -+ fn key_algorithm(&self) -> Option { -+ self.cert.key_algorithm() -+ } -+ -+ fn public_key_data(&self) -> Bytes { -+ self.cert.public_key_data() -+ } -+ -+ fn signature_algorithm(&self) -> Result { -+ match self.cert.key_algorithm() { -+ Some(KeyAlgorithm::Rsa) => Ok(SignatureAlgorithm::RsaSha256), -+ Some(algorithm) => Err(X509CertificateError::UnknownSignatureAlgorithm(format!( -+ "PKCS#11 signing currently supports RSA certificates, not {algorithm:?}" -+ ))), -+ None => Err(X509CertificateError::UnknownSignatureAlgorithm( -+ "failed to resolve PKCS#11 certificate key algorithm".into(), -+ )), -+ } -+ } -+ -+ fn private_key_data(&self) -> Option>> { -+ None -+ } -+ -+ fn rsa_primes( -+ &self, -+ ) -> Result>, Zeroizing>)>, X509CertificateError> { -+ Ok(None) -+ } -+} -+ -+impl x509_certificate::KeyInfoSigner for Pkcs11PrivateKey {} -+ -+impl PrivateKey for Pkcs11PrivateKey { -+ fn as_key_info_signer(&self) -> &dyn x509_certificate::KeyInfoSigner { -+ self -+ } -+ -+ fn to_public_key_peer_decrypt( -+ &self, -+ ) -> Result< -+ Box, -+ AppleCodesignError, -+ > { -+ Err(AppleCodesignError::CliGeneralError( -+ "PKCS#11 keys cannot decrypt remote-signing peer setup data".into(), -+ )) -+ } -+ -+ fn finish(&self) -> Result<(), AppleCodesignError> { -+ Ok(()) -+ } -+} -+ -+#[derive(Args, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -+#[serde(deny_unknown_fields)] -+pub struct Pkcs11SigningKey { -+ /// Path to pkcs11-tool -+ #[arg(id = "pkcs11_tool", long = "pkcs11-tool", default_value = "pkcs11-tool", value_name = "PATH")] -+ pub tool: String, -+ -+ /// Path to the PKCS#11 module to use for signing -+ #[arg( -+ long = "pkcs11-library", -+ alias = "pkcs11-module", -+ id = "pkcs11_library", -+ value_name = "PATH" -+ )] -+ pub module: Option, -+ -+ /// PKCS#11 slot id containing the signing key -+ #[arg(id = "pkcs11_slot_id", long = "pkcs11-slot-id", value_name = "SLOT")] -+ pub slot_id: Option, -+ -+ /// PKCS#11 token label containing the signing key -+ #[arg(id = "pkcs11_token_label", long = "pkcs11-token-label", value_name = "LABEL")] -+ pub token_label: Option, -+ -+ /// PKCS#11 private-key object id, usually hex -+ #[arg(id = "pkcs11_key_id", long = "pkcs11-key-id", value_name = "ID")] -+ pub key_id: Option, -+ -+ /// PKCS#11 private-key object label -+ #[arg(id = "pkcs11_key_label", long = "pkcs11-key-label", value_name = "LABEL")] -+ pub key_label: Option, -+ -+ /// PIN used to unlock the PKCS#11 token -+ #[arg(id = "pkcs11_pin", long = "pkcs11-pin", group = "pkcs11-pin-source", value_name = "PIN")] -+ pub pin: Option, -+ -+ /// File containing the PIN used to unlock the PKCS#11 token -+ #[arg(id = "pkcs11_pin_file", long = "pkcs11-pin-file", group = "pkcs11-pin-source", value_name = "PATH")] -+ pub pin_path: Option, -+ -+ /// Environment variable holding the PIN used to unlock the PKCS#11 token -+ #[arg(id = "pkcs11_pin_env", long = "pkcs11-pin-env", group = "pkcs11-pin-source", value_name = "ENV")] -+ #[serde(skip)] -+ pub pin_env: Option, -+ -+ /// DER certificate file corresponding to the PKCS#11 private key -+ #[arg( -+ long = "pkcs11-certificate-file", -+ alias = "pkcs11-certificate-der-file", -+ id = "pkcs11_certificate_file", -+ value_name = "PATH" -+ )] -+ pub certificate_path: Option, -+} -+ -+impl Pkcs11SigningKey { -+ pub(crate) fn configured(&self) -> bool { -+ self.module.is_some() -+ || self.slot_id.is_some() -+ || self.token_label.is_some() -+ || self.key_id.is_some() -+ || self.key_label.is_some() -+ || self.pin.is_some() -+ || self.pin_path.is_some() -+ || self.pin_env.is_some() -+ || self.certificate_path.is_some() -+ } -+ -+ fn not_configured(&self) -> bool { -+ !self.configured() -+ } -+ -+ fn resolve_pin(&self) -> Result { -+ if let Some(pin) = &self.pin { -+ Ok(pin.clone()) -+ } else if let Some(path) = &self.pin_path { -+ Ok(std::fs::read_to_string(path)?.trim().to_string()) -+ } else if let Some(env) = &self.pin_env { -+ std::env::var(env).map_err(|_| { -+ AppleCodesignError::CliGeneralError(format!( -+ "failed reading PKCS#11 PIN from {env} environment variable" -+ )) -+ }) -+ } else { -+ Err(AppleCodesignError::CliGeneralError( -+ "--pkcs11-pin, --pkcs11-pin-file, or --pkcs11-pin-env is required".into(), -+ )) -+ } -+ } -+} -+ -+impl KeySource for Pkcs11SigningKey { -+ fn resolve_certificates(&self) -> Result { -+ if !self.configured() { -+ return Ok(Default::default()); -+ } -+ -+ let module = self.module.clone().ok_or_else(|| { -+ AppleCodesignError::CliGeneralError("--pkcs11-library is required".into()) -+ })?; -+ if self.slot_id.is_none() && self.token_label.is_none() { -+ return Err(AppleCodesignError::CliGeneralError( -+ "--pkcs11-slot-id or --pkcs11-token-label is required".into(), -+ )); -+ } -+ if self.key_id.is_none() && self.key_label.is_none() { -+ return Err(AppleCodesignError::CliGeneralError( -+ "--pkcs11-key-id or --pkcs11-key-label is required".into(), -+ )); -+ } -+ let certificate_path = self.certificate_path.clone().ok_or_else(|| { -+ AppleCodesignError::CliGeneralError("--pkcs11-certificate-file is required".into()) -+ })?; -+ let pin = self.resolve_pin()?; -+ -+ warn!( -+ "reading PKCS#11 certificate data from {}", -+ certificate_path.display() -+ ); -+ let cert = CapturedX509Certificate::from_der(std::fs::read(certificate_path)?)?; -+ -+ match cert.key_algorithm() { -+ Some(KeyAlgorithm::Rsa) => {} -+ Some(algorithm) => { -+ return Err(AppleCodesignError::CliGeneralError(format!( -+ "PKCS#11 signing currently supports RSA certificates, not {algorithm:?}" -+ ))); -+ } -+ None => { -+ return Err(AppleCodesignError::CliGeneralError( -+ "failed to resolve PKCS#11 certificate key algorithm".into(), -+ )); -+ } -+ } -+ -+ let signer = Pkcs11PrivateKey { -+ tool: self.tool.clone(), -+ module, -+ slot_id: self.slot_id.clone(), -+ token_label: self.token_label.clone(), -+ key_id: self.key_id.clone(), -+ key_label: self.key_label.clone(), -+ pin, -+ cert: cert.clone(), -+ }; -+ -+ Ok(SigningCertificates { -+ keys: vec![Box::new(signer)], -+ certs: vec![cert], -+ }) -+ } -+} -+ -@@ -643,0 +1037,8 @@ -+ rename = "pkcs11", -+ skip_serializing_if = "Pkcs11SigningKey::not_configured" -+ )] -+ pub pkcs11_key: Pkcs11SigningKey, -+ -+ #[command(flatten)] -+ #[serde( -+ default, -@@ -680,0 +1082,2 @@ -+ res.push(&self.pkcs11_key as &dyn KeySource); -+ ---- a/apple-codesign/src/cli/mod.rs -+++ b/apple-codesign/src/cli/mod.rs -@@ -1529 +1532,6 @@ -- let certs = c.signer.resolve_certificates(true)?; -+ let signer = if self.certificate.pkcs11_key.configured() { -+ &self.certificate -+ } else { -+ &c.signer -+ }; -+ let certs = signer.resolve_certificates(true)?; diff --git a/proto/burrow.proto b/proto/burrow.proto index ed1f89e..efbb064 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -5,7 +5,6 @@ import "google/protobuf/timestamp.proto"; service Tunnel { rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse); - rpc TunnelPackets (stream TunnelPacket) returns (stream TunnelPacket); rpc TunnelStart (Empty) returns (Empty); rpc TunnelStop (Empty) returns (Empty); rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse); @@ -18,14 +17,6 @@ service Networks { rpc NetworkDelete (NetworkDeleteRequest) returns (Empty); } -service TailnetControl { - rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); - rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); - rpc LoginStart (TailnetLoginStartRequest) returns (TailnetLoginStatusResponse); - rpc LoginStatus (TailnetLoginStatusRequest) returns (TailnetLoginStatusResponse); - rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty); -} - message NetworkReorderRequest { int32 id = 1; int32 index = 2; @@ -54,7 +45,8 @@ message Network { enum NetworkType { WireGuard = 0; - Tailnet = 1; + HackClub = 1; + Tor = 2; } message NetworkListResponse { @@ -65,57 +57,6 @@ message Empty { } -message TailnetDiscoverRequest { - string email = 1; -} - -message TailnetDiscoverResponse { - string domain = 1; - string authority = 2; - string oidc_issuer = 3; - bool managed = 4; -} - -message TailnetProbeRequest { - string authority = 1; -} - -message TailnetProbeResponse { - string authority = 1; - int32 status_code = 2; - string summary = 3; - string detail = 4; - bool reachable = 5; -} - -message TailnetLoginStartRequest { - string account_name = 1; - string identity_name = 2; - string hostname = 3; - string authority = 4; -} - -message TailnetLoginStatusRequest { - string session_id = 1; -} - -message TailnetLoginCancelRequest { - string session_id = 1; -} - -message TailnetLoginStatusResponse { - string session_id = 1; - string backend_state = 2; - string auth_url = 3; - bool running = 4; - bool needs_login = 5; - string tailnet_name = 6; - string magic_dns_suffix = 7; - string self_dns_name = 8; - repeated string tailnet_ips = 9; - repeated string health = 10; -} - enum State { Stopped = 0; Running = 1; @@ -129,12 +70,4 @@ message TunnelStatusResponse { message TunnelConfigurationResponse { repeated string addresses = 1; int32 mtu = 2; - repeated string routes = 3; - repeated string dns_servers = 4; - repeated string search_domains = 5; - bool include_default_route = 6; -} - -message TunnelPacket { - bytes payload = 1; } diff --git a/secrets.nix b/secrets.nix index db95367..1e49f5d 100644 --- a/secrets.nix +++ b/secrets.nix @@ -1,41 +1 @@ -let - conradev = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBueQxNbP2246pxr/m7au4zNVm+ShC96xuOcfEcpIjWZ"; - contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa"; - agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net"; - jett = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./nixos/keys/jett_at_burrow_net.pub); - burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l"; - burrowForgeRecipients = [ - contact - agent - jett - burrowForgeHost - ]; - uiTestRecipients = burrowForgeRecipients ++ [ conradev ]; - releaseRecipients = burrowForgeRecipients ++ [ conradev ]; -in -{ - "secrets/apple/appstore-connect.env.age".publicKeys = releaseRecipients; - "secrets/apple/distribution-signing.env.age".publicKeys = releaseRecipients; - "secrets/apple/sparkle.env.age".publicKeys = releaseRecipients; - "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; - "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; - "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; - "secrets/infra/authentik-google-account-map.json.age".publicKeys = burrowForgeRecipients; - "secrets/infra/authentik-google-wif-client-secret.age".publicKeys = burrowForgeRecipients; - "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; - "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; - "secrets/infra/grafana-admin-password.age".publicKeys = burrowForgeRecipients; - "secrets/infra/grafana-secret-key.age".publicKeys = burrowForgeRecipients; - "secrets/infra/grafana-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/linear-scim-token.age".publicKeys = burrowForgeRecipients; - "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; - "secrets/infra/zulip-postgres-password.age".publicKeys = burrowForgeRecipients; - "secrets/infra/zulip-memcached-password.age".publicKeys = burrowForgeRecipients; - "secrets/infra/zulip-rabbitmq-password.age".publicKeys = burrowForgeRecipients; - "secrets/infra/zulip-redis-password.age".publicKeys = burrowForgeRecipients; - "secrets/infra/zulip-secret-key.age".publicKeys = burrowForgeRecipients; -} +import ./secrets/secrets.nix diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..706b374 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,28 @@ +# Secrets + +Burrow secrets live in `secrets/.age` and are managed with `agenix`. + +For the Forgejo Namespace Cloud runtime: + +- `secrets/forgejo/admin-password.age` +- `secrets/forgejo/agent-ssh-key.age` +- `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 +`services.burrow.forgejoNsc`. diff --git a/secrets/apple/appstore-connect.env.age b/secrets/apple/appstore-connect.env.age deleted file mode 100644 index 5762560..0000000 Binary files a/secrets/apple/appstore-connect.env.age and /dev/null differ diff --git a/secrets/apple/sparkle.env.age b/secrets/apple/sparkle.env.age deleted file mode 100644 index 7a73864..0000000 --- a/secrets/apple/sparkle.env.age +++ /dev/null @@ -1,14 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q 4lsJwrd+zftsXdTsQ3gHxHBPcj0vB4DI9rtV9tnNj34 -egBI8PYwaKvatPeSSz/IBBpsV/HSyYINcoVL1Z5+fMo --> ssh-ed25519 IrZmAg UMLZxY6J5w1Nt3oCnkKCELS0fP8LhRQzBuata2m0/m0 -p0Nj6XUD3/Z/VgNX99POOv9d3kvMTXlCndh40uzZwuU --> ssh-ed25519 0kWPgQ VqbeBJ9KELadFFhEUVQuXaayCw7m1XxBNoAhcavGYAs -zrUKaHsxMNI+YfFMi6w0qJ0QsGTl6lBPkAMAT6Np4U8 --> X25519 P3EVVvgL1O/2N1tDVqdiNnwpnocO1dsT1MZEBqTk+HQ -lEdIo0nWWIwWJ0gbt1X7H6do+lCjhMEyTv2dyDtEmNs --> ssh-ed25519 tbm5Cw YjftkJadPiA1/vTWS53xK9EkyDWXiL2+WJpAx+ZM+HQ -3b3CP3UA/TK3+2EuNDL8Vf22o+Mxgx6bsm5Ub7e7lNc ---- LKEHr9rQIZlVZQTW46XwDCDa5lsKrgIamb2ocpG5vHw -P -N D5zyL"ED0%oԄ| Oc!]# (xDTGXx؇#+XANz2bv] \ No newline at end of file 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/forgejo/admin-password.age b/secrets/forgejo/admin-password.age new file mode 100644 index 0000000..53cfa83 --- /dev/null +++ b/secrets/forgejo/admin-password.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q nmGFzw38TKiVVuA9CM8wHQDVib0RddB+M/UjQnD45jk +iZNLNBlS32zR+TNfcK27T1V3w27sFKJkWfuOzHwcOL0 +-> ssh-ed25519 IrZmAg Y53DC0wGX8mjaXkD3+jZn2DviO5iSXsnZDBNCBTmLgA +XLz+YXzT4fYb7q0xuZMKgv88lAd0gGKaquSMcA6Yu3c +-> ssh-ed25519 JzXUWA EDAXBKEvHccJ4KKtHjUTA+KA+wN9bBu9v+kzRTFt9AI +JNADezBCxx26+QPD2tIpz5O8cncrJwnqaYQEWY56VGY +--- RpjdftRPUGT80IMYKFDFuHkKEr1heJOvqrqYLufhc10 +_ +F( +((0ɉ',8d]d%T[MKRQxiIf0 \ No newline at end of file diff --git a/secrets/forgejo/agent-ssh-key.age b/secrets/forgejo/agent-ssh-key.age new file mode 100644 index 0000000..44ce114 Binary files /dev/null and b/secrets/forgejo/agent-ssh-key.age differ diff --git a/secrets/forgejo/nsc-autoscaler-config.age b/secrets/forgejo/nsc-autoscaler-config.age new file mode 100644 index 0000000..94e1535 Binary files /dev/null and b/secrets/forgejo/nsc-autoscaler-config.age differ diff --git a/secrets/forgejo/nsc-dispatcher-config.age b/secrets/forgejo/nsc-dispatcher-config.age new file mode 100644 index 0000000..ab4bff7 Binary files /dev/null and b/secrets/forgejo/nsc-dispatcher-config.age differ diff --git a/secrets/forgejo/nsc-token.age b/secrets/forgejo/nsc-token.age new file mode 100644 index 0000000..2f26639 Binary files /dev/null and b/secrets/forgejo/nsc-token.age differ 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/infra/authentik-google-account-map.json.age b/secrets/infra/authentik-google-account-map.json.age deleted file mode 100644 index 158814a..0000000 Binary files a/secrets/infra/authentik-google-account-map.json.age and /dev/null differ diff --git a/secrets/infra/authentik-google-client-id.age b/secrets/infra/authentik-google-client-id.age deleted file mode 100644 index 344c73b..0000000 --- a/secrets/infra/authentik-google-client-id.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q Cmf/vgRBGrP8KGwpc9XCXKo5H23Gcgi6dN688oazITQ -poYU28mmvkWFdciOiWLQ+powQcsHzof3Gyzq61V2olY --> ssh-ed25519 IrZmAg mowUPV3BbYR1IupBoT1o3KB+Fo7Q3E3DT0wRx82f4ic -TZ4r/L5EdHP9wwIbJWBjIITja2L2Pd4AX/U7JSfLm/Y --> ssh-ed25519 0kWPgQ v9NoFxsRERSgK5cgCHSdtZpn4EcPhvj4JCRR1axGqUM -ogDiLkSFr8i39b3y2WlnbTMprXiVJPG5KNHGKJIagLo --> X25519 4xouhPGq8wCmbbjLQsfZeGabsXxc4f74e2gXd+13kB4 -UM7/P0RZyu3PoU5mMY0aoGCdoqrOTgDshGuVjagoaEc ---- r6gIEDysfaqsHMaFF/vuLVaJv85uShPlNNTktMdpUvw -2TXěM"T뇵S=_U.=w -\Y/x*|tjZ'#uOcqL_hA$)ic{L @F \ No newline at end of file diff --git a/secrets/infra/authentik-google-client-secret.age b/secrets/infra/authentik-google-client-secret.age deleted file mode 100644 index 9a841c7..0000000 --- a/secrets/infra/authentik-google-client-secret.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q Q3rYrGroJXarMLdatYCHVERefWDyGwM0Ii/kOp5m3Fs -W3tgHNXLSVfGU5p8MhBj0mX72SNgMl8nf8sQX29yvBw --> ssh-ed25519 IrZmAg fyFQQkd51GthNZ4R+W5Al266LnlKbr4ZoMERlCM1OTQ -rNjnHTGCfF8LkqU8mzTrHlL5G4az1k62gvH4gW8zmjc --> ssh-ed25519 0kWPgQ OWokv9XAphqbkDi1cznb9V09VcM6Li1eIh0JpcIlVTY -TnPVlqKB78y7NPYp02UJmuRXdBMKJKCngpvo8TjpFZ8 --> X25519 HWaWhyejjo4IjDrNsBYxU1JaGU0899FqiBYgstInuiU -enbBGnhH+uJKY3NBD6mmy09Uos+in6ytRQ5BakvTUvI ---- gOBrh88hnvlUSmnRiowJiUIwgIz5zzVKH8YCRb8Ckdw -xokPn8v򵄙HRʏoMË9&Tb]ĉ'|<Pbe \ No newline at end of file diff --git a/secrets/infra/authentik-google-wif-client-secret.age b/secrets/infra/authentik-google-wif-client-secret.age deleted file mode 100644 index 427dbba..0000000 --- a/secrets/infra/authentik-google-wif-client-secret.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q dmNzqS+jc7JGI31mzsRRxSr7vAxRw3uxkbs4vzG20gk -sJWziiiMhKGzEiAeIxPfeqUYIu4CzZRw+eMXle5zMRs --> ssh-ed25519 IrZmAg McLHhYu/3wDQYB1bJ/rXsoTv2UgpVTRm4AqsCTLkhCc -Rrl0vy0mhdXR0pSHckRI39Eg0bxB/Km3lCXXxl9t9Io --> ssh-ed25519 0kWPgQ UQoa9fP3OPYtesWmMiWL1q8MqPxsXy//hj6805JQBRg -UKt/WM0t90OPDH9A+RVgrW60vlI2FmRewGsXGD5FCkU --> X25519 48f0e9+P3ihg3mJIQxvHvq1P/4JEfS1t7DO73/TRGwg -k70uLkBQt5F1ovKhemYRhyKuPQXfT5XSXMzaQczPC8Q ---- L9B029nBJvUvCKhU2Avmmdmw5sce7bvhFpiPm0p+TVE -nG$\_$s&ΪALlZɪ N!ZA%LVb$(`aK ssh-ed25519 ux4N8Q m92EIjRqUnhjOm40jyn0JXujyj/P8rvZX4K5T0zLvG8 -VHseS9X67DiViTwzPnTFa19CuNZX+iUDKIyeN1DlXyA --> ssh-ed25519 IrZmAg IK6wvWDy5HghH/KodWnb4X3PR12Ra0EKY6AVG2jvUU8 -Hu1GHi2C3tbL0WY5MDfZzm4CIbROh0l2exMNOEWmm3E --> ssh-ed25519 0kWPgQ S4PIujnjsoc/R1NoitDp+4BwGNFMvKkQh4Dcweo92W4 -HY9xiDOXSxl14ZVlNkUWVbHlOh1pizLcKiotrTdecy0 --> X25519 n0O4uO3oVQb8d42GwI+2SjTxsXObpU0PQ4zC1l9WWSc -WhOtVcYPF9xUsm2/jqutdWMPDaeRRITW3QsxGikPYm4 ---- CT5DMaqNGPPzYUCay11loEixWG0MID4yuubYR9wDFxA -UDX!xr#S>Vw}׍L4]#f`;ɂ/3Z)QV4 -d}=}- \ No newline at end of file diff --git a/secrets/infra/grafana-secret-key.age b/secrets/infra/grafana-secret-key.age deleted file mode 100644 index 42af338..0000000 Binary files a/secrets/infra/grafana-secret-key.age and /dev/null differ diff --git a/secrets/infra/headscale-oidc-client-secret.age b/secrets/infra/headscale-oidc-client-secret.age deleted file mode 100644 index 81cff1c..0000000 --- a/secrets/infra/headscale-oidc-client-secret.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q 8QtHVR8defharS9ppAsLOnwX1A3T5CqNLhaLDG41q0E -tQBUL3Wzh4lIwmIBGjLH5gjsvChWo6GJ4YxXc+cNddU --> ssh-ed25519 IrZmAg TgL3trgA3+4ivxpIpv/rEegjmZakSEx7B6e2sc4xhRw -NW4OgVJZhVJUXMBHaajk06CxEJjzrumqTNI2/6RDM4A --> ssh-ed25519 0kWPgQ uBosFXj4NCXBw5X+h/zr2QLCHnkhtgVZEYOHEBBGoFY -LTrparOr5iwAEEPM+rTZyDxJFJX/nQsTYpNdGSgKTes --> X25519 zbO7ax9E3Fya7mvNP/ueB/XL2UN1sHe8Is+2g6hM8WA -PnjKLk/ZQFrJ0mGIbX8fc9pqw3T2FTT0WSUaDjN1C+w ---- Aknf9dPdr3qD+tu5HyT74L2JMtg46ClYL0FBDhiLrxI -3_:޿rbA~vn™G/->5K\닊|iX~sX!EF'cv9>ԦrHxEP`> \ No newline at end of file diff --git a/secrets/infra/linear-scim-token.age b/secrets/infra/linear-scim-token.age deleted file mode 100644 index 5bed53e..0000000 --- a/secrets/infra/linear-scim-token.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q Tb3hxc6ZscCQpr7s8raup25FA8YAmq30jHZfOQp28Xs -L9YhaX9IVinud0IOs5K55ldGx82wjXHxnVBHZnRjiTA --> ssh-ed25519 IrZmAg etIe6hWDP9YkqDFCWybnvsOh7h8YO+z3tKc95pG64lU -BT3rH5a+LJZWv2xtWPbMJGS2oM9v4mOI9WPmnHebiew --> ssh-ed25519 0kWPgQ YpCf5m16VaKp7d+C3oF9MJQB/0xzCNtD7ODsTiV8t1o -xG8G/kSM+7VrWHm299A7fG/kBFnoiWZPiDZuldvimLw --> X25519 ETltnMPR7lWbBWJvJKmNZhS7wqX0WCa4aNu8UKzxMVE -Ys57VNuclgvN1nJIrLjNrwekbosa7KK9lFt0PTpr/MQ ---- ZeUmSOf8+NycQAFRGCJHYcQvTJqSBIGKEOEdCnNfJbE -<q1.O_դ7A۷_@%/5l7JɵčA xb "B \ No newline at end of file diff --git a/secrets/infra/tailscale-oidc-client-secret.age b/secrets/infra/tailscale-oidc-client-secret.age deleted file mode 100644 index 3c3c074..0000000 Binary files a/secrets/infra/tailscale-oidc-client-secret.age and /dev/null differ diff --git a/secrets/infra/zulip-memcached-password.age b/secrets/infra/zulip-memcached-password.age deleted file mode 100644 index 0769512..0000000 --- a/secrets/infra/zulip-memcached-password.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q x0r1UHgSibFIvKU34kP0+mnvQa5xXnac3P5fyqb7qFc -MfKnr5N0DV2NIoo4MFVFV0ULMayy0zzZqIq4FDzgDGc --> ssh-ed25519 IrZmAg rzoR8knGrsTGuh9Hqg/NB0NQKI1vx1WI0ZRyrLIPwVY -7gV/d1slrIT+W0+iX5YK/uUWjHGJfee6vA+f9a35nEY --> ssh-ed25519 0kWPgQ SyuEAfqmBAqLcuuQUHM5OzAv2hoquMMYtVdbKpBVhjI -7QqXens2363ln0euoormMh9a3Csh+nS2eBkHuQJmOWc --> X25519 qDjNNkYBUhWTYyBhrw9tYl8a7G6TCkVZbR4aPcP+J0c -QF33V6hFUuYRj0B8Eo4jqyyvCpBbpD2ViVWoS8A8f3E ---- 1/Jb0nvWlcszMmxI0yVr6kfexDN0sSk1p+wsTUL4WvU -9a5IكV[f,Db \v&LZ7!?4=JxFeV \ No newline at end of file diff --git a/secrets/infra/zulip-postgres-password.age b/secrets/infra/zulip-postgres-password.age deleted file mode 100644 index b03556c..0000000 Binary files a/secrets/infra/zulip-postgres-password.age and /dev/null differ diff --git a/secrets/infra/zulip-rabbitmq-password.age b/secrets/infra/zulip-rabbitmq-password.age deleted file mode 100644 index 9b1f6ec..0000000 --- a/secrets/infra/zulip-rabbitmq-password.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q s1hLIWvkXmlIv/VeHXpDSCe+dh09mE+iZd7xJiQccy0 -8WosTJQLGRPhTR06SIDjgtXNebcf+H/pFzY/lBCjXcs --> ssh-ed25519 IrZmAg zBNlK+o/RCTCyp8BRkoAYqsDn//kIKtYk3SICkMu3BA -EhBQy8QdSnCZKkdGzQho7zEMmAbJVoU5jZOMPN6tHG0 --> ssh-ed25519 0kWPgQ hv06idPXqAATkLeUC5vILdEO2NXNWPczlWnwMFvOdkA -3EeajviunGlcfcF1QlRJrVA9bwPT+fJZFX0uneYVs0c --> X25519 vm9rPYnQB16VSidi7+nr70lFaH0W/jIGY8zwUObZUV8 -jFgPy/w4j0/p1USKGjQY+coo1OUFXiIjJ5apIZCrZVI ---- Cf2c6WzLYOi8xE/sIn7ZtUqBy5AToASDUNpAxyjrI9M -:,+!ϨϬB4DmH|(9l9LPZ^zed=imz? \ No newline at end of file diff --git a/secrets/infra/zulip-redis-password.age b/secrets/infra/zulip-redis-password.age deleted file mode 100644 index 2aff8b6..0000000 --- a/secrets/infra/zulip-redis-password.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q DqDE3ZZlPUWUyyLA185xsOmfGi146SNk+hENMQXaiFY -D6FhZgynbdccPJQiFRJ18EYvCyDLz3cak0YuQa4f5p4 --> ssh-ed25519 IrZmAg lXgVeADmgjeHeVOOIS5oHqrhkN59ZWDemMOBJo3ubH8 -AQ24P+DnxNoHEguNnLaROIW4/Sq96w/UxzzQwEOyGRc --> ssh-ed25519 0kWPgQ 8x0pMohdACYueLY6jbNwg7MYVaZcjwBU4axthvDoFx4 -SgUVnd6MK1MccWVYOu9R3PtoMCBBNGKQ7jt5MSA+KkI --> X25519 UaO5huJPx8d8eMUnGhbI77tZjsFlIPWEffT4fgoO22w -DVz016ibRxJoa4TDmb2m0Qu9Dn8jpjWEBVtdm2TZx0c ---- 5+MHuvC26SjEBFSmRm0kXjiI27QnJGxvPl2w13EkMrw -FoQ]ȟeU//no.XGJ Э|+ž \ No newline at end of file diff --git a/secrets/infra/zulip-secret-key.age b/secrets/infra/zulip-secret-key.age deleted file mode 100644 index d903d66..0000000 --- a/secrets/infra/zulip-secret-key.age +++ /dev/null @@ -1,11 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q ml+kmLmuRb2nMXJyhKigby2+lPddxM/U7tjhGGQ/JGk -B3UCv/3+4GHeKR964o/m0CoicHwDgWQGEarPW94tb3I --> ssh-ed25519 IrZmAg AO0ELOuGGj+WanDZFRkHKUEJyZqJYFdhWbqmUfwbpiM -5RZMxVBvW5+TzCBFnn66ry3o5V5cJykweyoYMVBgczY --> ssh-ed25519 0kWPgQ gqQ/S33Re2OYLz1D9LoSAoqOKxuL4aUes8r6+NyAoXw -NHo2xFsxxJO1ZjnG9r3oxMuvjOUsCyyPvcar2ejZp9w --> X25519 vUAjBCE197YsckVNM4SYVIPBEESTWnBPCWnUlEwYs1I -L3l85DXFoAVm2ssHfjBeqRpWGlo1UGbmcNkEgoUB9fM ---- X/2O8ufjbTGrt2zCm4gSRqqoxT5v6a+13XjH4dpRsHs -Mkf"(qxF2BdMRYji ܴ<ґb_.!r+<Ussu?gD\V am(Ȉ&.& c/|w(WH4rѠ+j"B  \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix new file mode 100644 index 0000000..4a78a69 --- /dev/null +++ b/secrets/secrets.nix @@ -0,0 +1,19 @@ +let + contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa"; + 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; + "secrets/forgejo/agent-ssh-key.age".publicKeys = forgeAutomation; + "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 e84a71b..95167c1 100644 --- a/services/forgejo-nsc/README.md +++ b/services/forgejo-nsc/README.md @@ -45,6 +45,15 @@ profile. The important knobs are: - `namespace.machine_type` / `namespace.duration` – shape + TTL for the ephemeral Namespace environment. The dispatcher destroys the instance after a job so the TTL acts as a hard cap, not an idle timeout. +- macOS fallback launches still use `nsc create`. Bootstrap prefers the + Compute SSH config endpoint, and falls back to keychain-backed `nsc ssh` + only when the Compute bearer is rejected. That keeps the fast path on direct + TCP while preserving a working fallback when tenant auth drifts. +- `namespace.linux_cache_*` / `namespace.macos_cache_*` – persistent cache + volumes mounted into runners so Linux can keep `/nix` plus shared build + caches warm and macOS can reuse Rust toolchains, Xcode package caches, and + lane-local derived data. If Namespace keeps reusing an older undersized cache + volume, bump the cache tag name to force a fresh allocation at the new size. ### Running locally @@ -121,14 +130,14 @@ dispatcher: url: "http://dispatcher:8080" instances: - - name: burrow - forgejo: - base_url: "https://git.burrow.net" - token: "PENDING-FORGEJO-PAT" - scope: - level: "repository" - owner: "hackclub" - name: "burrow" +- name: burrow + forgejo: + base_url: "https://git.burrow.net" + token: "PENDING-FORGEJO-PAT" + scope: + level: "repository" + owner: "hackclub" + name: "burrow" disable_polling: true # webhook-only mode poll_interval: "30s" webhook_secret: "supersecret" @@ -141,6 +150,10 @@ instances: - labels: ["namespace-profile-linux-medium"] min_idle: 0 # set to 0 to scale-to-zero between jobs ttl: "20m" + - labels: ["namespace-profile-macos-large"] + min_idle: 0 + ttl: "90m" + machine_type: "6x14" - labels: ["namespace-profile-windows-large"] min_idle: 0 ttl: "45m" @@ -148,18 +161,28 @@ 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 the -dispatcher/autoscaler configs into `intake/forgejo_nsc_{dispatcher,autoscaler}.yaml` -plus `intake/forgejo_nsc_token.txt`. +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 long-lived `session_token` plus the +current `bearer_token`. The `nsc` CLI paths use the session-backed login flow, +while the Compute API path can consume the bearer token directly. The forge +host consumes the encrypted secrets through agenix; avoid keeping local +plaintext `intake/` copies around. -For ongoing operations, use `Scripts/seal-forgejo-nsc-secrets.sh`: +Long-lived runtime state is now sourced from age-encrypted files: -- `Scripts/seal-forgejo-nsc-secrets.sh --provision` refreshes the local - Namespace token from the logged-in Namespace account, mints a Forgejo PAT, - renders the dispatcher/autoscaler configs, and seals those runtime inputs into - agenix secrets consumed by `burrow-forge`. -- Deploy `burrow-forge` after sealing so the dispatcher and autoscaler consume - the new Namespace credentials. +- `secrets/forgejo/admin-password.age` +- `secrets/forgejo/agent-ssh-key.age` +- `secrets/forgejo/nsc-token.age` +- `secrets/forgejo/nsc-dispatcher-config.age` +- `secrets/forgejo/nsc-autoscaler-config.age` + +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`. +The Nix host module also installs a periodic `forgejo-prune-runners` timer that +marks stale offline runners deleted in Forgejo's database so wedged instances do +not leave the queue polluted indefinitely. Run it next to the dispatcher: diff --git a/services/forgejo-nsc/autoscaler.example.yaml b/services/forgejo-nsc/autoscaler.example.yaml index 35ba07a..2185469 100644 --- a/services/forgejo-nsc/autoscaler.example.yaml +++ b/services/forgejo-nsc/autoscaler.example.yaml @@ -1,7 +1,6 @@ listen: ":8090" dispatcher: url: "http://localhost:8080" - timeout: "2h" instances: - name: burrow @@ -24,7 +23,11 @@ instances: - labels: ["namespace-profile-linux-medium"] min_idle: 1 ttl: "20m" - machine_type: "8x16" + machine_type: "4x8" + - labels: ["namespace-profile-macos-large"] + min_idle: 0 + ttl: "90m" + machine_type: "6x14" - labels: ["namespace-profile-windows-large"] min_idle: 0 ttl: "45m" diff --git a/services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go b/services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go index 9dcbfb1..3a04a26 100644 --- a/services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go +++ b/services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go @@ -43,19 +43,23 @@ func main() { } dispatcher, err := nsc.NewDispatcher(nsc.Options{ - BinaryPath: cfg.Namespace.NSCBinary, - ComputeBaseURL: cfg.Namespace.ComputeBaseURL, - DefaultImage: cfg.Namespace.Image, - DefaultMachine: cfg.Namespace.MachineType, - MacosBaseImageID: cfg.Namespace.MacosBaseImageID, - MacosMachineArch: cfg.Namespace.MacosMachineArch, - DefaultDuration: cfg.Namespace.Duration.Duration, - WorkDir: cfg.Namespace.WorkDir, - MaxParallel: cfg.Namespace.MaxParallel, - RunnerNamePrefix: cfg.Runner.NamePrefix, - Executor: cfg.Runner.Executor, - Network: cfg.Namespace.Network, - Logger: logger, + BinaryPath: cfg.Namespace.NSCBinary, + ComputeBaseURL: cfg.Namespace.ComputeBaseURL, + DefaultImage: cfg.Namespace.Image, + DefaultMachine: cfg.Namespace.MachineType, + MacosBaseImageID: cfg.Namespace.MacosBaseImageID, + MacosMachineArch: cfg.Namespace.MacosMachineArch, + DefaultDuration: cfg.Namespace.Duration.Duration, + WorkDir: cfg.Namespace.WorkDir, + MaxParallel: cfg.Namespace.MaxParallel, + LinuxCachePath: cfg.Namespace.LinuxCachePath, + LinuxCacheVolumes: toNSCCacheVolumes(cfg.Namespace.LinuxCacheVolumes), + MacosCachePath: cfg.Namespace.MacosCachePath, + MacosCacheVolumes: toNSCCacheVolumes(cfg.Namespace.MacosCacheVolumes), + RunnerNamePrefix: cfg.Runner.NamePrefix, + Executor: cfg.Runner.Executor, + Network: cfg.Namespace.Network, + Logger: logger, }) if err != nil { logger.Error("failed to create dispatcher", "error", err) @@ -88,3 +92,15 @@ func main() { defer cancel() _ = srv.Shutdown(ctx) } + +func toNSCCacheVolumes(volumes []config.CacheVolumeConfig) []nsc.CacheVolume { + out := make([]nsc.CacheVolume, 0, len(volumes)) + for _, volume := range volumes { + out = append(out, nsc.CacheVolume{ + Tag: volume.Tag, + MountPoint: volume.MountPoint, + SizeGb: volume.SizeGb, + }) + } + return out +} diff --git a/services/forgejo-nsc/config.example.yaml b/services/forgejo-nsc/config.example.yaml index 5dc7551..b45234f 100644 --- a/services/forgejo-nsc/config.example.yaml +++ b/services/forgejo-nsc/config.example.yaml @@ -11,16 +11,35 @@ forgejo: timeout: "30s" namespace: - nsc_binary: "/app/bin/nsc" + nsc_binary: "nsc" compute_base_url: "https://ord4.compute.namespaceapis.com" - image: "ghcr.io/forgejo/runner:3" - machine_type: "8x16" + image: "code.forgejo.org/forgejo/runner:11" + machine_type: "4x8" macos_base_image_id: "tahoe" macos_machine_arch: "arm64" duration: "30m" workdir: "/var/lib/forgejo-runner" max_parallel: 4 network: "" + linux_cache_path: "/var/cache/burrow" + linux_cache_volumes: + - tag: "burrow-forgejo-linux-nix-v2" + mount_point: "/nix" + size_gb: 80 + - tag: "burrow-forgejo-linux-cache-v2" + mount_point: "/var/cache/burrow" + size_gb: 80 + macos_cache_path: "/Users/runner/.cache/burrow" + macos_cache_volumes: + - tag: "burrow-forgejo-macos-shared-v1" + mount_point: "/Users/runner/.cache/burrow/shared" + size_gb: 80 + - tag: "burrow-forgejo-macos-macos-v1" + mount_point: "/Users/runner/.cache/burrow/lane/macos" + size_gb: 80 + - tag: "burrow-forgejo-macos-ios-simulator-v1" + mount_point: "/Users/runner/.cache/burrow/lane/ios-simulator" + size_gb: 80 runner: name_prefix: "nscloud-" diff --git a/services/forgejo-nsc/deploy/autoscaler.yaml b/services/forgejo-nsc/deploy/autoscaler.yaml index 42ae6c7..30b2729 100644 --- a/services/forgejo-nsc/deploy/autoscaler.yaml +++ b/services/forgejo-nsc/deploy/autoscaler.yaml @@ -2,7 +2,6 @@ listen: "127.0.0.1:8090" dispatcher: url: "http://127.0.0.1:8080" - timeout: "3h" instances: - name: burrow @@ -25,27 +24,11 @@ instances: - labels: ["namespace-profile-linux-medium"] min_idle: 0 ttl: "20m" - machine_type: "8x16" - - labels: ["namespace-profile-linux-medium-apple-v2"] - min_idle: 0 - ttl: "30m" - machine_type: "8x16" - - labels: ["namespace-profile-linux-testflight"] - min_idle: 0 - ttl: "150m" - machine_type: "8x16" + machine_type: "4x8" - labels: ["namespace-profile-macos-large"] - min_idle: 0 - ttl: "60m" - machine_type: "12x28" - - labels: ["namespace-profile-macos-large-apple-v2"] min_idle: 0 ttl: "90m" - machine_type: "12x28" - - labels: ["namespace-profile-macos-large-testflight"] - min_idle: 0 - ttl: "150m" - machine_type: "12x28" + machine_type: "6x14" - labels: ["namespace-profile-windows-large"] min_idle: 0 ttl: "45m" diff --git a/services/forgejo-nsc/deploy/dispatcher.yaml b/services/forgejo-nsc/deploy/dispatcher.yaml index 28f55ac..0f183e1 100644 --- a/services/forgejo-nsc/deploy/dispatcher.yaml +++ b/services/forgejo-nsc/deploy/dispatcher.yaml @@ -13,16 +13,43 @@ forgejo: timeout: "30s" namespace: - nsc_binary: "/run/current-system/sw/bin/nsc" + nsc_binary: "nsc" compute_base_url: "https://ord4.compute.namespaceapis.com" image: "code.forgejo.org/forgejo/runner:11" - machine_type: "8x16" + machine_type: "4x8" macos_base_image_id: "tahoe" macos_machine_arch: "arm64" duration: "30m" workdir: "/var/lib/forgejo-runner" max_parallel: 4 + allow_labels: + - namespace-profile-linux-medium + - namespace-profile-macos-large + - namespace-profile-windows-large + allow_scopes: + - "repository:hackclub/burrow" + instance_tags: + - "burrow" network: "" + linux_cache_path: "/var/cache/burrow" + linux_cache_volumes: + - tag: "burrow-forgejo-linux-nix-v2" + mount_point: "/nix" + size_gb: 80 + - tag: "burrow-forgejo-linux-cache-v2" + mount_point: "/var/cache/burrow" + size_gb: 80 + macos_cache_path: "/Users/runner/.cache/burrow" + macos_cache_volumes: + - tag: "burrow-forgejo-macos-shared-v1" + mount_point: "/Users/runner/.cache/burrow/shared" + size_gb: 80 + - tag: "burrow-forgejo-macos-macos-v1" + mount_point: "/Users/runner/.cache/burrow/lane/macos" + size_gb: 80 + - tag: "burrow-forgejo-macos-ios-simulator-v1" + mount_point: "/Users/runner/.cache/burrow/lane/ios-simulator" + size_gb: 80 runner: name_prefix: "nscloud-" diff --git a/services/forgejo-nsc/internal/app/service.go b/services/forgejo-nsc/internal/app/service.go index 45b66eb..10639a5 100644 --- a/services/forgejo-nsc/internal/app/service.go +++ b/services/forgejo-nsc/internal/app/service.go @@ -94,6 +94,17 @@ type RunnerHandle struct { Name string `json:"name"` } +func launchContext(ttl time.Duration) (context.Context, context.CancelFunc) { + if ttl <= 0 { + return context.WithTimeout(context.Background(), 2*time.Hour) + } + // Provisioning can legitimately take several minutes before the runner starts + // processing the actual Forgejo job. Keep the launch context independent from + // the caller's HTTP timeout so autoscaler/webhook requests don't kill active + // bootstraps mid-flight. + return context.WithTimeout(context.Background(), ttl+30*time.Minute) +} + func (s *Service) Dispatch(ctx context.Context, req DispatchRequest) (DispatchResponse, error) { count := req.Count if count <= 0 { @@ -134,7 +145,10 @@ func (s *Service) Dispatch(ctx context.Context, req DispatchRequest) (DispatchRe return fmt.Errorf("fetching registration token: %w", err) } - name, err := s.dispatcher.LaunchRunner(egCtx, nsc.LaunchRequest{ + launchCtx, cancel := launchContext(ttl) + defer cancel() + + name, err := s.dispatcher.LaunchRunner(launchCtx, nsc.LaunchRequest{ Token: token, InstanceURL: s.instanceURL, Labels: labels, diff --git a/services/forgejo-nsc/internal/autoscaler/config.go b/services/forgejo-nsc/internal/autoscaler/config.go index d252664..7603e67 100644 --- a/services/forgejo-nsc/internal/autoscaler/config.go +++ b/services/forgejo-nsc/internal/autoscaler/config.go @@ -70,7 +70,7 @@ func LoadConfig(path string) (Config, error) { return Config{}, fmt.Errorf("dispatcher.url is required") } if cfg.Dispatcher.Timeout.Duration == 0 { - cfg.Dispatcher.Timeout = config.Duration{Duration: 2 * time.Hour} + cfg.Dispatcher.Timeout = config.Duration{Duration: 15 * time.Second} } if len(cfg.Instances) == 0 { return Config{}, fmt.Errorf("at least one instance must be configured") diff --git a/services/forgejo-nsc/internal/config/config.go b/services/forgejo-nsc/internal/config/config.go index 264cbd0..5ef8a7a 100644 --- a/services/forgejo-nsc/internal/config/config.go +++ b/services/forgejo-nsc/internal/config/config.go @@ -49,8 +49,14 @@ type Config struct { Runner RunnerConfig `yaml:"runner"` } +type CacheVolumeConfig struct { + Tag string `yaml:"tag"` + MountPoint string `yaml:"mount_point"` + SizeGb int64 `yaml:"size_gb"` +} + type ForgejoConfig struct { - BaseURL string `yaml:"base_url"` + BaseURL string `yaml:"base_url"` // InstanceURL is the URL runners should use when registering with Forgejo. // This must be reachable from the spawned runner (e.g. the public URL like // https://git.burrow.net), and may differ from BaseURL (which can be a local @@ -80,15 +86,19 @@ type NamespaceConfig struct { // MacosBaseImageID selects which macOS base image to use (e.g. "tahoe"). MacosBaseImageID string `yaml:"macos_base_image_id"` // MacosMachineArch is the architecture used for macOS instances (typically "arm64"). - MacosMachineArch string `yaml:"macos_machine_arch"` - Duration Duration `yaml:"duration"` - WorkDir string `yaml:"workdir"` - MaxParallel int64 `yaml:"max_parallel"` - Environment []string `yaml:"environment"` - AllowLabels []string `yaml:"allow_labels"` - AllowScopes []string `yaml:"allow_scopes"` - Network string `yaml:"network"` - InstanceTags []string `yaml:"instance_tags"` + MacosMachineArch string `yaml:"macos_machine_arch"` + Duration Duration `yaml:"duration"` + WorkDir string `yaml:"workdir"` + MaxParallel int64 `yaml:"max_parallel"` + Environment []string `yaml:"environment"` + AllowLabels []string `yaml:"allow_labels"` + AllowScopes []string `yaml:"allow_scopes"` + Network string `yaml:"network"` + InstanceTags []string `yaml:"instance_tags"` + LinuxCachePath string `yaml:"linux_cache_path"` + LinuxCacheVolumes []CacheVolumeConfig `yaml:"linux_cache_volumes"` + MacosCachePath string `yaml:"macos_cache_path"` + MacosCacheVolumes []CacheVolumeConfig `yaml:"macos_cache_volumes"` } type RunnerConfig struct { @@ -160,6 +170,56 @@ func (c *Config) Validate() error { if c.Namespace.MaxParallel <= 0 { c.Namespace.MaxParallel = 4 } + if c.Namespace.LinuxCachePath == "" { + c.Namespace.LinuxCachePath = "/var/cache/burrow" + } + if len(c.Namespace.LinuxCacheVolumes) == 0 { + c.Namespace.LinuxCacheVolumes = []CacheVolumeConfig{ + { + Tag: "burrow-forgejo-linux-nix-v2", + MountPoint: "/nix", + SizeGb: 80, + }, + { + Tag: "burrow-forgejo-linux-cache-v2", + MountPoint: c.Namespace.LinuxCachePath, + SizeGb: 80, + }, + } + } + if c.Namespace.MacosCachePath == "" { + c.Namespace.MacosCachePath = "/Users/runner/.cache/burrow" + } + if len(c.Namespace.MacosCacheVolumes) == 0 { + c.Namespace.MacosCacheVolumes = []CacheVolumeConfig{ + { + Tag: "burrow-forgejo-macos-shared-v1", + MountPoint: c.Namespace.MacosCachePath + "/shared", + SizeGb: 80, + }, + { + Tag: "burrow-forgejo-macos-macos-v1", + MountPoint: c.Namespace.MacosCachePath + "/lane/macos", + SizeGb: 80, + }, + { + Tag: "burrow-forgejo-macos-ios-simulator-v1", + MountPoint: c.Namespace.MacosCachePath + "/lane/ios-simulator", + SizeGb: 80, + }, + } + } + for _, volume := range append(append([]CacheVolumeConfig{}, c.Namespace.LinuxCacheVolumes...), c.Namespace.MacosCacheVolumes...) { + if strings.TrimSpace(volume.Tag) == "" { + return errors.New("namespace cache volume tag is required") + } + if strings.TrimSpace(volume.MountPoint) == "" { + return fmt.Errorf("namespace cache volume %q mount_point is required", volume.Tag) + } + if volume.SizeGb <= 0 { + return fmt.Errorf("namespace cache volume %q size_gb must be positive", volume.Tag) + } + } return nil } diff --git a/services/forgejo-nsc/internal/nsc/dispatcher.go b/services/forgejo-nsc/internal/nsc/dispatcher.go index 33721b6..4a579a6 100644 --- a/services/forgejo-nsc/internal/nsc/dispatcher.go +++ b/services/forgejo-nsc/internal/nsc/dispatcher.go @@ -17,19 +17,29 @@ import ( ) type Options struct { - BinaryPath string - DefaultImage string - DefaultMachine string - DefaultDuration time.Duration - WorkDir string - MaxParallel int64 - RunnerNamePrefix string - Executor string - Network string - ComputeBaseURL string - MacosBaseImageID string - MacosMachineArch string - Logger *slog.Logger + BinaryPath string + DefaultImage string + DefaultMachine string + DefaultDuration time.Duration + WorkDir string + MaxParallel int64 + RunnerNamePrefix string + Executor string + Network string + ComputeBaseURL string + MacosBaseImageID string + MacosMachineArch string + LinuxCachePath string + LinuxCacheVolumes []CacheVolume + MacosCachePath string + MacosCacheVolumes []CacheVolume + Logger *slog.Logger +} + +type CacheVolume struct { + Tag string + MountPoint string + SizeGb int64 } type LaunchRequest struct { @@ -73,6 +83,12 @@ func NewDispatcher(opts Options) (*Dispatcher, error) { if opts.DefaultDuration == 0 { opts.DefaultDuration = 30 * time.Minute } + if opts.LinuxCachePath == "" { + opts.LinuxCachePath = "/var/cache/burrow" + } + if opts.MacosCachePath == "" { + opts.MacosCachePath = "/Users/runner/.cache/burrow" + } logger := opts.Logger if logger == nil { logger = slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -104,6 +120,9 @@ func (d *Dispatcher) LaunchRunner(ctx context.Context, req LaunchRequest) (strin } machineType := choose(req.MachineType, d.opts.DefaultMachine) image := choose(req.Image, d.opts.DefaultImage) + if req.ExtraEnv == nil { + req.ExtraEnv = make(map[string]string) + } if hasWindowsLabel(req.Labels) { if err := d.launchWindowsRunnerViaWinRM(ctx, runnerName, req, duration, machineType); err != nil { @@ -113,10 +132,13 @@ func (d *Dispatcher) LaunchRunner(ctx context.Context, req LaunchRequest) (strin } if hasMacOSLabel(req.Labels) { + if _, ok := req.ExtraEnv["NSC_CACHE_PATH"]; !ok { + req.ExtraEnv["NSC_CACHE_PATH"] = d.opts.MacosCachePath + } // Compute macOS shapes differ from the Linux "run" defaults. If the request // didn't specify a machine type, ensure we pick a macOS-valid default. if machineType == "" || machineType == d.opts.DefaultMachine { - machineType = "12x28" + machineType = "6x14" } // Prefer the Compute API path because it uses the service token (NSC_TOKEN_FILE) @@ -129,6 +151,9 @@ func (d *Dispatcher) LaunchRunner(ctx context.Context, req LaunchRequest) (strin } return runnerName, nil } + if _, ok := req.ExtraEnv["NSC_CACHE_PATH"]; !ok { + req.ExtraEnv["NSC_CACHE_PATH"] = d.opts.LinuxCachePath + } env := map[string]string{ "FORGEJO_INSTANCE_URL": req.InstanceURL, @@ -140,9 +165,6 @@ func (d *Dispatcher) LaunchRunner(ctx context.Context, req LaunchRequest) (strin for k, v := range req.ExtraEnv { env[k] = v } - if _, ok := env["NSC_CACHE_PATH"]; !ok { - env["NSC_CACHE_PATH"] = "/nix/store" - } script := d.bootstrapScript() args := []string{ @@ -161,6 +183,7 @@ func (d *Dispatcher) LaunchRunner(ctx context.Context, req LaunchRequest) (strin if d.opts.Network != "" { args = append(args, "--network", d.opts.Network) } + args = appendVolumeArgs(args, d.opts.LinuxCacheVolumes) for key, value := range env { if value == "" { continue @@ -174,6 +197,10 @@ func (d *Dispatcher) LaunchRunner(ctx context.Context, req LaunchRequest) (strin args = append(args, "--", "/bin/sh", "-c", script) cmd := exec.CommandContext(ctx, d.opts.BinaryPath, args...) + // The Linux `nsc run` path uses the CLI auth flow. Keep using the service + // account's refreshed Namespace login session instead of forcing the + // short-lived NSC_TOKEN_FILE bearer token into CLI requests. + cmd.Env = nscCLIEnv() var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf @@ -284,28 +311,22 @@ func instanceStopped(output string) bool { if len(payload) == 0 { return false } - seenTerminalState := false for _, entry := range payload { - if len(entry.PerResource) == 0 { - return false - } for _, target := range entry.PerResource { if target.Tombstone != "" { - seenTerminalState = true return true } if len(target.Container) == 0 { - return false + continue } for _, container := range target.Container { if container.Status != "stopped" && container.TerminatedAt == "" { return false } - seenTerminalState = true } } } - return seenTerminalState + return true } func (d *Dispatcher) waitForInstanceStop(ctx context.Context, runnerName, instanceID string, timeout time.Duration) bool { @@ -376,9 +397,21 @@ func choose(values ...string) string { return "" } +func appendVolumeArgs(args []string, volumes []CacheVolume) []string { + for _, volume := range volumes { + if strings.TrimSpace(volume.Tag) == "" || strings.TrimSpace(volume.MountPoint) == "" || volume.SizeGb <= 0 { + continue + } + args = append(args, "--volume", fmt.Sprintf("cache:%s:%s:%d", volume.Tag, volume.MountPoint, volume.SizeGb)) + } + return args +} + func (d *Dispatcher) bootstrapScript() string { var builder strings.Builder builder.WriteString(`set -euo pipefail +export HOME=/root +export USER=root mkdir -p "${FORGEJO_RUNNER_WORKDIR:-/tmp/forgejo-runner}" cd "${FORGEJO_RUNNER_WORKDIR:-/tmp/forgejo-runner}" @@ -394,8 +427,23 @@ fi if ! command -v xz >/dev/null 2>&1; then apk add --no-cache xz >/dev/null fi +if ! command -v nix >/dev/null 2>&1; then + apk add --no-cache nix >/dev/null +fi export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +if [ -f /etc/profile.d/nix.sh ]; then + # shellcheck disable=SC1091 + . /etc/profile.d/nix.sh +fi +if [ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]; then + # shellcheck disable=SC1091 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh +fi +export PATH="/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" +export NIX_CONFIG="experimental-features = nix-command flakes +accept-flake-config = true" node --version >/dev/null +nix --version >/dev/null cat > runner.yaml <<'EOF' log: @@ -419,13 +467,7 @@ for label in ${FORGEJO_RUNNER_LABELS//,/ } ; do fi case "${label}" in *:*) resolved="${label}" ;; - *) - if [ "$runner_exec" = "host" ]; then - resolved="${label}:host" - else - resolved="${label}:${runner_exec}" - fi - ;; + *) resolved="${label}:${runner_exec}" ;; esac echo " - ${resolved}" >> runner.yaml if [ -z "${resolved_labels}" ]; then diff --git a/services/forgejo-nsc/internal/nsc/dispatcher_test.go b/services/forgejo-nsc/internal/nsc/dispatcher_test.go deleted file mode 100644 index bcf450e..0000000 --- a/services/forgejo-nsc/internal/nsc/dispatcher_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package nsc - -import "testing" - -func TestInstanceStoppedRequiresObservedTerminalState(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - output string - want bool - }{ - {name: "empty output", output: "", want: false}, - {name: "empty response", output: "[]", want: false}, - {name: "no resources yet", output: `[{"per_resource":{}}]`, want: false}, - {name: "resource without containers", output: `[{"per_resource":{"runner":{}}}]`, want: false}, - {name: "running container", output: `[{"per_resource":{"runner":{"container":[{"status":"running"}]}}}]`, want: false}, - {name: "stopped container", output: `[{"per_resource":{"runner":{"container":[{"status":"stopped"}]}}}]`, want: true}, - {name: "terminated container", output: `[{"per_resource":{"runner":{"container":[{"status":"running","terminated_at":"2026-06-07T15:00:00Z"}]}}}]`, want: true}, - {name: "tombstone", output: `[{"per_resource":{"runner":{"tombstone":"destroyed"}}}]`, want: true}, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := instanceStopped(tc.output) - if got != tc.want { - t.Fatalf("instanceStopped(%s) = %v, want %v", tc.output, got, tc.want) - } - }) - } -} diff --git a/services/forgejo-nsc/internal/nsc/macos.go b/services/forgejo-nsc/internal/nsc/macos.go index 05324eb..0b1e39a 100644 --- a/services/forgejo-nsc/internal/nsc/macos.go +++ b/services/forgejo-nsc/internal/nsc/macos.go @@ -125,6 +125,16 @@ func macosComputeBaseImageID(baseImageID string) string { } } +func macosWorkDir(workdir string) string { + workdir = strings.TrimSpace(workdir) + switch workdir { + case "", "/var/lib/forgejo-runner": + return "/tmp/forgejo-runner" + default: + return workdir + } +} + type nscBearerTokenFile struct { BearerToken string `json:"bearer_token"` } @@ -151,14 +161,6 @@ func readNSCBearerToken() (string, error) { return trimmed, nil } -func macosRunnerWorkDir(configured string) string { - workdir := strings.TrimSpace(configured) - if workdir == "" || strings.HasPrefix(workdir, "/var/lib/") { - return "/tmp/forgejo-runner" - } - return workdir -} - func parseMachineTypeCPUxMemGB(machineType string) (vcpu int32, memoryMB int32, err error) { parts := strings.Split(machineType, "x") if len(parts) != 2 { @@ -191,7 +193,7 @@ func (d *Dispatcher) launchMacOSRunner(ctx context.Context, runnerName string, r httpClient := &http.Client{Timeout: 60 * time.Second} client := computev1betaconnect.NewComputeServiceClient(httpClient, d.opts.ComputeBaseURL) - workdir := macosRunnerWorkDir(d.opts.WorkDir) + workdir := macosWorkDir(d.opts.WorkDir) env := map[string]string{ "FORGEJO_INSTANCE_URL": req.InstanceURL, @@ -204,12 +206,8 @@ func (d *Dispatcher) launchMacOSRunner(ctx context.Context, runnerName string, r for k, v := range req.ExtraEnv { env[k] = v } - // Best-effort caching: workflows call Scripts/nscloud-cache.sh, which is a - // no-op unless NSC_CACHE_PATH is set. This may still be skipped if spacectl - // lacks credentials, but setting the path is harmless and keeps behavior - // consistent across macOS / Linux runners. if _, ok := env["NSC_CACHE_PATH"]; !ok { - env["NSC_CACHE_PATH"] = "/Users/runner/.cache/nscloud" + env["NSC_CACHE_PATH"] = d.opts.MacosCachePath } deadline := timestamppb.New(time.Now().Add(ttl)) @@ -241,10 +239,15 @@ func (d *Dispatcher) launchMacOSRunner(ctx context.Context, runnerName string, r }, }, } + experimental := &computev1beta.CreateInstanceRequest_ExperimentalFeatures{} if imageID := macosComputeBaseImageID(d.opts.MacosBaseImageID); imageID != "" { - createReq.Experimental = &computev1beta.CreateInstanceRequest_ExperimentalFeatures{ - MacosBaseImageId: imageID, - } + experimental.MacosBaseImageId = imageID + } + if volumes := computeCacheVolumeRequests(d.opts.MacosCacheVolumes); len(volumes) > 0 { + experimental.Volumes = volumes + } + if experimental.MacosBaseImageId != "" || len(experimental.Volumes) > 0 { + createReq.Experimental = experimental } d.log.Info("launching Namespace macos runner", @@ -570,6 +573,22 @@ func (d *Dispatcher) destroyComputeInstance(ctx context.Context, client computev d.log.Info("macos runner destroyed", "runner", runnerName, "instance", instanceID) } +func computeCacheVolumeRequests(volumes []CacheVolume) []*computev1beta.VolumeRequest { + var out []*computev1beta.VolumeRequest + for _, volume := range volumes { + if strings.TrimSpace(volume.Tag) == "" || strings.TrimSpace(volume.MountPoint) == "" || volume.SizeGb <= 0 { + continue + } + out = append(out, &computev1beta.VolumeRequest{ + MountPoint: volume.MountPoint, + Tag: volume.Tag, + SizeMb: volume.SizeGb * 1024, + PersistencyKind: computev1beta.VolumeRequest_CACHE, + }) + } + return out +} + func macosBootstrapScript() string { // Keep this script self-contained: it runs on a fresh macOS VM base image. var b strings.Builder @@ -578,64 +597,70 @@ func macosBootstrapScript() string { workdir="${FORGEJO_RUNNER_WORKDIR:-/tmp/forgejo-runner}" mkdir -p "${workdir}" cd "${workdir}" +if ! mkdir -p "/Users/runner/.cache/act" 2>/dev/null; then + sudo install -d -m 0775 -o "$(id -un)" -g "$(id -gn)" /Users/runner/.cache /Users/runner/.cache/act +fi export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH}" +cache_base="${NSC_CACHE_PATH:-$HOME/.cache/burrow}" +cache_root="${NSC_SHARED_CACHE_PATH:-${cache_base}/shared}" +cache_owner="$(id -un)" +cache_group="$(id -gn)" +if ! install -d -m 0775 -o "${cache_owner}" -g "${cache_group}" \ + "${cache_root}" \ + "${cache_root}/bin" \ + "${cache_root}/downloads" \ + "${cache_root}/go/path" \ + "${cache_root}/go/mod" \ + "${cache_root}/go/build" \ + "${cache_root}/homebrew" 2>/dev/null; then + sudo install -d -m 0775 -o "${cache_owner}" -g "${cache_group}" \ + "${cache_root}" \ + "${cache_root}/bin" \ + "${cache_root}/downloads" \ + "${cache_root}/go/path" \ + "${cache_root}/go/mod" \ + "${cache_root}/go/build" \ + "${cache_root}/homebrew" +fi +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 exit 1 fi -if ! command -v nix >/dev/null 2>&1; then - echo "Installing nix (Determinate Systems installer)..." - installer="/tmp/nix-installer.$$" - curl -fsSL -o "${installer}" https://install.determinate.systems/nix - chmod +x "${installer}" - - if command -v sudo >/dev/null 2>&1; then - if sudo -n true 2>/dev/null; then - sudo -n sh "${installer}" install --no-confirm - else - sudo sh "${installer}" install --no-confirm - fi - else - sh "${installer}" install --no-confirm - fi - - rm -f "${installer}" -fi - +# Apple build workflows do not require Nix just to bootstrap the Forgejo runner. +# If Nix is already present on the base image, keep it on PATH; otherwise leave +# installation to the job itself. if [[ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]]; then # shellcheck disable=SC1091 . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + export PATH="/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" fi -export PATH="/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" - -# Flake builds need nix-command + flakes enabled. Workflows may layer additional -# config, but ensure a sane default exists. -mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/nix" -cat > "${XDG_CONFIG_HOME:-$HOME/.config}/nix/nix.conf" <<'EOF' -experimental-features = nix-command flakes -sandbox = true -fallback = true -substituters = https://cache.nixos.org -trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= -EOF - mkdir -p bin export PATH="${PWD}/bin:${PATH}" -runner_version="v12.6.4" +# Keep the ad-hoc macOS bootstrap on the same Forgejo runner major line as the +# Linux runner image. Forgejo runner 11.x is currently published as v11.3.1. +runner_version="v11.3.1" 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 @@ -645,21 +670,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 @@ -682,9 +709,7 @@ for label in ${FORGEJO_RUNNER_LABELS//,/ } ; do fi case "${label}" in *:*) resolved="${label}" ;; - *) - resolved="${label}:host" - ;; + *) resolved="${label}:${runner_exec}" ;; esac echo " - ${resolved}" >> runner.yaml if [ -z "${resolved_labels}" ]; then diff --git a/services/forgejo-nsc/internal/nsc/macos_nsc.go b/services/forgejo-nsc/internal/nsc/macos_nsc.go index b9b9a1b..159634a 100644 --- a/services/forgejo-nsc/internal/nsc/macos_nsc.go +++ b/services/forgejo-nsc/internal/nsc/macos_nsc.go @@ -12,8 +12,22 @@ import ( "path/filepath" "strings" "time" + + "connectrpc.com/connect" ) +func nscCLIEnv() []string { + env := os.Environ() + out := env[:0] + for _, entry := range env { + if strings.HasPrefix(entry, "NSC_TOKEN_FILE=") { + continue + } + out = append(out, entry) + } + return out +} + func normalizeMacOSNSCMachineType(machineType string) (normalized string, changed bool, err error) { vcpu, memoryMB, err := parseMachineTypeCPUxMemGB(machineType) if err != nil { @@ -52,29 +66,17 @@ func normalizeMacOSNSCMachineType(machineType string) (normalized string, change return normalized, changed, nil } -type macosNSCAttemptConfig struct { - waitTimeout time.Duration - createTimeout time.Duration -} +type macosNSCSSHOutcome int -func macosNSCAttemptConfigFor(index int, attempts []macosNSCAttemptConfig) macosNSCAttemptConfig { - if len(attempts) == 0 { - return macosNSCAttemptConfig{} - } - if index < len(attempts) { - return attempts[index] - } - return attempts[len(attempts)-1] -} +const ( + macosNSCSSHCompleted macosNSCSSHOutcome = iota + macosNSCSSHHandoff +) func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName string, req LaunchRequest, ttl time.Duration, machineType string) error { if machineType == "" { return errors.New("machine_type is required for macos runners") } - if strings.TrimSpace(os.Getenv("NSC_TOKEN_FILE")) == "" { - // The Burrow forge host feeds NSC_TOKEN_FILE from the intake-backed runtime token. - return errors.New("NSC_TOKEN_FILE is required for macos runners") - } selectors := macosSelectorsArg(d.opts.MacosBaseImageID) if selectors == "" { @@ -107,13 +109,17 @@ func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName str } candidates = uniq - attempts := []macosNSCAttemptConfig{ + type attemptCfg struct { + waitTimeout time.Duration + createTimeout time.Duration + } + attempts := []attemptCfg{ {waitTimeout: 6 * time.Minute, createTimeout: 8 * time.Minute}, {waitTimeout: 4 * time.Minute, createTimeout: 6 * time.Minute}, {waitTimeout: 3 * time.Minute, createTimeout: 5 * time.Minute}, } - createInstance := func(mt string, a macosNSCAttemptConfig) (instanceID string, out string, err error) { + createInstance := func(mt string, a attemptCfg) (instanceID string, out string, err error) { tmpDir, err := os.MkdirTemp("", "forgejo-nsc-macos-*") if err != nil { return "", "", fmt.Errorf("mktemp: %w", err) @@ -147,11 +153,13 @@ func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName str "--wait_timeout", a.waitTimeout.String(), } args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + args = appendVolumeArgs(args, d.opts.MacosCacheVolumes) createCtx, cancel := context.WithTimeout(ctx, a.createTimeout) defer cancel() cmd := exec.CommandContext(createCtx, d.opts.BinaryPath, args...) + cmd.Env = nscCLIEnv() var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf @@ -184,7 +192,10 @@ func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName str lastErr error ) for i, mt := range candidates { - a := macosNSCAttemptConfigFor(i, attempts) + a := attempts[i] + if i >= len(attempts) { + a = attempts[len(attempts)-1] + } d.log.Info("launching Namespace macos runner via nsc", "runner", runnerName, @@ -214,14 +225,38 @@ func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName str return fmt.Errorf("nsc create failed without producing an instance id\n%s", lastOut) } - // Always attempt cleanup even if the runner fails. - defer d.destroyNSCInstance(context.Background(), runnerName, instanceID) + destroyOnReturn := true + defer func() { + if destroyOnReturn { + d.destroyNSCInstance(context.Background(), runnerName, instanceID) + } + }() script := macosBootstrapWrapperScript(runnerName, req, d.opts.Executor, d.opts.WorkDir) // Use the Compute SSH config endpoint (direct TCP) instead of `nsc ssh`, which - // relies on a websocket-based SSH proxy that is not supported by the - // revokable tenant token we run the dispatcher with. + // relies on a websocket-based SSH proxy that is less reliable under the + // revokable tenant token flow used by the dispatcher. if err := d.runMacOSComputeSSHScript(ctx, runnerName, instanceID, script); err != nil { + if shouldFallbackToNSCSSH(err) { + d.log.Warn("compute ssh bootstrap failed; falling back to nsc ssh", + "runner", runnerName, + "instance", instanceID, + "err", err, + ) + outcome, sshErr := d.runMacOSNSCSSHScript(ctx, runnerName, instanceID, script) + if sshErr != nil { + return sshErr + } + if outcome == macosNSCSSHHandoff { + destroyOnReturn = false + d.log.Info("leaving macos nsc instance running until TTL after runner handoff", + "runner", runnerName, + "instance", instanceID, + "ttl", ttl.String(), + ) + } + return nil + } return err } return nil @@ -293,6 +328,7 @@ func (d *Dispatcher) destroyNSCInstance(ctx context.Context, runnerName, instanc args := []string{"destroy", "--force", instanceID} args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) cmd := exec.CommandContext(ctx, d.opts.BinaryPath, args...) + cmd.Env = nscCLIEnv() var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf @@ -304,7 +340,7 @@ func (d *Dispatcher) destroyNSCInstance(ctx context.Context, runnerName, instanc } func macosBootstrapWrapperScript(runnerName string, req LaunchRequest, executor, workdir string) string { - workdir = macosRunnerWorkDir(workdir) + workdir = macosWorkDir(workdir) // Pass all values via stdin script so secrets do not appear in the nsc ssh argv. env := map[string]string{ @@ -342,6 +378,75 @@ func shellSingleQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'\"'\"'`) + "'" } +func shouldFallbackToNSCSSH(err error) bool { + if err == nil { + return false + } + + switch connect.CodeOf(err) { + case connect.CodeUnauthenticated, connect.CodePermissionDenied, connect.CodeUnimplemented: + return true + } + + errText := strings.ToLower(err.Error()) + return strings.Contains(errText, "compute get ssh config failed") && + (strings.Contains(errText, "unauthenticated") || + strings.Contains(errText, "permission_denied") || + strings.Contains(errText, "permission denied") || + strings.Contains(errText, "unimplemented")) +} + +func (d *Dispatcher) runMacOSNSCSSHScript(ctx context.Context, runnerName, instanceID, script string) (macosNSCSSHOutcome, error) { + sshCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + args := []string{"ssh", "--disable-pty", instanceID, "/bin/bash"} + args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + + cmd := exec.CommandContext(sshCtx, d.opts.BinaryPath, args...) + cmd.Env = nscCLIEnv() + cmd.Stdin = strings.NewReader(script) + + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + if err := cmd.Run(); err != nil { + if errors.Is(sshCtx.Err(), context.DeadlineExceeded) { + return macosNSCSSHCompleted, fmt.Errorf("nsc ssh timed out after %s\n%s", 5*time.Minute, strings.TrimSpace(buf.String())) + } + if nscSSHBootstrapLikelySucceeded(err, buf.String()) { + d.log.Warn("nsc ssh exited after runner handoff; treating bootstrap as successful", + "runner", runnerName, + "instance", instanceID, + "err", err, + ) + d.log.Info("macos runner bootstrap completed via nsc ssh", "runner", runnerName, "instance", instanceID) + return macosNSCSSHHandoff, nil + } + return macosNSCSSHCompleted, fmt.Errorf("nsc ssh runner bootstrap failed: %w\n%s", err, strings.TrimSpace(buf.String())) + } + + d.log.Info("macos runner bootstrap completed via nsc ssh", "runner", runnerName, "instance", instanceID) + return macosNSCSSHCompleted, nil +} + +func nscSSHBootstrapLikelySucceeded(err error, output string) bool { + if err == nil { + return false + } + + errText := strings.ToLower(err.Error()) + if !strings.Contains(errText, "remote command exited without exit status or exit signal") { + return false + } + + output = strings.ToLower(output) + return strings.Contains(output, "runner registered successfully") && + strings.Contains(output, "starting job") && + strings.Contains(output, "task ") +} + func prependNSCRegionArgs(args []string, computeBaseURL string) []string { region := strings.TrimSpace(os.Getenv("NSC_REGION")) if region == "" { diff --git a/services/forgejo-nsc/internal/nsc/macos_nsc_test.go b/services/forgejo-nsc/internal/nsc/macos_nsc_test.go index 025b52c..d2aabc6 100644 --- a/services/forgejo-nsc/internal/nsc/macos_nsc_test.go +++ b/services/forgejo-nsc/internal/nsc/macos_nsc_test.go @@ -1,26 +1,69 @@ package nsc import ( + "errors" "testing" - "time" ) -func TestMacOSNSCAttemptConfigForReusesLastFallback(t *testing.T) { - attempts := []macosNSCAttemptConfig{ - {waitTimeout: time.Minute, createTimeout: 2 * time.Minute}, - {waitTimeout: 30 * time.Second, createTimeout: time.Minute}, - } +func TestNormalizeMacOSNSCMachineTypeRoundsUp(t *testing.T) { + t.Parallel() - got := macosNSCAttemptConfigFor(3, attempts) - want := attempts[len(attempts)-1] - if got != want { - t.Fatalf("macosNSCAttemptConfigFor(3) = %+v, want %+v", got, want) + got, changed, err := normalizeMacOSNSCMachineType("5x10") + if err != nil { + t.Fatalf("normalizeMacOSNSCMachineType: %v", err) + } + if !changed { + t.Fatal("expected machine type to be normalized") + } + if got != "6x14" { + t.Fatalf("expected 6x14, got %q", got) } } -func TestMacOSNSCAttemptConfigForEmpty(t *testing.T) { - got := macosNSCAttemptConfigFor(0, nil) - if got != (macosNSCAttemptConfig{}) { - t.Fatalf("macosNSCAttemptConfigFor(0, nil) = %+v, want zero value", got) +func TestNormalizeMacOSNSCMachineTypeKeepsAllowedShape(t *testing.T) { + t.Parallel() + + got, changed, err := normalizeMacOSNSCMachineType("6x14") + if err != nil { + t.Fatalf("normalizeMacOSNSCMachineType: %v", err) + } + if changed { + t.Fatal("expected allowed machine type to remain unchanged") + } + if got != "6x14" { + t.Fatalf("expected 6x14, got %q", got) + } +} + +func TestShouldFallbackToNSCSSHFallbackForComputeAuthErrors(t *testing.T) { + t.Parallel() + + err := errors.New("compute get ssh config failed: unauthenticated: invalid tenant credentials") + if !shouldFallbackToNSCSSH(err) { + t.Fatal("expected compute auth error to fall back to nsc ssh") + } +} + +func TestShouldFallbackToNSCSSHRejectsOtherErrors(t *testing.T) { + t.Parallel() + + err := errors.New("compute ssh runner bootstrap failed: exit status 1") + if shouldFallbackToNSCSSH(err) { + t.Fatal("expected unrelated bootstrap errors to remain fatal") + } +} + +func TestNSCSSHBootstrapLikelySucceeded(t *testing.T) { + t.Parallel() + + err := errors.New("wait: remote command exited without exit status or exit signal") + output := ` +level=info msg="Runner registered successfully." +time="2026-03-19T11:29:49Z" level=info msg="Starting job" +time="2026-03-19T11:29:50Z" level=info msg="task 124 repo is hackclub/burrow" +` + + if !nscSSHBootstrapLikelySucceeded(err, output) { + t.Fatal("expected handoff success heuristic to match") } } diff --git a/services/forgejo-nsc/internal/nsc/macos_test.go b/services/forgejo-nsc/internal/nsc/macos_test.go deleted file mode 100644 index 28fe86b..0000000 --- a/services/forgejo-nsc/internal/nsc/macos_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package nsc - -import "testing" - -func TestMacOSRunnerWorkDirUsesWritableEphemeralPath(t *testing.T) { - cases := []struct { - name string - configured string - want string - }{ - {name: "empty", configured: "", want: "/tmp/forgejo-runner"}, - {name: "linux var state", configured: "/var/lib/forgejo-runner", want: "/tmp/forgejo-runner"}, - {name: "custom tmp", configured: "/tmp/custom-runner", want: "/tmp/custom-runner"}, - {name: "home path", configured: "/Users/runner/forgejo-runner", want: "/Users/runner/forgejo-runner"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := macosRunnerWorkDir(tc.configured) - if got != tc.want { - t.Fatalf("macosRunnerWorkDir(%q) = %q, want %q", tc.configured, got, tc.want) - } - }) - } -} diff --git a/services/grafana/dashboards/burrow-overview.json b/services/grafana/dashboards/burrow-overview.json deleted file mode 100644 index 84b4c33..0000000 --- a/services/grafana/dashboards/burrow-overview.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "liveNow": false, - "panels": [ - { - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "fieldConfig": { - "defaults": {}, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "# Burrow Operations\n\nGrafana is managed by NixOS for service/auth and OpenTofu for API-owned folders and dashboards.\n\nStart here after deploys to add service-specific panels as data sources come online.", - "mode": "markdown" - }, - "pluginVersion": "12.3.0", - "title": "Overview", - "type": "text" - }, - { - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "fieldConfig": { - "defaults": {}, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 12, - "x": 0, - "y": 6 - }, - "id": 2, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "## Runtime Checks\n\n- `grafana.service` active\n- `https://graphs.burrow.net/login` reachable\n- Authentik generic OAuth login returns to `/login/generic_oauth`\n- OpenTofu dashboard import or apply succeeds", - "mode": "markdown" - }, - "pluginVersion": "12.3.0", - "title": "Readiness", - "type": "text" - }, - { - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "fieldConfig": { - "defaults": {}, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 12, - "x": 12, - "y": 6 - }, - "id": 3, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "## Ownership\n\nNix owns service startup, SSO, reverse proxy, and secrets.\n\nOpenTofu owns API-created folders and dashboards so visual iteration does not require a host switch.", - "mode": "markdown" - }, - "pluginVersion": "12.3.0", - "title": "Control Plane", - "type": "text" - } - ], - "refresh": "", - "schemaVersion": 39, - "style": "dark", - "tags": [ - "burrow", - "operations" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "browser", - "title": "Burrow Overview", - "uid": "burrow-overview", - "version": 1, - "weekStart": "" -} diff --git a/services/grafana/dashboards/headscale.json b/services/grafana/dashboards/headscale.json deleted file mode 100644 index 12eb33e..0000000 --- a/services/grafana/dashboards/headscale.json +++ /dev/null @@ -1,423 +0,0 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "0": { - "text": "down" - }, - "1": { - "text": "up" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": null - }, - { - "color": "green", - "value": 1 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.0.0", - "targets": [ - { - "expr": "up{job=\"$job\"}", - "legendFormat": "headscale scrape", - "refId": "A" - } - ], - "title": "Scrape", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "0": { - "text": "inactive" - }, - "1": { - "text": "active" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": null - }, - { - "color": "green", - "value": 1 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 2, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.0.0", - "targets": [ - { - "expr": "max(node_systemd_unit_state{name=\"headscale.service\",state=\"active\"}) or vector(0)", - "legendFormat": "headscale service", - "refId": "A" - } - ], - "title": "Service", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 3, - "options": { - "colorMode": "none", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.0.0", - "targets": [ - { - "expr": "headscale_nodes_total or headscale_machine_count or headscale_node_count or vector(0)", - "legendFormat": "nodes", - "refId": "A" - } - ], - "title": "Tailnet Nodes", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 4, - "options": { - "colorMode": "none", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.0.0", - "targets": [ - { - "expr": "sum(rate(headscale_http_requests_total[5m])) or vector(0)", - "legendFormat": "http requests", - "refId": "A" - } - ], - "title": "HTTP Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 12, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 4 - }, - "id": 5, - "options": { - "legend": { - "calcs": [ - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "expr": "headscale_nodes_total or headscale_machine_count or headscale_node_count or vector(0)", - "legendFormat": "nodes", - "refId": "A" - } - ], - "title": "Tailnet Node Count", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 12, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 4 - }, - "id": 6, - "options": { - "legend": { - "calcs": [ - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "expr": "sum by (code,method) (rate(headscale_http_requests_total[5m])) or vector(0)", - "legendFormat": "{{method}} {{code}}", - "refId": "A" - } - ], - "title": "Headscale HTTP Requests", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 12 - }, - "id": 7, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "targets": [ - { - "expr": "max by (name,state) (node_systemd_unit_state{name=~\"headscale.service|tailscaled.service\",state=~\"active|failed|activating\"})", - "format": "table", - "instant": true, - "refId": "A" - } - ], - "title": "Tailnet Service Units", - "type": "table" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": [ - "burrow", - "headscale", - "tailnet" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "headscale", - "value": "headscale" - }, - "hide": 2, - "name": "job", - "query": "headscale", - "type": "constant" - } - ] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "browser", - "title": "Burrow Headscale", - "uid": "burrow-headscale", - "version": 1, - "weekStart": "" -} diff --git a/services/grafana/dashboards/observability.json b/services/grafana/dashboards/observability.json deleted file mode 100644 index 344f44d..0000000 --- a/services/grafana/dashboards/observability.json +++ /dev/null @@ -1,477 +0,0 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "0": { - "text": "down" - }, - "1": { - "text": "up" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": null - }, - { - "color": "green", - "value": 1 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 8, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": true, - "sizing": "auto", - "valueMode": "color" - }, - "pluginVersion": "11.0.0", - "targets": [ - { - "expr": "up{job=~\"prometheus|grafana|headscale|otel-collector|jaeger|node|systemd|tailscale|garage\"}", - "legendFormat": "{{job}}", - "refId": "A" - } - ], - "title": "Scrape Health", - "type": "bargauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "0": { - "text": "inactive" - }, - "1": { - "text": "active" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": null - }, - { - "color": "green", - "value": 1 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 8, - "x": 8, - "y": 0 - }, - "id": 2, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": true, - "sizing": "auto", - "valueMode": "color" - }, - "pluginVersion": "11.0.0", - "targets": [ - { - "expr": "max by (name) (node_systemd_unit_state{name=~\"grafana.service|prometheus.service|opentelemetry-collector.service|podman-burrow-jaeger.service|headscale.service|tailscaled.service|forgejo.service\",state=\"active\"})", - "legendFormat": "{{name}}", - "refId": "A" - } - ], - "title": "Service State", - "type": "bargauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 8, - "x": 16, - "y": 0 - }, - "id": 3, - "options": { - "colorMode": "none", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.0.0", - "targets": [ - { - "expr": "sum(otelcol_receiver_accepted_spans) or sum(otelcol_receiver_refused_spans) or vector(0)", - "legendFormat": "collector spans", - "refId": "A" - } - ], - "title": "OTLP Spans", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 12, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 6 - }, - "id": 4, - "options": { - "legend": { - "calcs": [ - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "expr": "sum by (receiver,transport) (rate(otelcol_receiver_accepted_spans[5m])) or vector(0)", - "legendFormat": "{{receiver}} {{transport}}", - "refId": "A" - }, - { - "expr": "sum by (exporter) (rate(otelcol_exporter_sent_spans[5m])) or vector(0)", - "legendFormat": "exported {{exporter}}", - "refId": "B" - } - ], - "title": "OpenTelemetry Trace Flow", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 12, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 6 - }, - "id": 5, - "options": { - "legend": { - "calcs": [ - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "expr": "sum(rate(jaeger_collector_spans_received_total[5m])) or sum(rate(jaeger_collector_traces_received_total[5m])) or vector(0)", - "legendFormat": "jaeger received", - "refId": "A" - }, - { - "expr": "sum(rate(jaeger_query_requests_total[5m])) or vector(0)", - "legendFormat": "jaeger queries", - "refId": "B" - } - ], - "title": "Jaeger Activity", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 12, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 14 - }, - "id": 6, - "options": { - "legend": { - "calcs": [ - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "expr": "1 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))", - "legendFormat": "{{instance}}", - "refId": "A" - } - ], - "title": "Host CPU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 12, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 14 - }, - "id": 7, - "options": { - "legend": { - "calcs": [ - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "expr": "1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)", - "legendFormat": "{{instance}}", - "refId": "A" - } - ], - "title": "Host Memory", - "type": "timeseries" - }, - { - "datasource": { - "type": "jaeger", - "uid": "jaeger" - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 8, - "options": { - "showHeader": true - }, - "targets": [ - { - "datasource": { - "type": "jaeger", - "uid": "jaeger" - }, - "limit": 20, - "operation": "", - "queryType": "search", - "refId": "A", - "service": "burrow" - } - ], - "title": "Recent Burrow Traces", - "type": "traces" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": [ - "burrow", - "observability", - "opentelemetry", - "jaeger" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "browser", - "title": "Burrow Observability", - "uid": "burrow-observability", - "version": 1, - "weekStart": "" -} diff --git a/services/mcp-hub/README.md b/services/mcp-hub/README.md deleted file mode 100644 index 4b1c2a2..0000000 --- a/services/mcp-hub/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Burrow MCP Hub Extraction - -This directory is the in-repo extraction plan for a dedicated MCP hub repository. - -Target repository: - -```text -compatible.systems/burrow/mcp-hub -``` - -The hub should expose Burrow-operated tools for: - -- Authentik users, groups, OIDC, SAML, SCIM, and WIF applications -- OpenBao mounts, policies, AppRole, and secret-read workflows -- Google KMS key catalog, public-key export, and signing readiness -- Forgejo workflow dispatch, runner identity, and Namespace runner status -- Grafana dashboard and alert checks -- Jitsi meeting readiness and service health -- mail and webmail readiness after the Stalwart phase starts -- release signing, store upload readiness, and distribution metadata checks -- agent identity inventory derived from `contributors.nix` - -The hub must keep identity and authorization explicit. Agent identities should be -named, attributable, and backed by Authentik/WIF or OpenBao AppRole rather than -static shared tokens. diff --git a/site/layout/layout.tsx b/site/layout/layout.tsx index 057aa68..28ff24d 100644 --- a/site/layout/layout.tsx +++ b/site/layout/layout.tsx @@ -1,5 +1,20 @@ +import { Space_Mono, Poppins } from "next/font/google"; import localFont from "next/font/local"; +const space_mono = Space_Mono({ + weight: ["400", "700"], + subsets: ["latin"], + display: "swap", + variable: "--font-space-mono", +}); + +const poppins = Poppins({ + weight: ["400", "500", "600", "700", "800", "900"], + subsets: ["latin"], + display: "swap", + variable: "--font-poppins", +}); + const phantomSans = localFont({ src: [ { @@ -21,18 +36,10 @@ const phantomSans = localFont({ variable: "--font-phantom-sans", }); -const fallbackFontVariables = { - "--font-space-mono": - '"SFMono-Regular", "SF Mono", ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", monospace', - "--font-poppins": - 'var(--font-phantom-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', -} as React.CSSProperties; - export default function Layout({ children }: { children: React.ReactNode }) { return (
{children}
diff --git a/site/package-lock.json b/site/package-lock.json deleted file mode 100644 index e1357f9..0000000 --- a/site/package-lock.json +++ /dev/null @@ -1,3907 +0,0 @@ -{ - "name": "burrow", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "burrow", - "version": "0.1.0", - "dependencies": { - "@fortawesome/fontawesome-free": "^6.4.2", - "@fortawesome/fontawesome-svg-core": "^6.4.2", - "@fortawesome/free-brands-svg-icons": "^6.4.2", - "@fortawesome/free-solid-svg-icons": "^6.4.2", - "@fortawesome/react-fontawesome": "^0.2.0", - "@headlessui/react": "^1.7.17", - "@headlessui/tailwindcss": "^0.2.0", - "@types/node": "20.5.8", - "@types/react": "18.2.21", - "@types/react-dom": "18.2.7", - "autoprefixer": "10.4.15", - "eslint": "8.48.0", - "eslint-config-next": "13.4.19", - "next": "13.4.19", - "postcss": "8.4.29", - "react": "18.2.0", - "react-dom": "18.2.0", - "tailwindcss": "3.3.3", - "typescript": "5.2.2" - }, - "devDependencies": { - "prettier": "^3.0.3", - "prettier-plugin-tailwindcss": "^0.5.4" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/runtime": { - "version": "7.22.11", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.8.0", - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.2", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.48.0", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/fontawesome-free": { - "version": "6.5.1", - "hasInstallScript": true, - "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", - "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", - "license": "MIT", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.7.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/fontawesome-svg-core/node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", - "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.5.1", - "hasInstallScript": true, - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.5.1", - "hasInstallScript": true, - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/react-fontawesome": { - "version": "0.2.0", - "license": "MIT", - "dependencies": { - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "~1 || ~6", - "react": ">=16.3" - } - }, - "node_modules/@headlessui/react": { - "version": "1.7.18", - "license": "MIT", - "dependencies": { - "@tanstack/react-virtual": "^3.0.0-beta.60", - "client-only": "^0.0.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16 || ^17 || ^18", - "react-dom": "^16 || ^17 || ^18" - } - }, - "node_modules/@headlessui/tailwindcss": { - "version": "0.2.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "tailwindcss": "^3.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.11", - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "license": "BSD-3-Clause" - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "13.4.19", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "13.4.19", - "license": "MIT", - "dependencies": { - "glob": "7.1.7" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "13.4.19", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.19.tgz", - "integrity": "sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.19.tgz", - "integrity": "sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.19.tgz", - "integrity": "sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.19.tgz", - "integrity": "sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.19.tgz", - "integrity": "sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.19.tgz", - "integrity": "sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.19.tgz", - "integrity": "sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.19.tgz", - "integrity": "sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.3.3", - "license": "MIT" - }, - "node_modules/@swc/helpers": { - "version": "0.5.1", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.2.0", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.2.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.5.8", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.5", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.2.21", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.2.7", - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "license": "MIT" - }, - "node_modules/@typescript-eslint/parser": { - "version": "6.5.0", - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "6.5.0", - "@typescript-eslint/types": "6.5.0", - "@typescript-eslint/typescript-estree": "6.5.0", - "@typescript-eslint/visitor-keys": "6.5.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.5.0", - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.5.0", - "@typescript-eslint/visitor-keys": "6.5.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "6.5.0", - "license": "MIT", - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.5.0", - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "6.5.0", - "@typescript-eslint/visitor-keys": "6.5.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.5.4", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.5.0", - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.5.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/acorn": { - "version": "8.10.0", - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.6", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.1", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.1", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", - "is-shared-array-buffer": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.7", - "license": "ISC" - }, - "node_modules/asynciterator.prototype": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.15", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001520", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.5", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.7.2", - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "3.2.1", - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "license": "MIT", - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.21.10", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "caniuse-lite": "^1.0.30001517", - "electron-to-chromium": "^1.4.477", - "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.11" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001525", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.5.3", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.2", - "license": "MIT" - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "license": "BSD-2-Clause" - }, - "node_modules/debug": { - "version": "4.3.4", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "license": "MIT" - }, - "node_modules/define-properties": { - "version": "1.2.0", - "license": "MIT", - "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "license": "Apache-2.0" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "license": "MIT" - }, - "node_modules/doctrine": { - "version": "3.0.0", - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.4.508", - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.15.0", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-abstract": { - "version": "1.22.1", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.1", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.1", - "get-symbol-description": "^1.0.0", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", - "safe-array-concat": "^1.0.0", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.0.14", - "license": "MIT", - "dependencies": { - "asynciterator.prototype": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-set-tostringtag": "^2.0.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.2.1", - "globalthis": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "iterator.prototype": "^1.1.0", - "safe-array-concat": "^1.0.0" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "has": "^1.0.3" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "license": "MIT", - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.48.0", - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.2", - "@eslint/js": "8.48.0", - "@humanwhocodes/config-array": "^0.11.10", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-next": { - "version": "13.4.19", - "license": "MIT", - "dependencies": { - "@next/eslint-plugin-next": "13.4.19", - "@rushstack/eslint-patch": "^1.1.3", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.31.7", - "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.6.0", - "license": "ISC", - "dependencies": { - "debug": "^4.3.4", - "enhanced-resolve": "^5.12.0", - "eslint-module-utils": "^2.7.4", - "fast-glob": "^3.3.1", - "get-tsconfig": "^4.5.0", - "is-core-module": "^2.11.0", - "is-glob": "^4.0.3" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.8.0", - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.28.1", - "license": "MIT", - "peer": true, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.findlastindex": "^1.2.2", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", - "eslint-module-utils": "^2.8.0", - "has": "^1.0.3", - "is-core-module": "^2.13.0", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.6", - "object.groupby": "^1.0.0", - "object.values": "^1.1.6", - "semver": "^6.3.1", - "tsconfig-paths": "^3.14.2" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.7.1", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.7", - "aria-query": "^5.1.3", - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.6.2", - "axobject-query": "^3.1.1", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.3", - "language-tags": "=1.0.5", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { - "version": "6.3.1", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.33.2", - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.12", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.4", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.8" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.4", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.1", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.15.0", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.7", - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.3", - "license": "MIT", - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/fraction.js": { - "version": "4.3.6", - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" - }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.1", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.7.0", - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "7.1.7", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "license": "BSD-2-Clause" - }, - "node_modules/globals": { - "version": "13.21.0", - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.3", - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "license": "MIT" - }, - "node_modules/has": { - "version": "1.0.3", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ignore": { - "version": "5.2.4", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "license": "ISC" - }, - "node_modules/internal-slot": { - "version": "1.0.5", - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.2", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.13.0", - "license": "MIT", - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "license": "MIT", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "license": "MIT", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.2", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "license": "MIT", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.2", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "license": "MIT", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.12", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.11" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.2", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "license": "ISC" - }, - "node_modules/iterator.prototype": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.0", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "reflect.getprototypeof": "^1.0.3" - } - }, - "node_modules/jiti": { - "version": "1.19.3", - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "license": "MIT" - }, - "node_modules/json5": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.3", - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.22", - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.5", - "license": "MIT", - "dependencies": { - "language-subtag-registry": "~0.3.2" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.5", - "license": "MIT", - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.6", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "license": "MIT" - }, - "node_modules/next": { - "version": "13.4.19", - "license": "MIT", - "dependencies": { - "@next/env": "13.4.19", - "@swc/helpers": "0.5.1", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001406", - "postcss": "8.4.14", - "styled-jsx": "5.1.1", - "watchpack": "2.4.0", - "zod": "3.21.4" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=16.8.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "13.4.19", - "@next/swc-darwin-x64": "13.4.19", - "@next/swc-linux-arm64-gnu": "13.4.19", - "@next/swc-linux-arm64-musl": "13.4.19", - "@next/swc-linux-x64-gnu": "13.4.19", - "@next/swc-linux-x64-musl": "13.4.19", - "@next/swc-win32-arm64-msvc": "13.4.19", - "@next/swc-win32-ia32-msvc": "13.4.19", - "@next/swc-win32-x64-msvc": "13.4.19" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.14", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-releases": { - "version": "2.0.13", - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.4", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.7", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.7", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1" - } - }, - "node_modules/object.hasown": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.1.7", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.3", - "license": "MIT", - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.4.29", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.1", - "license": "MIT", - "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^2.1.1" - }, - "engines": { - "node": ">= 14" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.0.11" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.13", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.2.5", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-plugin-tailwindcss": { - "version": "0.5.13", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "@ianvs/prettier-plugin-sort-imports": "*", - "@prettier/plugin-pug": "*", - "@shopify/prettier-plugin-liquid": "*", - "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig-melody": "*", - "prettier": "^3.0", - "prettier-plugin-astro": "*", - "prettier-plugin-css-order": "*", - "prettier-plugin-import-sort": "*", - "prettier-plugin-jsdoc": "*", - "prettier-plugin-marko": "*", - "prettier-plugin-organize-attributes": "*", - "prettier-plugin-organize-imports": "*", - "prettier-plugin-sort-imports": "*", - "prettier-plugin-style-order": "*", - "prettier-plugin-svelte": "*" - }, - "peerDependenciesMeta": { - "@ianvs/prettier-plugin-sort-imports": { - "optional": true - }, - "@prettier/plugin-pug": { - "optional": true - }, - "@shopify/prettier-plugin-liquid": { - "optional": true - }, - "@trivago/prettier-plugin-sort-imports": { - "optional": true - }, - "@zackad/prettier-plugin-twig-melody": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - }, - "prettier-plugin-css-order": { - "optional": true - }, - "prettier-plugin-import-sort": { - "optional": true - }, - "prettier-plugin-jsdoc": { - "optional": true - }, - "prettier-plugin-marko": { - "optional": true - }, - "prettier-plugin-organize-attributes": { - "optional": true - }, - "prettier-plugin-organize-imports": { - "optional": true - }, - "prettier-plugin-sort-imports": { - "optional": true - }, - "prettier-plugin-style-order": { - "optional": true - }, - "prettier-plugin-svelte": { - "optional": true - } - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.3.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react": { - "version": "18.2.0", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.2.0", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" - }, - "peerDependencies": { - "react": "^18.2.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "license": "MIT" - }, - "node_modules/read-cache": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "globalthis": "^1.0.3", - "which-builtin-type": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.0", - "license": "MIT" - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.0", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "1.22.4", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/scheduler": { - "version": "0.23.0", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.9", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "regexp.prototype.flags": "^1.5.0", - "side-channel": "^1.0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.7", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.1", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/sucrase": { - "version": "3.34.0", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "7.1.6", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "7.1.6", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.3.3", - "license": "MIT", - "peer": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.12", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.18.2", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tapable": { - "version": "2.2.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "license": "MIT" - }, - "node_modules/thenify": { - "version": "3.3.1", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "1.0.2", - "license": "MIT", - "engines": { - "node": ">=16.13.0" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "license": "Apache-2.0" - }, - "node_modules/tsconfig-paths": { - "version": "3.14.2", - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.2.2", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.0.11", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "license": "MIT" - }, - "node_modules/watchpack": { - "version": "2.4.0", - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "function.prototype.name": "^1.1.5", - "has-tostringtag": "^1.0.0", - "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", - "is-finalizationregistry": "^1.0.2", - "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "is-map": "^2.0.1", - "is-set": "^2.0.1", - "is-weakmap": "^2.0.1", - "is-weakset": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.11", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "license": "ISC" - }, - "node_modules/yallist": { - "version": "4.0.0", - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.3.2", - "license": "ISC", - "engines": { - "node": ">= 14" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.21.4", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 0356c17..73fbc33 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -1,23 +1,13 @@ +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Head from "next/head"; +import { + faChevronDown, + faChevronUp, + faUpRightFromSquare, +} from "@fortawesome/free-solid-svg-icons"; import { Menu, Transition } from "@headlessui/react"; import { useState, useRef, useEffect } from "react"; - -function ChevronIcon({ open }: { open: boolean }) { - return ( - - ); -} - -function ExternalLinkIcon() { - return ( - - ); -} - export default function Page() { const [chevron, setChevron] = useState(false); const menuButtonRef = useRef(null); @@ -81,7 +71,17 @@ export default function Page() { className="w-50 h-12 rounded-2xl bg-hackClubRed px-3 font-SpaceMono hover:scale-105 md:h-12 md:w-auto md:rounded-3xl md:text-xl 2xl:h-16 2xl:text-2xl " > Install for Linux - + {chevron ? ( + + ) : ( + + )} diff --git a/store-metadata/android/README.md b/store-metadata/android/README.md deleted file mode 100644 index 806d885..0000000 --- a/store-metadata/android/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Android Store Metadata - -This directory is the repo-owned home for Android distribution metadata. - -Current state: - -- Kotlin app shell exists under `Android/app`. -- Rust core FFI exists under `crates/burrow-mobile-ffi`. -- full VPN support is not enabled yet. - -Before Play Store upload: - -- implement Android `VpnService` -- complete the Play VPN service declaration -- complete data-safety review -- wire signing through the release signing lane - -Before F-Droid upload: - -- add reproducible metadata -- keep a source-built flavor without proprietary Play dependencies -- document Rust core build inputs and target ABIs diff --git a/store-metadata/android/icon.png b/store-metadata/android/icon.png deleted file mode 100644 index c5997ae..0000000 Binary files a/store-metadata/android/icon.png and /dev/null differ diff --git a/store-metadata/app-store-connect/README.md b/store-metadata/app-store-connect/README.md deleted file mode 100644 index e565b9a..0000000 --- a/store-metadata/app-store-connect/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# App Store Connect Metadata - -This directory is the repo-owned home for Burrow App Store Connect metadata. - -The release upload lane expects dedicated App Store Connect API credentials -sealed with agenix: - -- `secrets/apple/appstore-connect.env.age` - -That single secret contains the key ID, issuer ID, and base64-encoded `.p8`. -CI decrypts it into a temporary App Store Connect key file, syncs provisioning -profiles from App Store Connect, and then uploads `.ipa`/`.pkg` artifacts. -Forgejo secrets should only provide the runner age identity fallback, not the -App Store key itself. - -The initial release pipeline can upload `.ipa` and `.pkg` artifacts once the -Apple build lane produces signed outputs. Internal TestFlight distribution waits -for processing and skips external beta-review submission; App Store Connect then -exposes the valid build through the configured internal tester group. TestFlight -external distribution must remain explicit until review metadata, groups, and -tester policy are complete. diff --git a/store-metadata/app-store-connect/testflight/en-US/beta_app_description.txt b/store-metadata/app-store-connect/testflight/en-US/beta_app_description.txt deleted file mode 100644 index 82656b1..0000000 --- a/store-metadata/app-store-connect/testflight/en-US/beta_app_description.txt +++ /dev/null @@ -1 +0,0 @@ -Burrow is a VPN client for infrastructure operated by the Burrow project. diff --git a/store-metadata/app-store-connect/testflight/en-US/feedback_email.txt b/store-metadata/app-store-connect/testflight/en-US/feedback_email.txt deleted file mode 100644 index 2de2c1a..0000000 --- a/store-metadata/app-store-connect/testflight/en-US/feedback_email.txt +++ /dev/null @@ -1 +0,0 @@ -contact@burrow.net diff --git a/store-metadata/app-store-connect/testflight/review/contact_email.txt b/store-metadata/app-store-connect/testflight/review/contact_email.txt deleted file mode 100644 index 2de2c1a..0000000 --- a/store-metadata/app-store-connect/testflight/review/contact_email.txt +++ /dev/null @@ -1 +0,0 @@ -contact@burrow.net diff --git a/store-metadata/app-store-connect/testflight/review/contact_first_name.txt b/store-metadata/app-store-connect/testflight/review/contact_first_name.txt deleted file mode 100644 index d7bed7b..0000000 --- a/store-metadata/app-store-connect/testflight/review/contact_first_name.txt +++ /dev/null @@ -1 +0,0 @@ -Burrow diff --git a/store-metadata/app-store-connect/testflight/review/contact_last_name.txt b/store-metadata/app-store-connect/testflight/review/contact_last_name.txt deleted file mode 100644 index 9136ac3..0000000 --- a/store-metadata/app-store-connect/testflight/review/contact_last_name.txt +++ /dev/null @@ -1 +0,0 @@ -Operator diff --git a/store-metadata/app-store-connect/testflight/review/demo_account_required.txt b/store-metadata/app-store-connect/testflight/review/demo_account_required.txt deleted file mode 100644 index 27ba77d..0000000 --- a/store-metadata/app-store-connect/testflight/review/demo_account_required.txt +++ /dev/null @@ -1 +0,0 @@ -true diff --git a/store-metadata/app-store-connect/testflight/review/notes.txt b/store-metadata/app-store-connect/testflight/review/notes.txt deleted file mode 100644 index 2e500d1..0000000 --- a/store-metadata/app-store-connect/testflight/review/notes.txt +++ /dev/null @@ -1 +0,0 @@ -Use the dedicated Burrow test account supplied out of band for review. The app talks to the local Burrow daemon over its gRPC boundary. diff --git a/store-metadata/flatpak/README.md b/store-metadata/flatpak/README.md deleted file mode 100644 index 8733e31..0000000 --- a/store-metadata/flatpak/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Flatpak Metadata - -Flatpak is a desktop GUI distribution lane for Burrow. - -The Flatpak package should not be considered a complete VPN backend unless a -host daemon or privileged helper is installed and verified. The GUI can manage -configuration and status through a constrained local API, while TUN, routes, -DNS, and firewall state remain outside the sandbox. diff --git a/store-metadata/sparkle/README.md b/store-metadata/sparkle/README.md deleted file mode 100644 index bc446ed..0000000 --- a/store-metadata/sparkle/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Sparkle Release Channel - -Burrow stages Sparkle files under `dist/builds//sparkle//` and -publishes them through `.forgejo/workflows/publish-store-uploads.yml`. - -The current implementation prepares the channel and public pointer layout: - -- `sparkle//appcast.xml` -- `sparkle/appcast.xml` -- `sparkle/default/` -- `sparkle/main-channel.txt` - -Sparkle enclosure signatures can be produced with the non-exportable -`sparkle-ed25519` Google KMS key: - -```sh -Scripts/sparkle/sign-appcast-kms.py \ - --appcast dist/builds//sparkle//appcast.xml \ - --artifact-dir dist/builds//sparkle/ -``` - -The public EdDSA key embedded in macOS release builds is: - -```text -Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI= -``` - -The build lane only signs appcasts when `BURROW_SPARKLE_SIGN_WITH_KMS=true`. -The live Google KMS key uses `EC_SIGN_ED25519` with software protection because -Google KMS rejects Ed25519 for HSM protection level. diff --git a/tun/Cargo.toml b/tun/Cargo.toml index 1b07833..019439d 100644 --- a/tun/Cargo.toml +++ b/tun/Cargo.toml @@ -8,7 +8,7 @@ libc = "0.2" fehler = "1.0" nix = { version = "0.26", features = ["ioctl"] } socket2 = "0.5" -tokio = { version = "1.37", default-features = false, optional = true } +tokio = { version = "1.50.0", default-features = false, optional = true } byteorder = "1.4" tracing = "0.1" log = "0.4" @@ -19,7 +19,7 @@ futures = { version = "0.3.28", optional = true } [features] serde = ["dep:serde", "dep:schemars"] -tokio = ["tokio/net", "dep:tokio", "dep:futures"] +tokio = ["tokio/macros", "tokio/net", "tokio/rt", "dep:tokio", "dep:futures"] [target.'cfg(windows)'.dependencies] lazy_static = "1.4" @@ -34,7 +34,7 @@ windows = { version = "0.48", features = [ [target.'cfg(windows)'.build-dependencies] anyhow = "1.0" bindgen = "0.65" -reqwest = { version = "0.11" } +reqwest = { version = "0.13.2" } ssri = { version = "9.0", default-features = false } -tokio = { version = "1.28", features = ["rt", "macros"] } +tokio = { version = "1.50.0", features = ["rt", "macros"] } zip = { version = "0.6", features = ["deflate"] } diff --git a/tun/build.rs b/tun/build.rs index 03ee131..8da8a40 100644 --- a/tun/build.rs +++ b/tun/build.rs @@ -26,7 +26,7 @@ async fn generate(out_dir: &std::path::Path) -> anyhow::Result<()> { println!("cargo:rerun-if-changed={}", binary_path.to_str().unwrap()); if let (Ok(..), Ok(..)) = (File::open(&bindings_path), File::open(&binary_path)) { - return Ok(()); + return Ok(()) }; let archive = download(out_dir) diff --git a/tun/src/tokio/mod.rs b/tun/src/tokio/mod.rs index f56f3d2..bd27109 100644 --- a/tun/src/tokio/mod.rs +++ b/tun/src/tokio/mod.rs @@ -33,7 +33,7 @@ impl TunInterface { Ok(result) => return result, Err(_would_block) => { tracing::debug!("WouldBlock"); - continue; + continue } } } diff --git a/tun/src/unix/apple/mod.rs b/tun/src/unix/apple/mod.rs index 66a2f15..0fc701e 100644 --- a/tun/src/unix/apple/mod.rs +++ b/tun/src/unix/apple/mod.rs @@ -114,10 +114,6 @@ impl TunInterface { ifname_to_string(buf) } - pub(crate) fn packet_information_size(&self) -> usize { - 4 - } - #[throws] #[instrument] fn ifreq(&self) -> sys::ifreq { diff --git a/tun/src/unix/linux/mod.rs b/tun/src/unix/linux/mod.rs index 9fc963a..03b6f09 100644 --- a/tun/src/unix/linux/mod.rs +++ b/tun/src/unix/linux/mod.rs @@ -73,21 +73,6 @@ impl TunInterface { ifname_to_string(iff.ifr_name) } - pub(crate) fn packet_information_size(&self) -> usize { - let mut iff = unsafe { mem::zeroed::() }; - match unsafe { sys::tun_get_iff(self.socket.as_raw_fd(), &mut iff) } { - Ok(_) => { - let flags = unsafe { iff.ifr_ifru.ifru_flags }; - if flags & libc::IFF_NO_PI as i16 != 0 { - 0 - } else { - 4 - } - } - Err(_) => 4, - } - } - #[throws] #[instrument] fn ifreq(&self) -> sys::ifreq { @@ -298,16 +283,6 @@ impl TunInterface { #[throws] #[instrument] pub fn send(&self, buf: &[u8]) -> usize { - let len = unsafe { - libc::write( - self.as_raw_fd(), - buf.as_ptr().cast::(), - buf.len(), - ) - }; - if len < 0 { - Err(Error::last_os_error())?; - } - len as usize + self.socket.send(buf)? } } diff --git a/tun/src/unix/mod.rs b/tun/src/unix/mod.rs index ad25667..f1d7da1 100644 --- a/tun/src/unix/mod.rs +++ b/tun/src/unix/mod.rs @@ -48,26 +48,12 @@ impl TunInterface { #[throws] #[instrument] pub fn recv(&self, buf: &mut [u8]) -> usize { - let packet_information_size = self.packet_information_size(); - let mut tmp_buf = [MaybeUninit::uninit(); 1504]; - let len = unsafe { - libc::read( - self.as_raw_fd(), - tmp_buf.as_mut_ptr().cast::(), - tmp_buf.len(), - ) - }; - if len < 0 { - Err(Error::last_os_error())?; - } - let len = len as usize; - if len < packet_information_size { - return 0; - } - - let result_buf = unsafe { assume_init(&tmp_buf[packet_information_size..len]) }; - buf[..len - packet_information_size].copy_from_slice(result_buf); - len - packet_information_size + // Use IoVec to read directly into target buffer + let mut tmp_buf = [MaybeUninit::uninit(); 1500]; + let len = self.socket.recv(&mut tmp_buf)?; + let result_buf = unsafe { assume_init(&tmp_buf[4..len]) }; + buf[..len - 4].copy_from_slice(result_buf); + len - 4 } #[throws] diff --git a/tun/tests/configure.rs b/tun/tests/configure.rs index bfa56ef..e5cef80 100644 --- a/tun/tests/configure.rs +++ b/tun/tests/configure.rs @@ -1,33 +1,34 @@ -use std::{io::Error, net::Ipv4Addr}; +use std::{ + io::{Error, ErrorKind}, + net::Ipv4Addr, +}; -use fehler::throws; use tun::TunInterface; -fn open_tun() -> Result, Error> { +fn open_test_tun() -> Result, Error> { match TunInterface::new() { Ok(tun) => Ok(Some(tun)), - Err(err) - if err.kind() == std::io::ErrorKind::PermissionDenied - || matches!(err.raw_os_error(), Some(1 | 13)) => - { - eprintln!("skipping tun test without tunnel privileges: {err}"); + Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::PermissionDenied) => { + eprintln!("skipping test: {}", error); Ok(None) } - Err(err) => Err(err), + Err(error) => Err(error), } } #[test] -#[throws] -fn test_create() { - let _ = open_tun()?; +fn test_create() -> Result<(), Error> { + if open_test_tun()?.is_none() { + return Ok(()); + } + + Ok(()) } #[test] -#[throws] #[cfg(not(any(target_os = "windows", target_vendor = "apple")))] -fn test_set_get_broadcast_addr() { - let Some(tun) = open_tun()? else { +fn test_set_get_broadcast_addr() -> Result<(), Error> { + let Some(tun) = open_test_tun()? else { return Ok(()); }; let addr = Ipv4Addr::new(10, 0, 0, 1); @@ -38,13 +39,14 @@ fn test_set_get_broadcast_addr() { let result = tun.broadcast_addr()?; assert_eq!(broadcast_addr, result); + + Ok(()) } #[test] -#[throws] #[cfg(not(target_os = "windows"))] -fn test_set_get_ipv4() { - let Some(tun) = open_tun()? else { +fn test_set_get_ipv4() -> Result<(), Error> { + let Some(tun) = open_test_tun()? else { return Ok(()); }; @@ -53,15 +55,16 @@ fn test_set_get_ipv4() { let result = tun.ipv4_addr()?; assert_eq!(addr, result); + + Ok(()) } #[test] -#[throws] #[cfg(not(any(target_os = "windows", target_vendor = "apple")))] -fn test_set_get_ipv6() { +fn test_set_get_ipv6() -> Result<(), Error> { use std::net::Ipv6Addr; - let Some(tun) = open_tun()? else { + let Some(tun) = open_test_tun()? else { return Ok(()); }; @@ -70,26 +73,28 @@ fn test_set_get_ipv6() { // let result = tun.ipv6_addr()?; // assert_eq!(addr, result); + + Ok(()) } #[test] -#[throws] #[cfg(not(target_os = "windows"))] -fn test_set_get_mtu() { - let Some(interf) = open_tun()? else { +fn test_set_get_mtu() -> Result<(), Error> { + let Some(interf) = open_test_tun()? else { return Ok(()); }; interf.set_mtu(500)?; assert_eq!(interf.mtu().unwrap(), 500); + + Ok(()) } #[test] -#[throws] #[cfg(not(target_os = "windows"))] -fn test_set_get_netmask() { - let Some(interf) = open_tun()? else { +fn test_set_get_netmask() -> Result<(), Error> { + let Some(interf) = open_test_tun()? else { return Ok(()); }; @@ -100,4 +105,6 @@ fn test_set_get_netmask() { interf.set_netmask(netmask)?; assert_eq!(interf.netmask()?, netmask); + + Ok(()) } diff --git a/tun/tests/tokio.rs b/tun/tests/tokio.rs index 3b89777..ddec6b3 100644 --- a/tun/tests/tokio.rs +++ b/tun/tests/tokio.rs @@ -1,25 +1,25 @@ #[cfg(all(feature = "tokio", not(target_os = "windows")))] -use std::net::Ipv4Addr; +use std::{ + io::ErrorKind, + net::Ipv4Addr, +}; #[cfg(all(feature = "tokio", not(target_os = "windows")))] -fn open_tun() -> Option { +fn open_test_tun() -> Option { match tun::TunInterface::new() { Ok(tun) => Some(tun), - Err(err) - if err.kind() == std::io::ErrorKind::PermissionDenied - || matches!(err.raw_os_error(), Some(1 | 13)) => - { - eprintln!("skipping tokio tun test without tunnel privileges: {err}"); + Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::PermissionDenied) => { + eprintln!("skipping test: {}", error); None } - Err(err) => panic!("failed to create tun interface: {err}"), + Err(error) => panic!("failed to create tun interface: {error}"), } } #[tokio::test] #[cfg(all(feature = "tokio", not(target_os = "windows")))] async fn test_create() { - let Some(tun) = open_tun() else { + let Some(tun) = open_test_tun() else { return; }; let _ = tun::tokio::TunInterface::new(tun).unwrap(); @@ -29,7 +29,7 @@ async fn test_create() { #[ignore = "requires interactivity"] #[cfg(all(feature = "tokio", not(target_os = "windows")))] async fn test_write() { - let Some(tun) = open_tun() else { + let Some(tun) = open_test_tun() else { return; }; tun.set_ipv4_addr(Ipv4Addr::from([192, 168, 1, 10]))