diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 0000000..92d46f3 --- /dev/null +++ b/.bazelignore @@ -0,0 +1,10 @@ +.build +.derived-data +.git +.nscloud-cache +Apple/build +ci-artifacts +dist +publish +release +target diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000..47da986 --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +9.1.0 diff --git a/.forgejo/actions/decrypt-release-secrets/action.yml b/.forgejo/actions/decrypt-release-secrets/action.yml new file mode 100644 index 0000000..f9b8590 --- /dev/null +++ b/.forgejo/actions/decrypt-release-secrets/action.yml @@ -0,0 +1,327 @@ +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 new file mode 100644 index 0000000..4c8901b --- /dev/null +++ b/.forgejo/workflows/apple-developer-id-kms-csr.yml @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..f108237 --- /dev/null +++ b/.forgejo/workflows/apple-distribute-testers.yml @@ -0,0 +1,530 @@ +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 new file mode 100644 index 0000000..d9cb614 --- /dev/null +++ b/.forgejo/workflows/apple-ios-distribution-kms-csr.yml @@ -0,0 +1,99 @@ +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 new file mode 100644 index 0000000..77d08aa --- /dev/null +++ b/.forgejo/workflows/backup-garage-storage.yml @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..0cc61cd --- /dev/null +++ b/.forgejo/workflows/build-android.yml @@ -0,0 +1,74 @@ +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-release.yml b/.forgejo/workflows/build-release.yml new file mode 100644 index 0000000..85c2e76 --- /dev/null +++ b/.forgejo/workflows/build-release.yml @@ -0,0 +1,480 @@ +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/distribute-testflight.yml b/.forgejo/workflows/distribute-testflight.yml new file mode 100644 index 0000000..8962c9a --- /dev/null +++ b/.forgejo/workflows/distribute-testflight.yml @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..dad4f01 --- /dev/null +++ b/.forgejo/workflows/infra-opentofu.yml @@ -0,0 +1,237 @@ +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/publish-nix-cache.yml b/.forgejo/workflows/publish-nix-cache.yml new file mode 100644 index 0000000..5ff5d27 --- /dev/null +++ b/.forgejo/workflows/publish-nix-cache.yml @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..315e43b --- /dev/null +++ b/.forgejo/workflows/publish-package-repositories.yml @@ -0,0 +1,148 @@ +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 new file mode 100644 index 0000000..904abce --- /dev/null +++ b/.forgejo/workflows/publish-store-uploads.yml @@ -0,0 +1,385 @@ +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 new file mode 100644 index 0000000..cc39214 --- /dev/null +++ b/.forgejo/workflows/release-if-needed.yml @@ -0,0 +1,92 @@ +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 index 3d1e92a..cf05fd3 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -10,6 +10,10 @@ 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 @@ -39,8 +43,21 @@ jobs: 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@v4 + uses: https://code.forgejo.org/actions/upload-artifact@v3 with: name: burrow-release-${{ github.ref_name }} path: dist/* diff --git a/.gitignore b/.gitignore index 7efe903..8237d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Xcode xcuserdata Apple/build/ +.derived-data/ +AuthKey_*.p8 +burrow-certs-pass.txt +*.p12 # Swift Apple/Package/.swiftpm/ @@ -9,11 +13,19 @@ Apple/Package/.swiftpm/ target/ .env +# Bazel +bazel-* + .DS_STORE .idea/ tmp/ +.cache/ intake/ +dist/ +publish/ +release/ +ci-artifacts/ *.db *.sqlite3 diff --git a/Android/README.md b/Android/README.md new file mode 100644 index 0000000..755b722 --- /dev/null +++ b/Android/README.md @@ -0,0 +1,14 @@ +# 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 new file mode 100644 index 0000000..cb1a8ac --- /dev/null +++ b/Android/app/build.gradle.kts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..2bb3a13 --- /dev/null +++ b/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/Android/app/src/main/java/net/burrow/android/BurrowCore.kt b/Android/app/src/main/java/net/burrow/android/BurrowCore.kt new file mode 100644 index 0000000..cf286ba --- /dev/null +++ b/Android/app/src/main/java/net/burrow/android/BurrowCore.kt @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..810a403 --- /dev/null +++ b/Android/app/src/main/java/net/burrow/android/MainActivity.kt @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..5d973f2 Binary files /dev/null and b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 new file mode 100644 index 0000000..5d973f2 Binary files /dev/null and b/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png 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 new file mode 100644 index 0000000..68cf88b Binary files /dev/null and b/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 new file mode 100644 index 0000000..68cf88b Binary files /dev/null and b/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png 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 new file mode 100644 index 0000000..2f341d8 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 new file mode 100644 index 0000000..2f341d8 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png 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 new file mode 100644 index 0000000..ed3c1ba Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 new file mode 100644 index 0000000..ed3c1ba Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png 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 new file mode 100644 index 0000000..3e052d2 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 new file mode 100644 index 0000000..3e052d2 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2f79d4b --- /dev/null +++ b/Android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Burrow + diff --git a/Android/app/src/main/res/values/styles.xml b/Android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..3bc0947 --- /dev/null +++ b/Android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + diff --git a/Android/build.gradle.kts b/Android/build.gradle.kts new file mode 100644 index 0000000..60678e4 --- /dev/null +++ b/Android/build.gradle.kts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..a4925f9 --- /dev/null +++ b/Android/settings.gradle.kts @@ -0,0 +1,18 @@ +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-macOS.entitlements b/Apple/App/App-macOS.entitlements index 595fe08..53fcbb7 100644 --- a/Apple/App/App-macOS.entitlements +++ b/Apple/App/App-macOS.entitlements @@ -15,7 +15,5 @@ $(APP_GROUP_IDENTIFIER) - com.apple.security.network.client - diff --git a/Apple/App/App.xcconfig b/Apple/App/App.xcconfig index bfdd25d..05fbc27 100644 --- a/Apple/App/App.xcconfig +++ b/Apple/App/App.xcconfig @@ -10,15 +10,20 @@ 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 EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*] = 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 - -SWIFT_INCLUDE_PATHS = $(inherited) $(PROJECT_DIR)/NetworkExtension diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index a322943..8eccc07 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -1,17 +1,29 @@ #if os(macOS) import AppKit -import BurrowConfiguration -import BurrowCore import BurrowUI +import Sparkle import SwiftUI -import libburrow +@main @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private var windowController: NSWindowController? - private let logger = Logger.logger(for: AppDelegate.self) + private let updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) - private let quitItem = AppDelegate.makeQuitItem() + private let quitItem: NSMenuItem = { + let quitItem = NSMenuItem( + title: "Quit Burrow", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + quitItem.target = NSApplication.shared + quitItem.keyEquivalentModifierMask = .command + return quitItem + }() private lazy var openItem: NSMenuItem = { let item = NSMenuItem( @@ -24,6 +36,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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) @@ -39,6 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { menu.items = [ toggleItem, openItem, + checkForUpdatesItem, .separator(), quitItem ] @@ -55,85 +78,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { }() func applicationDidFinishLaunching(_ notification: Notification) { - installMainMenu() - startDaemon() statusItem.menu = menu } - private func startDaemon() { - do { - libburrow.spawnInProcess( - socketPath: try Constants.socketURL.path(percentEncoded: false), - databasePath: try Constants.databaseURL.path(percentEncoded: false) - ) - } catch { - logger.error("Failed to spawn daemon thread: \(error)") - } - } - - private func installMainMenu() { - let mainMenu = NSMenu(title: "Burrow") - - let appItem = NSMenuItem(title: "Burrow", action: nil, keyEquivalent: "") - let appMenu = NSMenu(title: "Burrow") - - let aboutItem = NSMenuItem( - title: "About Burrow", - action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), - keyEquivalent: "" - ) - aboutItem.target = NSApplication.shared - appMenu.addItem(aboutItem) - appMenu.addItem(.separator()) - appMenu.addItem(Self.makeQuitItem()) - - appItem.submenu = appMenu - mainMenu.addItem(appItem) - - let editItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") - editItem.submenu = makeEditMenu() - mainMenu.addItem(editItem) - - NSApplication.shared.mainMenu = mainMenu - } - - private static func makeQuitItem() -> NSMenuItem { - let quitItem = NSMenuItem( - title: "Quit Burrow", - action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q" - ) - quitItem.target = NSApplication.shared - quitItem.keyEquivalentModifierMask = .command - return quitItem - } - - private func makeEditMenu() -> NSMenu { - let editMenu = NSMenu(title: "Edit") - - editMenu.addItem(NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z")) - editMenu.addItem(NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")) - editMenu.addItem(.separator()) - - editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) - editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) - editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) - - let pastePlainItem = NSMenuItem( - title: "Paste and Match Style", - action: #selector(NSTextView.pasteAsPlainText(_:)), - keyEquivalent: "V" - ) - pastePlainItem.keyEquivalentModifierMask = [.command, .option, .shift] - editMenu.addItem(pastePlainItem) - - editMenu.addItem(NSMenuItem(title: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: "")) - editMenu.addItem(.separator()) - editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) - - return editMenu - } - @objc private func openWindow() { if let window = windowController?.window { @@ -144,13 +91,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { let contentView = BurrowView() let hostingController = NSHostingController(rootView: contentView) - if #available(macOS 13.0, *) { - hostingController.sizingOptions = [] - } let window = NSWindow(contentViewController: hostingController) window.title = "Burrow" window.setContentSize(NSSize(width: 820, height: 720)) - window.contentMinSize = NSSize(width: 640, height: 560) window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable]) window.center() @@ -160,20 +103,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { windowController = controller NSApplication.shared.activate(ignoringOtherApps: true) } -} -@main -enum BurrowApplication { - @MainActor - private static let delegate = AppDelegate() - - @MainActor - static func main() { - let application = NSApplication.shared - application.delegate = delegate - application.setActivationPolicy(.accessory) - application.finishLaunching() - application.run() + @objc + private func checkForUpdates(_ sender: Any?) { + updaterController.checkForUpdates(sender) } } #endif diff --git a/Apple/App/BurrowApp.swift b/Apple/App/BurrowApp.swift index 838ef54..45529dc 100644 --- a/Apple/App/BurrowApp.swift +++ b/Apple/App/BurrowApp.swift @@ -1,14 +1,46 @@ #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/App/MainMenu.xib b/Apple/App/MainMenu.xib index 5c7bc1e..50ba431 100644 --- a/Apple/App/MainMenu.xib +++ b/Apple/App/MainMenu.xib @@ -1,8 +1,7 @@ - + - - + diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 6c64803..71de427 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* 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 */; }; @@ -16,9 +17,9 @@ 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 */; }; - D0BCC60C2A09A0C200AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -36,6 +37,7 @@ 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, ); }; }; @@ -43,14 +45,21 @@ 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 */; }; D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; }; D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; }; - D0FA10012D10200100112233 /* Generated/burrow.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10032D10200100112233 /* Generated/burrow.pb.swift */; }; - D0FA10022D10200100112233 /* Generated/burrow.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */; }; - D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.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 */; @@ -100,13 +109,6 @@ remoteGlobalIDString = D0D4E5302C8D996F007F820A; remoteInfo = Core; }; - D11000022F70000100112233 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; - remoteInfo = App; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -139,6 +141,9 @@ /* 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 = ""; }; @@ -186,14 +191,18 @@ 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 /* Generated/burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = ""; }; - D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.grpc.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 = ""; }; + 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; @@ -212,7 +221,7 @@ D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */, D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */, D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */, - D0BCC60C2A09A0C200AD070D /* libburrow.a in Frameworks */, + D0C0DE102FA0000100112233 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -233,17 +242,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D0A11C102F90000100112233 /* DequeModule in Frameworks */, D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - D11000062F70000100112233 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -326,6 +329,14 @@ path = App; sourceTree = ""; }; + D11000072F70000100112233 /* AppUITests */ = { + isa = PBXGroup; + children = ( + D11000042F70000100112233 /* BurrowUITests.swift */, + ); + path = AppUITests; + sourceTree = ""; + }; D0B98FD729FDDB57004E7149 /* libburrow */ = { isa = PBXGroup; children = ( @@ -340,8 +351,8 @@ isa = PBXGroup; children = ( D0D4E4952C8D921A007F820A /* burrow.proto */, - D0FA10032D10200100112233 /* Generated/burrow.pb.swift */, - D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */, + D0FA10032D10200100112233 /* burrow.pb.swift */, + D0FA10042D10200100112233 /* burrow.grpc.swift */, ); path = Client; sourceTree = ""; @@ -395,17 +406,27 @@ path = Constants; sourceTree = ""; }; - D11000072F70000100112233 /* AppUITests */ = { - isa = PBXGroup; - children = ( - D11000042F70000100112233 /* BurrowUITests.swift */, - ); - path = AppUITests; - sourceTree = ""; - }; /* 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" */; @@ -444,6 +465,9 @@ D020F65C29E4A697002790F6 /* PBXTargetDependency */, ); name = App; + packageProductDependencies = ( + D0C0DE122FA0000100112233 /* Sparkle */, + ); productName = Burrow; productReference = D05B9F7229E39EEC008CB1F9 /* Burrow.app */; productType = "com.apple.product-type.application"; @@ -488,6 +512,7 @@ ); name = UI; packageProductDependencies = ( + D0A11C0F2F90000100112233 /* DequeModule */, ); productName = Core; productReference = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; @@ -511,24 +536,6 @@ productReference = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; productType = "com.apple.product-type.framework"; }; - 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"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -539,54 +546,19 @@ LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { - D020F65229E4A697002790F6 = { - CreatedOnToolsVersion = 14.3; - DevelopmentTeam = 2KPBQHRJ2D; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.ApplicationGroups.Mac = { - enabled = 1; - }; - com.apple.NetworkExtensions = { - enabled = 1; - }; - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - D05B9F7129E39EEC008CB1F9 = { - CreatedOnToolsVersion = 14.3; - DevelopmentTeam = 2KPBQHRJ2D; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.ApplicationGroups.Mac = { - enabled = 1; - }; - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - com.apple.AssociatedDomains = { - enabled = 1; - }; - com.apple.NetworkExtensions = { - enabled = 1; - }; - com.apple.NetworkExtensions.iOS = { - enabled = 1; - }; - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - D0D4E5302C8D996F007F820A = { - CreatedOnToolsVersion = 16.0; - }; D11000082F70000100112233 = { CreatedOnToolsVersion = 16.0; TestTargetID = D05B9F7129E39EEC008CB1F9; }; + D020F65229E4A697002790F6 = { + CreatedOnToolsVersion = 14.3; + }; + D05B9F7129E39EEC008CB1F9 = { + CreatedOnToolsVersion = 14.3; + }; + D0D4E5302C8D996F007F820A = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = D05B9F6D29E39EEC008CB1F9 /* Build configuration list for PBXProject "Burrow" */; @@ -604,6 +576,8 @@ 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 = ""; @@ -620,11 +594,19 @@ /* 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; }; @@ -635,13 +617,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - D11000092F70000100112233 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -690,6 +665,14 @@ /* 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; @@ -711,8 +694,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0FA10012D10200100112233 /* Generated/burrow.pb.swift in Sources */, - D0FA10022D10200100112233 /* Generated/burrow.grpc.swift in Sources */, + D0FA10012D10200100112233 /* burrow.pb.swift in Sources */, + D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */, D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */, D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */, ); @@ -745,17 +728,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - D110000A2F70000100112233 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D11000012F70000100112233 /* BurrowUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + D110000B2F70000100112233 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D05B9F7129E39EEC008CB1F9 /* App */; + targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; + }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; @@ -795,19 +775,27 @@ isa = PBXTargetDependency; productRef = D0F759892C8DB34200126CF3 /* GRPC */; }; - D110000B2F70000100112233 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D05B9F7129E39EEC008CB1F9 /* App */; - targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; - }; /* 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 */; buildSettings = { - DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Debug; }; @@ -815,7 +803,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Release; }; @@ -823,7 +810,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Debug; }; @@ -831,7 +817,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Release; }; @@ -839,10 +824,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */; buildSettings = { - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2KPBQHRJ2D; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Debug; }; @@ -850,10 +831,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */; buildSettings = { - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2KPBQHRJ2D; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Release; }; @@ -899,23 +876,18 @@ }; name = Release; }; - D110000C2F70000100112233 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; - buildSettings = { - }; - name = Debug; - }; - D110000D2F70000100112233 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; - buildSettings = { - }; - name = Release; - }; /* 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 = ( @@ -970,15 +942,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D110000C2F70000100112233 /* Debug */, - D110000D2F70000100112233 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -998,6 +961,14 @@ 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"; @@ -1006,6 +977,14 @@ 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"; @@ -1050,11 +1029,21 @@ 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 */ = { + isa = XCSwiftPackageProductDependency; + package = D0C0DE112FA0000100112233 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; D0F7597D2C8DB30500126CF3 /* CGRPCZlib */ = { isa = XCSwiftPackageProductDependency; package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; diff --git a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 739b77c..a633fc0 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" : "fa512b990383b7e309c5854a5279817052294a8191a6d3c55c49cfb38e88c0c3", + "originHash" : "5d81b59cbecacd7aae6aa598b32e3513c636250dadab9280a55b905bb0e0ee60", "pins" : [ { "identity" : "grpc-swift", @@ -10,6 +10,15 @@ "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/Certificates/README.md b/Apple/Certificates/README.md new file mode 100644 index 0000000..a579568 --- /dev/null +++ b/Apple/Certificates/README.md @@ -0,0 +1,41 @@ +# Apple Certificates + +Public Apple certificate material that can be inspected by release tooling lives +here. Private keys do not belong in this directory. + +## Developer ID Application + +- Apple certificate id: `9JKN6HXBHC` +- Certificate file: `Apple/Certificates/developer-id-application-9JKN6HXBHC.cer` +- Subject: `Developer ID Application: Conrad Kramer (87PW93R2ZR)` +- Issuer: `Developer ID Certification Authority`, `G2` +- Team id: `87PW93R2ZR` +- Validity: `2026-06-07T12:13:47Z` through `2031-06-08T12:13:46Z` +- SHA-256 fingerprint: `7B:2D:58:A5:BB:1E:34:5E:FB:FF:7F:E0:A1:CA:4F:B1:5B:6E:08:AF:CE:FD:BE:D0:2B:0A:E9:9C:82:E3:4F:74` +- Google KMS key: + `projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/apple-developer-id-application/cryptoKeyVersions/1` +- KMS public-key SHA-256: + `9cfaf4755158f445f20a215ef52e7a7ba00da1a310e2e934ba7db5dbb29d2869` + +The certificate was created from a CSR signed by the non-exportable Google KMS +key above. There is no `.p12` for this identity. Release signing must use a +signer that delegates the relevant Apple code-signing operations to Google KMS. + +## iOS Distribution + +- Apple certificate id: `3G42677598` +- Certificate file: `Apple/Certificates/ios-distribution-3G42677598.cer` +- Subject: `iPhone Distribution: Conrad Kramer (87PW93R2ZR)` +- Issuer: `Apple Worldwide Developer Relations Certification Authority`, `G3` +- Team id: `87PW93R2ZR` +- Validity: `2026-06-07T12:21:32Z` through `2027-06-07T12:21:31Z` +- SHA-256 fingerprint: `5A:2F:BA:6E:FB:CA:2D:58:01:1F:A0:C8:0F:CF:6B:58:BE:AA:73:7F:4E:F8:06:1C:8A:14:1B:22:8A:2A:29:2B` +- Google KMS key: + `projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/apple-ios-distribution/cryptoKeyVersions/1` +- KMS public-key SHA-256: + `f8613ce059de90b29acf01cbd4a7deb37567653bd154bdf6c447098b5c4fae91` + +The certificate was created from a CSR signed by the non-exportable Google KMS +key above through App Store Connect certificate creation. There is no `.p12` +for this identity. iOS signing requires a signing path that can delegate Apple +code-signing operations to Google KMS. diff --git a/Apple/Certificates/developer-id-application-9JKN6HXBHC.cer b/Apple/Certificates/developer-id-application-9JKN6HXBHC.cer new file mode 100644 index 0000000..3e4d20d Binary files /dev/null and b/Apple/Certificates/developer-id-application-9JKN6HXBHC.cer differ diff --git a/Apple/Certificates/ios-distribution-3G42677598.cer b/Apple/Certificates/ios-distribution-3G42677598.cer new file mode 100644 index 0000000..f145b1e Binary files /dev/null and b/Apple/Certificates/ios-distribution-3G42677598.cer differ diff --git a/Apple/Configuration/Constants/Constants.swift b/Apple/Configuration/Constants/Constants.swift index 648ded2..95d8c78 100644 --- a/Apple/Configuration/Constants/Constants.swift +++ b/Apple/Configuration/Constants/Constants.swift @@ -16,13 +16,6 @@ public enum Constants { try groupContainerURL.appending(component: "burrow.sock", directoryHint: .notDirectory) } } - - public static var packetTunnelSocketURL: URL { - get throws { - try groupContainerURL.appending(component: "burrow-packet-tunnel.sock", directoryHint: .notDirectory) - } - } - public static var databaseURL: URL { get throws { try groupContainerURL.appending(component: "burrow.db", directoryHint: .notDirectory) diff --git a/Apple/Configuration/Identity.xcconfig b/Apple/Configuration/Identity.xcconfig index 7318401..e9ca78d 100644 --- a/Apple/Configuration/Identity.xcconfig +++ b/Apple/Configuration/Identity.xcconfig @@ -1,2 +1,2 @@ -DEVELOPMENT_TEAM = 2KPBQHRJ2D -APP_BUNDLE_IDENTIFIER = com.tianruichen.burrow +DEVELOPMENT_TEAM = P6PV2R9443 +APP_BUNDLE_IDENTIFIER = com.hackclub.burrow diff --git a/Apple/Configuration/Info.plist b/Apple/Configuration/Info.plist index 7632dad..519d374 100644 --- a/Apple/Configuration/Info.plist +++ b/Apple/Configuration/Info.plist @@ -4,5 +4,9 @@ CFBundleName $(INFOPLIST_KEY_CFBundleDisplayName) + SUFeedURL + $(BURROW_SPARKLE_FEED_URL) + SUPublicEDKey + $(BURROW_SPARKLE_PUBLIC_ED_KEY) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index 559238f..7d4cfc7 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -108,83 +108,6 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable { public init() {} } -public struct Burrow_ProxySubscriptionImportRequest: Sendable { - public var url: String = "" - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_ProxySubscriptionNodePreview: Sendable { - public var ordinal: Int32 = 0 - public var name: String = "" - public var `protocol`: String = "" - public var server: String = "" - public var port: Int32 = 0 - public var warnings: [String] = [] - public var runtimeSupported: Bool = false - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_ProxySubscriptionRejectedEntry: Sendable { - public var ordinal: Int32 = 0 - public var reason: String = "" - public var scheme: String = "" - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_ProxySubscriptionPreviewResponse: Sendable { - public var suggestedName: String = "" - public var detectedFormat: String = "" - public var compatibleCount: Int32 = 0 - public var rejectedCount: Int32 = 0 - public var node: [Burrow_ProxySubscriptionNodePreview] = [] - public var rejected: [Burrow_ProxySubscriptionRejectedEntry] = [] - public var warnings: [String] = [] - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_ProxySubscriptionApplyRequest: Sendable { - public var id: Int32 = 0 - public var url: String = "" - public var name: String = "" - public var selectedOrdinal: Int32 = 0 - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_ProxySubscriptionApplyResponse: Sendable { - public var id: Int32 = 0 - public var importedCount: Int32 = 0 - public var selectedOrdinal: Int32 = 0 - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_ProxySubscriptionSelectRequest: Sendable { - public var id: Int32 = 0 - public var selectedOrdinal: Int32 = 0 - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Burrow_ProxySubscriptionSelectResponse: Sendable { - public var id: Int32 = 0 - public var selectedOrdinal: Int32 = 0 - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - public struct Burrow_TunnelPacket: Sendable { public var payload = Data() public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -494,239 +417,6 @@ extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl } } -extension Burrow_ProxySubscriptionImportRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionImportRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "url") - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.url) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.url.isEmpty { - try visitor.visitSingularStringField(value: self.url, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_ProxySubscriptionNodePreview: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionNodePreview" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "ordinal"), - 2: .same(proto: "name"), - 3: .same(proto: "protocol"), - 4: .same(proto: "server"), - 5: .same(proto: "port"), - 6: .same(proto: "warnings"), - 7: .standard(proto: "runtime_supported"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularInt32Field(value: &self.ordinal) - case 2: try decoder.decodeSingularStringField(value: &self.name) - case 3: try decoder.decodeSingularStringField(value: &self.`protocol`) - case 4: try decoder.decodeSingularStringField(value: &self.server) - case 5: try decoder.decodeSingularInt32Field(value: &self.port) - case 6: try decoder.decodeRepeatedStringField(value: &self.warnings) - case 7: try decoder.decodeSingularBoolField(value: &self.runtimeSupported) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.ordinal != 0 { try visitor.visitSingularInt32Field(value: self.ordinal, fieldNumber: 1) } - if !self.name.isEmpty { try visitor.visitSingularStringField(value: self.name, fieldNumber: 2) } - if !self.`protocol`.isEmpty { try visitor.visitSingularStringField(value: self.`protocol`, fieldNumber: 3) } - if !self.server.isEmpty { try visitor.visitSingularStringField(value: self.server, fieldNumber: 4) } - if self.port != 0 { try visitor.visitSingularInt32Field(value: self.port, fieldNumber: 5) } - if !self.warnings.isEmpty { try visitor.visitRepeatedStringField(value: self.warnings, fieldNumber: 6) } - if self.runtimeSupported { try visitor.visitSingularBoolField(value: self.runtimeSupported, fieldNumber: 7) } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_ProxySubscriptionRejectedEntry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionRejectedEntry" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "ordinal"), - 2: .same(proto: "reason"), - 3: .same(proto: "scheme"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularInt32Field(value: &self.ordinal) - case 2: try decoder.decodeSingularStringField(value: &self.reason) - case 3: try decoder.decodeSingularStringField(value: &self.scheme) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.ordinal != 0 { try visitor.visitSingularInt32Field(value: self.ordinal, fieldNumber: 1) } - if !self.reason.isEmpty { try visitor.visitSingularStringField(value: self.reason, fieldNumber: 2) } - if !self.scheme.isEmpty { try visitor.visitSingularStringField(value: self.scheme, fieldNumber: 3) } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_ProxySubscriptionPreviewResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionPreviewResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .standard(proto: "suggested_name"), - 2: .standard(proto: "detected_format"), - 3: .standard(proto: "compatible_count"), - 4: .standard(proto: "rejected_count"), - 5: .same(proto: "node"), - 6: .same(proto: "rejected"), - 7: .same(proto: "warnings"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularStringField(value: &self.suggestedName) - case 2: try decoder.decodeSingularStringField(value: &self.detectedFormat) - case 3: try decoder.decodeSingularInt32Field(value: &self.compatibleCount) - case 4: try decoder.decodeSingularInt32Field(value: &self.rejectedCount) - case 5: try decoder.decodeRepeatedMessageField(value: &self.node) - case 6: try decoder.decodeRepeatedMessageField(value: &self.rejected) - case 7: try decoder.decodeRepeatedStringField(value: &self.warnings) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.suggestedName.isEmpty { try visitor.visitSingularStringField(value: self.suggestedName, fieldNumber: 1) } - if !self.detectedFormat.isEmpty { try visitor.visitSingularStringField(value: self.detectedFormat, fieldNumber: 2) } - if self.compatibleCount != 0 { try visitor.visitSingularInt32Field(value: self.compatibleCount, fieldNumber: 3) } - if self.rejectedCount != 0 { try visitor.visitSingularInt32Field(value: self.rejectedCount, fieldNumber: 4) } - if !self.node.isEmpty { try visitor.visitRepeatedMessageField(value: self.node, fieldNumber: 5) } - if !self.rejected.isEmpty { try visitor.visitRepeatedMessageField(value: self.rejected, fieldNumber: 6) } - if !self.warnings.isEmpty { try visitor.visitRepeatedStringField(value: self.warnings, fieldNumber: 7) } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_ProxySubscriptionApplyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionApplyRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - 2: .same(proto: "url"), - 3: .same(proto: "name"), - 4: .standard(proto: "selected_ordinal"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularInt32Field(value: &self.id) - case 2: try decoder.decodeSingularStringField(value: &self.url) - case 3: try decoder.decodeSingularStringField(value: &self.name) - case 4: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } - if !self.url.isEmpty { try visitor.visitSingularStringField(value: self.url, fieldNumber: 2) } - if !self.name.isEmpty { try visitor.visitSingularStringField(value: self.name, fieldNumber: 3) } - if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 4) } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_ProxySubscriptionApplyResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionApplyResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - 2: .standard(proto: "imported_count"), - 3: .standard(proto: "selected_ordinal"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularInt32Field(value: &self.id) - case 2: try decoder.decodeSingularInt32Field(value: &self.importedCount) - case 3: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } - if self.importedCount != 0 { try visitor.visitSingularInt32Field(value: self.importedCount, fieldNumber: 2) } - if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 3) } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_ProxySubscriptionSelectRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionSelectRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - 2: .standard(proto: "selected_ordinal"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularInt32Field(value: &self.id) - case 2: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } - if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 2) } - try unknownFields.traverse(visitor: &visitor) - } -} - -extension Burrow_ProxySubscriptionSelectResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = "burrow.ProxySubscriptionSelectResponse" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - 2: .standard(proto: "selected_ordinal"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularInt32Field(value: &self.id) - case 2: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } - if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 2) } - try unknownFields.traverse(visitor: &visitor) - } -} - public struct TailnetClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions @@ -797,52 +487,6 @@ public struct TailnetClient: Client, GRPCClient { } } -public struct ProxySubscriptionClient: Client, GRPCClient { - public let channel: GRPCChannel - public var defaultCallOptions: CallOptions - - public init(channel: any GRPCChannel) { - self.channel = channel - self.defaultCallOptions = .init() - } - - public func previewImport( - _ request: Burrow_ProxySubscriptionImportRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_ProxySubscriptionPreviewResponse { - try await self.performAsyncUnaryCall( - path: "/burrow.ProxySubscriptions/PreviewImport", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } - - public func applyImport( - _ request: Burrow_ProxySubscriptionApplyRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_ProxySubscriptionApplyResponse { - try await self.performAsyncUnaryCall( - path: "/burrow.ProxySubscriptions/ApplyImport", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } - - public func selectNode( - _ request: Burrow_ProxySubscriptionSelectRequest, - callOptions: CallOptions? = nil - ) async throws -> Burrow_ProxySubscriptionSelectResponse { - try await self.performAsyncUnaryCall( - path: "/burrow.ProxySubscriptions/SelectNode", - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: [] - ) - } -} - public struct TunnelPacketClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions diff --git a/Apple/Core/Client/Generated/burrow.pb.swift b/Apple/Core/Client/Generated/burrow.pb.swift index d235078..fccd769 100644 --- a/Apple/Core/Client/Generated/burrow.pb.swift +++ b/Apple/Core/Client/Generated/burrow.pb.swift @@ -25,7 +25,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case wireGuard // = 0 case tailnet // = 1 - case proxySubscription // = 3 case UNRECOGNIZED(Int) public init() { @@ -36,7 +35,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { switch rawValue { case 0: self = .wireGuard case 1: self = .tailnet - case 3: self = .proxySubscription default: self = .UNRECOGNIZED(rawValue) } } @@ -45,7 +43,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { switch self { case .wireGuard: return 0 case .tailnet: return 1 - case .proxySubscription: return 3 case .UNRECOGNIZED(let i): return i } } @@ -54,7 +51,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { public static let allCases: [Burrow_NetworkType] = [ .wireGuard, .tailnet, - .proxySubscription, ] } @@ -221,8 +217,6 @@ public struct Burrow_TunnelConfigurationResponse: Sendable { public var routes: [String] = [] - public var excludedRoutes: [String] = [] - public var dnsServers: [String] = [] public var searchDomains: [String] = [] @@ -242,7 +236,6 @@ extension Burrow_NetworkType: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 0: .same(proto: "WireGuard"), 1: .same(proto: "Tailnet"), - 3: .same(proto: "ProxySubscription"), ] } @@ -551,7 +544,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob 4: .standard(proto: "dns_servers"), 5: .standard(proto: "search_domains"), 6: .standard(proto: "include_default_route"), - 7: .standard(proto: "excluded_routes"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -566,7 +558,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob 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) }() - case 7: try { try decoder.decodeRepeatedStringField(value: &self.excludedRoutes) }() default: break } } @@ -582,9 +573,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob if !self.routes.isEmpty { try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3) } - if !self.excludedRoutes.isEmpty { - try visitor.visitRepeatedStringField(value: self.excludedRoutes, fieldNumber: 7) - } if !self.dnsServers.isEmpty { try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4) } @@ -601,7 +589,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob if lhs.addresses != rhs.addresses {return false} if lhs.mtu != rhs.mtu {return false} if lhs.routes != rhs.routes {return false} - if lhs.excludedRoutes != rhs.excludedRoutes {return false} if lhs.dnsServers != rhs.dnsServers {return false} if lhs.searchDomains != rhs.searchDomains {return false} if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false} diff --git a/Apple/NetworkExtension/NetworkExtension.xcconfig b/Apple/NetworkExtension/NetworkExtension.xcconfig index a6a2635..35c7198 100644 --- a/Apple/NetworkExtension/NetworkExtension.xcconfig +++ b/Apple/NetworkExtension/NetworkExtension.xcconfig @@ -9,5 +9,3 @@ CODE_SIGN_ENTITLEMENTS = NetworkExtension/NetworkExtension-iOS.entitlements CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = NetworkExtension/NetworkExtension-macOS.entitlements SWIFT_INCLUDE_PATHS = $(inherited) $(PROJECT_DIR)/NetworkExtension - -OTHER_LDFLAGS = $(inherited) -framework SystemConfiguration diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 12f0cd0..3f3d8b4 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -28,13 +28,13 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { get throws { try _client.get() } } private let _client: Result = Result { - try TunnelClient.unix(socketURL: Constants.packetTunnelSocketURL) + try TunnelClient.unix(socketURL: Constants.socketURL) } override init() { do { libburrow.spawnInProcess( - socketPath: try Constants.packetTunnelSocketURL.path(percentEncoded: false), + socketPath: try Constants.socketURL.path(percentEncoded: false), databasePath: try Constants.databaseURL.path(percentEncoded: false) ) } catch { @@ -54,11 +54,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard let settings = configuration?.settings else { throw Error.missingTunnelConfiguration } - if let configuration { - logger.log( - "Applying tunnel configuration addresses=\(configuration.addresses.joined(separator: ","), privacy: .public) routes=\(configuration.routes.joined(separator: ","), privacy: .public) excludedRoutes=\(configuration.excludedRoutes.joined(separator: ","), privacy: .public) dns=\(configuration.dnsServers.joined(separator: ","), privacy: .public) includeDefaultRoute=\(configuration.includeDefaultRoute, privacy: .public)" - ) - } try await setTunnelNetworkSettings(settings) try startPacketBridge() logger.log("Started tunnel with network settings: \(settings)") @@ -93,22 +88,15 @@ extension PacketTunnelProvider { private func startPacketBridge() throws { stopPacketBridge() - let packetClient = TunnelPacketClient.unix(socketURL: try Constants.packetTunnelSocketURL) + 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 } - var packetCount = 0 - var byteCount = 0 do { for try await packet in call.responseStream { let payload = packet.payload - packetCount += 1 - byteCount += payload.count - if packetCount == 1 || packetCount % 1024 == 0 { - self.logger.log("Tunnel packet receive progress packets=\(packetCount, privacy: .public) bytes=\(byteCount, privacy: .public) lastBytes=\(payload.count, privacy: .public)") - } self.packetFlow.writePackets( [payload], withProtocols: [Self.protocolNumber(for: payload)] @@ -123,17 +111,10 @@ extension PacketTunnelProvider { outboundPacketTask = Task { [weak self] in guard let self else { return } defer { call.requestStream.finish() } - var packetCount = 0 - var byteCount = 0 do { while !Task.isCancelled { let packets = await self.readPacketsBatch() for (payload, _) in packets { - packetCount += 1 - byteCount += payload.count - if packetCount == 1 || packetCount % 1024 == 0 { - self.logger.log("Tunnel packet send progress packets=\(packetCount, privacy: .public) bytes=\(byteCount, privacy: .public) lastBytes=\(payload.count, privacy: .public)") - } var packet = Burrow_TunnelPacket() packet.payload = payload try await call.requestStream.send(packet) @@ -147,7 +128,6 @@ extension PacketTunnelProvider { } private func stopPacketBridge() { - logger.log("Stopping tunnel packet bridge") inboundPacketTask?.cancel() inboundPacketTask = nil outboundPacketTask?.cancel() @@ -185,9 +165,6 @@ extension Burrow_TunnelConfigurationResponse { let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:)) var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route) var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route) - let parsedExcludedRoutes = excludedRoutes.compactMap(ParsedTunnelRoute.init(rawValue:)) - let excludedIPv4Routes = parsedExcludedRoutes.compactMap(\.ipv4Route) - let excludedIPv6Routes = parsedExcludedRoutes.compactMap(\.ipv6Route) if includeDefaultRoute { ipv4Routes.append(.default()) ipv6Routes.append(.default()) @@ -203,9 +180,6 @@ extension Burrow_TunnelConfigurationResponse { if !ipv4Routes.isEmpty { ipv4Settings.includedRoutes = ipv4Routes } - if !excludedIPv4Routes.isEmpty { - ipv4Settings.excludedRoutes = excludedIPv4Routes - } settings.ipv4Settings = ipv4Settings } if !ipv6Addresses.isEmpty { @@ -216,9 +190,6 @@ extension Burrow_TunnelConfigurationResponse { if !ipv6Routes.isEmpty { ipv6Settings.includedRoutes = ipv6Routes } - if !excludedIPv6Routes.isEmpty { - ipv6Settings.excludedRoutes = excludedIPv6Routes - } settings.ipv6Settings = ipv6Settings } if !dnsServers.isEmpty { diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index 79a45d6..81cb901 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -3,7 +3,7 @@ # This is a build script. It is run by Xcode as a build step. # The type of build is described in various environment variables set by Xcode. -export PATH="${PATH}:${HOME}/.cargo/bin:${HOME}/.nix-profile/bin:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/${USER}/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin" +export PATH="${PATH}:${HOME}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/${USER}/bin" if ! [[ -x "$(command -v cargo)" ]]; then echo 'error: Unable to find cargo' @@ -45,6 +45,27 @@ 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 @@ -65,11 +86,19 @@ fi if [[ -x "$(command -v rustup)" ]]; then CARGO_PATH="$(dirname "$(rustup which cargo)"):/usr/bin" else - CARGO_PATH="$(dirname "$(readlink -f "$(command -v cargo)")"):/usr/bin" + CARGO_PATH="$(dirname "$(command -v cargo)"):/usr/bin" fi -PROTOC="$(readlink -f "$(command -v protoc)")" -CARGO_PATH="$(dirname "$PROTOC"):$CARGO_PATH" +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 +CARGO_PATH="$(dirname $PROTOC):$CARGO_PATH" # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. diff --git a/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json b/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..127b747 100644 --- a/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,15 @@ { "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 deleted file mode 100644 index f86c139..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/100.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index 872c9ce..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png deleted file mode 100644 index 3bb278d..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index 185615e..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png deleted file mode 100644 index 51bd97c..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png deleted file mode 100644 index b05e371..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png deleted file mode 100644 index c95ea8a..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png deleted file mode 100644 index 3cb15a5..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png deleted file mode 100644 index a3ad6a2..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png deleted file mode 100644 index 9f3bdb4..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index 53c1237..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png deleted file mode 100644 index ea95961..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index aec8236..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png deleted file mode 100644 index 9f0e3ce..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png deleted file mode 100644 index a82ce93..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index 8dc25c1..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png deleted file mode 100644 index 655a424..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index 1f7f5e9..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png deleted file mode 100644 index 4a67ebf..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png deleted file mode 100644 index 88985d8..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png deleted file mode 100644 index e5cbf6a..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png deleted file mode 100644 index dc079ea..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png deleted file mode 100644 index de4fddc..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index 961adad..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png deleted file mode 100644 index 2a9e939..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png deleted file mode 100644 index c67e407..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png deleted file mode 100644 index d09aebe..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png deleted file mode 100644 index 3e649b6..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png deleted file mode 100644 index 6dad29f..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index a8ccb38..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png deleted file mode 100644 index b1a478a..0000000 Binary files a/Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png and /dev/null differ diff --git a/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json b/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json index f78687a..092f7cd 100644 --- a/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,344 +1,218 @@ { - "images" : [ + "images": [ { - "filename" : "40.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" + "filename": "ios-panel-01-40.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" }, { - "filename" : "60.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" + "filename": "ios-panel-01-60.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" }, { - "filename" : "29.png", - "idiom" : "iphone", - "scale" : "1x", - "size" : "29x29" + "filename": "ios-panel-01-29.png", + "idiom": "iphone", + "scale": "1x", + "size": "29x29" }, { - "filename" : "58.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" + "filename": "ios-panel-01-58.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" }, { - "filename" : "87.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" + "filename": "ios-panel-01-87.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" }, { - "filename" : "80.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" + "filename": "ios-panel-01-80.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" }, { - "filename" : "120.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" + "filename": "ios-panel-01-120.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" }, { - "filename" : "57.png", - "idiom" : "iphone", - "scale" : "1x", - "size" : "57x57" + "filename": "ios-panel-01-57.png", + "idiom": "iphone", + "scale": "1x", + "size": "57x57" }, { - "filename" : "114.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "57x57" + "filename": "ios-panel-01-114.png", + "idiom": "iphone", + "scale": "2x", + "size": "57x57" }, { - "filename" : "120.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" + "filename": "ios-panel-01-120.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" }, { - "filename" : "180.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" + "filename": "ios-panel-01-180.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" }, { - "filename" : "20.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" + "filename": "ios-panel-01-20.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" }, { - "filename" : "40.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" + "filename": "ios-panel-01-40.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" }, { - "filename" : "29.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" + "filename": "ios-panel-01-29.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" }, { - "filename" : "58.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" + "filename": "ios-panel-01-58.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" }, { - "filename" : "40.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" + "filename": "ios-panel-01-40.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" }, { - "filename" : "80.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" + "filename": "ios-panel-01-80.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" }, { - "filename" : "50.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "50x50" + "filename": "ios-panel-01-50.png", + "idiom": "ipad", + "scale": "1x", + "size": "50x50" }, { - "filename" : "100.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "50x50" + "filename": "ios-panel-01-100.png", + "idiom": "ipad", + "scale": "2x", + "size": "50x50" }, { - "filename" : "72.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "72x72" + "filename": "ios-panel-01-72.png", + "idiom": "ipad", + "scale": "1x", + "size": "72x72" }, { - "filename" : "144.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "72x72" + "filename": "ios-panel-01-144.png", + "idiom": "ipad", + "scale": "2x", + "size": "72x72" }, { - "filename" : "76.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" + "filename": "ios-panel-01-76.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" }, { - "filename" : "152.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" + "filename": "ios-panel-01-152.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" }, { - "filename" : "167.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" + "filename": "ios-panel-01-167.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" }, { - "filename" : "1024.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" + "filename": "ios-panel-01-1024.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" }, { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" + "filename": "mac-panel-05-16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" }, { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" + "filename": "mac-panel-05-32.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" }, { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "filename": "mac-panel-05-32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" }, { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" + "filename": "mac-panel-05-64.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" }, { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "filename": "mac-panel-05-128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" }, { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "filename": "mac-panel-05-256.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" }, { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "filename": "mac-panel-05-256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" }, { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" + "filename": "mac-panel-05-512.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" }, { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "filename": "mac-panel-05-512.png", + "idiom": "mac", + "scale": "1x", + "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" + "filename": "mac-panel-05-1024.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" } ], - "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 new file mode 100644 index 0000000..dbe8e53 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-100.png 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 new file mode 100644 index 0000000..e335771 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-1024.png 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 new file mode 100644 index 0000000..af8f95a Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-114.png 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 new file mode 100644 index 0000000..eea76eb Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-120.png 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 new file mode 100644 index 0000000..ed3c1ba Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-144.png 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 new file mode 100644 index 0000000..2c7a7cd Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-152.png 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 new file mode 100644 index 0000000..7ce3660 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-167.png 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 new file mode 100644 index 0000000..488ac42 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-180.png 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 new file mode 100644 index 0000000..bd3cc95 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-20.png 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 new file mode 100644 index 0000000..227cb6c Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-29.png 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 new file mode 100644 index 0000000..c0ef687 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-40.png 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 new file mode 100644 index 0000000..377281b Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-50.png 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 new file mode 100644 index 0000000..b9b88e1 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-57.png 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 new file mode 100644 index 0000000..0847ee9 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-58.png 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 new file mode 100644 index 0000000..cd8358b Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-60.png 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 new file mode 100644 index 0000000..5d973f2 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-72.png 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 new file mode 100644 index 0000000..1cde009 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-76.png 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 new file mode 100644 index 0000000..ecf97e9 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-80.png 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 new file mode 100644 index 0000000..e1f3193 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/ios-panel-01-87.png 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 new file mode 100644 index 0000000..065e8b6 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-1024.png 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 new file mode 100644 index 0000000..f4bd4bc Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-128.png 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 new file mode 100644 index 0000000..30e2ad9 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-16.png 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 new file mode 100644 index 0000000..d919e9c Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-256.png 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 new file mode 100644 index 0000000..3c1fd9f Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-32.png 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 new file mode 100644 index 0000000..441a139 Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-512.png 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 new file mode 100644 index 0000000..e6a72db Binary files /dev/null and b/Apple/UI/Assets.xcassets/AppIcon.appiconset/mac-panel-05-64.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/Contents.json new file mode 100644 index 0000000..d6ee58c --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..561abcb Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel01Preview.imageset/preview.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-100.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-100.png new file mode 100644 index 0000000..48c06a4 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-100.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-1024.png new file mode 100644 index 0000000..278575e Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-1024.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-114.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-114.png new file mode 100644 index 0000000..411802b Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-114.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-120.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-120.png new file mode 100644 index 0000000..95473ee Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-120.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-144.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-144.png new file mode 100644 index 0000000..2b2afb9 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-144.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-152.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-152.png new file mode 100644 index 0000000..0668af2 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-152.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-167.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-167.png new file mode 100644 index 0000000..5486437 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-167.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-180.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-180.png new file mode 100644 index 0000000..18111cf Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-180.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-20.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-20.png new file mode 100644 index 0000000..b0df643 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-20.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-29.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-29.png new file mode 100644 index 0000000..49d677b Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-29.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-40.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-40.png new file mode 100644 index 0000000..1fb9e0f Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-40.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-50.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-50.png new file mode 100644 index 0000000..c73d033 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-50.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-57.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-57.png new file mode 100644 index 0000000..c55bc86 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-57.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-58.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-58.png new file mode 100644 index 0000000..470ab9c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-58.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-60.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-60.png new file mode 100644 index 0000000..914fe25 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-60.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-72.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-72.png new file mode 100644 index 0000000..463a7cc Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-72.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-76.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-76.png new file mode 100644 index 0000000..7080ec3 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-76.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-80.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-80.png new file mode 100644 index 0000000..90be22c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-80.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-87.png b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-87.png new file mode 100644 index 0000000..f494783 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/BurrowPanel02-87.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/Contents.json new file mode 100644 index 0000000..a6ab553 --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel02.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "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 new file mode 100644 index 0000000..d6ee58c --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..f6d9feb Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel02Preview.imageset/preview.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-100.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-100.png new file mode 100644 index 0000000..8d72c1b Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-100.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-1024.png new file mode 100644 index 0000000..6f40b0a Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-1024.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-114.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-114.png new file mode 100644 index 0000000..9d44be4 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-114.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-120.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-120.png new file mode 100644 index 0000000..434c1a8 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-120.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-144.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-144.png new file mode 100644 index 0000000..d837b45 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-144.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-152.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-152.png new file mode 100644 index 0000000..c507534 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-152.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-167.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-167.png new file mode 100644 index 0000000..e79fc1e Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-167.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-180.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-180.png new file mode 100644 index 0000000..7022128 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-180.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-20.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-20.png new file mode 100644 index 0000000..28468f0 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-20.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-29.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-29.png new file mode 100644 index 0000000..b4dc026 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-29.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-40.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-40.png new file mode 100644 index 0000000..2ed0ee4 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-40.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-50.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-50.png new file mode 100644 index 0000000..7015cf1 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-50.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-57.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-57.png new file mode 100644 index 0000000..6cfb78d Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-57.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-58.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-58.png new file mode 100644 index 0000000..1efe951 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-58.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-60.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-60.png new file mode 100644 index 0000000..e007091 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-60.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-72.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-72.png new file mode 100644 index 0000000..f82f554 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-72.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-76.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-76.png new file mode 100644 index 0000000..5f92d14 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-76.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-80.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-80.png new file mode 100644 index 0000000..37337e7 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-80.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-87.png b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-87.png new file mode 100644 index 0000000..e37171c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/BurrowPanel03-87.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/Contents.json new file mode 100644 index 0000000..923e927 --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel03.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "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 new file mode 100644 index 0000000..d6ee58c --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..76d432e Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel03Preview.imageset/preview.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-100.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-100.png new file mode 100644 index 0000000..3c70fad Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-100.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-1024.png new file mode 100644 index 0000000..4925f9f Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-1024.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-114.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-114.png new file mode 100644 index 0000000..6ea1195 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-114.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-120.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-120.png new file mode 100644 index 0000000..b07aa10 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-120.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-144.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-144.png new file mode 100644 index 0000000..783367c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-144.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-152.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-152.png new file mode 100644 index 0000000..5a13fd3 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-152.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-167.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-167.png new file mode 100644 index 0000000..694d1f1 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-167.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-180.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-180.png new file mode 100644 index 0000000..af7e0fb Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-180.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-20.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-20.png new file mode 100644 index 0000000..6889afc Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-20.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-29.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-29.png new file mode 100644 index 0000000..81f027c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-29.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-40.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-40.png new file mode 100644 index 0000000..6fc8b68 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-40.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-50.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-50.png new file mode 100644 index 0000000..cf8d516 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-50.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-57.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-57.png new file mode 100644 index 0000000..6bddc05 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-57.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-58.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-58.png new file mode 100644 index 0000000..c80a63f Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-58.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-60.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-60.png new file mode 100644 index 0000000..22bf565 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-60.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-72.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-72.png new file mode 100644 index 0000000..1ce020b Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-72.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-76.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-76.png new file mode 100644 index 0000000..d0f2173 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-76.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-80.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-80.png new file mode 100644 index 0000000..3d4be54 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-80.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-87.png b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-87.png new file mode 100644 index 0000000..0de4b64 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/BurrowPanel04-87.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/Contents.json new file mode 100644 index 0000000..ee5c59a --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel04.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "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 new file mode 100644 index 0000000..d6ee58c --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..36b526c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel04Preview.imageset/preview.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-100.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-100.png new file mode 100644 index 0000000..caed322 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-100.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-1024.png new file mode 100644 index 0000000..065e8b6 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-1024.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-114.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-114.png new file mode 100644 index 0000000..a6a5dfa Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-114.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-120.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-120.png new file mode 100644 index 0000000..184604d Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-120.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-144.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-144.png new file mode 100644 index 0000000..89a64a9 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-144.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-152.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-152.png new file mode 100644 index 0000000..339670e Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-152.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-167.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-167.png new file mode 100644 index 0000000..ac23f7c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-167.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-180.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-180.png new file mode 100644 index 0000000..d2c8089 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-180.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-20.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-20.png new file mode 100644 index 0000000..821044f Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-20.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-29.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-29.png new file mode 100644 index 0000000..b20c630 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-29.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-40.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-40.png new file mode 100644 index 0000000..9fa18b5 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-40.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-50.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-50.png new file mode 100644 index 0000000..619429d Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-50.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-57.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-57.png new file mode 100644 index 0000000..bde5672 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-57.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-58.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-58.png new file mode 100644 index 0000000..8d8311d Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-58.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-60.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-60.png new file mode 100644 index 0000000..5d37b74 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-60.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-72.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-72.png new file mode 100644 index 0000000..a3624bf Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-72.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-76.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-76.png new file mode 100644 index 0000000..35730d1 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-76.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-80.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-80.png new file mode 100644 index 0000000..43ad408 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-80.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-87.png b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-87.png new file mode 100644 index 0000000..1e7bbdf Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/BurrowPanel05-87.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/Contents.json new file mode 100644 index 0000000..a1e0e4b --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel05.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "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 new file mode 100644 index 0000000..d6ee58c --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..d919e9c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel05Preview.imageset/preview.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-100.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-100.png new file mode 100644 index 0000000..c723bc5 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-100.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-1024.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-1024.png new file mode 100644 index 0000000..54336b2 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-1024.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-114.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-114.png new file mode 100644 index 0000000..f0d4a55 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-114.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-120.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-120.png new file mode 100644 index 0000000..cf95959 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-120.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-144.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-144.png new file mode 100644 index 0000000..b1bb422 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-144.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-152.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-152.png new file mode 100644 index 0000000..a44333c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-152.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-167.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-167.png new file mode 100644 index 0000000..adb7652 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-167.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-180.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-180.png new file mode 100644 index 0000000..0e64715 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-180.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-20.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-20.png new file mode 100644 index 0000000..d9d652b Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-20.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-29.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-29.png new file mode 100644 index 0000000..e200e3c Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-29.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-40.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-40.png new file mode 100644 index 0000000..3db6db7 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-40.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-50.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-50.png new file mode 100644 index 0000000..7f3b15e Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-50.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-57.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-57.png new file mode 100644 index 0000000..49fd3f6 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-57.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-58.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-58.png new file mode 100644 index 0000000..7f7c2ff Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-58.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-60.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-60.png new file mode 100644 index 0000000..da1f3ca Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-60.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-72.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-72.png new file mode 100644 index 0000000..1a9e3dc Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-72.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-76.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-76.png new file mode 100644 index 0000000..e4407db Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-76.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-80.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-80.png new file mode 100644 index 0000000..0c0b7f4 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-80.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-87.png b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-87.png new file mode 100644 index 0000000..3740748 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/BurrowPanel06-87.png differ diff --git a/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/Contents.json b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/Contents.json new file mode 100644 index 0000000..91d9323 --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel06.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "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 new file mode 100644 index 0000000..d6ee58c --- /dev/null +++ b/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..7c63df0 Binary files /dev/null and b/Apple/UI/Assets.xcassets/BurrowPanel06Preview.imageset/preview.png differ diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index bc9e361..8145928 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,22 +1,29 @@ import BurrowConfiguration import Foundation -import GRPC import SwiftUI #if canImport(AuthenticationServices) import AuthenticationServices #endif -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif + +@MainActor +public protocol AppIconManaging { + var supportsAlternateIcons: Bool { get } + var alternateIconName: String? { get } + func setAlternateIconName(_ iconName: String?) async throws +} 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 - @State private var expandedProxySubscriptionID: Int32? + #if os(iOS) + @State private var selectedAppIconName: String? + @State private var appIconError: String? + @State private var isSettingAppIcon = false + #endif public var body: some View { NavigationStack { @@ -39,9 +46,6 @@ public struct BurrowView: View { Button("Add WireGuard Network") { activeSheet = .wireGuard } - Button("Import Proxy Subscription") { - activeSheet = .proxySubscription - } Button("Save Tor Account") { activeSheet = .tor } @@ -61,6 +65,10 @@ public struct BurrowView: View { quickAddSection } + if showsAppIconSection { + appIconSection + } + VStack(alignment: .leading, spacing: 12) { sectionHeader( title: "Networks", @@ -72,22 +80,8 @@ public struct BurrowView: View { Text(connectionError) .font(.footnote) .foregroundStyle(.secondary) - .lineLimit(4) - .truncationMode(.middle) - } - NetworkCarouselView( - networks: networkViewModel.cards, - selectedNetworkID: expandedProxySubscriptionID - ) { network in - guard networkViewModel.proxySubscriptions.contains(where: { $0.id == network.id }) else { - return - } - withAnimation(.snappy) { - expandedProxySubscriptionID = expandedProxySubscriptionID == network.id - ? nil - : network.id - } } + NetworkCarouselView(networks: networkViewModel.cards) } if showsAccountsSection { @@ -138,21 +132,14 @@ public struct BurrowView: View { accountStore: accountStore ) } - .sheet(isPresented: proxySubscriptionSheetBinding) { - ProxySubscriptionManagerView( - networks: networkViewModel.proxySubscriptions, - networkViewModel: networkViewModel, - selectedNetworkID: $expandedProxySubscriptionID - ) - .frame(width: 820, height: 600) - .padding(20) - } .onAppear { runAutomationIfNeeded() + refreshAppIconSelection() } } - public init() { + public init(appIconManager: (any AppIconManaging)? = nil) { + self.appIconManager = appIconManager _networkViewModel = State( initialValue: NetworkViewModel( socketURLResult: Result { try Constants.socketURL } @@ -171,6 +158,12 @@ public struct BurrowView: View { activeSheet = .tailnet } + private func refreshAppIconSelection() { + #if os(iOS) + selectedAppIconName = appIconManager?.alternateIconName + #endif + } + @ViewBuilder private var quickAddSection: some View { VStack(alignment: .leading, spacing: 12) { @@ -185,29 +178,90 @@ public struct BurrowView: View { } } - private var proxySubscriptionSheetBinding: Binding { - Binding( - get: { expandedProxySubscriptionID != nil }, - set: { isPresented in - guard !isPresented else { return } - expandedProxySubscriptionID = nil + @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)) - .lineLimit(1) - .truncationMode(.tail) if let detail, !detail.isEmpty { Text(detail) .font(.subheadline) .foregroundStyle(.secondary) - .lineLimit(3) - .truncationMode(.tail) } } } @@ -232,14 +286,87 @@ public struct BurrowView: View { #if os(iOS) !accountStore.accounts.isEmpty #else - !accountStore.accounts.isEmpty || networkViewModel.networks.isEmpty + 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 proxySubscription case tor case tailnet @@ -248,7 +375,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { var kind: AccountNetworkKind { switch self { case .wireGuard: .wireGuard - case .proxySubscription: .proxySubscription case .tor: .tor case .tailnet: .tailnet } @@ -258,8 +384,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: "wave.3.right" - case .proxySubscription: - "link.badge.plus" case .tor: "shield.lefthalf.filled.badge.checkmark" case .tailnet: @@ -271,8 +395,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: "WireGuard" - case .proxySubscription: - "Proxy Subscription" case .tor: "Tor" case .tailnet: @@ -284,8 +406,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: "Import a tunnel" - case .proxySubscription: - "Import Trojan or Shadowsocks" case .tor: "Save an Arti profile" case .tailnet: @@ -297,8 +417,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: .blue - case .proxySubscription: - .teal case .tor, .tailnet: kind.accentColor } @@ -338,8 +456,6 @@ private struct AccountDraft { var accountName = "" var identityName = "" var wireGuardConfig = "" - var proxySubscriptionURL = "" - var proxySubscriptionName = "" var discoveryEmail = "" var authority = "" @@ -358,8 +474,6 @@ private struct AccountDraft { switch sheet { case .wireGuard: break - case .proxySubscription: - title = "Proxy Subscription" case .tor: title = "Default Tor" accountName = "default" @@ -394,11 +508,6 @@ private struct ConfigurationSheetView: View { @State private var tailnetLoginError: String? @State private var tailnetLoginSessionID: String? @State private var isStartingTailnetLogin = false - @State private var proxySubscriptionPreview: ProxySubscriptionPreview? - @State private var proxySubscriptionPreviewError: String? - @State private var isPreviewingProxySubscription = false - @State private var selectedProxySubscriptionOrdinal: Int32? - @State private var proxySubscriptionNodeFilter = "" @State private var tailnetPresentedAuthURL: URL? @State private var preserveTailnetLoginSession = false @State private var usesCustomTailnetAuthority = false @@ -435,11 +544,31 @@ private struct ConfigurationSheetView: View { } } - configurationSections + 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 { - feedbackText(errorMessage, color: .red) + Text(errorMessage) + .foregroundStyle(.red) } } } @@ -486,8 +615,7 @@ private struct ConfigurationSheetView: View { } } #if os(macOS) - .frame(width: 520) - .frame(minHeight: 620) + .frame(minWidth: 520, minHeight: 620) #endif .safeAreaInset(edge: .bottom) { if showsBottomActionButton { @@ -515,12 +643,6 @@ private struct ConfigurationSheetView: View { await cancelTailnetLoginIfNeeded() } } - .onChange(of: draft.proxySubscriptionURL) { _, _ in - proxySubscriptionPreview = nil - proxySubscriptionPreviewError = nil - selectedProxySubscriptionOrdinal = nil - proxySubscriptionNodeFilter = "" - } .onDisappear { tailnetLoginPollTask?.cancel() tailnetDiscoveryTask?.cancel() @@ -534,71 +656,6 @@ private struct ConfigurationSheetView: View { } } - @ViewBuilder - private var configurationSections: some View { - switch sheet { - case .wireGuard: - wireGuardConfigurationSection - case .proxySubscription: - proxySubscriptionSections - case .tor: - torPreferenceSection - case .tailnet: - tailnetSections - } - } - - private var wireGuardConfigurationSection: some View { - Section("WireGuard Configuration") { - TextEditor(text: $draft.wireGuardConfig) - .font(.body.monospaced()) - .frame(minHeight: wireGuardEditorHeight) - .contextMenu { - wireGuardContextActions - } - } - } - - @ViewBuilder - private var proxySubscriptionSections: some View { - Section("Subscription") { - TextField("Subscription URL", text: $draft.proxySubscriptionURL) - .autocorrectionDisabled() - TextField("Name", text: $draft.proxySubscriptionName) - } - - Section("Preview") { - proxySubscriptionPreviewButton - - if let proxySubscriptionPreview { - proxySubscriptionPreviewView(proxySubscriptionPreview) - } else if let proxySubscriptionPreviewError { - feedbackText(proxySubscriptionPreviewError, color: .red) - } - } - } - - private var proxySubscriptionPreviewButton: some View { - Button { - previewProxySubscription() - } label: { - Label( - isPreviewingProxySubscription ? "Previewing" : "Preview", - systemImage: isPreviewingProxySubscription ? "hourglass" : "eye" - ) - } - .disabled(isPreviewingProxySubscription || normalizedOptional(draft.proxySubscriptionURL) == nil) - } - - private var torPreferenceSection: some View { - 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) - } - } - @ViewBuilder private var identityFields: some View { TextField("Title", text: $draft.title) @@ -905,143 +962,6 @@ private struct ConfigurationSheetView: View { ) } - private func proxySubscriptionPreviewView(_ preview: ProxySubscriptionPreview) -> some View { - VStack(alignment: .leading, spacing: 8) { - labeledValue("Format", preview.detectedFormat) - labeledValue("Compatible", "\(preview.compatibleCount)") - labeledValue("Runtime Supported", "\(preview.nodes.filter { $0.runtimeSupported }.count)") - labeledValue("Rejected", "\(preview.rejectedCount)") - - let selectableNodes = supportedProxySubscriptionNodes(in: preview) - if !selectableNodes.isEmpty { - TextField("Filter nodes", text: $proxySubscriptionNodeFilter) - .autocorrectionDisabled() - - let filteredNodes = filteredProxySubscriptionNodes(selectableNodes) - proxySubscriptionNodeList(nodes: filteredNodes, preview: preview) - - if !proxySubscriptionNodeFilter.isEmpty && filteredNodes.isEmpty { - feedbackText("No nodes match the current filter.", color: .orange) - } - } else if !preview.nodes.isEmpty { - Text("No previewed nodes are supported by the packet runtime.") - .font(.caption) - .foregroundStyle(.orange) - } - - ForEach(preview.warnings, id: \.self) { warning in - feedbackText(warning, color: .orange) - } - } - } - - private func proxySubscriptionNodeList( - nodes: [ProxySubscriptionNodePreview], - preview: ProxySubscriptionPreview - ) -> some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 6) { - ForEach(nodes) { node in - proxySubscriptionNodeRow( - node: node, - isSelected: selectedProxySubscriptionNode(in: preview)?.ordinal == node.ordinal - ) - } - } - .padding(.vertical, 2) - } - .frame(maxHeight: 260) - } - - private func proxySubscriptionNodeRow( - node: ProxySubscriptionNodePreview, - isSelected: Bool - ) -> some View { - Button { - selectedProxySubscriptionOrdinal = node.ordinal - } label: { - HStack(spacing: 8) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - .imageScale(.small) - .frame(width: 16) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(node.name) - .font(.footnote.weight(isSelected ? .semibold : .regular)) - .lineLimit(1) - .truncationMode(.tail) - Text(node.proxyProtocol.uppercased()) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background( - Capsule() - .fill(.secondary.opacity(0.12)) - ) - } - - Text("\(node.server):\(node.port)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - } - - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.accentColor.opacity(0.45) : Color.clear, lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - - private func supportedProxySubscriptionNodes( - in preview: ProxySubscriptionPreview - ) -> [ProxySubscriptionNodePreview] { - preview.nodes.filter { $0.runtimeSupported } - } - - private func filteredProxySubscriptionNodes( - _ nodes: [ProxySubscriptionNodePreview] - ) -> [ProxySubscriptionNodePreview] { - let query = proxySubscriptionNodeFilter.trimmingCharacters(in: .whitespacesAndNewlines) - guard !query.isEmpty else { return nodes } - return nodes.filter { node in - node.name.localizedCaseInsensitiveContains(query) - || node.server.localizedCaseInsensitiveContains(query) - || node.proxyProtocol.localizedCaseInsensitiveContains(query) - } - } - - private func selectedProxySubscriptionNode(in preview: ProxySubscriptionPreview) -> ProxySubscriptionNodePreview? { - let ordinal = selectedProxySubscriptionOrdinal - ?? preview.nodes.first(where: { $0.runtimeSupported })?.ordinal - return preview.nodes.first { $0.ordinal == ordinal } - } - - private func feedbackText(_ message: String, color: Color) -> some View { - Text(message) - .font(.caption) - .foregroundStyle(color) - .lineLimit(6) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - @ViewBuilder private var bottomActionBar: some View { VStack(spacing: 0) { @@ -1077,12 +997,6 @@ private struct ConfigurationSheetView: View { } .disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - case .proxySubscription: - Button("Preview Subscription") { - previewProxySubscription() - } - .disabled(normalizedOptional(draft.proxySubscriptionURL) == nil) - case .tor: Menu("Presets") { Button("Recommended Tor Defaults") { @@ -1139,8 +1053,6 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: .blue - case .proxySubscription: - .teal case .tor, .tailnet: sheet.kind.accentColor } @@ -1150,8 +1062,6 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: "Import WireGuard" - case .proxySubscription: - "Import Proxy Subscription" case .tor: "Configure Tor" case .tailnet: @@ -1171,8 +1081,6 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard, .tor: return true - case .proxySubscription: - return false case .tailnet: return showsAdvancedTailnetSettings } @@ -1190,8 +1098,6 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: return "Add Network" - case .proxySubscription: - return "Import" case .tor: return "Save Account" case .tailnet: @@ -1206,7 +1112,7 @@ private struct ConfigurationSheetView: View { return normalizedOptional(draft.authority) == nil } return false - case .wireGuard, .tor, .proxySubscription: + case .wireGuard, .tor: return true } } @@ -1215,9 +1121,6 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - case .proxySubscription: - return normalizedOptional(draft.proxySubscriptionURL) == nil - || proxySubscriptionPreview?.nodes.contains(where: { $0.runtimeSupported }) != true case .tor: return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil case .tailnet: @@ -1292,9 +1195,6 @@ private struct ConfigurationSheetView: View { case .wireGuard: try await submitWireGuard() dismiss() - case .proxySubscription: - try await submitProxySubscription() - dismiss() case .tor: try submitTor() dismiss() @@ -1302,7 +1202,7 @@ private struct ConfigurationSheetView: View { try await submitTailnet() } } catch { - errorMessage = displayError(error) + errorMessage = error.localizedDescription } } } @@ -1332,44 +1232,6 @@ private struct ConfigurationSheetView: View { try accountStore.upsert(record, secret: nil) } - private func submitProxySubscription() async throws { - let url = normalized(draft.proxySubscriptionURL, fallback: "") - let name = normalized(draft.proxySubscriptionName, fallback: "Proxy Subscription") - let selectedOrdinal = selectedProxySubscriptionOrdinal - ?? proxySubscriptionPreview?.nodes.first(where: { $0.runtimeSupported })?.ordinal - _ = try await networkViewModel.importProxySubscription( - url: url, - name: name, - selectedOrdinal: selectedOrdinal - ) - } - - private func previewProxySubscription() { - guard let url = normalizedOptional(draft.proxySubscriptionURL) else { - proxySubscriptionPreviewError = "Enter a subscription URL before previewing." - return - } - isPreviewingProxySubscription = true - proxySubscriptionPreviewError = nil - Task { @MainActor in - defer { isPreviewingProxySubscription = false } - do { - let preview = try await networkViewModel.previewProxySubscription(url: url) - proxySubscriptionPreview = preview - selectedProxySubscriptionOrdinal = preview.nodes.first(where: { $0.runtimeSupported })?.ordinal - proxySubscriptionNodeFilter = "" - if draft.proxySubscriptionName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - draft.proxySubscriptionName = preview.suggestedName - } - } catch { - proxySubscriptionPreview = nil - selectedProxySubscriptionOrdinal = nil - proxySubscriptionNodeFilter = "" - proxySubscriptionPreviewError = displayError(error) - } - } - } - private func submitTor() throws { let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))") let note = [ @@ -1837,8 +1699,6 @@ private struct ConfigurationSheetView: View { .foregroundStyle(.secondary) Text(value) .font(.body.monospaced()) - .lineLimit(3) - .truncationMode(.middle) } } } @@ -1853,11 +1713,9 @@ private struct AccountRowView: View { VStack(alignment: .leading, spacing: 4) { Text(account.title) .font(.headline) - .lineLimit(1) - .truncationMode(.tail) Text(account.kind.title) - .font(.subheadline) - .foregroundStyle(account.kind.accentColor) + .font(.subheadline) + .foregroundStyle(account.kind.accentColor) } Spacer() if hasSecret { @@ -1890,8 +1748,6 @@ private struct AccountRowView: View { Text(note) .font(.footnote) .foregroundStyle(.secondary) - .lineLimit(3) - .truncationMode(.middle) } } .padding() @@ -1910,378 +1766,11 @@ private struct AccountRowView: View { .foregroundStyle(.secondary) Text(value) .font(.body.monospaced()) - .lineLimit(3) - .truncationMode(.middle) - } - } -} - -private struct ProxySubscriptionManagerView: View { - @Environment(\.tunnel) - private var tunnel: any Tunnel - - let networks: [ProxySubscriptionNetwork] - let networkViewModel: NetworkViewModel - @Binding var selectedNetworkID: Int32? - - @State private var nodeFilter = "" - @State private var operationID: String? - @State private var feedback: String? - @State private var errorMessage: String? - @State private var pendingDelete: ProxySubscriptionNetwork? - - var body: some View { - if let network = selectedNetwork { - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Proxy Subscription") - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - Text(network.name) - .font(.title3.weight(.semibold)) - .lineLimit(1) - .truncationMode(.tail) - if let selectedNode = network.selectedNode { - Text("\(selectedNode.proxyProtocol.uppercased()) \(selectedNode.server):\(selectedNode.port)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - - Spacer(minLength: 0) - - HStack(spacing: 8) { - Button { - refresh(network) - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .disabled(isBusy) - - Button(role: .destructive) { - pendingDelete = network - } label: { - Label("Remove", systemImage: "trash") - } - .disabled(isBusy) - } - .burrowGlassControl() - .controlSize(.small) - - Button { - withAnimation(.snappy) { - selectedNetworkID = nil - } - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title3) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel("Close proxy subscription picker") - } - - if networks.count > 1 { - Picker("Subscription", selection: selectedNetworkIDBinding) { - ForEach(networks) { network in - Text(network.name).tag(network.id) - } - } - .pickerStyle(.menu) - } - - HStack(spacing: 16) { - Label(network.detectedFormat, systemImage: "doc.text") - Label("\(network.runtimeSupportedNodes.count) usable of \(network.nodes.count) nodes", systemImage: "circle.grid.2x1") - } - .font(.caption) - .foregroundStyle(.secondary) - - TextField("Filter nodes", text: $nodeFilter) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled() - - HStack { - Text("Nodes") - .font(.subheadline.weight(.semibold)) - Spacer() - Text("\(filteredNodes(for: network).count) shown") - .font(.caption) - .foregroundStyle(.secondary) - } - - ScrollView { - LazyVGrid(columns: nodeColumns, alignment: .leading, spacing: 10) { - ForEach(filteredNodes(for: network)) { node in - nodeTile(node, in: network) - } - } - .padding(.vertical, 2) - } - .frame(maxHeight: 410) - .scrollIndicators(.visible) - - if let feedback { - Text(feedback) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - .truncationMode(.tail) - } - - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) - .lineLimit(3) - .truncationMode(.middle) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .onAppear { - ensureSelectedNetwork() - } - .onChange(of: networks) { _, _ in - ensureSelectedNetwork() - } - .confirmationDialog( - "Remove proxy subscription?", - isPresented: Binding( - get: { pendingDelete != nil }, - set: { if !$0 { pendingDelete = nil } } - ), - presenting: pendingDelete - ) { network in - Button("Remove \(network.name)", role: .destructive) { - delete(network) - } - } - } - } - - private var selectedNetwork: ProxySubscriptionNetwork? { - guard let selectedNetworkID else { - return nil - } - return networks.first(where: { $0.id == selectedNetworkID }) - } - - private var selectedNetworkIDBinding: Binding { - Binding( - get: { selectedNetwork?.id ?? networks.first?.id ?? 0 }, - set: { selectedNetworkID = $0 } - ) - } - - private var isBusy: Bool { - operationID != nil - } - - private var nodeColumns: [GridItem] { - [ - GridItem( - .adaptive(minimum: 245, maximum: 320), - spacing: 10, - alignment: .top - ) - ] - } - - private func ensureSelectedNetwork() { - guard let selectedNetworkID else { - return - } - if !networks.contains(where: { $0.id == selectedNetworkID }) { - self.selectedNetworkID = nil - } - } - - private func filteredNodes(for network: ProxySubscriptionNetwork) -> [ProxySubscriptionNetworkNode] { - let query = nodeFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !query.isEmpty else { - return network.nodes - } - return network.nodes.filter { node in - node.name.lowercased().contains(query) - || node.server.lowercased().contains(query) - || node.proxyProtocol.lowercased().contains(query) - } - } - - private func nodeTile( - _ node: ProxySubscriptionNetworkNode, - in network: ProxySubscriptionNetwork - ) -> some View { - let isSelected = network.selectedNode?.ordinal == node.ordinal - return Button { - select(node, in: network) - } label: { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 8) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - .frame(width: 18) - - VStack(alignment: .leading, spacing: 4) { - Text(node.name) - .font(.footnote.weight(isSelected ? .semibold : .regular)) - .lineLimit(1) - .truncationMode(.tail) - - HStack(spacing: 6) { - Text(node.proxyProtocol.uppercased()) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(Capsule().fill(.secondary.opacity(0.12))) - if !node.runtimeSupported { - Text("UNSUPPORTED") - .font(.caption2.weight(.medium)) - .foregroundStyle(.orange) - } - } - } - - Spacer(minLength: 0) - } - - Text("\(node.server):\(node.port)") - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - if operationID == operationIdentifier(networkID: network.id, ordinal: node.ordinal) { - ProgressView() - .controlSize(.small) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - .padding(12) - .frame(minHeight: 94, alignment: .topLeading) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? Color.accentColor.opacity(0.10) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.accentColor.opacity(0.65) : Color.secondary.opacity(0.16), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .disabled(isBusy || !node.runtimeSupported) - } - - private func select( - _ node: ProxySubscriptionNetworkNode, - in network: ProxySubscriptionNetwork - ) { - let operationIdentifier = operationIdentifier(networkID: network.id, ordinal: node.ordinal) - operationID = operationIdentifier - feedback = nil - errorMessage = nil - Task { @MainActor in - defer { operationID = nil } - do { - _ = try await networkViewModel.updateProxySubscriptionSelection( - network: network, - selectedOrdinal: node.ordinal - ) - if isTunnelRunning { - _ = try await networkViewModel.updateActiveProxySubscriptionSelection( - network: network, - selectedOrdinal: node.ordinal - ) - feedback = "Selected \(node.name) for the active tunnel" - } else { - feedback = "Selected \(node.name)" - } - } catch { - errorMessage = displayError(error) - } - } - } - - private func refresh(_ network: ProxySubscriptionNetwork) { - operationID = "refresh-\(network.id)" - feedback = nil - errorMessage = nil - Task { @MainActor in - defer { operationID = nil } - do { - _ = try await networkViewModel.refreshProxySubscription(network: network) - if isTunnelRunning { - _ = try await networkViewModel.refreshActiveProxySubscription(network: network) - feedback = "Refreshed \(network.name) for the active tunnel" - } else { - feedback = "Refreshed \(network.name)" - } - } catch { - errorMessage = displayError(error) - } - } - } - - private func delete(_ network: ProxySubscriptionNetwork) { - operationID = "delete-\(network.id)" - feedback = nil - errorMessage = nil - Task { @MainActor in - defer { operationID = nil } - do { - try await networkViewModel.deleteNetwork(id: network.id) - pendingDelete = nil - selectedNetworkID = nil - feedback = nil - } catch { - errorMessage = displayError(error) - } - } - } - - private func operationIdentifier(networkID: Int32, ordinal: Int32) -> String { - "select-\(networkID)-\(ordinal)" - } - - @MainActor - private var isTunnelRunning: Bool { - switch tunnel.status { - case .connected, .reasserting: - true - default: - false - } - } - - private func labeledValue(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.caption.monospaced()) - .lineLimit(1) - .truncationMode(.tail) } } } private extension View { - @ViewBuilder - func burrowGlassControl() -> some View { - if #available(macOS 26.0, iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.bordered) - } - } - @ViewBuilder func burrowLoginField() -> some View { #if os(iOS) @@ -2355,13 +1844,6 @@ private final class TailnetBrowserAuthenticator { } #endif -private func displayError(_ error: Error) -> String { - if let status = error as? GRPCStatus { - return status.message ?? status.description - } - return error.localizedDescription -} - private struct BurrowAutomationConfig { enum Action: String { case tailnetLogin = "tailnet-login" diff --git a/Apple/UI/NetworkCarouselView.swift b/Apple/UI/NetworkCarouselView.swift index d109672..e7368db 100644 --- a/Apple/UI/NetworkCarouselView.swift +++ b/Apple/UI/NetworkCarouselView.swift @@ -2,18 +2,6 @@ import SwiftUI struct NetworkCarouselView: View { var networks: [NetworkCardModel] - var selectedNetworkID: Int32? - var onSelect: ((NetworkCardModel) -> Void)? - - init( - networks: [NetworkCardModel], - selectedNetworkID: Int32? = nil, - onSelect: ((NetworkCardModel) -> Void)? = nil - ) { - self.networks = networks - self.selectedNetworkID = selectedNetworkID - self.onSelect = onSelect - } var body: some View { Group { @@ -41,19 +29,10 @@ struct NetworkCarouselView: View { .frame(maxWidth: .infinity, minHeight: 175) #endif } else { - #if os(macOS) - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(networks) { network in - networkCard(network) - .frame(maxWidth: 560) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - #else ScrollView(.horizontal) { LazyHStack { ForEach(networks) { network in - networkCard(network) + NetworkView(network: network) .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) .scrollTransition(.interactive, axis: .horizontal) { content, phase in content @@ -68,33 +47,9 @@ struct NetworkCarouselView: View { .defaultScrollAnchor(.center) .scrollTargetBehavior(.viewAligned) .containerRelativeFrame(.horizontal) - #endif } } } - - @ViewBuilder - private func networkCard(_ network: NetworkCardModel) -> some View { - if let onSelect { - Button { - onSelect(network) - } label: { - NetworkView(network: network) - .overlay(selectionOverlay(for: network)) - } - .buttonStyle(.plain) - } else { - NetworkView(network: network) - } - } - - private func selectionOverlay(for network: NetworkCardModel) -> some View { - RoundedRectangle(cornerRadius: 10) - .stroke( - selectedNetworkID == network.id ? Color.accentColor : Color.clear, - lineWidth: 3 - ) - } } #if DEBUG diff --git a/Apple/UI/NetworkExtensionTunnel.swift b/Apple/UI/NetworkExtensionTunnel.swift index 151e683..23559f3 100644 --- a/Apple/UI/NetworkExtensionTunnel.swift +++ b/Apple/UI/NetworkExtensionTunnel.swift @@ -98,33 +98,23 @@ public final class NetworkExtensionTunnel: Tunnel { } } - let manager = managers.first ?? NETunnelProviderManager() - var needsSave = managers.isEmpty - if manager.localizedDescription != "Burrow" { - manager.localizedDescription = "Burrow" - needsSave = true - } + guard managers.isEmpty else { return } + + let manager = NETunnelProviderManager() + manager.localizedDescription = "Burrow" let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = bundleIdentifier proto.serverAddress = "burrow.rs" - proto.proxySettings = nil - if !manager.protocolConfiguration.isBurrowEquivalent(to: proto) { - manager.protocolConfiguration = proto - needsSave = true - } - - if needsSave { - try await manager.save() - } + manager.protocolConfiguration = proto + try await manager.save() } public func start() { Task { + guard let manager = try await NETunnelProviderManager.managers.first else { return } do { - try await configure() - guard let manager = try await NETunnelProviderManager.managers.first else { return } if !manager.isEnabled { manager.isEnabled = true try await manager.save() @@ -159,17 +149,6 @@ public final class NetworkExtensionTunnel: Tunnel { } } -private extension Optional where Wrapped == NEVPNProtocol { - func isBurrowEquivalent(to expected: NETunnelProviderProtocol) -> Bool { - guard let current = self as? NETunnelProviderProtocol else { - return false - } - return current.providerBundleIdentifier == expected.providerBundleIdentifier - && current.serverAddress == expected.serverAddress - && current.proxySettings == nil - } -} - extension NEVPNConnection { fileprivate var tunnelStatus: TunnelStatus { switch status { diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 4b68430..35bd0e1 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -53,456 +53,6 @@ struct TailnetLoginStatus: Sendable { var health: [String] } -struct ProxySubscriptionNodePreview: Sendable, Identifiable { - var id: Int32 { ordinal } - var ordinal: Int32 - var name: String - var proxyProtocol: String - var server: String - var port: Int32 - var warnings: [String] - var runtimeSupported: Bool -} - -struct ProxySubscriptionPreview: Sendable { - var suggestedName: String - var detectedFormat: String - var compatibleCount: Int32 - var rejectedCount: Int32 - var nodes: [ProxySubscriptionNodePreview] - var warnings: [String] -} - -struct ProxySubscriptionNetwork: Sendable, Identifiable, Hashable { - var id: Int32 - var name: String - var sourceURL: String - var detectedFormat: String - var selectedOrdinal: Int32? - var selectedName: String? - var nodes: [ProxySubscriptionNetworkNode] - var warnings: [String] - - var selectedNode: ProxySubscriptionNetworkNode? { - selectedName - .flatMap { name in nodes.first { $0.name == name && $0.runtimeSupported } } - ?? selectedOrdinal - .flatMap { ordinal in nodes.first { $0.ordinal == ordinal && $0.runtimeSupported } } - ?? nodes.first { $0.runtimeSupported } - ?? nodes.first - } - - var runtimeSupportedNodes: [ProxySubscriptionNetworkNode] { - nodes.filter(\.runtimeSupported) - } - - init?(network: Burrow_Network) { - guard network.type == .proxySubscription, - let payload = try? JSONDecoder().decode(StoredProxySubscriptionPayload.self, from: network.payload) - else { - return nil - } - id = network.id - name = payload.name - sourceURL = payload.source.url - detectedFormat = payload.detectedFormat - selectedOrdinal = payload.selectedOrdinal.map(Int32.init) - selectedName = payload.selectedName - nodes = payload.nodes.map(ProxySubscriptionNetworkNode.init) - warnings = payload.warnings - } -} - -struct ProxySubscriptionNetworkNode: Sendable, Identifiable, Hashable { - var id: Int32 { ordinal } - var ordinal: Int32 - var name: String - var proxyProtocol: String - var server: String - var port: Int32 - var warnings: [String] - var runtimeSupported: Bool - - fileprivate init(stored: StoredProxySubscriptionNode) { - ordinal = Int32(stored.ordinal) - name = stored.name - proxyProtocol = stored.proxyProtocol - server = stored.server - port = Int32(stored.port) - warnings = stored.warnings - runtimeSupported = stored.config.runtimeSupported - } -} - -private struct StoredProxySubscriptionPayload: Decodable { - var name: String - var source: StoredProxySubscriptionSource - var detectedFormat: String - var selectedOrdinal: Int? - var selectedName: String? - var nodes: [StoredProxySubscriptionNode] - var warnings: [String] - - enum CodingKeys: String, CodingKey { - case name - case source - case detectedFormat = "detected_format" - case selectedOrdinal = "selected_ordinal" - case selectedName = "selected_name" - case nodes - case warnings - } -} - -private struct StoredProxySubscriptionSource: Decodable { - var url: String -} - -private struct StoredProxySubscriptionNode: Decodable { - var ordinal: Int - var name: String - var proxyProtocol: String - var server: String - var port: Int - var warnings: [String] - var config: StoredProxySubscriptionNodeConfig - - enum CodingKeys: String, CodingKey { - case ordinal - case name - case proxyProtocol = "protocol" - case server - case port - case warnings - case config - } -} - -private struct StoredProxySubscriptionNodeConfig: Decodable { - var type: String - var network: StoredProxySubscriptionNetworkTransport? - var cipher: String? - var plugin: StoredJSONValue? - var smux: StoredJSONValue? - var udp: Bool? - var udpOverTCP: Bool? - var clientFingerprint: String? - - var runtimeSupported: Bool { - switch type { - case "trojan": - return network?.isTrojanTCP ?? true - case "shadowsocks": - guard Self.supportedShadowsocksSmux(smux) else { - return false - } - return Self.supportedShadowsocksPlugin(plugin, clientFingerprint: clientFingerprint) - && Self.supportedShadowsocksCiphers.contains(cipher?.lowercased() ?? "") - default: - return false - } - } - - enum CodingKeys: String, CodingKey { - case type - case network - case cipher - case plugin - case smux - case udp - case udpOverTCP = "udp_over_tcp" - case clientFingerprint = "client_fingerprint" - } - - private static let supportedShadowsocksCiphers: Set = [ - "none", - "plain", - "table", - "rc4-md5", - "rc4", - "aes-128-ctr", - "aes-192-ctr", - "aes-256-ctr", - "aes-128-cfb", - "aes-128-cfb1", - "aes-128-cfb8", - "aes-128-cfb128", - "aes-192-cfb", - "aes-192-cfb1", - "aes-192-cfb8", - "aes-192-cfb128", - "aes-256-cfb", - "aes-256-cfb1", - "aes-256-cfb8", - "aes-256-cfb128", - "aes-128-ofb", - "aes-192-ofb", - "aes-256-ofb", - "camellia-128-ctr", - "camellia-192-ctr", - "camellia-256-ctr", - "camellia-128-cfb", - "camellia-128-cfb1", - "camellia-128-cfb8", - "camellia-128-cfb128", - "camellia-192-cfb", - "camellia-192-cfb1", - "camellia-192-cfb8", - "camellia-192-cfb128", - "camellia-256-cfb", - "camellia-256-cfb1", - "camellia-256-cfb8", - "camellia-256-cfb128", - "camellia-128-ofb", - "camellia-192-ofb", - "camellia-256-ofb", - "chacha20", - "chacha20-ietf", - "xchacha20", - "aes-128-gcm", - "aead_aes_128_gcm", - "aes-192-gcm", - "aes-256-gcm", - "aead_aes_256_gcm", - "aes-128-ccm", - "aes-192-ccm", - "aes-256-ccm", - "aes-128-gcm-siv", - "aes-256-gcm-siv", - "chacha8-ietf-poly1305", - "chacha20-ietf-poly1305", - "chacha20-poly1305", - "aead_chacha20_poly1305", - "rabbit128-poly1305", - "xchacha8-ietf-poly1305", - "xchacha20-ietf-poly1305", - "sm4-gcm", - "sm4-ccm", - "aegis-128l", - "aegis-256", - "aez-384", - "deoxys-ii-256-128", - "ascon128", - "ascon128a", - "lea-128-gcm", - "lea-192-gcm", - "lea-256-gcm", - "2022-blake3-aes-128-gcm", - "2022-blake3-aes-256-gcm", - "2022-blake3-aes-128-ccm", - "2022-blake3-aes-256-ccm", - "2022-blake3-chacha20-poly1305", - "2022-blake3-chacha8-poly1305", - ] - - private static func supportedShadowsocksPlugin( - _ plugin: StoredJSONValue?, - clientFingerprint: String? - ) -> Bool { - guard let plugin else { - return true - } - guard case .object(let object) = plugin, - case .string(let name)? = object["name"] - else { - return false - } - let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - let opts: [String: StoredJSONValue] - if case .object(let decodedOpts)? = object["opts"] { - opts = decodedOpts - } else { - opts = [:] - } - if normalizedName == "restls", - let clientFingerprint, - Self.shadowsocksClientFingerprintIsActive(clientFingerprint) - { - return false - } - if normalizedName == "v2ray-plugin" || normalizedName == "gost-plugin" { - guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else { - return false - } - guard mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "websocket" else { - return false - } - return true - } - if normalizedName == "shadow-tls" { - let version = opts["version"]?.stringValue ?? "2" - let normalizedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) - switch normalizedVersion { - case "1": - return true - case "2", "3": - if let clientFingerprint, - Self.shadowsocksClientFingerprintIsActive(clientFingerprint) - { - return false - } - return true - default: - return false - } - } - if normalizedName == "kcptun" { - guard let smuxVersion = opts["smuxver"]?.stringValue else { - return true - } - guard let version = Double(smuxVersion.trimmingCharacters(in: .whitespacesAndNewlines)) else { - return false - } - return version >= 0 - } - if normalizedName == "restls" { - let versionHint = opts["version-hint"]?.stringValue ?? "" - switch versionHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "tls12", "tls13": - return true - default: - return false - } - } - guard normalizedName == "obfs" else { - return true - } - guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else { - return false - } - switch mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "http", "tls": - return true - default: - return false - } - } - - private static func shadowsocksClientFingerprintIsActive(_ value: String) -> Bool { - let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return [ - "chrome", - "firefox", - "safari", - "ios", - "android", - "edge", - "360", - "qq", - "random", - "chrome120", - "firefox120", - "safari16", - "chrome_psk", - "chrome_psk_shuffle", - "chrome_padding_psk_shuffle", - "chrome_pq", - "chrome_pq_psk", - "randomized", - ].contains(normalized) - } - - private static func supportedShadowsocksSmux(_ smux: StoredJSONValue?) -> Bool { - guard case .object(let object)? = smux else { - return true - } - guard object["enabled"]?.boolValue ?? false else { - return true - } - let protocolName = object["protocol"]?.stringValue? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() ?? "h2mux" - guard protocolName == "h2mux" || protocolName == "smux" || protocolName == "yamux" else { - return false - } - return true - } -} - -private enum StoredProxySubscriptionNetworkTransport: Decodable, Hashable { - case named(String) - case keyed(String) - - var isTrojanTCP: Bool { - switch self { - case .named(let value), .keyed(let value): - return value == "tcp" - } - } - - init(from decoder: Swift.Decoder) throws { - let container = try decoder.singleValueContainer() - if let value = try? container.decode(String.self) { - self = .named(value) - return - } - let object = try container.decode([String: StoredJSONValue].self) - self = .keyed(object.keys.first ?? "") - } -} - -private enum StoredJSONValue: Decodable, Hashable { - case string(String) - case number(Double) - case bool(Bool) - case object([String: StoredJSONValue]) - case array([StoredJSONValue]) - case null - - var stringValue: String? { - switch self { - case .string(let value): - return value - case .number(let value): - if value.rounded(.towardZero) == value { - return String(Int(value)) - } - return String(value) - case .bool(let value): - return String(value) - default: - return nil - } - } - - var boolValue: Bool? { - switch self { - case .bool(let value): - return value - case .string(let value): - switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "1", "true", "yes", "y": - return true - case "0", "false", "no", "n": - return false - default: - return nil - } - case .number(let value): - return value != 0 - default: - return nil - } - } - - init(from decoder: Swift.Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Double.self) { - self = .number(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([StoredJSONValue].self) { - self = .array(value) - } else { - self = .object(try container.decode([String: StoredJSONValue].self)) - } - } -} - enum TailnetDiscoveryClient { static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { var request = Burrow_TailnetDiscoverRequest() @@ -589,76 +139,6 @@ enum TailnetLoginClient { } } -enum ProxySubscriptionImportClient { - static func preview(url: String, socketURL: URL) async throws -> ProxySubscriptionPreview { - var request = Burrow_ProxySubscriptionImportRequest() - request.url = url - - let response = try await ProxySubscriptionClient.unix(socketURL: socketURL) - .previewImport(request) - return ProxySubscriptionPreview( - suggestedName: response.suggestedName, - detectedFormat: response.detectedFormat, - compatibleCount: response.compatibleCount, - rejectedCount: response.rejectedCount, - nodes: response.node.map { node in - ProxySubscriptionNodePreview( - ordinal: node.ordinal, - name: node.name, - proxyProtocol: node.`protocol`, - server: node.server, - port: node.port, - warnings: node.warnings, - runtimeSupported: node.runtimeSupported - ) - }, - warnings: response.warnings - ) - } - - static func apply( - id: Int32, - url: String, - name: String, - selectedOrdinal: Int32?, - socketURL: URL - ) async throws -> Int32 { - var request = Burrow_ProxySubscriptionApplyRequest() - request.id = id - request.url = url - request.name = name - request.selectedOrdinal = selectedOrdinal ?? -1 - let response = try await ProxySubscriptionClient.unix(socketURL: socketURL) - .applyImport(request) - return response.id - } - - static func selectNode( - id: Int32, - selectedOrdinal: Int32, - socketURL: URL - ) async throws -> Int32 { - var request = Burrow_ProxySubscriptionSelectRequest() - request.id = id - request.selectedOrdinal = selectedOrdinal - let response = try await ProxySubscriptionClient.unix(socketURL: socketURL) - .selectNode(request) - return response.selectedOrdinal - } -} - -private struct DaemonUnavailableError: LocalizedError { - var socketPath: String - - var errorDescription: String? { - "Burrow daemon is not reachable yet. Enable the tunnel or start the daemon, then try again." - } - - var failureReason: String? { - "The daemon socket does not exist at \(socketPath)." - } -} - @Observable @MainActor final class NetworkViewModel: Sendable { @@ -681,16 +161,6 @@ final class NetworkViewModel: Sendable { networks.map(Self.makeCard(for:)) } - var nonProxyCards: [NetworkCardModel] { - networks - .filter { $0.type != .proxySubscription } - .map(Self.makeCard(for:)) - } - - var proxySubscriptions: [ProxySubscriptionNetwork] { - networks.compactMap(ProxySubscriptionNetwork.init) - } - var nextNetworkID: Int32 { (networks.map(\.id).max() ?? 0) + 1 } @@ -703,83 +173,13 @@ final class NetworkViewModel: Sendable { try await addNetwork(type: .tailnet, payload: payload.encoded()) } - func previewProxySubscription(url: String) async throws -> ProxySubscriptionPreview { - let socketURL = try daemonSocketURL() - return try await ProxySubscriptionImportClient.preview(url: url, socketURL: socketURL) - } - - func importProxySubscription(url: String, name: String, selectedOrdinal: Int32?) async throws -> Int32 { - let socketURL = try daemonSocketURL() - let networkID = nextNetworkID - return try await ProxySubscriptionImportClient.apply( - id: networkID, - url: url, - name: name, - selectedOrdinal: selectedOrdinal, - socketURL: socketURL - ) - } - - func updateProxySubscriptionSelection( - network: ProxySubscriptionNetwork, - selectedOrdinal: Int32 - ) async throws -> Int32 { - let socketURL = try daemonSocketURL() - return try await ProxySubscriptionImportClient.selectNode( - id: network.id, - selectedOrdinal: selectedOrdinal, - socketURL: socketURL - ) - } - - func updateActiveProxySubscriptionSelection( - network: ProxySubscriptionNetwork, - selectedOrdinal: Int32 - ) async throws -> Int32 { - let socketURL = try Constants.packetTunnelSocketURL - return try await ProxySubscriptionImportClient.selectNode( - id: network.id, - selectedOrdinal: selectedOrdinal, - socketURL: socketURL - ) - } - - func refreshProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 { - let socketURL = try daemonSocketURL() - return try await ProxySubscriptionImportClient.apply( - id: network.id, - url: network.sourceURL, - name: network.name, - selectedOrdinal: network.selectedNode?.ordinal, - socketURL: socketURL - ) - } - - func refreshActiveProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 { - let socketURL = try Constants.packetTunnelSocketURL - return try await ProxySubscriptionImportClient.apply( - id: network.id, - url: network.sourceURL, - name: network.name, - selectedOrdinal: network.selectedNode?.ordinal, - socketURL: socketURL - ) - } - - func deleteNetwork(id: Int32) async throws { - let socketURL = try daemonSocketURL() - var request = Burrow_NetworkDeleteRequest() - request.id = id - _ = try await NetworksClient.unix(socketURL: socketURL).networkDelete(request) - } - func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse { - let socketURL = try daemonSocketURL() + let socketURL = try socketURLResult.get() return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL) } func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus { - let socketURL = try daemonSocketURL() + let socketURL = try socketURLResult.get() return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) } @@ -789,7 +189,7 @@ final class NetworkViewModel: Sendable { hostname: String?, authority: String ) async throws -> TailnetLoginStatus { - let socketURL = try daemonSocketURL() + let socketURL = try socketURLResult.get() return try await TailnetLoginClient.start( accountName: accountName, identityName: identityName, @@ -800,17 +200,17 @@ final class NetworkViewModel: Sendable { } func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus { - let socketURL = try daemonSocketURL() + let socketURL = try socketURLResult.get() return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL) } func cancelTailnetLogin(sessionID: String) async throws { - let socketURL = try daemonSocketURL() + 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 daemonSocketURL() + let socketURL = try socketURLResult.get() let networkID = nextNetworkID let request = Burrow_Network.with { $0.id = networkID @@ -823,25 +223,12 @@ final class NetworkViewModel: Sendable { return networkID } - private func daemonSocketURL() throws -> URL { - try Self.daemonSocketURL(from: socketURLResult) - } - - private static func daemonSocketURL(from result: Result) throws -> URL { - let socketURL = try result.get() - let socketPath = socketURL.path(percentEncoded: false) - guard FileManager.default.fileExists(atPath: socketPath) else { - throw DaemonUnavailableError(socketPath: socketPath) - } - return socketURL - } - private func startStreaming() { task?.cancel() let socketURLResult = self.socketURLResult task = Task { [weak self] in do { - let socketURL = try Self.daemonSocketURL(from: socketURLResult) + let socketURL = try socketURLResult.get() let client = NetworksClient.unix(socketURL: socketURL) for try await response in client.networkList(.init()) { guard !Task.isCancelled else { return } @@ -867,8 +254,6 @@ final class NetworkViewModel: Sendable { WireGuardCard(network: network).card case .tailnet: TailnetCard(network: network).card - case .proxySubscription: - proxySubscriptionCard(network: network) case .UNRECOGNIZED(let rawValue): unsupportedCard( id: network.id, @@ -884,76 +269,6 @@ final class NetworkViewModel: Sendable { } } - private static func proxySubscriptionCard(network: Burrow_Network) -> NetworkCardModel { - guard let subscription = ProxySubscriptionNetwork(network: network) else { - return unsupportedCard( - id: network.id, - title: "Proxy Subscription", - detail: "Imported proxy subscription." - ) - } - return NetworkCardModel( - id: subscription.id, - backgroundColor: .teal, - label: AnyView( - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text("Proxy Subscription") - .font(.headline) - .foregroundStyle(.white.opacity(0.85)) - Text(subscription.name) - .font(.title3.weight(.semibold)) - .foregroundStyle(.white) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer() - } - Spacer() - if let selectedNode = subscription.selectedNode { - VStack(alignment: .leading, spacing: 6) { - Text("Selected node") - .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.72)) - - HStack(spacing: 8) { - Text(selectedNode.name) - .font(.headline.weight(.semibold)) - .foregroundStyle(.white) - .lineLimit(1) - .truncationMode(.tail) - - Text(selectedNode.proxyProtocol.uppercased()) - .font(.caption2.weight(.bold)) - .foregroundStyle(.white.opacity(0.78)) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background( - Capsule() - .fill(.white.opacity(0.18)) - ) - } - - Text("\(selectedNode.server):\(selectedNode.port)") - .font(.caption.monospaced()) - .foregroundStyle(.white.opacity(0.78)) - .lineLimit(1) - .truncationMode(.middle) - } - } - Text("\(subscription.runtimeSupportedNodes.count) usable of \(subscription.nodes.count) nodes · \(subscription.detectedFormat)") - .font(.footnote) - .foregroundStyle(.white.opacity(0.78)) - .lineLimit(1) - .truncationMode(.tail) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - ) - ) - } - private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel { NetworkCardModel( id: id, @@ -963,13 +278,9 @@ final class NetworkViewModel: Sendable { Text(title) .font(.title3.weight(.semibold)) .foregroundStyle(.white) - .lineLimit(2) - .truncationMode(.tail) Text(detail) .font(.body) .foregroundStyle(.white.opacity(0.9)) - .lineLimit(4) - .truncationMode(.tail) Spacer() Text("Network #\(id)") .font(.footnote.monospaced()) @@ -1047,7 +358,6 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { case wireGuard - case proxySubscription case tor case tailnet @@ -1056,7 +366,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { case .wireGuard: "WireGuard" - case .proxySubscription: "Proxy Subscription" case .tor: "Tor" case .tailnet: "Tailnet" } @@ -1065,7 +374,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var subtitle: String { switch self { case .wireGuard: "Import a tunnel and optional account metadata." - case .proxySubscription: "Import Trojan and Shadowsocks subscription nodes." case .tor: "Store Arti account and identity preferences." case .tailnet: "Save Tailnet authority, identity defaults, and login material." } @@ -1074,7 +382,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var accentColor: Color { switch self { case .wireGuard: .init("WireGuard") - case .proxySubscription: .teal case .tor: .orange case .tailnet: .mint } @@ -1083,7 +390,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var actionTitle: String { switch self { case .wireGuard: "Add Network" - case .proxySubscription: "Import" case .tor: "Save Account" case .tailnet: "Save Account" } @@ -1091,7 +397,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var availabilityNote: String? { switch self { - case .wireGuard, .proxySubscription: + 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." diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 0000000..d67b251 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,69 @@ +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 7a24f95..020c072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # 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" @@ -14,30 +23,10 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.6", + "crypto-common", "generic-array", ] -[[package]] -name = "aead" -version = "0.6.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" -dependencies = [ - "crypto-common 0.2.2", - "inout 0.2.2", -] - -[[package]] -name = "aegis" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85687c501b265f8538af0b3245221b56ff5c40acd341ba06f050466516fcf2cd" -dependencies = [ - "cc", - "softaes", -] - [[package]] name = "aes" version = "0.8.4" @@ -45,75 +34,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "cipher", + "cpufeatures", "zeroize", ] -[[package]] -name = "aes" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" -dependencies = [ - "cipher 0.5.2", - "cpubits", - "cpufeatures 0.3.0", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead 0.5.2", - "aes 0.8.4", - "cipher 0.4.4", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aes-gcm-siv" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" -dependencies = [ - "aead 0.5.2", - "aes 0.8.4", - "cipher 0.4.4", - "ctr", - "polyval", - "subtle", - "zeroize", -] - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.16", - "once_cell", - "version_check", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.3", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -257,16 +182,10 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures", "password-hash 0.5.0", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.6" @@ -491,16 +410,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "asynk-strim" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "atomic" version = "0.5.3" @@ -528,28 +437,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.6.20" @@ -565,7 +452,7 @@ dependencies = [ "http-body 0.4.6", "hyper 0.14.32", "itoa", - "matchit 0.7.3", + "matchit", "memchr", "mime", "percent-encoding", @@ -594,7 +481,7 @@ dependencies = [ "hyper 1.7.0", "hyper-util", "itoa", - "matchit 0.7.3", + "matchit", "memchr", "mime", "percent-encoding", @@ -650,6 +537,21 @@ dependencies = [ "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" @@ -701,7 +603,7 @@ dependencies = [ "quote", "regex", "rustc-hash 1.1.0", - "shlex 1.3.0", + "shlex", "syn 1.0.109", "which", ] @@ -724,29 +626,11 @@ dependencies = [ "quote", "regex", "rustc-hash 1.1.0", - "shlex 1.3.0", + "shlex", "syn 2.0.106", "which", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.11.1", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex 1.3.0", - "syn 2.0.106", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -755,9 +639,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "bitvec" @@ -780,20 +664,6 @@ dependencies = [ "digest", ] -[[package]] -name = "blake3" -version = "1.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq 0.4.2", - "cpufeatures 0.3.0", -] - [[package]] name = "blanket" version = "0.3.0" @@ -814,35 +684,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "blowfish" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" -dependencies = [ - "byteorder", - "cipher 0.5.2", -] - -[[package]] -name = "borsh" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "bytes", - "cfg_aliases", -] - [[package]] name = "bstr" version = "1.12.1" @@ -864,10 +705,7 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" name = "burrow" version = "0.1.0" dependencies = [ - "aead 0.5.2", - "aegis", - "aes 0.8.4", - "aes-gcm", + "aead", "anyhow", "argon2", "arti-client", @@ -876,70 +714,45 @@ dependencies = [ "axum 0.7.9", "base64 0.21.7", "blake2", - "blake3", "bytes", "caps", - "ccm", - "chacha20", "chacha20poly1305", "clap", "console", "console-subscriber", - "deoxys", "dotenv", "fehler", "futures", - "h2 0.4.12", "hickory-proto", "hmac", - "http 1.3.1", "hyper-util", "insta", "ip_network", "ip_network_table", "ipnetwork", - "lea", "libc", "libsystemd", "log", - "md-5", - "native-tls", "netstack-smoltcp", "nix 0.27.1", "once_cell", - "parking_lot 0.12.4", - "poly1305", + "parking_lot", "prost 0.13.5", "prost-types 0.13.5", - "rabbit", - "rama-net", - "rama-tls-boring", - "rama-ua", "rand 0.8.5", "rand_core 0.6.4", "reqwest 0.12.23", "ring", "rusqlite", "rust-ini", - "rust_tokio_kcp", - "rustls", - "rustls-pemfile 2.2.0", "schemars 0.8.22", "serde", "serde_json", - "serde_yaml", - "sha2", - "shadowsocks", - "smux_rust", "subtle", "tempfile", "tokio", - "tokio-native-tls", - "tokio-rustls", - "tokio-snappy", "tokio-stream", "tokio-util", - "tokio_smux", "toml 0.8.23", "tonic 0.12.3", "tonic-build", @@ -951,25 +764,31 @@ dependencies = [ "tracing-oslog", "tracing-subscriber", "tun", - "webpki-roots 0.22.6", - "webpki-roots 0.26.11", "x25519-dalek", - "yamux", - "zears", ] +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" -[[package]] -name = "byte_string" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed" - [[package]] name = "bytemuck" version = "1.25.0" @@ -1008,16 +827,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "camellia" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" -dependencies = [ - "byteorder", - "cipher 0.4.4", -] - [[package]] name = "caps" version = "0.5.5" @@ -1040,38 +849,23 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" -[[package]] -name = "cast5" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ed80521f830bbce4d3a4857d6f2b91a8339bf0b65711fe189ab6f8d7a2f629e" -dependencies = [ - "cipher 0.5.2", -] - [[package]] name = "cc" -version = "1.2.63" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] -name = "ccm" -version = "0.5.0" +name = "cesu8" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" -dependencies = [ - "aead 0.5.2", - "cipher 0.4.4", - "ctr", - "subtle", -] +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cexpr" @@ -1101,8 +895,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "cipher", + "cpufeatures", ] [[package]] @@ -1111,9 +905,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead 0.5.2", + "aead", "chacha20", - "cipher 0.4.4", + "cipher", "poly1305", "zeroize", ] @@ -1157,37 +951,17 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" -dependencies = [ - "generic-array", -] - [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.6", - "inout 0.1.4", + "crypto-common", + "inout", "zeroize", ] -[[package]] -name = "cipher" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" -dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.2", - "inout 0.2.2", -] - [[package]] name = "clang-sys" version = "1.8.1" @@ -1239,15 +1013,6 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "coarsetime" version = "0.1.37" @@ -1265,6 +1030,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compression-codecs" version = "0.4.32" @@ -1369,39 +1144,12 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "const_format" -version = "0.2.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" -dependencies = [ - "const_format_proc_macros", - "konst", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "constant_time_eq" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - [[package]] name = "convert_case" version = "0.7.1" @@ -1436,12 +1184,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1451,15 +1193,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -1592,43 +1325,13 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -1638,7 +1341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1761,19 +1464,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core 0.9.11", -] - [[package]] name = "data-encoding" version = "2.9.0" @@ -1821,17 +1511,6 @@ dependencies = [ "thiserror 2.0.16", ] -[[package]] -name = "deoxys" -version = "0.2.0-rc.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cb5efc49a3af2efb079e9c9d1cfad0201ea65c2a6f1a4eb1ede501593d2ddb8" -dependencies = [ - "aead 0.6.0-rc.10", - "aes 0.9.1", - "subtle", -] - [[package]] name = "der" version = "0.7.10" @@ -1948,24 +1627,15 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "des" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" -dependencies = [ - "cipher 0.5.2", -] - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", + "block-buffer", "const-oid", - "crypto-common 0.1.6", + "crypto-common", "subtle", ] @@ -2031,38 +1701,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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "dynosaur" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12303417f378f29ba12cb12fc78a9df0d8e16ccb1ad94abf04d48d96bdda532" -dependencies = [ - "dynosaur_derive", -] - -[[package]] -name = "dynosaur_derive" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0713d5c1d52e774c5cd7bb8b043d7c0fc4f921abfb678556140bfbe6ab2364" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "ecdsa" version = "0.16.9" @@ -2155,12 +1799,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endian-type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" - [[package]] name = "enum-as-inner" version = "0.6.1" @@ -2282,9 +1920,6 @@ name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -dependencies = [ - "getrandom 0.2.16", -] [[package]] name = "fehler" @@ -2348,9 +1983,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" [[package]] name = "fixedbitset" @@ -2374,18 +2009,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" -[[package]] -name = "flume" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" -dependencies = [ - "fastrand", - "futures-core", - "futures-sink", - "spin 0.9.8", -] - [[package]] name = "fnv" version = "1.0.7" @@ -2410,28 +2033,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "foreign-types-shared", ] [[package]] @@ -2440,12 +2042,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2470,12 +2066,6 @@ 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" @@ -2581,21 +2171,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generator" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -2660,14 +2235,10 @@ dependencies = [ ] [[package]] -name = "ghash" -version = "0.5.1" +name = "gimli" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" @@ -2755,9 +2326,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] [[package]] name = "hashbrown" @@ -2941,12 +2509,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" @@ -2975,15 +2537,6 @@ dependencies = [ "serde", ] -[[package]] -name = "hybrid-array" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "0.14.32" @@ -3045,7 +2598,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.3", + "webpki-roots", ] [[package]] @@ -3282,7 +2835,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "inotify-sys", "libc", ] @@ -3305,15 +2858,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "hybrid-array", -] - [[package]] name = "insta" version = "1.43.2" @@ -3326,15 +2870,6 @@ dependencies = [ "similar", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "inventory" version = "0.3.24" @@ -3344,6 +2879,17 @@ 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" @@ -3430,6 +2976,50 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +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", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -3452,41 +3042,15 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kcp" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e387a924f42063d380be14857714a2f0c177e44dbeab8895244e5370d8a8e68" -dependencies = [ - "bytes", - "log", - "thiserror 1.0.69", -] - [[package]] name = "keccak" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", ] -[[package]] -name = "konst" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" -dependencies = [ - "konst_macro_rules", -] - -[[package]] -name = "konst_macro_rules" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" - [[package]] name = "kqueue" version = "1.1.1" @@ -3513,7 +3077,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -3522,16 +3086,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "lea" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f03a660d3662be37724af25ce5152a4df89c4ffc3649823e6d1b415c19608a" -dependencies = [ - "cfg-if", - "cipher 0.3.0", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -3596,7 +3150,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "libc", "plain", "redox_syscall 0.7.3", @@ -3665,43 +3219,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "pin-utils", - "scoped-tls", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" -dependencies = [ - "hashbrown 0.12.3", -] - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lru_time_cache" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd" - [[package]] name = "managed" version = "0.8.0" @@ -3723,28 +3246,6 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" -[[package]] -name = "matchit" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - -[[package]] -name = "md5" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" - [[package]] name = "memchr" version = "2.7.5" @@ -3819,16 +3320,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3856,23 +3347,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot 0.12.4", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - [[package]] name = "multimap" version = "0.10.1" @@ -3906,21 +3380,12 @@ dependencies = [ "futures", "rand 0.8.5", "smoltcp", - "spin 0.9.8", + "spin", "tokio", "tokio-util", "tracing", ] -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - [[package]] name = "nix" version = "0.26.4" @@ -3940,7 +3405,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "cfg-if", "libc", "memoffset 0.9.1", @@ -3952,19 +3417,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "cfg-if", "cfg_aliases", "libc", "memoffset 0.9.1", ] -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - [[package]] name = "nom" version = "7.1.3" @@ -3996,7 +3455,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "inotify", "kqueue", "libc", @@ -4013,7 +3472,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", ] [[package]] @@ -4124,7 +3583,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", ] [[package]] @@ -4137,6 +3596,15 @@ 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" @@ -4180,9 +3648,9 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "cfg-if", - "foreign-types 0.3.2", + "foreign-types", "libc", "once_cell", "openssl-macros", @@ -4306,17 +3774,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - [[package]] name = "parking_lot" version = "0.12.4" @@ -4324,21 +3781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", - "parking_lot_core 0.9.11", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", + "parking_lot_core", ] [[package]] @@ -4394,16 +3837,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - [[package]] name = "peeking_take_while" version = "0.1.2" @@ -4577,19 +4010,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "opaque-debug", "universal-hash", ] @@ -4609,7 +4030,7 @@ dependencies = [ "atomic 0.5.3", "crossbeam-queue", "futures", - "parking_lot 0.12.4", + "parking_lot", "pin-project", "static_assertions", "thiserror 1.0.69", @@ -4793,21 +4214,6 @@ dependencies = [ "prost 0.13.5", ] -[[package]] -name = "psl" -version = "2.1.212" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f6415ab1b08394487005d41ae7ee69e69903efa1ace538f3b274db605bf8ec" -dependencies = [ - "psl-types", -] - -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - [[package]] name = "pwd-grp" version = "1.0.2" @@ -4896,280 +4302,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rabbit" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7204c97584ac097d9749f4a52f2f96910d988f12303fe3e3b021628f88923f1" -dependencies = [ - "cipher 0.5.2", -] - [[package]] name = "radium" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "radix_trie" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" -dependencies = [ - "endian-type", - "nibble_vec", -] - -[[package]] -name = "rama-boring" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a8c66935959385543d370f6f1485461d520028d4ac9ae852131ec1c9226869b" -dependencies = [ - "bitflags 2.11.1", - "foreign-types 0.5.0", - "libc", - "openssl-macros", - "rama-boring-sys", -] - -[[package]] -name = "rama-boring-sys" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50c9107d147c768182b5d6494670c0a54eadc99ee3eb24784a5b5504480eed8" -dependencies = [ - "bindgen 0.72.1", - "cmake", - "fs_extra", - "fslock", -] - -[[package]] -name = "rama-boring-tokio" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7bccdd6efce0302b0fdd0e8b8ff8fba6202841bdfeeac357be115b9f42dd6b" -dependencies = [ - "rama-boring", - "rama-boring-sys", - "tokio", -] - -[[package]] -name = "rama-core" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" -dependencies = [ - "ahash 0.8.12", - "asynk-strim", - "bytes", - "futures", - "parking_lot 0.12.4", - "pin-project-lite", - "rama-error", - "rama-macros", - "rama-utils", - "serde", - "serde_json", - "tokio", - "tokio-graceful", - "tokio-util", - "tracing", -] - -[[package]] -name = "rama-error" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" - -[[package]] -name = "rama-http" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" -dependencies = [ - "ahash 0.8.12", - "base64 0.22.1", - "bitflags 2.11.1", - "chrono", - "const_format", - "csv", - "http 1.3.1", - "http-range-header", - "httpdate", - "iri-string", - "matchit 0.9.2", - "parking_lot 0.12.4", - "percent-encoding", - "pin-project-lite", - "radix_trie", - "rama-core", - "rama-error", - "rama-http-headers", - "rama-http-types", - "rama-net", - "rama-utils", - "rand 0.9.2", - "serde", - "serde_html_form", - "serde_json", - "tokio", - "uuid", -] - -[[package]] -name = "rama-http-headers" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" -dependencies = [ - "ahash 0.8.12", - "base64 0.22.1", - "chrono", - "const_format", - "httpdate", - "rama-core", - "rama-error", - "rama-http-types", - "rama-macros", - "rama-net", - "rama-utils", - "rand 0.9.2", - "serde", - "sha1", -] - -[[package]] -name = "rama-http-types" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" -dependencies = [ - "ahash 0.8.12", - "bytes", - "const_format", - "fnv", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "itoa", - "memchr", - "mime", - "mime_guess", - "nom 8.0.0", - "pin-project-lite", - "rama-core", - "rama-error", - "rama-macros", - "rama-utils", - "rand 0.9.2", - "serde", - "serde_json", - "sync_wrapper 1.0.2", - "tokio", -] - -[[package]] -name = "rama-macros" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "rama-net" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" -dependencies = [ - "ahash 0.8.12", - "const_format", - "flume", - "hex", - "ipnet", - "itertools 0.14.0", - "md5", - "nom 8.0.0", - "parking_lot 0.12.4", - "pin-project-lite", - "psl", - "radix_trie", - "rama-core", - "rama-http-types", - "rama-macros", - "rama-utils", - "serde", - "sha2", - "socket2 0.6.3", - "tokio", -] - -[[package]] -name = "rama-tls-boring" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def3d5d06d3ca3a2d2e4376cf93de0555cd9c7960f085bf77be9562f5c9ace8f" -dependencies = [ - "ahash 0.8.12", - "flume", - "itertools 0.14.0", - "moka", - "parking_lot 0.12.4", - "pin-project-lite", - "rama-boring", - "rama-boring-tokio", - "rama-core", - "rama-net", - "rama-utils", - "schannel", - "tokio", -] - -[[package]] -name = "rama-ua" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7abde8e7b428c80c5948885c1ee0492852a21d91844c8414a0de4a8a1d62262" -dependencies = [ - "ahash 0.8.12", - "itertools 0.14.0", - "rama-core", - "rama-http", - "rama-net", - "rama-utils", - "rand 0.9.2", - "serde", - "serde_html_form", - "serde_json", -] - -[[package]] -name = "rama-utils" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" -dependencies = [ - "const_format", - "parking_lot 0.12.4", - "pin-project-lite", - "rama-macros", - "regex", - "serde", - "smallvec", - "smol_str", - "tokio", - "wildcard", -] - [[package]] name = "rand" version = "0.8.5" @@ -5269,22 +4407,13 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", ] [[package]] @@ -5293,7 +4422,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", ] [[package]] @@ -5307,19 +4436,6 @@ dependencies = [ "thiserror 2.0.16", ] -[[package]] -name = "reed-solomon-erasure" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" -dependencies = [ - "libm", - "lru", - "parking_lot 0.11.2", - "smallvec", - "spin 0.9.8", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -5342,9 +4458,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -5354,9 +4470,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -5393,7 +4509,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", @@ -5444,7 +4560,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.3", + "webpki-roots", ] [[package]] @@ -5480,19 +4596,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "ring-compat" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccce7bae150b815f0811db41b8312fcb74bffa4cab9cee5429ee00f356dd5bd4" -dependencies = [ - "aead 0.5.2", - "ed25519", - "generic-array", - "pkcs8", - "ring", -] - [[package]] name = "rsa" version = "0.9.10" @@ -5530,7 +4633,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -5551,35 +4654,10 @@ dependencies = [ ] [[package]] -name = "rust_tokio_kcp" -version = "0.2.5" +name = "rustc-demangle" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fa76f74fbbd4a3c15e3d688e8125e64583c4cd73f75f96ab2ba5b683708b1e" -dependencies = [ - "aes 0.9.1", - "aes-gcm", - "blowfish", - "byte_string", - "byteorder", - "bytes", - "cast5", - "cipher 0.5.2", - "crc32fast", - "des", - "futures-util", - "hybrid-array", - "kcp", - "log", - "pbkdf2 0.12.2", - "rand 0.8.5", - "reed-solomon-erasure", - "salsa20", - "sha1", - "sm4 0.6.0", - "spin 0.9.8", - "tokio", - "twofish", -] +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -5617,7 +4695,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5630,7 +4708,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.11.0", @@ -5643,8 +4721,6 @@ version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ - "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -5662,15 +4738,6 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -5687,7 +4754,6 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5718,16 +4784,6 @@ dependencies = [ "thiserror 2.0.16", ] -[[package]] -name = "salsa20" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" -dependencies = [ - "cfg-if", - "cipher 0.5.2", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5809,29 +4865,12 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sealed" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "sec1" version = "0.7.3" @@ -5852,7 +4891,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "core-foundation", "core-foundation-sys", "libc", @@ -5875,16 +4914,6 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -[[package]] -name = "sendfd" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b183bfd5b1bc64ab0c1ef3ee06b008a9ef1b68a7d3a99ba566fbfe7a7c6d745b" -dependencies = [ - "libc", - "tokio", -] - [[package]] name = "serde" version = "1.0.228" @@ -5936,19 +4965,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "serde_html_form" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" -dependencies = [ - "form_urlencoded", - "indexmap 2.11.4", - "itoa", - "ryu", - "serde_core", -] - [[package]] name = "serde_ignored" version = "0.1.14" @@ -6044,19 +5060,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.11.4", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha-1" version = "0.10.1" @@ -6064,7 +5067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -6075,7 +5078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -6086,7 +5089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -6100,70 +5103,6 @@ dependencies = [ "keccak", ] -[[package]] -name = "shadowsocks" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "482831bf9d55acf3c98e211b6c852c3dfdf1d1b0d23fdf1d887c5a4b2acad4e4" -dependencies = [ - "aes 0.8.4", - "base64 0.22.1", - "blake3", - "byte_string", - "bytes", - "cfg-if", - "dynosaur", - "futures", - "libc", - "log", - "lru_time_cache", - "percent-encoding", - "pin-project", - "rand 0.9.2", - "sealed", - "sendfd", - "serde", - "serde_json", - "serde_urlencoded", - "shadowsocks-crypto", - "socket2 0.6.3", - "spin 0.10.0", - "thiserror 2.0.16", - "tokio", - "tokio-tfo", - "trait-variant", - "url", - "windows-sys 0.61.0", -] - -[[package]] -name = "shadowsocks-crypto" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d038a3d17586f1c1ab3c1c3b9e4d5ef8fba98fb3890ad740c8487038b2e2ca5" -dependencies = [ - "aead 0.5.2", - "aes 0.8.4", - "aes-gcm", - "aes-gcm-siv", - "blake3", - "bytes", - "camellia", - "ccm", - "cfg-if", - "chacha20", - "chacha20poly1305", - "ctr", - "ghash", - "hkdf", - "md-5", - "rand 0.9.2", - "ring-compat", - "sha1", - "sm4 0.5.1", - "subtle", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -6190,12 +5129,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -6256,42 +5189,11 @@ dependencies = [ "void", ] -[[package]] -name = "sm4" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d7abf5135ffd68fb4b438e1fb246923b80d25eda386d8b798bb4ad3ed00f75f" -dependencies = [ - "cipher 0.4.4", -] - -[[package]] -name = "sm4" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2d621883cdeeaee6759d39f4bc8cd314b29db72b6ac3d6714b31422512ee11" -dependencies = [ - "cipher 0.5.2", -] - [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "smol_str" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" -dependencies = [ - "borsh", - "serde_core", -] [[package]] name = "smoltcp" @@ -6308,23 +5210,6 @@ dependencies = [ "managed", ] -[[package]] -name = "smux_rust" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6da4231ce17872ec83fffdc189d64a121e1df79e2c0e08ee6c2d9f12f13f9994" -dependencies = [ - "bytes", - "futures", - "tokio", -] - -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - [[package]] name = "socket2" version = "0.5.10" @@ -6345,12 +5230,6 @@ dependencies = [ "windows-sys 0.61.0", ] -[[package]] -name = "softaes" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" - [[package]] name = "spin" version = "0.9.8" @@ -6360,15 +5239,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" -dependencies = [ - "lock_api", -] - [[package]] name = "spki" version = "0.7.3" @@ -6397,7 +5267,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ - "cipher 0.4.4", + "cipher", "ssh-encoding", ] @@ -6583,12 +5453,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "tap" version = "1.0.1" @@ -6735,33 +5599,22 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ + "backtrace", "bytes", + "io-uring", "libc", "mio", - "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", + "slab", "socket2 0.6.3", "tokio-macros", "tracing", - "windows-sys 0.61.0", -] - -[[package]] -name = "tokio-graceful" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" -dependencies = [ - "loom", - "pin-project-lite", - "slab", - "tokio", - "tracing", + "windows-sys 0.59.0", ] [[package]] @@ -6776,9 +5629,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", @@ -6805,19 +5658,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-snappy" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb0ab9ee987e7134c0cdfe038b737102433aa8d7e04ad99cf1ec258907af976" -dependencies = [ - "bytes", - "futures", - "pin-project", - "snap", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.17" @@ -6829,23 +5669,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tfo" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ad2c3b3bb958ad992354a7ebc468fc0f7cdc9af4997bf4d3fd3cb28bad36dc" -dependencies = [ - "cfg-if", - "futures", - "libc", - "log", - "once_cell", - "pin-project", - "socket2 0.6.3", - "tokio", - "windows-sys 0.60.2", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -6860,19 +5683,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio_smux" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5826746b94900340cfda9e18a29a75986992df653d5941b5e359ca3b667117" -dependencies = [ - "byteorder", - "dashmap", - "log", - "thiserror 1.0.69", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -7097,7 +5907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ab0c79bc92a957e85959cf397a2d8f9c8294c35fa4f65247a9393b20ac95551" dependencies = [ "amplify", - "bitflags 2.11.1", + "bitflags 2.9.4", "bytes", "caret", "derive-deftly", @@ -7596,7 +6406,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c6989a1c6d06ffd6835e2917edaae4aeef544f8e5fdd68b54cc365f2af523de" dependencies = [ - "aes 0.8.4", + "aes", "base64ct", "ctr", "curve25519-dalek", @@ -7695,7 +6505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41be8f47f521fc95206d2ba5facac8fb1a5b5b82169bd41ebeecdf46d1e77246" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.9.4", "derive_more", "futures", "humantime", @@ -7724,7 +6534,7 @@ checksum = "ea8bce73d2c78bd78a2a927336ca639cf6bd5d8ad092ebcd0b3fdeaa47dcc77e" dependencies = [ "amplify", "base64ct", - "cipher 0.4.4", + "cipher", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -7799,7 +6609,7 @@ dependencies = [ "bytes", "caret", "cfg-if", - "cipher 0.4.4", + "cipher", "coarsetime", "criterion-cycles-per-byte", "derive-deftly", @@ -8031,7 +6841,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "bytes", "futures-util", "http 1.3.1", @@ -8131,7 +6941,7 @@ dependencies = [ "cfg-if", "fnv", "once_cell", - "parking_lot 0.12.4", + "parking_lot", "tracing-core", "tracing-subscriber", ] @@ -8175,17 +6985,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "trait-variant" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -8219,15 +7018,6 @@ dependencies = [ "zip", ] -[[package]] -name = "twofish" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0218ef702a91e18ba84dd516edf8df33a9f9fb4431d6ada13e6f9502c3bcb6fc" -dependencies = [ - "cipher 0.5.2", -] - [[package]] name = "typed-index-collections" version = "3.5.0" @@ -8240,9 +7030,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "uncased" @@ -8253,12 +7043,6 @@ 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" @@ -8295,16 +7079,10 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.6", + "crypto-common", "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -8347,7 +7125,6 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", "js-sys", "serde", "wasm-bindgen", @@ -8532,7 +7309,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.9.4", "hashbrown 0.15.5", "indexmap 2.11.4", "semver", @@ -8564,34 +7341,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "webpki-roots" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" -dependencies = [ - "webpki", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.3", -] - [[package]] name = "webpki-roots" version = "1.0.3" @@ -8619,15 +7368,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" -[[package]] -name = "wildcard" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" -dependencies = [ - "thiserror 2.0.16", -] - [[package]] name = "winapi" version = "0.3.9" @@ -8807,6 +7547,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -8852,6 +7601,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -8909,6 +7673,12 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -8927,6 +7697,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -8945,6 +7721,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -8975,6 +7757,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -8993,6 +7781,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -9011,6 +7805,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -9029,6 +7829,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -9136,7 +7942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.9.4", "indexmap 2.11.4", "log", "serde", @@ -9199,22 +8005,6 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" -[[package]] -name = "yamux" -version = "0.13.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" -dependencies = [ - "futures", - "log", - "nohash-hasher", - "parking_lot 0.12.4", - "pin-project", - "rand 0.9.2", - "static_assertions", - "web-time", -] - [[package]] name = "yoke" version = "0.8.0" @@ -9239,18 +8029,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zears" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e54700681e12629b566fc71a9ee538961b451ebf65b7509c2e12f114dd8e5aa" -dependencies = [ - "aes 0.8.4", - "blake2", - "constant_time_eq 0.4.2", - "cpufeatures 0.2.17", -] - [[package]] name = "zerocopy" version = "0.8.27" @@ -9351,15 +8129,15 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ - "aes 0.8.4", + "aes", "byteorder", "bzip2", - "constant_time_eq 0.1.5", + "constant_time_eq", "crc32fast", "crossbeam-utils", "flate2", "hmac", - "pbkdf2 0.11.0", + "pbkdf2", "sha1", "time", "zstd 0.11.2+zstd.1.5.2", diff --git a/Cargo.toml b/Cargo.toml index 362ba2b..13b3e08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,11 @@ [workspace] -members = ["burrow", "tun"] +members = [ + "burrow", + "tun", + "crates/burrow-core", + "crates/burrow-mobile-ffi", + "crates/burrow-windows-stub", +] resolver = "2" exclude = ["burrow-gtk"] diff --git a/Dockerfile b/Dockerfile index 3497e22..2c4415e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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://github.com/hackclub/burrow" \ - org.opencontainers.image.source="https://github.com/hackclub/burrow" \ + org.opencontainers.image.url="https://git.burrow.net/hackclub/burrow" \ + org.opencontainers.image.source="https://git.burrow.net/hackclub/burrow" \ org.opencontainers.image.vendor="hackclub" \ org.opencontainers.image.licenses="GPL-3.0" diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 0000000..03e942e --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..28f5c18 --- /dev/null +++ b/MODULE.bazel.lock @@ -0,0 +1,448 @@ +{ + "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/README.md b/README.md index ba4f50c..ebb6709 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Burrow -![License](https://img.shields.io/github/license/hackclub/burrow) ![Apple Build Status](https://img.shields.io/github/actions/workflow/status/hackclub/burrow/build-apple.yml?branch=main&label=macos%2C%20ios&logo=Apple) ![Crate Build Status](https://img.shields.io/github/actions/workflow/status/hackclub/burrow/build-rust.yml?branch=main&label=crate&logo=Rust) - Burrow is an open source tool for burrowing through firewalls, built by teenagers at [Hack Club](https://hackclub.com/). `burrow` provides a simple command-line tool to open virtual interfaces and direct traffic through them. Routine verification now runs unprivileged with `cargo test --workspace --all-features`; only tunnel startup needs elevation. +The canonical source forge is [git.burrow.net/hackclub/burrow](https://git.burrow.net/hackclub/burrow). The repository now carries its own design and deployment record: @@ -18,7 +17,7 @@ The repository now carries its own design and deployment record: ## Contributing -Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). +Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). Platform service and distribution rollout notes live in [`docs/PLATFORM_SERVICES.md`](./docs/PLATFORM_SERVICES.md) and [`docs/DISTRIBUTION.md`](./docs/DISTRIBUTION.md). Agent and governance-sensitive work should start with [AGENTS.md](./AGENTS.md), [CONSTITUTION.md](./CONSTITUTION.md), and the relevant BEPs under [`evolution/proposals/`](./evolution/proposals/). Identity and bootstrap metadata now live in [`contributors.nix`](./contributors.nix). @@ -49,6 +48,4 @@ Burrow is open source and licensed under the [GNU General Public License v3.0](. ## Contributors - - - +See the canonical forge history for current contributors. diff --git a/Scripts/_burrow-flake.sh b/Scripts/_burrow-flake.sh index ba4e372..25fb6e1 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="${HOME}/.cache/burrow" + local cache_root="${BURROW_FLAKE_CACHE_ROOT:-${HOME}/.cache/burrow}" mkdir -p "${cache_root}" local copy_root @@ -37,6 +37,8 @@ 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/apple/create-asc-certificate.mjs b/Scripts/apple/create-asc-certificate.mjs new file mode 100755 index 0000000..9c530d9 --- /dev/null +++ b/Scripts/apple/create-asc-certificate.mjs @@ -0,0 +1,164 @@ +#!/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 new file mode 100755 index 0000000..15a7d89 --- /dev/null +++ b/Scripts/apple/google-kms-csr.py @@ -0,0 +1,249 @@ +#!/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 new file mode 100755 index 0000000..26bd10d --- /dev/null +++ b/Scripts/apple/profile-entitlements.py @@ -0,0 +1,120 @@ +#!/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 new file mode 100755 index 0000000..526c6d6 --- /dev/null +++ b/Scripts/apple/sign-release-artifacts-kms.sh @@ -0,0 +1,263 @@ +#!/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 new file mode 100755 index 0000000..33d5c7e --- /dev/null +++ b/Scripts/apple/sign-with-google-kms.sh @@ -0,0 +1,263 @@ +#!/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 new file mode 100755 index 0000000..1a592d6 --- /dev/null +++ b/Scripts/apple/sync-provisioning-profiles.mjs @@ -0,0 +1,331 @@ +#!/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-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh index 277c5f4..68dbcff 100644 --- a/Scripts/authentik-sync-burrow-directory.sh +++ b/Scripts/authentik-sync-burrow-directory.sh @@ -6,6 +6,7 @@ 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() { @@ -20,6 +21,7 @@ Optional environment: AUTHENTIK_URL AUTHENTIK_BURROW_USERS_GROUP AUTHENTIK_BURROW_ADMINS_GROUP + AUTHENTIK_BURROW_EXTRA_GROUPS_JSON AUTHENTIK_FORGEJO_APPLICATION_SLUG EOF } @@ -39,6 +41,11 @@ if ! printf '%s' "$directory_json" | jq -e 'type == "array"' >/dev/null; then 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" @@ -251,6 +258,9 @@ ensure_application_group_binding() { 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" diff --git a/Scripts/authentik-sync-forgejo-oidc.sh b/Scripts/authentik-sync-forgejo-oidc.sh index 7b292dc..b490698 100644 --- a/Scripts/authentik-sync-forgejo-oidc.sh +++ b/Scripts/authentik-sync-forgejo-oidc.sh @@ -11,8 +11,7 @@ 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\", - \"https://git.burrow.net/user/oauth2/GitHub/callback\" + \"https://git.burrow.net/user/oauth2/authentik/callback\" ]}" usage() { diff --git a/Scripts/authentik-sync-google-wif-oidc.sh b/Scripts/authentik-sync-google-wif-oidc.sh new file mode 100755 index 0000000..c8bae10 --- /dev/null +++ b/Scripts/authentik-sync-google-wif-oidc.sh @@ -0,0 +1,347 @@ +#!/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 new file mode 100755 index 0000000..0d32c06 --- /dev/null +++ b/Scripts/authentik-sync-grafana-oidc.sh @@ -0,0 +1,332 @@ +#!/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/burrow-doctor b/Scripts/burrow-doctor deleted file mode 100755 index a364e59..0000000 --- a/Scripts/burrow-doctor +++ /dev/null @@ -1,434 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: Scripts/burrow-doctor [--last 10m] - -Print a single stdout diagnostics report for the CURRENT macOS networking state. - -Run this while Burrow is the active VPN but routing/websites are broken, then -redirect stdout to a file before switching to a working VPN: - - Scripts/burrow-doctor --last 10m > burrow-broken.txt - -The report focuses on live VPN/proxy/DNS/route state, Burrow NetworkExtension -state, socket ownership, the selected Burrow node, and recent Burrow logs. -It intentionally does not redact local output, because broken proxy state often -depends on exact subscription and node configuration. -EOF -} - -last="10m" -while [[ $# -gt 0 ]]; do - case "$1" in - --last) - [[ $# -ge 2 ]] || { echo "--last requires a value" >&2; exit 2; } - last="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -group_id="${BURROW_APP_GROUP_ID:-2KPBQHRJ2D.com.tianruichen.burrow}" -group_dir="${BURROW_APP_GROUP_DIR:-$HOME/Library/Group Containers/$group_id}" -db_path="${BURROW_DB_PATH:-$group_dir/burrow.db}" -runtime_log="${BURROW_RUNTIME_LOG:-$group_dir/burrow-runtime.log}" -bundle_id="${BURROW_BUNDLE_ID:-com.tianruichen.burrow}" -extension_process="${BURROW_EXTENSION_PROCESS:-BurrowNetworkExtension}" - -section() { - printf '\n## %s\n\n' "$1" -} - -cmd() { - printf '$' - printf ' %q' "$@" - printf '\n' -} - -run() { - local title="$1" - shift - section "$title" - cmd "$@" - printf '\n' - "$@" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?" -} - -run_zsh() { - local title="$1" - local script="$2" - section "$title" - printf '$ %s\n\n' "$script" - /bin/zsh -lc "$script" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?" -} - -run_python() { - if command -v uv >/dev/null 2>&1; then - uv run python "$@" - elif command -v python3 >/dev/null 2>&1; then - python3 "$@" - else - return 127 - fi -} - -capture() { - "$@" 2>&1 || true -} - -nc_list="$(capture scutil --nc list)" -proxy_state="$(capture scutil --proxy)" -dns_state="$(capture scutil --dns)" -inet_routes="$(capture netstat -rn -f inet)" -proxy_listeners="$(capture /bin/zsh -lc "lsof -nP -iTCP:6152 -iTCP:6153 -iTCP:7890 -iTCP:7891 -iTCP:7897 -iTCP:1080 -iTCP:1087 -sTCP:LISTEN")" - -cat < dict | None: - nodes = payload.get("nodes") or [] - selected_name = payload.get("selected_name") - selected_ordinal = payload.get("selected_ordinal") - for node in nodes: - if selected_name is not None and node.get("name") == selected_name: - return node - if selected_ordinal is not None and node.get("ordinal") == selected_ordinal: - return node - return nodes[0] if nodes else None - - -conn = sqlite3.connect(db_path) -conn.row_factory = sqlite3.Row -row = conn.execute( - "select payload from network where type = 'ProxySubscription' order by idx limit 1" -).fetchone() -if row is None: - print("No ProxySubscription network in database.") - raise SystemExit - -payload_blob = row["payload"] -payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob -payload = json.loads(payload_text) -node = choose_node(payload) -if not node: - print("ProxySubscription has no nodes.") - raise SystemExit - -server = node.get("server") -port = int(node.get("port")) -print(f"selected_proxy_server={server}:{port}") - -addresses = [] -try: - for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(server, port, type=socket.SOCK_STREAM): - host, resolved_port = sockaddr[:2] - key = (host, resolved_port) - if key not in addresses: - addresses.append(key) -except Exception as exc: - print(f"resolve_error={type(exc).__name__}: {exc}") - raise SystemExit - -print("resolved_addresses=" + ",".join(f"{host}:{resolved_port}" for host, resolved_port in addresses)) - -for host, _ in addresses: - print(f"\n$ route -n get {host}") - try: - result = subprocess.run( - ["route", "-n", "get", host], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - timeout=5, - ) - print(result.stdout.rstrip()) - print(f"[exit={result.returncode}]") - except Exception as exc: - print(f"route_probe_error={type(exc).__name__}: {exc}") - -for host, resolved_port in addresses: - started = time.monotonic() - try: - with socket.create_connection((host, resolved_port), timeout=5): - elapsed_ms = (time.monotonic() - started) * 1000 - print(f"tcp_connect_ok address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f}") - except Exception as exc: - elapsed_ms = (time.monotonic() - started) * 1000 - print(f"tcp_connect_error address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f} error={type(exc).__name__}: {exc}") -PY -else - echo "DB not found: $db_path" -fi - -run_zsh "Route Probes" "for host in 1.1.1.1 8.8.8.8 google.com; do echo; echo \"\$ route -n get \$host\"; route -n get \"\$host\" 2>&1 || true; done" - -run_zsh "DNS Probes" "if command -v dig >/dev/null 2>&1; then dig +time=3 +tries=1 google.com A; echo; dig +time=3 +tries=1 @1.1.1.1 google.com A; else dscacheutil -q host -a name google.com; fi" - -run_zsh "Scoped Physical DNS Probes" "if command -v dig >/dev/null 2>&1; then servers=\$(scutil --dns | awk 'BEGIN { scoped=0; current=\"\" } /^DNS configuration \\(for scoped queries\\)/ { scoped=1; next } scoped && /^resolver #/ { current=\"\"; next } scoped && /nameserver\\[[0-9]+\\]/ { current=\$3 } scoped && /if_index/ && \$0 !~ /utun/ && current != \"\" { print current; current=\"\" }'); if [[ -z \"\$servers\" ]]; then echo 'No scoped non-utun DNS servers found.'; else for server in \$servers; do echo; echo \"\$ dig +time=3 +tries=1 @\$server google.com A\"; dig +time=3 +tries=1 @\"\$server\" google.com A; done; fi; else echo 'dig not found'; fi" - -run_zsh "TCP Probes" "for target in '1.1.1.1 53' '1.1.1.1 443' 'google.com 443'; do set -- \$target; echo; echo \"\$ nc -vz -w 5 \$1 \$2\"; nc -vz -w 5 \"\$1\" \"\$2\" 2>&1 || true; done" - -run_zsh "HTTP Probes" "for url in 'http://www.gstatic.com/generate_204' 'https://www.google.com/generate_204' 'https://www.cloudflare.com/cdn-cgi/trace'; do echo; echo \"\$ curl -4 -I --connect-timeout 5 --max-time 10 \$url\"; curl -4 -I --connect-timeout 5 --max-time 10 \"\$url\" 2>&1 || true; done" - -run_zsh "Burrow Controlled Proxy Self-Test" "if [[ -x '$repo_root/Scripts/burrow-proxy-selftest' ]]; then '$repo_root/Scripts/burrow-proxy-selftest'; else echo 'Scripts/burrow-proxy-selftest not found or not executable'; fi" - -run_zsh "Burrow Direct Provider Proxy Ladder" "if [[ -x '$repo_root/Scripts/burrow-proxy-ladder' ]]; then '$repo_root/Scripts/burrow-proxy-ladder' --db '$db_path' --resolve-server doh-all --target google.com:80 --http-host google.com --skip-udp --timeout 8; else echo 'Scripts/burrow-proxy-ladder not found or not executable'; fi" - -run_zsh "Burrow Daemon Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-tcp-probe --dns-name google.com --remote 1.1.1.1:80 --timeout-ms 12000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi" - -run_zsh "Burrow Daemon HTTPS Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-https-probe --dns-name news.ycombinator.com --remote 1.1.1.1:443 --timeout-ms 20000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi" - -section "Proxy Subscription Endpoint Status" -if [[ -f "$db_path" ]]; then - run_python - "$db_path" <<'PY' -from __future__ import annotations - -import json -import sqlite3 -import sys -import urllib.error -import urllib.request - -db_path = sys.argv[1] - -conn = sqlite3.connect(db_path) -conn.row_factory = sqlite3.Row -row = conn.execute( - "select payload from network where type = 'ProxySubscription' order by idx limit 1" -).fetchone() -if row is None: - print("No ProxySubscription network in database.") - raise SystemExit - -payload_blob = row["payload"] -payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob -payload = json.loads(payload_text) -url = (payload.get("source") or {}).get("url") -if not url: - print("ProxySubscription has no source URL.") - raise SystemExit - -print("source_url_redacted=yes") -request = urllib.request.Request(url, headers={"User-Agent": "mihomo/1.18.3"}) -try: - with urllib.request.urlopen(request, timeout=20) as response: - body = response.read(1024 * 1024) - print(f"status={response.status}") - for key, value in response.headers.items(): - lower = key.lower() - if ( - lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"} - or "expire" in lower - or "subscription" in lower - ): - print(f"header_{key}={value}") - print(f"body_bytes={len(body)}") -except urllib.error.HTTPError as exc: - print(f"status={exc.code}") - print(f"reason={exc.reason}") - for key, value in exc.headers.items(): - lower = key.lower() - if ( - lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"} - or "expire" in lower - or "subscription" in lower - ): - print(f"header_{key}={value}") - body = exc.read(1000).decode("utf-8", errors="replace").strip() - if body: - print(f"body_prefix={body[:200]}") -except Exception as exc: - print(f"fetch_error={type(exc).__name__}: {exc}") -PY -else - echo "DB not found: $db_path" -fi - -section "Burrow Runtime Log File" -if [[ -f "$runtime_log" ]]; then - cmd tail -400 "$runtime_log" - printf '\n' - tail -400 "$runtime_log" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?" -else - echo "Runtime log not found: $runtime_log" -fi - -section "Recent Burrow NetworkExtension Logs" -cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\"" -printf '\n' -{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\"" 2>&1 \ - || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; } - -section "Recent Burrow Extension Logs" -cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\"" -printf '\n' -{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\"" 2>&1 \ - || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; } - -section "Recent Burrow Errors" -cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")" -printf '\n' -{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")" 2>&1 \ - || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; } diff --git a/Scripts/burrow-proxy-ladder b/Scripts/burrow-proxy-ladder deleted file mode 100755 index 9c1da3d..0000000 --- a/Scripts/burrow-proxy-ladder +++ /dev/null @@ -1,576 +0,0 @@ -#!/usr/bin/env python3 -"""Probe a Burrow proxy subscription node without enabling system proxying. - -This script intentionally bypasses the Burrow daemon, Network Extension, -routes, DNS settings, and browsers. It loads the selected proxy node from the -Burrow SQLite database and speaks the provider protocol directly. -""" - -from __future__ import annotations - -import argparse -import hashlib -import ipaddress -import json -import os -import socket -import sqlite3 -import ssl -import struct -import sys -import time -import urllib.parse -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Any - - -DEFAULT_APP_GROUP = "2KPBQHRJ2D.com.tianruichen.burrow" -DEFAULT_DB = ( - Path.home() - / "Library" - / "Group Containers" - / DEFAULT_APP_GROUP - / "burrow.db" -) -DEFAULT_TARGET = "1.1.1.1:80" -DEFAULT_HTTP_HOST = "one.one.one.one" -DEFAULT_DNS_SERVER = "1.1.1.1:53" -DEFAULT_DNS_NAME = "google.com" -DEFAULT_TIMEOUT = 5.0 -DOH_PROVIDERS = { - "doh-google": "https://dns.google/resolve", - "doh-cloudflare": "https://cloudflare-dns.com/dns-query", -} - -TROJAN_CMD_CONNECT = 0x01 -TROJAN_CMD_UDP_ASSOCIATE = 0x03 - - -@dataclass(frozen=True) -class Endpoint: - host: str - port: int - - -@dataclass(frozen=True) -class ProbeResult: - ok: bool - detail: str - - -def parse_endpoint(value: str, default_port: int | None = None) -> Endpoint: - text = value.strip() - if not text: - raise ValueError("endpoint must not be empty") - - if text.startswith("["): - end = text.find("]") - if end == -1: - raise ValueError(f"invalid bracketed IPv6 endpoint: {value}") - host = text[1:end] - rest = text[end + 1 :] - if rest.startswith(":"): - port = int(rest[1:]) - elif default_port is not None: - port = default_port - else: - raise ValueError(f"endpoint is missing a port: {value}") - return Endpoint(host, port) - - if text.count(":") == 1: - host, port_text = text.rsplit(":", 1) - return Endpoint(host, int(port_text)) - - try: - ipaddress.ip_address(text) - except ValueError: - if ":" in text: - if default_port is None: - raise ValueError(f"IPv6 endpoint is missing a port: {value}") - return Endpoint(text, default_port) - if default_port is None: - raise ValueError(f"endpoint is missing a port: {value}") - return Endpoint(text, default_port) - - if default_port is None: - raise ValueError(f"endpoint is missing a port: {value}") - return Endpoint(text, default_port) - - -def encode_socks_addr(endpoint: Endpoint) -> bytes: - try: - ip = ipaddress.ip_address(endpoint.host) - except ValueError: - host = endpoint.host.encode("idna") - if len(host) > 255: - raise ValueError(f"SOCKS domain is too long: {endpoint.host}") - return bytes([3, len(host)]) + host + struct.pack("!H", endpoint.port) - - if ip.version == 4: - return bytes([1]) + ip.packed + struct.pack("!H", endpoint.port) - return bytes([4]) + ip.packed + struct.pack("!H", endpoint.port) - - -def read_exact(sock: ssl.SSLSocket, length: int) -> bytes: - chunks: list[bytes] = [] - remaining = length - while remaining: - chunk = sock.recv(remaining) - if not chunk: - raise EOFError(f"expected {remaining} more bytes") - chunks.append(chunk) - remaining -= len(chunk) - return b"".join(chunks) - - -def read_socks_addr(sock: ssl.SSLSocket) -> Endpoint: - atyp = read_exact(sock, 1)[0] - if atyp == 1: - host = str(ipaddress.IPv4Address(read_exact(sock, 4))) - elif atyp == 4: - host = str(ipaddress.IPv6Address(read_exact(sock, 16))) - elif atyp == 3: - length = read_exact(sock, 1)[0] - host = read_exact(sock, length).decode("idna") - else: - raise ValueError(f"unsupported SOCKS address type in response: {atyp}") - port = struct.unpack("!H", read_exact(sock, 2))[0] - return Endpoint(host, port) - - -def trojan_password_hash(password: str) -> bytes: - return hashlib.sha224(password.encode()).hexdigest().encode() - - -def trojan_header(password: str, command: int, target: Endpoint) -> bytes: - return ( - trojan_password_hash(password) - + b"\r\n" - + bytes([command]) - + encode_socks_addr(target) - + b"\r\n" - ) - - -def trojan_udp_frame(target: Endpoint, payload: bytes) -> bytes: - return encode_socks_addr(target) + struct.pack("!H", len(payload)) + b"\r\n" + payload - - -def build_dns_query(name: str) -> tuple[int, bytes]: - query_id = int(time.time() * 1000) & 0xFFFF - labels = [label for label in name.rstrip(".").split(".") if label] - qname = b"".join(bytes([len(label)]) + label.encode("ascii") for label in labels) + b"\x00" - header = struct.pack("!HHHHHH", query_id, 0x0100, 1, 0, 0, 0) - question = qname + struct.pack("!HH", 1, 1) - return query_id, header + question - - -def parse_dns_response(query_id: int, payload: bytes) -> str: - if len(payload) < 12: - return f"truncated DNS response: {len(payload)} bytes" - response_id, flags, questions, answers, authority, additional = struct.unpack( - "!HHHHHH", payload[:12] - ) - rcode = flags & 0x000F - qr = bool(flags & 0x8000) - id_note = "matching id" if response_id == query_id else f"id mismatch {response_id:#06x}" - return ( - f"{len(payload)} bytes, qr={qr}, rcode={rcode}, answers={answers}, " - f"authority={authority}, additional={additional}, questions={questions}, {id_note}" - ) - - -def load_payload(db_path: Path, network_id: int | None) -> tuple[int, dict[str, Any]]: - if not db_path.exists(): - raise FileNotFoundError(f"Burrow database not found: {db_path}") - conn = sqlite3.connect(str(db_path)) - conn.row_factory = sqlite3.Row - try: - if network_id is None: - row = conn.execute( - "select id,payload from network where type = 'ProxySubscription' order by idx limit 1" - ).fetchone() - else: - row = conn.execute( - "select id,payload from network where id = ? and type = 'ProxySubscription'", - (network_id,), - ).fetchone() - finally: - conn.close() - if row is None: - selector = "first ProxySubscription" if network_id is None else f"ProxySubscription id={network_id}" - raise LookupError(f"could not find {selector} in {db_path}") - payload_blob = row["payload"] - if isinstance(payload_blob, bytes): - payload_text = payload_blob.decode("utf-8", errors="replace") - else: - payload_text = payload_blob or "{}" - return int(row["id"]), json.loads(payload_text) - - -def choose_node( - payload: dict[str, Any], - node_name: str | None = None, - node_ordinal: int | None = None, -) -> dict[str, Any]: - nodes = payload.get("nodes") or [] - if node_name is not None: - for node in nodes: - if node.get("name") == node_name: - return node - raise LookupError(f"ProxySubscription payload has no node named {node_name!r}") - if node_ordinal is not None: - for node in nodes: - if node.get("ordinal") == node_ordinal: - return node - raise LookupError(f"ProxySubscription payload has no node with ordinal {node_ordinal}") - - selected_name = payload.get("selected_name") - selected_ordinal = payload.get("selected_ordinal") - for node in nodes: - if selected_name is not None and node.get("name") == selected_name: - return node - if selected_ordinal is not None and node.get("ordinal") == selected_ordinal: - return node - if nodes: - return nodes[0] - raise LookupError("ProxySubscription payload has no compatible nodes") - - -def resolved_stream_addresses(endpoint: Endpoint) -> list[tuple[int, tuple[Any, ...]]]: - addresses: list[tuple[int, tuple[Any, ...]]] = [] - seen: set[tuple[Any, ...]] = set() - for family, _, _, _, sockaddr in socket.getaddrinfo( - endpoint.host, endpoint.port, type=socket.SOCK_STREAM - ): - key = (family, sockaddr) - if key not in seen: - seen.add(key) - addresses.append((family, sockaddr)) - return addresses - - -def resolve_doh(host: str, port: int, provider: str, timeout: float) -> list[Endpoint]: - base = DOH_PROVIDERS[provider] - url = base + "?" + urllib.parse.urlencode({"name": host, "type": "A"}) - request = urllib.request.Request( - url, - headers={"Accept": "application/dns-json", "User-Agent": "burrow-proxy-ladder"}, - ) - with urllib.request.urlopen(request, timeout=timeout) as response: - body = json.load(response) - endpoints: list[Endpoint] = [] - for answer in body.get("Answer") or []: - if answer.get("type") != 1: - continue - address = str(answer.get("data") or "") - try: - ipaddress.IPv4Address(address) - except ValueError: - continue - endpoint = Endpoint(address, port) - if endpoint not in endpoints: - endpoints.append(endpoint) - return endpoints - - -def resolve_doh_all(host: str, port: int, timeout: float) -> list[Endpoint]: - endpoints: list[Endpoint] = [] - errors: list[str] = [] - for provider in DOH_PROVIDERS: - try: - provider_endpoints = resolve_doh(host, port, provider, timeout) - except BaseException as exc: - errors.append(f"{provider}={type(exc).__name__}: {exc}") - continue - print( - f"{provider}_answers=" - + ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in provider_endpoints) - ) - for endpoint in provider_endpoints: - if endpoint not in endpoints: - endpoints.append(endpoint) - if errors: - print("doh_warnings=" + "; ".join(errors)) - return endpoints - - -def is_fake_ip(host: str) -> bool: - try: - ip = ipaddress.ip_address(host) - except ValueError: - return False - return ip in ipaddress.ip_network("198.18.0.0/15") - - -def server_hostname(server: str, sni: str | None) -> str | None: - value = sni or server - if not value: - return None - return value - - -def tls_context(config: dict[str, Any]) -> ssl.SSLContext: - skip_verify = bool(config.get("skip_cert_verify")) - if skip_verify: - context = ssl._create_unverified_context() - else: - context = ssl.create_default_context() - - alpn_present = bool(config.get("alpn_present")) - alpn = config.get("alpn") or [] - if not alpn_present: - alpn = ["h2", "http/1.1"] - if alpn: - context.set_alpn_protocols([str(value) for value in alpn]) - return context - - -def connect_tls( - connect_endpoint: Endpoint, - tls_server_name: str, - node: dict[str, Any], - timeout: float, -) -> ssl.SSLSocket: - config = node["config"] - context = tls_context(config) - sni = server_hostname(tls_server_name, config.get("sni")) - last_error: BaseException | None = None - for _family, sockaddr in resolved_stream_addresses(connect_endpoint): - started = time.monotonic() - raw_sock: socket.socket | None = None - try: - raw_sock = socket.create_connection(sockaddr, timeout=timeout) - raw_sock.settimeout(timeout) - tls_sock = context.wrap_socket(raw_sock, server_hostname=sni) - tls_sock.settimeout(timeout) - elapsed_ms = (time.monotonic() - started) * 1000 - peer = sockaddr[0] if isinstance(sockaddr, tuple) else str(sockaddr) - print( - f" tls_connect_ok address={peer}:{connect_endpoint.port} " - f"elapsed_ms={elapsed_ms:.1f} alpn={tls_sock.selected_alpn_protocol()!r}" - ) - return tls_sock - except BaseException as exc: - last_error = exc - elapsed_ms = (time.monotonic() - started) * 1000 - peer = sockaddr[0] if isinstance(sockaddr, tuple) else str(sockaddr) - print( - f" tls_connect_error address={peer}:{connect_endpoint.port} " - f"elapsed_ms={elapsed_ms:.1f} error={type(exc).__name__}: {exc}" - ) - if raw_sock is not None: - raw_sock.close() - assert last_error is not None - raise last_error - - -def probe_trojan_tcp( - connect_endpoint: Endpoint, - tls_server_name: str, - node: dict[str, Any], - target: Endpoint, - http_host: str, - timeout: float, -) -> ProbeResult: - request = ( - f"GET / HTTP/1.1\r\n" - f"Host: {http_host}\r\n" - f"Connection: close\r\n" - f"User-Agent: burrow-proxy-ladder\r\n\r\n" - ).encode() - try: - with connect_tls(connect_endpoint, tls_server_name, node, timeout) as sock: - sock.sendall(trojan_header(node["config"]["password"], TROJAN_CMD_CONNECT, target)) - sock.sendall(request) - data = sock.recv(2048) - except BaseException as exc: - return ProbeResult(False, f"{type(exc).__name__}: {exc}") - if not data: - return ProbeResult(False, "proxy returned EOF before any HTTP response bytes") - first_line = data.splitlines()[0].decode("utf-8", errors="replace") - return ProbeResult(True, f"received {len(data)} bytes; first_line={first_line!r}") - - -def probe_trojan_udp_dns( - connect_endpoint: Endpoint, - tls_server_name: str, - node: dict[str, Any], - dns_server: Endpoint, - dns_name: str, - timeout: float, -) -> ProbeResult: - if node["config"].get("udp") is False: - return ProbeResult(False, "selected Trojan node has udp=false") - query_id, query = build_dns_query(dns_name) - try: - with connect_tls(connect_endpoint, tls_server_name, node, timeout) as sock: - sock.sendall( - trojan_header(node["config"]["password"], TROJAN_CMD_UDP_ASSOCIATE, dns_server) - ) - sock.sendall(trojan_udp_frame(dns_server, query)) - source = read_socks_addr(sock) - payload_len = struct.unpack("!H", read_exact(sock, 2))[0] - delimiter = read_exact(sock, 2) - if delimiter != b"\r\n": - return ProbeResult(False, f"invalid Trojan UDP delimiter: {delimiter!r}") - payload = read_exact(sock, payload_len) - except BaseException as exc: - return ProbeResult(False, f"{type(exc).__name__}: {exc}") - dns_summary = parse_dns_response(query_id, payload) - return ProbeResult( - True, - f"received UDP frame from {source.host}:{source.port}; DNS response {dns_summary}", - ) - - -def print_node_summary(network_id: int, payload: dict[str, Any], node: dict[str, Any]) -> None: - config = node.get("config") or {} - print(f"network_id={network_id}") - print(f"subscription={payload.get('name')!r}") - print(f"selected_node={node.get('name')!r}") - print(f"protocol={node.get('protocol')!r}") - print(f"server={node.get('server')}:{node.get('port')}") - print(f"sni={config.get('sni')!r}") - print(f"skip_cert_verify={bool(config.get('skip_cert_verify'))}") - print(f"alpn_present={bool(config.get('alpn_present'))} alpn={config.get('alpn') or []!r}") - print(f"udp={config.get('udp', True)!r}") - - -def main() -> int: - parser = argparse.ArgumentParser( - description=( - "Directly probe the selected Burrow proxy subscription node without " - "using the daemon, Network Extension, system proxy, routes, or DNS settings." - ) - ) - parser.add_argument("--db", type=Path, default=Path(os.environ.get("BURROW_DB", DEFAULT_DB))) - parser.add_argument("--network-id", type=int) - parser.add_argument("--node-name", help="Probe this subscription node instead of the selected node.") - parser.add_argument("--node-ordinal", type=int, help="Probe this subscription ordinal instead of the selected node.") - parser.add_argument( - "--server-address", - help=( - "Override the proxy connect address, as host or host:port. " - "This bypasses system DNS for the proxy hostname while preserving SNI." - ), - ) - parser.add_argument( - "--resolve-server", - choices=["system", "doh-google", "doh-cloudflare", "doh-all"], - default="system", - help="How to resolve the proxy server when --server-address is not provided.", - ) - parser.add_argument( - "--all-server-addresses", - action="store_true", - help="Probe every resolved proxy server address and pass if any address works.", - ) - parser.add_argument("--target", default=DEFAULT_TARGET, help="TCP HTTP target, host:port") - parser.add_argument("--http-host", default=DEFAULT_HTTP_HOST) - parser.add_argument("--dns-server", default=DEFAULT_DNS_SERVER, help="UDP DNS target, host:port") - parser.add_argument("--dns-name", default=DEFAULT_DNS_NAME) - parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) - parser.add_argument("--skip-udp", action="store_true") - parser.add_argument("--require-udp", action="store_true") - args = parser.parse_args() - - print("Burrow proxy ladder: direct provider/protocol probe") - print("bypasses=daemon,NetworkExtension,system_proxy,routes,system_dns,browser") - print(f"db={args.db}") - - try: - if args.node_name is not None and args.node_ordinal is not None: - raise ValueError("--node-name and --node-ordinal are mutually exclusive") - network_id, payload = load_payload(args.db, args.network_id) - node = choose_node(payload, args.node_name, args.node_ordinal) - print_node_summary(network_id, payload, node) - if node.get("protocol") != "trojan": - print(f"unsupported protocol for this direct probe: {node.get('protocol')!r}") - return 2 - server = Endpoint(str(node["server"]), int(node["port"])) - if args.server_address: - connect_endpoints = [parse_endpoint(args.server_address, default_port=server.port)] - elif args.resolve_server == "system": - system_addresses = resolved_stream_addresses(server) - connect_endpoints = [ - Endpoint(str(sockaddr[0]), int(sockaddr[1])) for _family, sockaddr in system_addresses - ] - if not args.all_server_addresses and connect_endpoints: - connect_endpoints = connect_endpoints[:1] - if not connect_endpoints: - connect_endpoints = [server] - elif args.resolve_server == "doh-all": - connect_endpoints = resolve_doh_all(server.host, server.port, args.timeout) - if not args.all_server_addresses and connect_endpoints: - connect_endpoints = connect_endpoints[:1] - else: - connect_endpoints = resolve_doh(server.host, server.port, args.resolve_server, args.timeout) - print( - f"{args.resolve_server}_answers=" - + ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in connect_endpoints) - ) - if not args.all_server_addresses and connect_endpoints: - connect_endpoints = connect_endpoints[:1] - if not connect_endpoints: - raise LookupError("proxy server resolution returned no usable A records") - target = parse_endpoint(args.target) - dns_server = parse_endpoint(args.dns_server) - except BaseException as exc: - print(f"setup_error={type(exc).__name__}: {exc}", file=sys.stderr) - return 2 - - print( - "connect_endpoints=" - + ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in connect_endpoints) - ) - if any(endpoint != server for endpoint in connect_endpoints): - print(f"tls_server_name_source={server.host!r} sni_preserved=True") - if any(is_fake_ip(endpoint.host) for endpoint in connect_endpoints): - print( - "warning=proxy hostname resolved into 198.18.0.0/15; " - "rerun with --resolve-server doh-all or --server-address REAL_IP to avoid fake-IP DNS" - ) - - any_tcp_ok = False - any_required_udp_ok = False - for idx, connect_endpoint in enumerate(connect_endpoints, start=1): - if len(connect_endpoints) > 1: - print(f"\n=== proxy server candidate {idx}/{len(connect_endpoints)} ===") - print(f"connect_endpoint={connect_endpoint.host}:{connect_endpoint.port}") - - print("\n[1/2] Trojan TCP CONNECT probe") - print(f"target={target.host}:{target.port} http_host={args.http_host!r}") - tcp_result = probe_trojan_tcp( - connect_endpoint, server.host, node, target, args.http_host, args.timeout - ) - print(("ok: " if tcp_result.ok else "fail: ") + tcp_result.detail) - any_tcp_ok = any_tcp_ok or tcp_result.ok - - udp_result = ProbeResult(True, "skipped") - if not args.skip_udp: - print("\n[2/2] Trojan UDP DNS probe") - print(f"dns_server={dns_server.host}:{dns_server.port} dns_name={args.dns_name!r}") - udp_result = probe_trojan_udp_dns( - connect_endpoint, server.host, node, dns_server, args.dns_name, args.timeout - ) - print(("ok: " if udp_result.ok else "fail: ") + udp_result.detail) - else: - print("\n[2/2] Trojan UDP DNS probe") - print("skipped by --skip-udp") - any_required_udp_ok = any_required_udp_ok or udp_result.ok - - if tcp_result.ok and (not args.require_udp or udp_result.ok): - break - - if not any_tcp_ok: - return 1 - if args.require_udp and not any_required_udp_ok: - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/Scripts/burrow-proxy-selftest b/Scripts/burrow-proxy-selftest deleted file mode 100755 index fd3daaa..0000000 --- a/Scripts/burrow-proxy-selftest +++ /dev/null @@ -1,1417 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: Scripts/burrow-proxy-selftest - -Run a controlled Burrow proxy packet-runtime test without changing system proxy -settings or using the user's Burrow database. The script starts local Trojan and -Shadowsocks servers, starts a Burrow daemon against a temporary database/socket, -imports a temporary ProxySubscription pointed at those servers, then runs the -packet-level proxy TCP, UDP, UDP-over-TCP, and plugin probes through daemon -packet streaming. -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -burrow_bin="${BURROW_BIN:-$repo_root/target/debug/burrow}" - -if [[ -z "${BURROW_BIN:-}" ]]; then - (cd "$repo_root" && cargo build -p burrow) -elif [[ ! -x "$burrow_bin" ]]; then - echo "BURROW_BIN is not executable: $burrow_bin" >&2 - exit 2 -fi - -if ! command -v openssl >/dev/null 2>&1; then - echo "openssl is required for the local TLS server certificate" >&2 - exit 2 -fi - -if ! command -v uv >/dev/null 2>&1; then - echo "uv is required for the local Python self-test harness" >&2 - exit 2 -fi - -tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-proxy-selftest.XXXXXX")" -daemon_pid="" -server_pid="" -ss_server_pid="" -status=0 - -cleanup() { - if [[ -n "$ss_server_pid" ]]; then - kill "$ss_server_pid" >/dev/null 2>&1 || true - fi - if [[ -n "$server_pid" ]]; then - kill "$server_pid" >/dev/null 2>&1 || true - fi - if [[ -n "$daemon_pid" ]]; then - kill "$daemon_pid" >/dev/null 2>&1 || true - fi - if [[ "${BURROW_SELFTEST_KEEP:-}" == "1" || "$status" -ne 0 ]]; then - echo "preserving self-test artifacts: $tmpdir" >&2 - else - rm -rf "$tmpdir" - fi -} -trap 'status=$?; cleanup' EXIT - -cert="$tmpdir/cert.pem" -key="$tmpdir/key.pem" -socket="$tmpdir/burrow.sock" -port_file="$tmpdir/server.port" -ss_port_file="$tmpdir/shadowsocks-server.port" -server_result="$tmpdir/server-result.json" -ss_server_result="$tmpdir/shadowsocks-server-result.json" -payload="$tmpdir/proxy-payload.json" -daemon_log="$tmpdir/daemon.log" -server_log="$tmpdir/server.log" -ss_server_log="$tmpdir/shadowsocks-server.log" - -openssl req \ - -x509 \ - -newkey rsa:2048 \ - -nodes \ - -subj /CN=localhost \ - -keyout "$key" \ - -out "$cert" \ - -days 1 >/dev/null 2>&1 - -uv run python - "$port_file" "$server_result" "$cert" "$key" >"$server_log" 2>&1 <<'PY' & -from __future__ import annotations - -import hashlib -import json -import socket -import ssl -import struct -import sys - -port_file, result_file, cert_file, key_file = sys.argv[1:] -password = "secret" - - -def read_exact(sock: ssl.SSLSocket, length: int) -> bytes: - chunks: list[bytes] = [] - remaining = length - while remaining: - chunk = sock.recv(remaining) - if not chunk: - raise EOFError(f"expected {remaining} more bytes") - chunks.append(chunk) - remaining -= len(chunk) - return b"".join(chunks) - - -def read_socks_addr(sock: ssl.SSLSocket) -> dict[str, object]: - atyp = read_exact(sock, 1)[0] - if atyp == 1: - host = socket.inet_ntop(socket.AF_INET, read_exact(sock, 4)) - elif atyp == 4: - host = socket.inet_ntop(socket.AF_INET6, read_exact(sock, 16)) - elif atyp == 3: - length = read_exact(sock, 1)[0] - host = read_exact(sock, length).decode("idna") - else: - raise ValueError(f"unsupported SOCKS address type {atyp}") - port = struct.unpack("!H", read_exact(sock, 2))[0] - return {"atyp": atyp, "host": host, "port": port} - - -def read_http_headers(sock: ssl.SSLSocket) -> bytes: - data = bytearray() - while b"\r\n\r\n" not in data: - chunk = sock.recv(4096) - if not chunk: - break - data.extend(chunk) - if len(data) > 65535: - raise ValueError("HTTP request headers exceeded 64 KiB") - return bytes(data) - - -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(cert_file, key_file) -ctx.set_alpn_protocols(["h2", "http/1.1"]) - -listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -listener.bind(("127.0.0.1", 0)) -listener.listen(1) -port = listener.getsockname()[1] -with open(port_file, "w", encoding="utf-8") as f: - f.write(str(port)) - -raw, peer = listener.accept() -raw.settimeout(10) -result: dict[str, object] = {"peer": str(peer)} -try: - with ctx.wrap_socket(raw, server_side=True) as tls: - tls.settimeout(10) - result["alpn"] = tls.selected_alpn_protocol() - got_hash = read_exact(tls, 56) - expected_hash = hashlib.sha224(password.encode()).hexdigest().encode() - result["password_hash_ok"] = got_hash == expected_hash - if not result["password_hash_ok"]: - raise ValueError("unexpected Trojan password hash") - if read_exact(tls, 2) != b"\r\n": - raise ValueError("missing Trojan password delimiter") - command = read_exact(tls, 1)[0] - result["command"] = command - target = read_socks_addr(tls) - result["target"] = target - if read_exact(tls, 2) != b"\r\n": - raise ValueError("missing Trojan header delimiter") - request = read_http_headers(tls) - result["request_first_line"] = request.splitlines()[0].decode("utf-8", "replace") - body = json.dumps( - { - "ok": True, - "target": target, - "request_first_line": result["request_first_line"], - }, - sort_keys=True, - ).encode() - tls.sendall( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) -finally: - with open(result_file, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True) - listener.close() -PY -server_pid=$! - -for _ in {1..100}; do - if [[ -s "$port_file" ]]; then - break - fi - if ! kill -0 "$server_pid" >/dev/null 2>&1; then - echo "local Trojan server exited before listening" >&2 - cat "$server_log" >&2 || true - exit 1 - fi - sleep 0.05 -done - -if [[ ! -s "$port_file" ]]; then - echo "timed out waiting for local Trojan server" >&2 - cat "$server_log" >&2 || true - exit 1 -fi -server_port="$(cat "$port_file")" - -uv run python - "$ss_port_file" "$ss_server_result" >"$ss_server_log" 2>&1 <<'PY' & -from __future__ import annotations - -import json -import base64 -import socket -import struct -import sys - -port_file, result_file = sys.argv[1:] - - -def read_exact(sock: socket.socket, length: int) -> bytes: - chunks: list[bytes] = [] - remaining = length - while remaining: - chunk = sock.recv(remaining) - if not chunk: - raise EOFError(f"expected {remaining} more bytes") - chunks.append(chunk) - remaining -= len(chunk) - return b"".join(chunks) - - -def read_socks_addr(sock: socket.socket) -> dict[str, object]: - atyp = read_exact(sock, 1)[0] - if atyp == 1: - host = socket.inet_ntop(socket.AF_INET, read_exact(sock, 4)) - elif atyp == 4: - host = socket.inet_ntop(socket.AF_INET6, read_exact(sock, 16)) - elif atyp == 3: - length = read_exact(sock, 1)[0] - host = read_exact(sock, length).decode("idna") - else: - raise ValueError(f"unsupported SOCKS address type {atyp}") - port = struct.unpack("!H", read_exact(sock, 2))[0] - return {"atyp": atyp, "host": host, "port": port} - - -def parse_socks_addr_packet(data: bytes) -> tuple[dict[str, object], int]: - offset = 0 - atyp = data[offset] - offset += 1 - if atyp == 1: - host = socket.inet_ntop(socket.AF_INET, data[offset : offset + 4]) - offset += 4 - elif atyp == 4: - host = socket.inet_ntop(socket.AF_INET6, data[offset : offset + 16]) - offset += 16 - elif atyp == 3: - length = data[offset] - offset += 1 - host = data[offset : offset + length].decode("idna") - offset += length - else: - raise ValueError(f"unsupported SOCKS address type {atyp}") - port = struct.unpack("!H", data[offset : offset + 2])[0] - offset += 2 - return {"atyp": atyp, "host": host, "port": port}, offset - - -def read_uot_addr(sock: socket.socket) -> tuple[dict[str, object], bytes]: - raw = bytearray() - atyp = read_exact(sock, 1)[0] - raw.append(atyp) - if atyp == 0: - addr = read_exact(sock, 4) - raw.extend(addr) - host = socket.inet_ntop(socket.AF_INET, addr) - elif atyp == 1: - addr = read_exact(sock, 16) - raw.extend(addr) - host = socket.inet_ntop(socket.AF_INET6, addr) - elif atyp == 2: - length = read_exact(sock, 1)[0] - raw.append(length) - addr = read_exact(sock, length) - raw.extend(addr) - host = addr.decode("idna") - else: - raise ValueError(f"unsupported UDP-over-TCP address type {atyp}") - port_bytes = read_exact(sock, 2) - raw.extend(port_bytes) - port = struct.unpack("!H", port_bytes)[0] - return {"atyp": atyp, "host": host, "port": port}, bytes(raw) - - -def read_uot_frame(sock: socket.socket) -> tuple[dict[str, object], bytes, bytes]: - target, target_bytes = read_uot_addr(sock) - length_bytes = read_exact(sock, 2) - payload_len = struct.unpack("!H", length_bytes)[0] - payload = read_exact(sock, payload_len) - return target, target_bytes + length_bytes + payload, payload - - -def read_uot_request(sock: socket.socket) -> dict[str, object]: - version = read_exact(sock, 1)[0] - target = read_socks_addr(sock) - return {"version": version, "target": target} - - -def handle_uot_connection( - listener: socket.socket, - result: dict[str, object], - prefix: str, - expect_request: bool, -) -> None: - uot_raw, uot_peer = listener.accept() - uot_raw.settimeout(10) - try: - uot_magic = read_socks_addr(uot_raw) - if expect_request: - result[f"{prefix}_request"] = read_uot_request(uot_raw) - uot_target, uot_frame, uot_payload = read_uot_frame(uot_raw) - result[f"{prefix}_peer"] = str(uot_peer) - result[f"{prefix}_magic_target"] = uot_magic - result[f"{prefix}_target"] = uot_target - result[f"{prefix}_payload"] = uot_payload.decode("utf-8", "replace") - uot_raw.sendall(uot_frame) - finally: - uot_raw.close() - - -def read_http_headers(sock: socket.socket) -> bytes: - return read_http_headers_with_prefix(sock, b"") - - -def read_http_headers_with_prefix(sock: socket.socket, prefix: bytes) -> bytes: - data = bytearray(prefix) - while b"\r\n\r\n" not in data: - chunk = sock.recv(4096) - if not chunk: - break - data.extend(chunk) - if len(data) > 65535: - raise ValueError("HTTP request headers exceeded 64 KiB") - return bytes(data) - - -def read_obfs_http_headers(sock: socket.socket) -> tuple[bytes, bytes]: - data = bytearray() - while b"\r\n\r\n" not in data: - chunk = sock.recv(4096) - if not chunk: - break - data.extend(chunk) - if len(data) > 65535: - raise ValueError("simple-obfs HTTP headers exceeded 64 KiB") - header_end = bytes(data).find(b"\r\n\r\n") - if header_end < 0: - raise ValueError("simple-obfs HTTP headers were incomplete") - header_end += 4 - return bytes(data[:header_end]), bytes(data[header_end:]) - - -def read_simple_obfs_tls_payload(sock: socket.socket) -> tuple[bytes, str | None]: - header = read_exact(sock, 5) - if header[0] != 22: - raise ValueError(f"expected TLS handshake record, got type {header[0]}") - record_len = struct.unpack("!H", header[3:5])[0] - record = read_exact(sock, record_len) - if len(record) < 4 or record[0] != 1: - raise ValueError("expected TLS ClientHello handshake") - body_len = int.from_bytes(record[1:4], "big") - body = record[4 : 4 + body_len] - offset = 0 - offset += 2 # version - offset += 32 # random - session_id_len = body[offset] - offset += 1 + session_id_len - cipher_len = struct.unpack("!H", body[offset : offset + 2])[0] - offset += 2 + cipher_len - compression_len = body[offset] - offset += 1 + compression_len - extension_len = struct.unpack("!H", body[offset : offset + 2])[0] - offset += 2 - end = offset + extension_len - ticket_payload: bytes | None = None - server_name: str | None = None - while offset + 4 <= end: - extension_type = struct.unpack("!H", body[offset : offset + 2])[0] - length = struct.unpack("!H", body[offset + 2 : offset + 4])[0] - offset += 4 - extension = body[offset : offset + length] - offset += length - if extension_type == 0x0023: - ticket_payload = extension - elif extension_type == 0x0000 and len(extension) >= 5: - name_len = struct.unpack("!H", extension[3:5])[0] - if len(extension) >= 5 + name_len: - server_name = extension[5 : 5 + name_len].decode("idna") - if ticket_payload is None: - raise ValueError("simple-obfs TLS ClientHello did not include payload extension") - return ticket_payload, server_name - - -def read_simple_obfs_tls_application_payload(sock: socket.socket) -> bytes: - header = read_exact(sock, 5) - if header[:3] != b"\x17\x03\x03": - raise ValueError(f"expected TLS application-data record, got {header.hex()}") - record_len = struct.unpack("!H", header[3:5])[0] - return read_exact(sock, record_len) - - -def read_simple_obfs_tls_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: - data = bytearray(prefix) - while b"\r\n\r\n" not in data: - data.extend(read_simple_obfs_tls_application_payload(sock)) - if len(data) > 65535: - raise ValueError("simple-obfs TLS plaintext headers exceeded 64 KiB") - return bytes(data) - - -def read_client_websocket_frame(sock: socket.socket) -> bytes: - header = read_exact(sock, 2) - if header[0] & 0x0F != 2: - raise ValueError(f"expected binary websocket frame, got opcode {header[0] & 0x0F}") - if header[1] & 0x80 == 0: - raise ValueError("client websocket frame was not masked") - length = header[1] & 0x7F - if length == 126: - length = struct.unpack("!H", read_exact(sock, 2))[0] - elif length == 127: - length = struct.unpack("!Q", read_exact(sock, 8))[0] - mask = read_exact(sock, 4) - payload = bytearray(read_exact(sock, length)) - for index, byte in enumerate(payload): - payload[index] = byte ^ mask[index % 4] - return bytes(payload) - - -def server_websocket_frame(payload: bytes) -> bytes: - if len(payload) < 126: - return b"\x82" + bytes([len(payload)]) + payload - if len(payload) <= 65535: - return b"\x82\x7e" + struct.pack("!H", len(payload)) + payload - return b"\x82\x7f" + struct.pack("!Q", len(payload)) + payload - - -def read_websocket_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: - data = bytearray(prefix) - while b"\r\n\r\n" not in data: - data.extend(read_client_websocket_frame(sock)) - if len(data) > 65535: - raise ValueError("websocket plaintext headers exceeded 64 KiB") - return bytes(data) - - -def http_header_value(headers: bytes, name: bytes) -> str | None: - name = name.lower() + b":" - for line in headers.splitlines(): - if line.lower().startswith(name): - return line.decode("utf-8", "replace").split(":", 1)[1].strip() - return None - - -def decode_websocket_early_data(value: str | None) -> bytes: - if not value: - raise ValueError("missing websocket early-data protocol header") - padding = "=" * (-len(value) % 4) - return base64.urlsafe_b64decode((value + padding).encode()) - - -def parse_v2ray_mux_client_payload(payload: bytes) -> bytes: - open_len = struct.unpack("!H", payload[:2])[0] - open_frame = payload[2 : 2 + open_len] - if len(open_frame) != open_len or open_frame[:4] != b"\x00\x00\x01\x00": - raise ValueError("v2ray mux open frame was invalid") - offset = 2 + open_len - data_header = payload[offset : offset + 8] - if len(data_header) != 8 or data_header[:6] != b"\x00\x04\x00\x00\x02\x01": - raise ValueError("v2ray mux data frame header was invalid") - data_len = struct.unpack("!H", data_header[6:8])[0] - data_start = offset + 8 - data = payload[data_start : data_start + data_len] - if len(data) != data_len: - raise ValueError("v2ray mux data frame payload was incomplete") - return data - - -def v2ray_mux_server_data_frame(payload: bytes) -> bytes: - if len(payload) > 65535: - raise ValueError("v2ray mux payload is too large") - return b"\x00\x04\x00\x00\x02\x01" + struct.pack("!H", len(payload)) + payload - - -def parse_v2ray_mux_data_payload(payload: bytes) -> bytes: - if len(payload) < 8 or payload[:6] != b"\x00\x04\x00\x00\x02\x01": - raise ValueError("v2ray mux data frame header was invalid") - data_len = struct.unpack("!H", payload[6:8])[0] - data = payload[8 : 8 + data_len] - if len(data) != data_len: - raise ValueError("v2ray mux data frame payload was incomplete") - return data - - -def read_v2ray_mux_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: - data = bytearray(prefix) - while b"\r\n\r\n" not in data: - data.extend(parse_v2ray_mux_data_payload(read_client_websocket_frame(sock))) - if len(data) > 65535: - raise ValueError("v2ray mux plaintext headers exceeded 64 KiB") - return bytes(data) - - -listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -listener.bind(("127.0.0.1", 0)) -listener.listen(16) -port = listener.getsockname()[1] -udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -udp.bind(("127.0.0.1", port)) -udp.settimeout(10) -with open(port_file, "w", encoding="utf-8") as f: - f.write(str(port)) - -raw, peer = listener.accept() -raw.settimeout(10) -result: dict[str, object] = {"tcp_peer": str(peer)} -try: - target = read_socks_addr(raw) - result["tcp_target"] = target - request = read_http_headers(raw) - result["tcp_request_first_line"] = request.splitlines()[0].decode("utf-8", "replace") - body = json.dumps( - { - "ok": True, - "target": target, - "request_first_line": result["tcp_request_first_line"], - }, - sort_keys=True, - ).encode() - raw.sendall( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - datagram, udp_peer = udp.recvfrom(65535) - udp_target, used = parse_socks_addr_packet(datagram) - udp_payload = datagram[used:] - result["udp_peer"] = str(udp_peer) - result["udp_target"] = udp_target - result["udp_payload"] = udp_payload.decode("utf-8", "replace") - udp.sendto(datagram[:used] + udp_payload, udp_peer) - handle_uot_connection(listener, result, "uot", False) - handle_uot_connection(listener, result, "uot_v2", True) - obfs_raw, obfs_peer = listener.accept() - obfs_raw.settimeout(10) - try: - obfs_headers, obfs_payload = read_obfs_http_headers(obfs_raw) - obfs_target, used = parse_socks_addr_packet(obfs_payload) - result["obfs_http_peer"] = str(obfs_peer) - result["obfs_http_first_line"] = obfs_headers.splitlines()[0].decode( - "utf-8", - "replace", - ) - result["obfs_http_host"] = next( - ( - line.decode("utf-8", "replace").split(":", 1)[1].strip() - for line in obfs_headers.splitlines() - if line.lower().startswith(b"host:") - ), - None, - ) - result["obfs_http_content_length"] = next( - ( - line.decode("utf-8", "replace").split(":", 1)[1].strip() - for line in obfs_headers.splitlines() - if line.lower().startswith(b"content-length:") - ), - None, - ) - result["obfs_http_target"] = obfs_target - obfs_request = read_http_headers_with_prefix(obfs_raw, obfs_payload[used:]) - result["obfs_http_request_first_line"] = obfs_request.splitlines()[0].decode( - "utf-8", - "replace", - ) - body = json.dumps( - { - "ok": True, - "target": obfs_target, - "request_first_line": result["obfs_http_request_first_line"], - }, - sort_keys=True, - ).encode() - obfs_raw.sendall( - b"HTTP/1.1 101 Switching Protocols\r\n" - b"Upgrade: websocket\r\n" - b"Connection: Upgrade\r\n\r\n" - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - finally: - obfs_raw.close() - obfs_tls_raw, obfs_tls_peer = listener.accept() - obfs_tls_raw.settimeout(10) - try: - obfs_tls_payload, obfs_tls_server_name = read_simple_obfs_tls_payload(obfs_tls_raw) - obfs_tls_target, used = parse_socks_addr_packet(obfs_tls_payload) - obfs_tls_request = read_simple_obfs_tls_plaintext_headers( - obfs_tls_raw, - obfs_tls_payload[used:], - ) - result["obfs_tls_peer"] = str(obfs_tls_peer) - result["obfs_tls_server_name"] = obfs_tls_server_name - result["obfs_tls_target"] = obfs_tls_target - result["obfs_tls_request_first_line"] = obfs_tls_request.splitlines()[0].decode( - "utf-8", - "replace", - ) - body = json.dumps( - { - "ok": True, - "target": obfs_tls_target, - "request_first_line": result["obfs_tls_request_first_line"], - }, - sort_keys=True, - ).encode() - response = ( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - obfs_tls_raw.sendall(bytes(105) + len(response).to_bytes(2, "big") + response) - finally: - obfs_tls_raw.close() - v2ray_raw, v2ray_peer = listener.accept() - v2ray_raw.settimeout(10) - try: - v2ray_headers = read_http_headers(v2ray_raw) - result["v2ray_http_upgrade_peer"] = str(v2ray_peer) - result["v2ray_http_upgrade_first_line"] = v2ray_headers.splitlines()[0].decode( - "utf-8", - "replace", - ) - result["v2ray_http_upgrade_host"] = next( - ( - line.decode("utf-8", "replace").split(":", 1)[1].strip() - for line in v2ray_headers.splitlines() - if line.lower().startswith(b"host:") - ), - None, - ) - result["v2ray_http_upgrade_has_websocket_key"] = any( - line.lower().startswith(b"sec-websocket-key:") - for line in v2ray_headers.splitlines() - ) - v2ray_raw.sendall( - b"HTTP/1.1 101 Switching Protocols\r\n" - b"Upgrade: websocket\r\n" - b"Connection: Upgrade\r\n\r\n" - ) - v2ray_target = read_socks_addr(v2ray_raw) - v2ray_request = read_http_headers(v2ray_raw) - result["v2ray_http_upgrade_target"] = v2ray_target - result["v2ray_http_upgrade_request_first_line"] = ( - v2ray_request.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - body = json.dumps( - { - "ok": True, - "target": v2ray_target, - "request_first_line": result["v2ray_http_upgrade_request_first_line"], - }, - sort_keys=True, - ).encode() - v2ray_raw.sendall( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - finally: - v2ray_raw.close() - v2ray_ws_raw, v2ray_ws_peer = listener.accept() - v2ray_ws_raw.settimeout(10) - try: - v2ray_ws_headers = read_http_headers(v2ray_ws_raw) - result["v2ray_websocket_peer"] = str(v2ray_ws_peer) - result["v2ray_websocket_first_line"] = ( - v2ray_ws_headers.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - result["v2ray_websocket_host"] = next( - ( - line.decode("utf-8", "replace").split(":", 1)[1].strip() - for line in v2ray_ws_headers.splitlines() - if line.lower().startswith(b"host:") - ), - None, - ) - result["v2ray_websocket_has_websocket_key"] = any( - line.lower().startswith(b"sec-websocket-key:") - for line in v2ray_ws_headers.splitlines() - ) - v2ray_ws_raw.sendall( - b"HTTP/1.1 101 Switching Protocols\r\n" - b"Upgrade: websocket\r\n" - b"Connection: Upgrade\r\n\r\n" - ) - v2ray_ws_payload = read_client_websocket_frame(v2ray_ws_raw) - v2ray_ws_target, used = parse_socks_addr_packet(v2ray_ws_payload) - v2ray_ws_request = read_websocket_plaintext_headers( - v2ray_ws_raw, - v2ray_ws_payload[used:], - ) - result["v2ray_websocket_target"] = v2ray_ws_target - result["v2ray_websocket_request_first_line"] = ( - v2ray_ws_request.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - body = json.dumps( - { - "ok": True, - "target": v2ray_ws_target, - "request_first_line": result["v2ray_websocket_request_first_line"], - }, - sort_keys=True, - ).encode() - response = ( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - v2ray_ws_raw.sendall(server_websocket_frame(response)) - finally: - v2ray_ws_raw.close() - v2ray_mux_raw, v2ray_mux_peer = listener.accept() - v2ray_mux_raw.settimeout(10) - try: - v2ray_mux_headers = read_http_headers(v2ray_mux_raw) - result["v2ray_mux_peer"] = str(v2ray_mux_peer) - result["v2ray_mux_first_line"] = ( - v2ray_mux_headers.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - result["v2ray_mux_host"] = next( - ( - line.decode("utf-8", "replace").split(":", 1)[1].strip() - for line in v2ray_mux_headers.splitlines() - if line.lower().startswith(b"host:") - ), - None, - ) - result["v2ray_mux_has_websocket_key"] = any( - line.lower().startswith(b"sec-websocket-key:") - for line in v2ray_mux_headers.splitlines() - ) - v2ray_mux_raw.sendall( - b"HTTP/1.1 101 Switching Protocols\r\n" - b"Upgrade: websocket\r\n" - b"Connection: Upgrade\r\n\r\n" - ) - v2ray_mux_payload = parse_v2ray_mux_client_payload( - read_client_websocket_frame(v2ray_mux_raw), - ) - v2ray_mux_target, used = parse_socks_addr_packet(v2ray_mux_payload) - v2ray_mux_request = read_v2ray_mux_plaintext_headers( - v2ray_mux_raw, - v2ray_mux_payload[used:], - ) - result["v2ray_mux_target"] = v2ray_mux_target - result["v2ray_mux_request_first_line"] = ( - v2ray_mux_request.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - body = json.dumps( - { - "ok": True, - "target": v2ray_mux_target, - "request_first_line": result["v2ray_mux_request_first_line"], - }, - sort_keys=True, - ).encode() - response = ( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - v2ray_mux_raw.sendall( - server_websocket_frame(v2ray_mux_server_data_frame(response)), - ) - finally: - v2ray_mux_raw.close() - gost_ws_raw, gost_ws_peer = listener.accept() - gost_ws_raw.settimeout(10) - try: - gost_ws_headers = read_http_headers(gost_ws_raw) - result["gost_websocket_peer"] = str(gost_ws_peer) - result["gost_websocket_first_line"] = ( - gost_ws_headers.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - result["gost_websocket_host"] = next( - ( - line.decode("utf-8", "replace").split(":", 1)[1].strip() - for line in gost_ws_headers.splitlines() - if line.lower().startswith(b"host:") - ), - None, - ) - result["gost_websocket_has_websocket_key"] = any( - line.lower().startswith(b"sec-websocket-key:") - for line in gost_ws_headers.splitlines() - ) - gost_ws_raw.sendall( - b"HTTP/1.1 101 Switching Protocols\r\n" - b"Upgrade: websocket\r\n" - b"Connection: Upgrade\r\n\r\n" - ) - gost_ws_payload = read_client_websocket_frame(gost_ws_raw) - gost_ws_target, used = parse_socks_addr_packet(gost_ws_payload) - gost_ws_request = read_websocket_plaintext_headers( - gost_ws_raw, - gost_ws_payload[used:], - ) - result["gost_websocket_target"] = gost_ws_target - result["gost_websocket_request_first_line"] = ( - gost_ws_request.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - body = json.dumps( - { - "ok": True, - "target": gost_ws_target, - "request_first_line": result["gost_websocket_request_first_line"], - }, - sort_keys=True, - ).encode() - response = ( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - gost_ws_raw.sendall(server_websocket_frame(response)) - finally: - gost_ws_raw.close() - v2ray_ed_raw, v2ray_ed_peer = listener.accept() - v2ray_ed_raw.settimeout(10) - try: - v2ray_ed_headers = read_http_headers(v2ray_ed_raw) - result["v2ray_early_data_peer"] = str(v2ray_ed_peer) - result["v2ray_early_data_first_line"] = ( - v2ray_ed_headers.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - result["v2ray_early_data_host"] = http_header_value(v2ray_ed_headers, b"host") - result["v2ray_early_data_has_websocket_key"] = ( - http_header_value(v2ray_ed_headers, b"sec-websocket-key") is not None - ) - early_data = decode_websocket_early_data( - http_header_value(v2ray_ed_headers, b"sec-websocket-protocol"), - ) - result["v2ray_early_data_len"] = len(early_data) - v2ray_ed_raw.sendall( - b"HTTP/1.1 101 Switching Protocols\r\n" - b"Upgrade: websocket\r\n" - b"Connection: Upgrade\r\n\r\n" - ) - v2ray_ed_payload = early_data + read_client_websocket_frame(v2ray_ed_raw) - v2ray_ed_target, used = parse_socks_addr_packet(v2ray_ed_payload) - v2ray_ed_request = read_websocket_plaintext_headers( - v2ray_ed_raw, - v2ray_ed_payload[used:], - ) - result["v2ray_early_data_target"] = v2ray_ed_target - result["v2ray_early_data_request_first_line"] = ( - v2ray_ed_request.splitlines()[0].decode( - "utf-8", - "replace", - ) - ) - body = json.dumps( - { - "ok": True, - "target": v2ray_ed_target, - "request_first_line": result["v2ray_early_data_request_first_line"], - }, - sort_keys=True, - ).encode() - response = ( - b"HTTP/1.1 200 OK\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"Content-Type: application/json\r\n\r\n" - + body - ) - v2ray_ed_raw.sendall(server_websocket_frame(response)) - finally: - v2ray_ed_raw.close() -finally: - raw.close() - udp.close() - with open(result_file, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True) - listener.close() -PY -ss_server_pid=$! - -for _ in {1..100}; do - if [[ -s "$ss_port_file" ]]; then - break - fi - if ! kill -0 "$ss_server_pid" >/dev/null 2>&1; then - echo "local Shadowsocks server exited before listening" >&2 - cat "$ss_server_log" >&2 || true - exit 1 - fi - sleep 0.05 -done - -if [[ ! -s "$ss_port_file" ]]; then - echo "timed out waiting for local Shadowsocks server" >&2 - cat "$ss_server_log" >&2 || true - exit 1 -fi -ss_server_port="$(cat "$ss_port_file")" - -cat >"$payload" <"$daemon_log" 2>&1 -) & -daemon_pid=$! - -for _ in {1..100}; do - if [[ -S "$socket" ]]; then - break - fi - if ! kill -0 "$daemon_pid" >/dev/null 2>&1; then - echo "Burrow daemon exited before creating socket" >&2 - cat "$daemon_log" >&2 || true - exit 1 - fi - sleep 0.05 -done - -if [[ ! -S "$socket" ]]; then - echo "timed out waiting for Burrow daemon socket" >&2 - cat "$daemon_log" >&2 || true - exit 1 -fi - -( - cd "$tmpdir" - BURROW_SOCKET_PATH="$socket" "$burrow_bin" network-add 1 3 "$payload" - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 -) - -wait "$server_pid" -server_pid="" - -echo -echo "Local Trojan server observed:" -cat "$server_result" -echo - -( - cd "$tmpdir" - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 2 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ - --message burrow-shadowsocks-udp-selftest \ - --timeout-ms 8000 \ - 9.9.9.9:5353 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 3 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ - --message burrow-shadowsocks-uot-selftest \ - --timeout-ms 8000 \ - 9.9.9.9:5354 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 4 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ - --message burrow-shadowsocks-uot-v2-selftest \ - --timeout-ms 8000 \ - 9.9.9.9:5355 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 5 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 6 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 7 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 8 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 9 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 10 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 11 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 -) - -wait "$ss_server_pid" -ss_server_pid="" - -echo -echo "Local Shadowsocks server observed:" -cat "$ss_server_result" -echo - -uv run python - "$server_result" "$ss_server_result" <<'PY' -from __future__ import annotations - -import json -import sys - -trojan_path, shadowsocks_path = sys.argv[1:] - - -def load(path: str) -> dict[str, object]: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def expect(value: object, expected: object, label: str) -> None: - if value != expected: - raise AssertionError(f"{label}: expected {expected!r}, got {value!r}") - - -def expect_startswith(value: object, prefix: str, label: str) -> None: - if not isinstance(value, str) or not value.startswith(prefix): - raise AssertionError(f"{label}: expected prefix {prefix!r}, got {value!r}") - - -def expect_target(result: dict[str, object], key: str, host: str, port: int) -> None: - target = result.get(key) - if not isinstance(target, dict): - raise AssertionError(f"{key}: missing target object") - expect(target.get("host"), host, f"{key}.host") - expect(target.get("port"), port, f"{key}.port") - - -trojan = load(trojan_path) -expect(trojan.get("password_hash_ok"), True, "trojan password hash") -expect(trojan.get("command"), 1, "trojan tcp command") -expect(trojan.get("request_first_line"), "GET / HTTP/1.1", "trojan request") -expect_target(trojan, "target", "selftest.invalid", 80) - -ss = load(shadowsocks_path) -for key in [ - "tcp_target", - "obfs_http_target", - "obfs_tls_target", - "v2ray_http_upgrade_target", - "v2ray_websocket_target", - "v2ray_mux_target", - "gost_websocket_target", - "v2ray_early_data_target", -]: - expect_target(ss, key, "selftest.invalid", 80) - -expect(ss.get("udp_payload"), "burrow-shadowsocks-udp-selftest", "native UDP payload") -expect_target(ss, "udp_target", "9.9.9.9", 5353) -expect(ss.get("uot_payload"), "burrow-shadowsocks-uot-selftest", "legacy UOT payload") -expect_target(ss, "uot_magic_target", "sp.udp-over-tcp.arpa", 0) -expect_target(ss, "uot_target", "9.9.9.9", 5354) -expect(ss.get("uot_v2_payload"), "burrow-shadowsocks-uot-v2-selftest", "v2 UOT payload") -expect_target(ss, "uot_v2_magic_target", "sp.v2.udp-over-tcp.arpa", 0) -expect_target(ss, "uot_v2_target", "9.9.9.9", 5355) -expect_startswith(ss.get("obfs_http_host"), "front.example:", "simple-obfs HTTP host") -expect(ss.get("obfs_tls_server_name"), "front.example", "simple-obfs TLS SNI") -expect(ss.get("v2ray_http_upgrade_has_websocket_key"), False, "v2ray raw upgrade key") -expect(ss.get("v2ray_websocket_has_websocket_key"), True, "v2ray websocket key") -expect(ss.get("v2ray_mux_has_websocket_key"), True, "v2ray mux websocket key") -expect(ss.get("gost_websocket_has_websocket_key"), True, "gost websocket key") -expect(ss.get("v2ray_early_data_first_line"), "GET /ed?trace=1 HTTP/1.1", "early-data path") -expect(ss.get("v2ray_early_data_len"), 8, "early-data length") - -print("Proxy self-test assertions passed") -PY diff --git a/Scripts/burrow-shadowsocks-singmux-interop b/Scripts/burrow-shadowsocks-singmux-interop deleted file mode 100755 index e02374b..0000000 --- a/Scripts/burrow-shadowsocks-singmux-interop +++ /dev/null @@ -1,504 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: Scripts/burrow-shadowsocks-singmux-interop - -Run controlled Burrow Shadowsocks top-level sing-mux interop tests against the -same github.com/metacubex/sing-mux Go module used by Mihomo. By default this -checks smux, yamux, and h2mux with and without Mihomo's padded sing-mux -preface/framing. The test does not change system proxy settings or use the -user's Burrow database. -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -burrow_bin="${BURROW_BIN:-$repo_root/target/debug/burrow}" -sing_mux_dir="${SING_MUX_DIR:-/Users/jettchen/go/pkg/mod/github.com/metacubex/sing-mux@v0.3.9}" -sing_dir="${SING_DIR:-/Users/jettchen/go/pkg/mod/github.com/metacubex/sing@v0.5.7}" - -if [[ -z "${BURROW_SINGMUX_INTEROP_SINGLE:-}" ]]; then - for protocol in ${BURROW_SINGMUX_PROTOCOLS:-smux yamux h2mux}; do - for padding in ${BURROW_SINGMUX_PADDING_MODES:-false true}; do - echo "=== Mihomo sing-mux interop: $protocol padding=$padding ===" - BURROW_SINGMUX_INTEROP_SINGLE=1 \ - BURROW_SINGMUX_PROTOCOL="$protocol" \ - BURROW_SINGMUX_PADDING="$padding" \ - "$0" - done - done - exit 0 -fi - -protocol="${BURROW_SINGMUX_PROTOCOL:-smux}" -case "$protocol" in - smux | yamux | h2mux) ;; - *) - echo "unsupported sing-mux protocol: $protocol" >&2 - exit 2 - ;; -esac - -padding="${BURROW_SINGMUX_PADDING:-false}" -case "$padding" in - false | true) ;; - *) - echo "unsupported sing-mux padding mode: $padding" >&2 - exit 2 - ;; -esac - -node_udp="${BURROW_SINGMUX_NODE_UDP:-false}" -case "$node_udp" in - false | true) ;; - *) - echo "unsupported Shadowsocks node udp flag: $node_udp" >&2 - exit 2 - ;; -esac - -if [[ -z "${BURROW_BIN:-}" ]]; then - (cd "$repo_root" && cargo build -p burrow) -elif [[ ! -x "$burrow_bin" ]]; then - echo "BURROW_BIN is not executable: $burrow_bin" >&2 - exit 2 -fi - -if ! command -v go >/dev/null 2>&1; then - echo "go is required for the Mihomo sing-mux interop peer" >&2 - exit 2 -fi - -if ! command -v uv >/dev/null 2>&1; then - echo "uv is required for result assertions" >&2 - exit 2 -fi - -if [[ ! -d "$sing_mux_dir" ]]; then - echo "sing-mux module directory not found: $sing_mux_dir" >&2 - exit 2 -fi - -if [[ ! -d "$sing_dir" ]]; then - echo "sing module directory not found: $sing_dir" >&2 - exit 2 -fi - -tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-singmux-interop.XXXXXX")" -daemon_pid="" -server_pid="" -status=0 - -cleanup() { - if [[ -n "$server_pid" ]]; then - kill "$server_pid" >/dev/null 2>&1 || true - fi - if [[ -n "$daemon_pid" ]]; then - kill "$daemon_pid" >/dev/null 2>&1 || true - fi - if [[ "${BURROW_SELFTEST_KEEP:-}" == "1" || "$status" -ne 0 ]]; then - echo "preserving sing-mux interop artifacts: $tmpdir" >&2 - else - rm -rf "$tmpdir" - fi -} -trap 'status=$?; cleanup' EXIT - -socket="$tmpdir/burrow.sock" -port_file="$tmpdir/singmux.port" -result_file="$tmpdir/singmux-result.json" -payload="$tmpdir/proxy-payload.json" -server_log="$tmpdir/singmux-server.log" -daemon_log="$tmpdir/daemon.log" -go_dir="$tmpdir/go-peer" -mkdir -p "$go_dir" - -cat >"$go_dir/go.mod" < $sing_dir -replace github.com/metacubex/sing-mux => $sing_mux_dir -EOF - -cat >"$go_dir/main.go" <<'EOF' -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "os" - "strings" - "sync" - "time" - - mux "github.com/metacubex/sing-mux" - "github.com/metacubex/sing/common/buf" - "github.com/metacubex/sing/common/bufio" - "github.com/metacubex/sing/common/logger" - M "github.com/metacubex/sing/common/metadata" - N "github.com/metacubex/sing/common/network" -) - -type noopLogger struct{} - -func (noopLogger) Trace(args ...any) {} -func (noopLogger) Debug(args ...any) {} -func (noopLogger) Info(args ...any) {} -func (noopLogger) Warn(args ...any) {} -func (noopLogger) Error(args ...any) {} -func (noopLogger) Fatal(args ...any) {} -func (noopLogger) Panic(args ...any) {} -func (noopLogger) TraceContext(context.Context, ...any) {} -func (noopLogger) DebugContext(context.Context, ...any) {} -func (noopLogger) InfoContext(context.Context, ...any) {} -func (noopLogger) WarnContext(context.Context, ...any) {} -func (noopLogger) ErrorContext(context.Context, ...any) {} -func (noopLogger) FatalContext(context.Context, ...any) {} -func (noopLogger) PanicContext(context.Context, ...any) {} - -var _ logger.ContextLogger = noopLogger{} - -type observed struct { - MuxTarget map[string]any `json:"mux_target,omitempty"` - TCPDestination map[string]any `json:"tcp_destination,omitempty"` - TCPFirstLine string `json:"tcp_first_line,omitempty"` - UDPDestination map[string]any `json:"udp_destination,omitempty"` - UDPPayload string `json:"udp_payload,omitempty"` - Protocol string `json:"protocol,omitempty"` - Padding bool `json:"padding,omitempty"` - PacketReplyCount int `json:"packet_reply_count,omitempty"` -} - -type handler struct { - mu sync.Mutex - result observed - done chan struct{} - doneOnce sync.Once -} - -func (h *handler) markDoneIfReady() { - if h.result.TCPFirstLine != "" && h.result.UDPPayload != "" { - h.doneOnce.Do(func() { close(h.done) }) - } -} - -func (h *handler) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error { - defer conn.Close() - request, err := readHTTPHeaders(conn) - if err != nil { - return err - } - firstLine := strings.SplitN(string(request), "\r\n", 2)[0] - body := []byte("burrow sing-mux tcp ok\n") - response := fmt.Sprintf( - "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n%s", - len(body), - body, - ) - if _, err := conn.Write([]byte(response)); err != nil { - return err - } - h.mu.Lock() - h.result.TCPDestination = socksaddrJSON(metadata.Destination) - h.result.TCPFirstLine = firstLine - h.markDoneIfReady() - h.mu.Unlock() - return nil -} - -func (h *handler) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error { - defer conn.Close() - packet := buf.NewPacket() - defer packet.Release() - destination, err := conn.ReadPacket(packet) - if err != nil { - return err - } - payload := append([]byte(nil), packet.Bytes()...) - if _, err := bufio.WritePacketBuffer(conn, buf.As(payload), destination); err != nil { - return err - } - h.mu.Lock() - h.result.UDPDestination = socksaddrJSON(metadata.Destination) - h.result.UDPPayload = string(payload) - h.result.PacketReplyCount++ - h.markDoneIfReady() - h.mu.Unlock() - return nil -} - -func main() { - portFile := os.Args[1] - resultFile := os.Args[2] - protocol := os.Args[3] - padding := os.Args[4] == "true" - - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - panic(err) - } - defer listener.Close() - port := listener.Addr().(*net.TCPAddr).Port - if err := os.WriteFile(portFile, []byte(fmt.Sprint(port)), 0o644); err != nil { - panic(err) - } - - h := &handler{done: make(chan struct{})} - h.result.Protocol = protocol - h.result.Padding = padding - service, err := mux.NewService(mux.ServiceOptions{ - NewStreamContext: func(ctx context.Context, conn net.Conn) context.Context { return ctx }, - Logger: noopLogger{}, - Handler: h, - }) - if err != nil { - panic(err) - } - - raw, err := listener.Accept() - if err != nil { - panic(err) - } - raw.SetDeadline(time.Now().Add(20 * time.Second)) - target, err := readSocksAddr(raw) - if err != nil { - panic(err) - } - h.mu.Lock() - h.result.MuxTarget = target - h.mu.Unlock() - - go func() { - if err := service.NewConnection(context.Background(), raw, M.Metadata{}); err != nil { - fmt.Fprintln(os.Stderr, err) - } - }() - - select { - case <-h.done: - case <-time.After(20 * time.Second): - panic("timed out waiting for sing-mux TCP and UDP probes") - } - - h.mu.Lock() - output, err := json.MarshalIndent(h.result, "", " ") - h.mu.Unlock() - if err != nil { - panic(err) - } - if err := os.WriteFile(resultFile, append(output, '\n'), 0o644); err != nil { - panic(err) - } -} - -func readHTTPHeaders(reader io.Reader) ([]byte, error) { - data := make([]byte, 0, 512) - tmp := make([]byte, 1) - for !strings.Contains(string(data), "\r\n\r\n") { - if _, err := io.ReadFull(reader, tmp); err != nil { - return nil, err - } - data = append(data, tmp[0]) - if len(data) > 65535 { - return nil, fmt.Errorf("HTTP headers exceeded 64 KiB") - } - } - return data, nil -} - -func readSocksAddr(reader io.Reader) (map[string]any, error) { - destination, err := M.SocksaddrSerializer.ReadAddrPort(reader) - if err != nil { - return nil, err - } - return socksaddrJSON(destination), nil -} - -func socksaddrJSON(destination M.Socksaddr) map[string]any { - if destination.IsFqdn() { - return map[string]any{ - "host": destination.Fqdn, - "port": destination.Port, - } - } - return map[string]any{ - "host": destination.Addr.String(), - "port": destination.Port, - } -} - -func (h *handler) NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, metadata M.Metadata) error { - return fmt.Errorf("unexpected packet handler path") -} -EOF - -( - cd "$go_dir" - go mod tidy -) - -( - cd "$go_dir" - go run . "$port_file" "$result_file" "$protocol" "$padding" -) >"$server_log" 2>&1 & -server_pid=$! - -for _ in {1..600}; do - if [[ -s "$port_file" ]]; then - break - fi - if ! kill -0 "$server_pid" >/dev/null 2>&1; then - echo "sing-mux peer exited before listening" >&2 - cat "$server_log" >&2 || true - exit 1 - fi - sleep 0.05 -done - -if [[ ! -s "$port_file" ]]; then - echo "timed out waiting for sing-mux peer" >&2 - cat "$server_log" >&2 || true - exit 1 -fi -server_port="$(cat "$port_file")" - -cat >"$payload" <"$daemon_log" 2>&1 -) & -daemon_pid=$! - -for _ in {1..100}; do - if [[ -S "$socket" ]]; then - break - fi - if ! kill -0 "$daemon_pid" >/dev/null 2>&1; then - echo "Burrow daemon exited before creating socket" >&2 - cat "$daemon_log" >&2 || true - exit 1 - fi - sleep 0.05 -done - -if [[ ! -S "$socket" ]]; then - echo "timed out waiting for Burrow daemon socket" >&2 - cat "$daemon_log" >&2 || true - exit 1 -fi - -( - cd "$tmpdir" - BURROW_SOCKET_PATH="$socket" "$burrow_bin" network-add 1 3 "$payload" - BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ - --dns-name selftest.invalid \ - --remote 1.1.1.1:80 \ - --timeout-ms 8000 - BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ - --message burrow-singmux-udp-selftest \ - --timeout-ms 8000 \ - 9.9.9.9:5353 -) - -wait "$server_pid" -server_pid="" - -echo -echo "Mihomo sing-mux peer observed:" -cat "$result_file" -echo - -uv run python - "$result_file" "$protocol" "$padding" <<'PY' -from __future__ import annotations - -import json -import sys - -with open(sys.argv[1], "r", encoding="utf-8") as f: - result = json.load(f) -expected_protocol = sys.argv[2] -expected_padding = sys.argv[3] == "true" - - -def expect(value: object, expected: object, label: str) -> None: - if value != expected: - raise AssertionError(f"{label}: expected {expected!r}, got {value!r}") - - -def expect_target(key: str, host: str, port: int) -> None: - target = result.get(key) - if not isinstance(target, dict): - raise AssertionError(f"{key}: missing target object") - expect(target.get("host"), host, f"{key}.host") - expect(target.get("port"), port, f"{key}.port") - - -expect(result.get("protocol"), expected_protocol, "sing-mux protocol") -expect(result.get("padding", False), expected_padding, "sing-mux padding") -expect_target("mux_target", "sp.mux.sing-box.arpa", 444) -expect_target("tcp_destination", "selftest.invalid", 80) -expect(result.get("tcp_first_line"), "GET / HTTP/1.1", "tcp first line") -expect_target("udp_destination", "9.9.9.9", 5353) -expect(result.get("udp_payload"), "burrow-singmux-udp-selftest", "udp payload") -expect(result.get("packet_reply_count"), 1, "packet reply count") -padding_label = "padded" if expected_padding else "unpadded" -print(f"Mihomo sing-mux {expected_protocol} {padding_label} interop assertions passed") -PY diff --git a/Scripts/check-brand-icon-lowres.sh b/Scripts/check-brand-icon-lowres.sh new file mode 100755 index 0000000..c9243a9 --- /dev/null +++ b/Scripts/check-brand-icon-lowres.sh @@ -0,0 +1,92 @@ +#!/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 0f79bf4..e4ba995 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -75,6 +75,7 @@ set -euo pipefail base_services=( forgejo.service + grafana.service caddy.service burrow-forgejo-bootstrap.service burrow-forgejo-runner-bootstrap.service @@ -89,6 +90,7 @@ nsc_services=( tailnet_services=( burrow-authentik-runtime.service burrow-authentik-ready.service + burrow-authentik-grafana-oidc.service headscale.service headscale-bootstrap.service ) @@ -161,6 +163,8 @@ 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 @@ -177,6 +181,7 @@ if command -v curl >/dev/null 2>&1; then 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 diff --git a/Scripts/ci/backup-garage-to-gcs.sh b/Scripts/ci/backup-garage-to-gcs.sh new file mode 100755 index 0000000..9bd95e5 --- /dev/null +++ b/Scripts/ci/backup-garage-to-gcs.sh @@ -0,0 +1,104 @@ +#!/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 index 20b4c06..ca8bcb0 100755 --- a/Scripts/ci/build-release-artifacts.sh +++ b/Scripts/ci/build-release-artifacts.sh @@ -4,17 +4,8 @@ set -euo pipefail repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" cd "${repo_root}" -release_ref="${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}" -target="x86_64-unknown-linux-gnu" -out_dir="${repo_root}/dist" -staging="${out_dir}/burrow-${release_ref}-${target}" +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}}" -mkdir -p "${staging}" - -cargo build --locked --release -p burrow --bin burrow -install -m 0755 target/release/burrow "${staging}/burrow" -cp README.md "${staging}/README.md" - -tarball="${out_dir}/burrow-${release_ref}-${target}.tar.gz" -tar -C "${out_dir}" -czf "${tarball}" "$(basename "${staging}")" -shasum -a 256 "${tarball}" > "${tarball}.sha256" +Scripts/ci/package-release-artifacts.sh "${1:-all}" diff --git a/Scripts/ci/check-release-config.sh b/Scripts/ci/check-release-config.sh new file mode 100755 index 0000000..17bc3e6 --- /dev/null +++ b/Scripts/ci/check-release-config.sh @@ -0,0 +1,68 @@ +#!/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 new file mode 100755 index 0000000..7c09ee1 --- /dev/null +++ b/Scripts/ci/distribute-testflight.sh @@ -0,0 +1,85 @@ +#!/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 index 14be895..05a56af 100755 --- a/Scripts/ci/ensure-nix.sh +++ b/Scripts/ci/ensure-nix.sh @@ -134,6 +134,14 @@ 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 @@ -143,13 +151,18 @@ if [[ -e "${config_file}" && ! -w "${config_file}" ]]; then fi mkdir -p "$(dirname -- "${config_file}")" -cat > "${config_file}" <<'EOF' -experimental-features = nix-command flakes -sandbox = true -fallback = true -substituters = https://cache.nixos.org -trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= -EOF +{ + 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 diff --git a/Scripts/ci/ensure-nsc.sh b/Scripts/ci/ensure-nsc.sh new file mode 100755 index 0000000..87cd9d9 --- /dev/null +++ b/Scripts/ci/ensure-nsc.sh @@ -0,0 +1,73 @@ +#!/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 new file mode 100755 index 0000000..8216a94 --- /dev/null +++ b/Scripts/ci/ensure-spacectl.sh @@ -0,0 +1,60 @@ +#!/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 new file mode 100755 index 0000000..c3eda3c --- /dev/null +++ b/Scripts/ci/google-wif-auth.sh @@ -0,0 +1,156 @@ +#!/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 new file mode 100755 index 0000000..c5dc0a9 --- /dev/null +++ b/Scripts/ci/install-apple-signing-assets.sh @@ -0,0 +1,124 @@ +#!/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 new file mode 100755 index 0000000..1f9ce09 --- /dev/null +++ b/Scripts/ci/nscloud-cache.sh @@ -0,0 +1,257 @@ +#!/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 new file mode 100755 index 0000000..c910f0d --- /dev/null +++ b/Scripts/ci/package-apple-artifacts.sh @@ -0,0 +1,592 @@ +#!/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 new file mode 100755 index 0000000..4ee85ee --- /dev/null +++ b/Scripts/ci/package-release-artifacts.sh @@ -0,0 +1,332 @@ +#!/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 index 338f71b..5a976b8 100755 --- a/Scripts/ci/publish-forgejo-release.sh +++ b/Scripts/ci/publish-forgejo-release.sh @@ -7,7 +7,8 @@ set -euo pipefail : "${TOKEN:?TOKEN is required}" release_api="${API_URL}/repos/${REPOSITORY}/releases" -tag_api="${release_api}/tags/${RELEASE_TAG}" +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 @@ -50,6 +51,9 @@ if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then 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 diff --git a/Scripts/ci/publish-nix-cache.sh b/Scripts/ci/publish-nix-cache.sh new file mode 100755 index 0000000..f8e55e3 --- /dev/null +++ b/Scripts/ci/publish-nix-cache.sh @@ -0,0 +1,49 @@ +#!/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 new file mode 100755 index 0000000..47f0cb0 --- /dev/null +++ b/Scripts/ci/resolve-nix-bin.sh @@ -0,0 +1,56 @@ +#!/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 new file mode 100755 index 0000000..72fe6d8 --- /dev/null +++ b/Scripts/ci/sync-apple-provisioning-profiles.sh @@ -0,0 +1,72 @@ +#!/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 new file mode 100755 index 0000000..5dd87ba --- /dev/null +++ b/Scripts/ci/upload-package-garage.sh @@ -0,0 +1,35 @@ +#!/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 new file mode 100755 index 0000000..f6f0b8c --- /dev/null +++ b/Scripts/ci/upload-package-repositories.sh @@ -0,0 +1,28 @@ +#!/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 new file mode 100755 index 0000000..7c4eb63 --- /dev/null +++ b/Scripts/ci/upload-package-storage.sh @@ -0,0 +1,39 @@ +#!/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 new file mode 100755 index 0000000..f5019aa --- /dev/null +++ b/Scripts/ci/upload-release-garage.sh @@ -0,0 +1,33 @@ +#!/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 new file mode 100755 index 0000000..14bbce4 --- /dev/null +++ b/Scripts/ci/upload-release-gcs.sh @@ -0,0 +1,25 @@ +#!/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 new file mode 100755 index 0000000..3e6bd5f --- /dev/null +++ b/Scripts/ci/upload-release-storage.sh @@ -0,0 +1,41 @@ +#!/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/forgejo-dispatch-via-host.sh b/Scripts/forgejo-dispatch-via-host.sh new file mode 100755 index 0000000..eee0c3f --- /dev/null +++ b/Scripts/forgejo-dispatch-via-host.sh @@ -0,0 +1,205 @@ +#!/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 new file mode 100755 index 0000000..a9a7090 --- /dev/null +++ b/Scripts/forgejo-dispatch.sh @@ -0,0 +1,240 @@ +#!/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/generate-brand-icons.py b/Scripts/generate-brand-icons.py new file mode 100755 index 0000000..02dd15d --- /dev/null +++ b/Scripts/generate-brand-icons.py @@ -0,0 +1,450 @@ +#!/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 new file mode 100755 index 0000000..d1f6a91 --- /dev/null +++ b/Scripts/grafana-tofu.sh @@ -0,0 +1,41 @@ +#!/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/identity-tofu.sh b/Scripts/identity-tofu.sh new file mode 100755 index 0000000..882717c --- /dev/null +++ b/Scripts/identity-tofu.sh @@ -0,0 +1,41 @@ +#!/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/openbao-tofu.sh b/Scripts/openbao-tofu.sh new file mode 100755 index 0000000..96f8d87 --- /dev/null +++ b/Scripts/openbao-tofu.sh @@ -0,0 +1,41 @@ +#!/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 new file mode 100755 index 0000000..2188e20 --- /dev/null +++ b/Scripts/package/bootstrap-native-daemon.sh @@ -0,0 +1,78 @@ +#!/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 new file mode 100755 index 0000000..05f5656 --- /dev/null +++ b/Scripts/package/build-repositories.sh @@ -0,0 +1,248 @@ +#!/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 new file mode 100755 index 0000000..c349069 --- /dev/null +++ b/Scripts/package/google-kms-openpgp.py @@ -0,0 +1,313 @@ +#!/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 b31de21..b82757c 100755 --- a/Scripts/provision-forgejo-nsc.sh +++ b/Scripts/provision-forgejo-nsc.sh @@ -23,7 +23,7 @@ Options: --no-refresh-token Reuse intake/forgejo_nsc_token.txt 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: burrow) + --scope-owner Forgejo org/user owner for the default NSC scope (default: hackclub) --scope-name Forgejo repository name for the default NSC scope (default: burrow) -h, --help Show this help text. EOF @@ -36,7 +36,7 @@ KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-k REFRESH_TOKEN=1 TOKEN_NAME_PREFIX="${FORGEJO_PAT_NAME:-forgejo-nsc}" CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}" -SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-burrow}" +SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-hackclub}" SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}" BURROW_FLAKE_TMPDIRS=() @@ -136,7 +136,20 @@ autoscaler_src="${REPO_ROOT}/services/forgejo-nsc/deploy/autoscaler.yaml" if [[ "${REFRESH_TOKEN}" -eq 1 || ! -s "${token_file}" ]]; then "${NSC_BIN}" auth check-login --duration 20m >/dev/null - "${NSC_BIN}" auth generate-dev-token --output_to "${token_file}" >/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 chmod 600 "${token_file}" fi @@ -155,6 +168,7 @@ 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' )" diff --git a/Scripts/release/google-kms-openssl-dgst-shim.sh b/Scripts/release/google-kms-openssl-dgst-shim.sh new file mode 100755 index 0000000..4925386 --- /dev/null +++ b/Scripts/release/google-kms-openssl-dgst-shim.sh @@ -0,0 +1,196 @@ +#!/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 new file mode 100755 index 0000000..2e0dde8 --- /dev/null +++ b/Scripts/release/google-kms-pkcs11-materialize.sh @@ -0,0 +1,93 @@ +#!/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 new file mode 100755 index 0000000..0168c5b --- /dev/null +++ b/Scripts/releases-tofu.sh @@ -0,0 +1,41 @@ +#!/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 new file mode 100755 index 0000000..321a0a1 --- /dev/null +++ b/Scripts/resolve-age-identity.sh @@ -0,0 +1,172 @@ +#!/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 new file mode 100755 index 0000000..0ea566b --- /dev/null +++ b/Scripts/resolve-forge-ssh-key.sh @@ -0,0 +1,121 @@ +#!/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 index 5170a1e..2638c39 100755 --- a/Scripts/run-ios-tailnet-ui-tests.sh +++ b/Scripts/run-ios-tailnet-ui-tests.sh @@ -2,7 +2,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" +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:-}" diff --git a/Scripts/run-tailnet-connectivity-smoke.sh b/Scripts/run-tailnet-connectivity-smoke.sh index f3053d3..dea2f68 100755 --- a/Scripts/run-tailnet-connectivity-smoke.sh +++ b/Scripts/run-tailnet-connectivity-smoke.sh @@ -2,7 +2,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" +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" diff --git a/Scripts/seal-release-secrets.sh b/Scripts/seal-release-secrets.sh new file mode 100755 index 0000000..86281ee --- /dev/null +++ b/Scripts/seal-release-secrets.sh @@ -0,0 +1,261 @@ +#!/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 new file mode 100755 index 0000000..0e22c7c --- /dev/null +++ b/Scripts/sparkle/sign-appcast-kms.py @@ -0,0 +1,149 @@ +#!/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/version.sh b/Scripts/version.sh new file mode 100755 index 0000000..60b8544 --- /dev/null +++ b/Scripts/version.sh @@ -0,0 +1,147 @@ +#!/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/bazel/android/BUILD.bazel b/bazel/android/BUILD.bazel new file mode 100644 index 0000000..64b7cff --- /dev/null +++ b/bazel/android/BUILD.bazel @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..c6c67bd --- /dev/null +++ b/bazel/android/android_targets.bzl @@ -0,0 +1,12 @@ +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 new file mode 100755 index 0000000..c273218 --- /dev/null +++ b/bazel/android/runner.sh @@ -0,0 +1,73 @@ +#!/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 new file mode 100644 index 0000000..f35ec71 --- /dev/null +++ b/bazel/apple/BUILD.bazel @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..cedaf47 --- /dev/null +++ b/bazel/apple/apple_targets.bzl @@ -0,0 +1,13 @@ +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 new file mode 100755 index 0000000..cdf272a --- /dev/null +++ b/bazel/apple/runner.sh @@ -0,0 +1,42 @@ +#!/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.lock b/burrow-gtk/Cargo.lock index f64b325..a1a9ebd 100644 --- a/burrow-gtk/Cargo.lock +++ b/burrow-gtk/Cargo.lock @@ -36,7 +36,18 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures", - "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", ] [[package]] @@ -48,76 +59,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - -[[package]] -name = "amplify" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" -dependencies = [ - "amplify_derive", - "amplify_num", - "ascii", - "getrandom 0.2.12", - "getrandom 0.3.3", - "wasm-bindgen", -] - -[[package]] -name = "amplify_derive" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" -dependencies = [ - "amplify_syn", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "amplify_num" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afed304556696656d2d71495e1e5f2c4b524a3fb6eb0f2f3778ffc482a40b8a8" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "amplify_syn" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - [[package]] name = "anstream" version = "0.6.11" @@ -134,9 +75,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.14" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" [[package]] name = "anstyle-parse" @@ -172,123 +113,6 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "arti-client" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89842cae6e3bda0fd128a5c66eb3392ed412065dc698c77d9fcc4b77e4159f2" -dependencies = [ - "async-trait", - "cfg-if", - "derive-deftly", - "derive_builder_fork_arti", - "derive_more", - "educe", - "fs-mistrust", - "futures", - "hostname-validator", - "humantime", - "humantime-serde", - "libc", - "once_cell", - "postage", - "rand 0.9.2", - "safelog", - "serde", - "thiserror 2.0.16", - "time", - "tor-async-utils", - "tor-basic-utils", - "tor-chanmgr", - "tor-circmgr", - "tor-config", - "tor-config-path", - "tor-dircommon", - "tor-dirmgr", - "tor-error", - "tor-guardmgr", - "tor-keymgr", - "tor-linkspec", - "tor-llcrypto", - "tor-memquota", - "tor-netdir", - "tor-netdoc", - "tor-persist", - "tor-proto", - "tor-protover", - "tor-rtcompat", - "tracing", - "void", -] - -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.16", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "assert_matches" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" - [[package]] name = "async-channel" version = "2.1.1" @@ -296,40 +120,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ca33f4bc4ed1babef42cad36cc1f51fa88be00420404e5b1e80ab1b18f7678c" dependencies = [ "concurrent-queue", - "event-listener 4.0.3", + "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", ] -[[package]] -name = "async-compression" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5" -dependencies = [ - "flate2", - "futures-core", - "futures-io", - "memchr", - "pin-project-lite", - "xz2", - "zstd 0.13.0", - "zstd-safe 7.0.0", -] - -[[package]] -name = "async-native-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" -dependencies = [ - "futures-util", - "native-tls", - "thiserror 1.0.56", - "url", -] - [[package]] name = "async-stream" version = "0.2.1" @@ -384,49 +180,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "async_executors" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" -dependencies = [ - "blanket", - "futures-core", - "futures-task", - "futures-util", - "pin-project", - "rustc_version", - "tokio", -] - -[[package]] -name = "asynchronous-codec" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" -dependencies = [ - "bytes", - "futures-sink", - "futures-util", - "memchr", - "pin-project-lite", -] - -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -439,29 +192,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" -[[package]] -name = "aws-lc-rs" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff" -dependencies = [ - "bindgen 0.69.5", - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.7.5" @@ -532,12 +262,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.21.7" @@ -552,19 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "serde", - "unty", -] +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bindgen" @@ -611,29 +325,6 @@ dependencies = [ "which", ] -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.11.1", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.106", - "which", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -642,21 +333,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" [[package]] name = "blake2" @@ -667,17 +346,6 @@ dependencies = [ "digest", ] -[[package]] -name = "blanket" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "block" version = "0.1.6" @@ -693,26 +361,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata 0.4.14", - "serde", -] - [[package]] name = "bumpalo" version = "3.14.0" @@ -725,14 +373,11 @@ version = "0.1.0" dependencies = [ "aead", "anyhow", - "argon2", - "arti-client", "async-channel", "async-stream 0.2.1", "axum", "base64 0.21.7", "blake2", - "bytes", "caps", "chacha20poly1305", "clap", @@ -740,17 +385,12 @@ dependencies = [ "dotenv", "fehler", "futures", - "hickory-proto", "hmac", "hyper-util", "ip_network", "ip_network_table", - "ipnetwork", - "libc", "libsystemd", "log", - "md-5", - "netstack-smoltcp", "nix 0.27.1", "once_cell", "parking_lot", @@ -762,21 +402,14 @@ dependencies = [ "ring", "rusqlite", "rust-ini", - "rustls", - "schemars 0.8.16", + "schemars", "serde", "serde_json", - "serde_yaml", - "sha2", - "subtle", "tokio", - "tokio-rustls", "tokio-stream", - "tokio-util", - "toml 0.8.23", + "toml", "tonic", "tonic-build", - "tor-rtcompat", "tower", "tracing", "tracing-journald", @@ -784,7 +417,6 @@ dependencies = [ "tracing-oslog", "tracing-subscriber", "tun", - "webpki-roots 0.26.11", "x25519-dalek", ] @@ -797,23 +429,9 @@ dependencies = [ "gettext-rs", "glib-build-tools", "relm4", - "serde", - "serde_json", "tokio", ] -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - [[package]] name = "byteorder" version = "1.5.0" @@ -882,28 +500,14 @@ dependencies = [ "thiserror 1.0.56", ] -[[package]] -name = "caret" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - [[package]] name = "cc" -version = "1.2.62" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ - "find-msvc-tools", "jobserver", "libc", - "shlex", ] [[package]] @@ -961,45 +565,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link 0.2.1", -] - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "cipher" version = "0.4.4" @@ -1024,9 +589,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" dependencies = [ "clap_builder", "clap_derive", @@ -1034,23 +599,23 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", ] [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.106", @@ -1058,29 +623,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "coarsetime" -version = "0.1.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" -dependencies = [ - "libc", - "wasix", - "wasm-bindgen", -] +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" [[package]] name = "colorchoice" @@ -1110,12 +655,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-random" version = "0.1.18" @@ -1142,24 +681,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cookie-factory" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" -dependencies = [ - "futures", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1194,85 +715,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "criterion" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" -dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools 0.13.0", - "num-traits", - "oorandom", - "page_size", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-cycles-per-byte" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5396de42a52e9e5d8f67ef0702dae30451f310a9ba1c3094dcf228f0be0e54bc" -dependencies = [ - "cfg-if", - "criterion", -] - -[[package]] -name = "criterion-plot" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "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-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.19" @@ -1285,18 +727,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -1308,15 +738,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "curve25519-dalek" version = "4.1.1" @@ -1326,7 +747,6 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest", "fiat-crypto", "platforms", "rustc_version", @@ -1345,271 +765,13 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "darling" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" -dependencies = [ - "darling_core 0.14.4", - "darling_macro 0.14.4", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn 1.0.109", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.106", -] - -[[package]] -name = "darling_macro" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" -dependencies = [ - "darling_core 0.14.4", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "defmt" -version = "0.3.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" -dependencies = [ - "defmt 1.1.0", -] - -[[package]] -name = "defmt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" -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", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "cookie-factory", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", -] - [[package]] name = "deranged" -version = "0.5.8" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", - "serde_core", -] - -[[package]] -name = "derive-deftly" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284db66a66f03c3dafbe17360d959eb76b83f77cfe191677e2a7899c0da291f3" -dependencies = [ - "derive-deftly-macros", - "heck 0.5.0", -] - -[[package]] -name = "derive-deftly-macros" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caef6056a5788d05d173cdc3c562ac28ae093828f851f69378b74e4e3d578e41" -dependencies = [ - "heck 0.5.0", - "indexmap 2.11.4", - "itertools 0.14.0", - "proc-macro-crate", - "proc-macro2", - "quote", - "sha3", - "strum", - "syn 2.0.106", - "void", -] - -[[package]] -name = "derive_builder_core_fork_arti" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" -dependencies = [ - "darling 0.14.4", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "derive_builder_fork_arti" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" -dependencies = [ - "derive_builder_macro_fork_arti", -] - -[[package]] -name = "derive_builder_macro_fork_arti" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" -dependencies = [ - "derive_builder_core_fork_arti", - "syn 1.0.109", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.106", - "unicode-xid", ] [[package]] @@ -1619,52 +781,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "dlv-list" version = "0.5.2" @@ -1680,75 +800,11 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" -[[package]] -name = "downcast-rs" -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" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "merlin", - "rand_core 0.6.4", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "educe" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" -dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" [[package]] name = "either" @@ -1756,25 +812,6 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "encode_unicode" version = "0.3.6" @@ -1790,64 +827,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 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "enum-ordinalize" -version = "3.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" -dependencies = [ - "num-bigint", - "num-traits", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "enumset" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" -dependencies = [ - "enumset_derive", -] - -[[package]] -name = "enumset_derive" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -1864,15 +843,6 @@ dependencies = [ "windows-sys 0.52.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 = "4.0.3" @@ -1884,24 +854,13 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - [[package]] name = "event-listener-strategy" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" dependencies = [ - "event-listener 4.0.3", + "event-listener", "pin-project-lite", ] @@ -1919,9 +878,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fehler" @@ -1943,16 +902,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.5" @@ -1969,35 +918,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic 0.6.1", - "serde", - "toml 0.8.23", - "uncased", - "version_check", -] - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - [[package]] name = "fixedbitset" version = "0.5.7" @@ -2014,12 +934,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "fluid-let" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" - [[package]] name = "flume" version = "0.10.14" @@ -2030,7 +944,7 @@ dependencies = [ "futures-sink", "nanorand", "pin-project", - "spin 0.9.8", + "spin", ] [[package]] @@ -2039,18 +953,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.3.2" @@ -2068,9 +970,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -2081,43 +983,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" -[[package]] -name = "fs-mistrust" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2" -dependencies = [ - "derive_builder_fork_arti", - "dirs", - "libc", - "pwd-grp", - "serde", - "thiserror 2.0.16", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.30" @@ -2135,9 +1000,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", @@ -2145,9 +1010,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-executor" @@ -2162,15 +1027,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", @@ -2179,21 +1044,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-channel", "futures-core", @@ -2203,6 +1068,7 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", + "pin-utils", "slab", ] @@ -2274,7 +1140,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -2299,36 +1164,11 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", + "r-efi", "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "getset" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "gettext-rs" version = "0.7.0" @@ -2424,7 +1264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eca5c79337338391f1ab8058d6698125034ce8ef31b72a442437fa6c8580de26" dependencies = [ "anyhow", - "heck 0.4.1", + "heck", "proc-macro-crate", "proc-macro-error", "proc-macro2", @@ -2448,12 +1288,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" -[[package]] -name = "glob-match" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" - [[package]] name = "gobject-sys" version = "0.17.10" @@ -2488,17 +1322,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "gsk4" version = "0.6.3" @@ -2625,26 +1448,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "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" @@ -2656,42 +1459,23 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "ahash", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" [[package]] name = "hashlink" -version = "0.11.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", + "hashbrown 0.14.3", ] [[package]] @@ -2700,52 +1484,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hex" 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - [[package]] name = "hmac" version = "0.12.1" @@ -2764,12 +1508,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "hostname-validator" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" - [[package]] name = "http" version = "0.2.11" @@ -2838,22 +1576,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - -[[package]] -name = "humantime-serde" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" -dependencies = [ - "humantime", - "serde", -] - [[package]] name = "hyper" version = "0.14.28" @@ -2871,7 +1593,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.4.10", "tokio", "tower-service", "tracing", @@ -2957,149 +1679,20 @@ dependencies = [ "hyper 1.6.0", "libc", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" -version = "1.1.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", + "unicode-bidi", + "unicode-normalization", ] [[package]] @@ -3110,7 +1703,6 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "serde", ] [[package]] @@ -3120,29 +1712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "inotify" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" -dependencies = [ - "bitflags 2.11.1", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", + "hashbrown 0.16.0", ] [[package]] @@ -3154,22 +1724,13 @@ dependencies = [ "generic-array", ] -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -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.11.1", + "bitflags 2.4.2", "cfg-if", "libc", ] @@ -3202,33 +1763,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" -[[package]] -name = "ipnetwork" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" -dependencies = [ - "serde", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -3246,63 +1780,28 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" dependencies = [ - "getrandom 0.3.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures", -] - -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags 2.11.1", - "libc", -] - [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" -dependencies = [ - "spin 0.5.2", -] [[package]] name = "lazycell" @@ -3310,12 +1809,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libadwaita" version = "0.4.4" @@ -3375,26 +1868,11 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - [[package]] name = "libsqlite3-sys" -version = "0.36.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ "cc", "pkg-config", @@ -3425,12 +1903,6 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "locale_config" version = "0.3.0" @@ -3466,17 +1938,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "malloc_buf" version = "0.0.6" @@ -3486,12 +1947,6 @@ dependencies = [ "libc", ] -[[package]] -name = "managed" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" - [[package]] name = "matchers" version = "0.1.0" @@ -3507,31 +1962,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - [[package]] name = "memoffset" version = "0.7.1" @@ -3550,18 +1986,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "merlin" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" -dependencies = [ - "byteorder", - "keccak", - "rand_core 0.6.4", - "zeroize", -] - [[package]] name = "miette" version = "5.10.0" @@ -3613,7 +2037,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", - "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -3651,22 +2074,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "netstack-smoltcp" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "398691cef792b89eb5d29e6ea6b3999def706b908d355e29815ba8101cf5c4c8" -dependencies = [ - "etherparse", - "futures", - "rand 0.8.5", - "smoltcp", - "spin 0.9.8", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "nix" version = "0.26.4" @@ -3686,7 +2093,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.4.2", "cfg-if", "libc", "memoffset 0.9.0", @@ -3702,47 +2109,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nonany" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" - -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.11.1", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3753,90 +2119,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "objc" version = "0.2.7" @@ -3857,25 +2139,6 @@ dependencies = [ "objc_id", ] -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - [[package]] name = "objc_id" version = "0.1.1" @@ -3896,28 +2159,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.4" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "oneshot-fused-workaround" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" -dependencies = [ - "futures", -] - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "opaque-debug" @@ -3931,7 +2175,7 @@ version = "0.10.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.4.2", "cfg-if", "foreign-types", "libc", @@ -3969,21 +2213,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-multimap" version = "0.7.3" @@ -3994,69 +2223,12 @@ dependencies = [ "hashbrown 0.14.3", ] -[[package]] -name = "os_str_bytes" -version = "6.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" -dependencies = [ - "memchr", -] - [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p521" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" -dependencies = [ - "base16ct", - "ecdsa", - "elliptic-curve", - "primeorder", - "rand_core 0.6.4", - "sha2", -] - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "pango" version = "0.17.10" @@ -4123,23 +2295,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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pbkdf2" version = "0.11.0" @@ -4148,7 +2303,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash 0.4.2", + "password-hash", "sha2", ] @@ -4158,20 +2313,11 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" -version = "2.3.2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "petgraph" @@ -4183,49 +2329,6 @@ dependencies = [ "indexmap 2.11.4", ] -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros", - "phf_shared", - "serde", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project" version = "1.1.4" @@ -4258,27 +2361,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.29" @@ -4291,34 +2373,6 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - [[package]] name = "poly1305" version = "0.8.0" @@ -4330,36 +2384,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "postage" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" -dependencies = [ - "atomic 0.5.3", - "crossbeam-queue", - "futures", - "parking_lot", - "pin-project", - "static_assertions", - "thiserror 1.0.56", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -4374,34 +2398,14 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "prettyplease" -version = "0.2.37" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" dependencies = [ "proc-macro2", "syn 2.0.106", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "priority-queue" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" -dependencies = [ - "equivalent", - "indexmap 2.11.4", - "serde", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4436,28 +2440,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "proc-macro2" version = "1.0.101" @@ -4483,8 +2465,8 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", + "heck", + "itertools", "log", "multimap", "once_cell", @@ -4504,7 +2486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn 2.0.106", @@ -4519,18 +2501,6 @@ dependencies = [ "prost", ] -[[package]] -name = "pwd-grp" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" -dependencies = [ - "derive-deftly", - "libc", - "paste", - "thiserror 2.0.16", -] - [[package]] name = "quinn" version = "0.11.9" @@ -4544,7 +2514,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.16", "tokio", "tracing", @@ -4581,16 +2551,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -4601,18 +2571,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.5" @@ -4672,46 +2630,6 @@ dependencies = [ "getrandom 0.3.3", ] -[[package]] -name = "rand_jitter" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" -dependencies = [ - "libc", - "rand_core 0.9.3", - "winapi", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "rdrand" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -4721,47 +2639,16 @@ dependencies = [ "bitflags 1.3.2", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.12", - "libredox", - "thiserror 2.0.16", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "regex" -version = "1.12.3" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.14", - "regex-syntax 0.8.10", + "regex-automata 0.4.4", + "regex-syntax 0.8.2", ] [[package]] @@ -4775,13 +2662,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "3b7fa1134405e2ec9353fd416b17f8dacd46c473d7d3fd1cf202706a14eb792a" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.10", + "regex-syntax 0.8.2", ] [[package]] @@ -4792,9 +2679,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] name = "relm4" @@ -4905,25 +2792,6 @@ dependencies = [ "winreg 0.52.0", ] -[[package]] -name = "retry-error" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c322ea521636c5a3f13685a6266055b2dda7e54e2be35214d7c2a5d0672a5db" -dependencies = [ - "humantime", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "ring" version = "0.17.7" @@ -4933,56 +2801,23 @@ dependencies = [ "cc", "getrandom 0.2.12", "libc", - "spin 0.9.8", + "spin", "untrusted", "windows-sys 0.48.0", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "sha2", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.16", -] - [[package]] name = "rusqlite" -version = "0.38.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.4.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", - "sqlite-wasm-rs", - "time", ] [[package]] @@ -5022,22 +2857,13 @@ dependencies = [ "semver", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - [[package]] name = "rustix" version = "0.38.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.4.2", "errno", "libc", "linux-raw-sys", @@ -5050,8 +2876,6 @@ version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ - "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -5081,11 +2905,10 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5103,43 +2926,6 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" -[[package]] -name = "safelog" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a907e0d82c61b1b06a2030c968eb313dcf432686b77801a26bc4b206f96573" -dependencies = [ - "derive_more", - "educe", - "either", - "fluid-let", - "thiserror 2.0.16", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "sanitize-filename" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" -dependencies = [ - "regex", -] - -[[package]] -name = "saturating-time" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" - [[package]] name = "schannel" version = "0.1.23" @@ -5161,30 +2947,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "schemars_derive" version = "0.8.16" @@ -5203,20 +2965,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "2.9.2" @@ -5248,38 +2996,28 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -5297,27 +3035,15 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "serde_ignored" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" dependencies = [ "itoa", - "memchr", + "ryu", "serde", - "serde_core", - "zmij", ] [[package]] @@ -5340,15 +3066,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5361,51 +3078,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" -dependencies = [ - "base64 0.22.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.11.4", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.11.4", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha-1" version = "0.10.1" @@ -5439,16 +3111,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest", - "keccak", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -5458,17 +3120,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "bstr", - "dirs", - "os_str_bytes", -] - [[package]] name = "shlex" version = "1.3.0" @@ -5484,22 +3135,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - [[package]] name = "slab" version = "0.4.9" @@ -5509,29 +3144,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "serde", - "version_check", -] - -[[package]] -name = "slotmap-careful" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" -dependencies = [ - "paste", - "serde", - "slotmap", - "thiserror 2.0.16", - "void", -] - [[package]] name = "smallvec" version = "1.13.1" @@ -5539,18 +3151,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] -name = "smoltcp" -version = "0.12.0" +name = "socket2" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" dependencies = [ - "bitflags 1.3.2", - "byteorder", - "cfg-if", - "defmt 0.3.100", - "heapless", - "log", - "managed", + "libc", + "winapi", ] [[package]] @@ -5563,22 +3170,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -5588,70 +3179,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - -[[package]] -name = "ssh-cipher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" -dependencies = [ - "cipher", - "ssh-encoding", -] - -[[package]] -name = "ssh-encoding" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" -dependencies = [ - "base64ct", - "pem-rfc7468", - "sha2", -] - -[[package]] -name = "ssh-key" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" -dependencies = [ - "num-bigint-dig", - "p256", - "p384", - "p521", - "rand_core 0.6.4", - "rsa", - "sec1", - "sha2", - "signature", - "ssh-cipher", - "ssh-encoding", - "subtle", - "zeroize", -] - [[package]] name = "ssri" version = "9.2.0" @@ -5668,56 +3195,17 @@ dependencies = [ "xxhash-rust", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "subtle" -version = "2.6.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" @@ -5747,31 +3235,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "sysinfo" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - [[package]] name = "system-configuration" version = "0.5.1" @@ -5800,18 +3263,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2d580ff6a20c55dfb86be5f9c238f67835d0e81cbdea8bf5680e0897320331" dependencies = [ "cfg-expr", - "heck 0.4.1", + "heck", "pkg-config", - "toml 0.8.23", + "toml", "version-compare", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.12.13" @@ -5889,35 +3346,21 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" dependencies = [ "deranged", - "itoa", - "js-sys", - "num-conv", "powerfmt", - "serde_core", + "serde", "time-core", - "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tiny-keccak" @@ -5928,27 +3371,6 @@ dependencies = [ "crunchy", ] -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tinyvec" version = "1.6.0" @@ -6028,16 +3450,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" dependencies = [ "bytes", "futures-core", - "futures-io", "futures-sink", "pin-project-lite", "tokio", + "tracing", ] [[package]] @@ -6047,26 +3469,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_edit 0.22.27", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.11.4", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.13", -] - [[package]] name = "toml_datetime" version = "0.6.11" @@ -6076,15 +3483,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.19.15" @@ -6092,7 +3490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.11.4", - "toml_datetime 0.6.11", + "toml_datetime", "winnow 0.5.34", ] @@ -6104,33 +3502,18 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.11.4", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_write", "winnow 0.7.13", ] -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.3", -] - [[package]] name = "toml_write" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "tonic" version = "0.12.3" @@ -6175,959 +3558,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "tor-async-utils" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "895c61a46909134501c6815eceeb66c9c80fc494ee89429821b0f05ccf34b4f5" -dependencies = [ - "derive-deftly", - "educe", - "futures", - "oneshot-fused-workaround", - "pin-project", - "postage", - "thiserror 2.0.16", - "void", -] - -[[package]] -name = "tor-basic-utils" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac6e4d7e131b7d69bc85558383cd4ac61e46b4dd0d4ed51632f28fac98cac0c" -dependencies = [ - "derive_more", - "hex", - "itertools 0.14.0", - "libc", - "paste", - "rand 0.9.2", - "rand_chacha 0.9.0", - "serde", - "slab", - "smallvec", - "thiserror 2.0.16", - "weak-table", -] - -[[package]] -name = "tor-bytes" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64454947258e49f238a5f06a06250a0c54598a1c7409898b5c79505e6a99e7af" -dependencies = [ - "bytes", - "derive-deftly", - "digest", - "educe", - "getrandom 0.4.2", - "safelog", - "thiserror 2.0.16", - "tor-error", - "tor-llcrypto", - "zeroize", -] - -[[package]] -name = "tor-cell" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ab0c79bc92a957e85959cf397a2d8f9c8294c35fa4f65247a9393b20ac95551" -dependencies = [ - "amplify", - "bitflags 2.11.1", - "bytes", - "caret", - "derive-deftly", - "derive_more", - "educe", - "itertools 0.14.0", - "paste", - "rand 0.9.2", - "smallvec", - "thiserror 2.0.16", - "tor-basic-utils", - "tor-bytes", - "tor-cert", - "tor-error", - "tor-linkspec", - "tor-llcrypto", - "tor-memquota", - "tor-protover", - "tor-units", - "void", -] - -[[package]] -name = "tor-cert" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debc911738298ee801fce4577c36a50c55295b0bb9c5519461b83cc486a1f86e" -dependencies = [ - "caret", - "derive_builder_fork_arti", - "derive_more", - "digest", - "thiserror 2.0.16", - "tor-bytes", - "tor-checkable", - "tor-error", - "tor-llcrypto", -] - -[[package]] -name = "tor-chanmgr" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7af5b7c2f1e16d1304b5185fcbc91ca5c8df991c21be00702f925f055573eea1" -dependencies = [ - "async-trait", - "caret", - "cfg-if", - "derive-deftly", - "derive_more", - "educe", - "futures", - "oneshot-fused-workaround", - "percent-encoding", - "postage", - "rand 0.9.2", - "safelog", - "serde", - "serde_with", - "thiserror 2.0.16", - "tor-async-utils", - "tor-basic-utils", - "tor-cell", - "tor-config", - "tor-error", - "tor-keymgr", - "tor-linkspec", - "tor-llcrypto", - "tor-memquota", - "tor-netdir", - "tor-proto", - "tor-rtcompat", - "tor-socksproto", - "tor-units", - "tracing", - "url", - "void", -] - -[[package]] -name = "tor-checkable" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b13a5b50bb55037f2e81b25dde42f420d57c75154216b8ef989006cea3ebee" -dependencies = [ - "humantime", - "signature", - "thiserror 2.0.16", - "tor-llcrypto", -] - -[[package]] -name = "tor-circmgr" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b878f3f7c6be0c7f130d90b347ada2e7c46519dfbdde8273eae2e5d1792caa87" -dependencies = [ - "amplify", - "async-trait", - "cfg-if", - "derive-deftly", - "derive_builder_fork_arti", - "derive_more", - "downcast-rs", - "dyn-clone", - "educe", - "futures", - "humantime-serde", - "itertools 0.14.0", - "once_cell", - "oneshot-fused-workaround", - "pin-project", - "rand 0.9.2", - "retry-error", - "safelog", - "serde", - "thiserror 2.0.16", - "tor-async-utils", - "tor-basic-utils", - "tor-cell", - "tor-chanmgr", - "tor-config", - "tor-dircommon", - "tor-error", - "tor-guardmgr", - "tor-linkspec", - "tor-memquota", - "tor-netdir", - "tor-netdoc", - "tor-persist", - "tor-proto", - "tor-protover", - "tor-relay-selection", - "tor-rtcompat", - "tor-units", - "tracing", - "void", - "weak-table", -] - -[[package]] -name = "tor-config" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc74a00ab15bb986e3747c6969e40a58a63065d6f99077e7ee2f4657bb8b03" -dependencies = [ - "amplify", - "cfg-if", - "derive-deftly", - "derive_builder_fork_arti", - "educe", - "either", - "figment", - "fs-mistrust", - "futures", - "humantime-serde", - "itertools 0.14.0", - "notify", - "paste", - "postage", - "regex", - "serde", - "serde-value", - "serde_ignored", - "strum", - "thiserror 2.0.16", - "toml 0.9.12+spec-1.1.0", - "tor-basic-utils", - "tor-error", - "tor-rtcompat", - "tracing", - "void", -] - -[[package]] -name = "tor-config-path" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3005ab7b9a26a7271e5adf3dfb4ae18c09a943e32aeccc4f6d1c53a60de74b8d" -dependencies = [ - "directories", - "serde", - "shellexpand", - "thiserror 2.0.16", - "tor-error", - "tor-general-addr", -] - -[[package]] -name = "tor-consdiff" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bfa2b7b71c72830f61c48da4bb3e13191e0c0e1404b9c5168c795e4f5feb4a8" -dependencies = [ - "digest", - "hex", - "thiserror 2.0.16", - "tor-llcrypto", -] - -[[package]] -name = "tor-dirclient" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccd6fac844ac77c33ccdfcb56bf23ff40ebbb821ea708be79a481ec30e8c39c" -dependencies = [ - "async-compression", - "base64ct", - "derive_more", - "futures", - "hex", - "http 1.3.1", - "httparse", - "httpdate", - "itertools 0.14.0", - "memchr", - "thiserror 2.0.16", - "tor-circmgr", - "tor-error", - "tor-linkspec", - "tor-llcrypto", - "tor-netdoc", - "tor-proto", - "tor-rtcompat", - "tracing", -] - -[[package]] -name = "tor-dircommon" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cf39a3c30321d145a4d60753ae7ef5bb58a66a00ac9e2bfc30bd823faf2a4" -dependencies = [ - "base64ct", - "derive-deftly", - "getset", - "humantime", - "humantime-serde", - "serde", - "tor-basic-utils", - "tor-checkable", - "tor-config", - "tor-linkspec", - "tor-llcrypto", - "tor-netdoc", - "tracing", -] - -[[package]] -name = "tor-dirmgr" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52919aa9dbb82a354c5b904bef82e91beb702b9f8ad14e6eac4237d6128bf67" -dependencies = [ - "async-trait", - "base64ct", - "derive_builder_fork_arti", - "derive_more", - "digest", - "educe", - "event-listener 5.4.1", - "fs-mistrust", - "fslock", - "futures", - "hex", - "humantime", - "humantime-serde", - "itertools 0.14.0", - "memmap2", - "oneshot-fused-workaround", - "paste", - "postage", - "rand 0.9.2", - "rusqlite", - "safelog", - "scopeguard", - "serde", - "serde_json", - "signature", - "static_assertions", - "strum", - "thiserror 2.0.16", - "time", - "tor-async-utils", - "tor-basic-utils", - "tor-checkable", - "tor-circmgr", - "tor-config", - "tor-consdiff", - "tor-dirclient", - "tor-dircommon", - "tor-error", - "tor-guardmgr", - "tor-llcrypto", - "tor-netdir", - "tor-netdoc", - "tor-persist", - "tor-proto", - "tor-protover", - "tor-rtcompat", - "tracing", -] - -[[package]] -name = "tor-error" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595b005e6f571ac3890a34a00f361200aab781fd0218f2c528c86fc7af088df5" -dependencies = [ - "derive_more", - "futures", - "paste", - "retry-error", - "static_assertions", - "strum", - "thiserror 2.0.16", - "tracing", - "void", -] - -[[package]] -name = "tor-general-addr" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727b8c8bc01c1587486055edab5c2cd0d5c960f5bb3fac796fc9911872b8b397" -dependencies = [ - "derive_more", - "thiserror 2.0.16", - "void", -] - -[[package]] -name = "tor-guardmgr" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d337f465a477c0fb3b2faafa4654d70ff9df3590e57d22707591dddb4e4450c1" -dependencies = [ - "amplify", - "base64ct", - "derive-deftly", - "derive_builder_fork_arti", - "derive_more", - "dyn-clone", - "educe", - "futures", - "humantime", - "humantime-serde", - "itertools 0.14.0", - "num_enum", - "oneshot-fused-workaround", - "pin-project", - "postage", - "rand 0.9.2", - "safelog", - "serde", - "strum", - "thiserror 2.0.16", - "tor-async-utils", - "tor-basic-utils", - "tor-config", - "tor-dircommon", - "tor-error", - "tor-linkspec", - "tor-llcrypto", - "tor-netdir", - "tor-netdoc", - "tor-persist", - "tor-proto", - "tor-relay-selection", - "tor-rtcompat", - "tor-units", - "tracing", -] - -[[package]] -name = "tor-hscrypto" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3693cd43f05cd01ac0aaa060dae5c5e53c4364f89e0d769e33cd629a2fd3118" -dependencies = [ - "data-encoding", - "derive-deftly", - "derive_more", - "digest", - "hex", - "humantime", - "itertools 0.14.0", - "paste", - "rand 0.9.2", - "safelog", - "serde", - "signature", - "subtle", - "thiserror 2.0.16", - "tor-basic-utils", - "tor-bytes", - "tor-error", - "tor-key-forge", - "tor-llcrypto", - "tor-units", - "void", -] - -[[package]] -name = "tor-key-forge" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ade9065ae49cfe2ab020ca9ca9f2b3c5c9b5fc0d8980fa681d8b3a0668e042f" -dependencies = [ - "derive-deftly", - "derive_more", - "downcast-rs", - "paste", - "rand 0.9.2", - "rsa", - "signature", - "ssh-key", - "thiserror 2.0.16", - "tor-bytes", - "tor-cert", - "tor-checkable", - "tor-error", - "tor-llcrypto", -] - -[[package]] -name = "tor-keymgr" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243c3163d376c4723cd67271fcd6e5d6b498a6865c6b98299640e1be01c38826" -dependencies = [ - "amplify", - "arrayvec", - "cfg-if", - "derive-deftly", - "derive_builder_fork_arti", - "derive_more", - "downcast-rs", - "dyn-clone", - "fs-mistrust", - "glob-match", - "humantime", - "inventory", - "itertools 0.14.0", - "rand 0.9.2", - "safelog", - "serde", - "signature", - "ssh-key", - "thiserror 2.0.16", - "tor-basic-utils", - "tor-bytes", - "tor-config", - "tor-config-path", - "tor-error", - "tor-hscrypto", - "tor-key-forge", - "tor-llcrypto", - "tor-persist", - "tracing", - "visibility", - "walkdir", - "zeroize", -] - -[[package]] -name = "tor-linkspec" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f1ea8786900d6fbe4c9f775d341b1ba01bbd1f750d89bd63be78b6b01e1836" -dependencies = [ - "base64ct", - "by_address", - "caret", - "derive-deftly", - "derive_builder_fork_arti", - "derive_more", - "hex", - "itertools 0.14.0", - "safelog", - "serde", - "serde_with", - "strum", - "thiserror 2.0.16", - "tor-basic-utils", - "tor-bytes", - "tor-config", - "tor-llcrypto", - "tor-memquota", - "tor-protover", -] - -[[package]] -name = "tor-llcrypto" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c6989a1c6d06ffd6835e2917edaae4aeef544f8e5fdd68b54cc365f2af523de" -dependencies = [ - "aes", - "base64ct", - "ctr", - "curve25519-dalek", - "der-parser", - "derive-deftly", - "derive_more", - "digest", - "ed25519-dalek", - "educe", - "getrandom 0.4.2", - "hex", - "rand 0.9.2", - "rand_chacha 0.9.0", - "rand_core 0.6.4", - "rand_core 0.9.3", - "rand_jitter", - "rdrand", - "rsa", - "safelog", - "serde", - "sha1", - "sha2", - "sha3", - "signature", - "subtle", - "thiserror 2.0.16", - "tor-error", - "tor-memquota-cost", - "visibility", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "tor-log-ratelim" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1cd642180923d12e3fab5996b4aa2189718da7f465df6eb196ce2b9c70e293" -dependencies = [ - "futures", - "humantime", - "thiserror 2.0.16", - "tor-error", - "tor-rtcompat", - "tracing", - "weak-table", -] - -[[package]] -name = "tor-memquota" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599daea60fd3272eb72a795d1c593b45bbe15343cbc702340a81db124c06eed5" -dependencies = [ - "cfg-if", - "derive-deftly", - "derive_more", - "dyn-clone", - "educe", - "futures", - "itertools 0.14.0", - "paste", - "pin-project", - "serde", - "slotmap-careful", - "static_assertions", - "sysinfo", - "thiserror 2.0.16", - "tor-async-utils", - "tor-basic-utils", - "tor-config", - "tor-error", - "tor-log-ratelim", - "tor-memquota-cost", - "tor-rtcompat", - "tracing", - "void", -] - -[[package]] -name = "tor-memquota-cost" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd92b07c0fc24e6d8166a5ff45e5b8654e68d89743c46d01889a16ab74c0b578" -dependencies = [ - "derive-deftly", - "itertools 0.14.0", - "paste", - "void", -] - -[[package]] -name = "tor-netdir" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41be8f47f521fc95206d2ba5facac8fb1a5b5b82169bd41ebeecdf46d1e77246" -dependencies = [ - "async-trait", - "bitflags 2.11.1", - "derive_more", - "futures", - "humantime", - "itertools 0.14.0", - "num_enum", - "rand 0.9.2", - "serde", - "strum", - "thiserror 2.0.16", - "tor-basic-utils", - "tor-error", - "tor-linkspec", - "tor-llcrypto", - "tor-netdoc", - "tor-protover", - "tor-units", - "tracing", - "typed-index-collections", -] - -[[package]] -name = "tor-netdoc" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea8bce73d2c78bd78a2a927336ca639cf6bd5d8ad092ebcd0b3fdeaa47dcc77e" -dependencies = [ - "amplify", - "base64ct", - "cipher", - "derive-deftly", - "derive_builder_fork_arti", - "derive_more", - "digest", - "educe", - "enumset", - "hex", - "humantime", - "itertools 0.14.0", - "memchr", - "paste", - "phf", - "saturating-time", - "serde", - "serde_with", - "signature", - "smallvec", - "strum", - "subtle", - "thiserror 2.0.16", - "time", - "tinystr", - "tor-basic-utils", - "tor-bytes", - "tor-cell", - "tor-cert", - "tor-checkable", - "tor-error", - "tor-llcrypto", - "tor-protover", - "void", - "zeroize", -] - -[[package]] -name = "tor-persist" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507ab4b6a3d59ed0df5804eeed66dcacde75e3be13d3694216cdfdb666bce625" -dependencies = [ - "derive-deftly", - "derive_more", - "filetime", - "fs-mistrust", - "fslock", - "futures", - "itertools 0.14.0", - "oneshot-fused-workaround", - "paste", - "sanitize-filename", - "serde", - "serde_json", - "thiserror 2.0.16", - "time", - "tor-async-utils", - "tor-basic-utils", - "tor-error", - "tracing", - "void", -] - -[[package]] -name = "tor-proto" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfc552d535d36539d5782bb02028590bc472d219e49da51a96810725e80ff56" -dependencies = [ - "amplify", - "async-trait", - "asynchronous-codec", - "bitvec", - "bytes", - "caret", - "cfg-if", - "cipher", - "coarsetime", - "criterion-cycles-per-byte", - "derive-deftly", - "derive_builder_fork_arti", - "derive_more", - "digest", - "educe", - "enum_dispatch", - "futures", - "futures-util", - "hkdf", - "hmac", - "itertools 0.14.0", - "nonany", - "oneshot-fused-workaround", - "pin-project", - "postage", - "rand 0.9.2", - "rand_core 0.9.3", - "safelog", - "slotmap-careful", - "smallvec", - "static_assertions", - "subtle", - "sync_wrapper", - "thiserror 2.0.16", - "tokio", - "tokio-util", - "tor-async-utils", - "tor-basic-utils", - "tor-bytes", - "tor-cell", - "tor-cert", - "tor-checkable", - "tor-config", - "tor-error", - "tor-linkspec", - "tor-llcrypto", - "tor-log-ratelim", - "tor-memquota", - "tor-protover", - "tor-relay-crypto", - "tor-rtcompat", - "tor-rtmock", - "tor-units", - "tracing", - "typenum", - "visibility", - "void", - "zeroize", -] - -[[package]] -name = "tor-protover" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aed88527d070c4b7ea4e55a36d2d56d0500e30ca66298b5264f047f7f2f89cfa" -dependencies = [ - "caret", - "paste", - "serde_with", - "thiserror 2.0.16", - "tor-basic-utils", - "tor-bytes", -] - -[[package]] -name = "tor-relay-crypto" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e57e9f71b22ae1df63dbccc8e428cb07feec0abd654735109fa563c10bbb90" -dependencies = [ - "derive-deftly", - "derive_more", - "humantime", - "tor-cert", - "tor-checkable", - "tor-error", - "tor-key-forge", - "tor-keymgr", - "tor-llcrypto", - "tor-persist", -] - -[[package]] -name = "tor-relay-selection" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a372072ac9dea7d17e49693cc3f3ae77b3abf8125630516c9f2d622239b1920a" -dependencies = [ - "rand 0.9.2", - "serde", - "tor-basic-utils", - "tor-linkspec", - "tor-netdir", - "tor-netdoc", -] - -[[package]] -name = "tor-rtcompat" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14428b930e59003e801c0c32697c0aeb9b0495ad33ecbe8c6753bdb596233270" -dependencies = [ - "async-native-tls", - "async-trait", - "async_executors", - "asynchronous-codec", - "cfg-if", - "coarsetime", - "derive_more", - "dyn-clone", - "educe", - "futures", - "hex", - "libc", - "native-tls", - "paste", - "pin-project", - "socket2 0.6.3", - "thiserror 2.0.16", - "tokio", - "tokio-util", - "tor-error", - "tor-general-addr", - "tracing", - "void", - "zeroize", -] - -[[package]] -name = "tor-rtmock" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2da91a432cdaee8a93e0bb21b02f3e9c7667832ccbb4b54e00d9c1214638e70" -dependencies = [ - "amplify", - "assert_matches", - "async-trait", - "derive-deftly", - "derive_more", - "educe", - "futures", - "humantime", - "itertools 0.14.0", - "oneshot-fused-workaround", - "pin-project", - "priority-queue", - "slotmap-careful", - "strum", - "thiserror 2.0.16", - "tor-error", - "tor-general-addr", - "tor-rtcompat", - "tracing", - "tracing-test", - "void", -] - -[[package]] -name = "tor-socksproto" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adbc9115a2f506d9bb86ae4446f0ca70eb523dc2f5e900a33582e7c39decc23a" -dependencies = [ - "amplify", - "caret", - "derive-deftly", - "educe", - "safelog", - "subtle", - "thiserror 2.0.16", - "tor-bytes", - "tor-error", -] - -[[package]] -name = "tor-units" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da90e93b4b4aa4ec356ecbe9e19aced36fdd655e94ca459d1915120d873363f0" -dependencies = [ - "derive-deftly", - "derive_more", - "serde", - "thiserror 2.0.16", - "tor-memquota", -] - [[package]] name = "tower" version = "0.4.13" @@ -7162,9 +3592,9 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.44" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "log", "pin-project-lite", @@ -7174,9 +3604,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.31" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", @@ -7185,9 +3615,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.36" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", "valuable", @@ -7259,27 +3689,6 @@ dependencies = [ "tracing-log 0.2.0", ] -[[package]] -name = "tracing-test" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" -dependencies = [ - "tracing-core", - "tracing-subscriber", - "tracing-test-macro", -] - -[[package]] -name = "tracing-test-macro" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" -dependencies = [ - "quote", - "syn 2.0.106", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -7301,7 +3710,7 @@ dependencies = [ "log", "nix 0.26.4", "reqwest 0.11.23", - "schemars 0.8.16", + "schemars", "serde", "socket2 0.5.10", "ssri", @@ -7309,20 +3718,10 @@ dependencies = [ "tokio", "tracing", "widestring", - "windows 0.48.0", + "windows", "zip", ] -[[package]] -name = "typed-index-collections" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" -dependencies = [ - "bincode", - "serde", -] - [[package]] name = "typenum" version = "1.17.0" @@ -7330,13 +3729,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] -name = "uncased" -version = "0.9.10" +name = "unicode-bidi" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" @@ -7345,10 +3741,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] -name = "unicode-segmentation" -version = "1.13.2" +name = "unicode-normalization" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] [[package]] name = "unicode-width" @@ -7356,12 +3755,6 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -7372,42 +3765,23 @@ dependencies = [ "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "url" -version = "2.5.8" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", "percent-encoding", - "serde", ] -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.1" @@ -7447,33 +3821,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" -[[package]] -name = "visibility" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -7504,32 +3851,14 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen 0.46.0", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasix" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" -dependencies = [ - "wasi 0.11.0+wasi-snapshot-preview1", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", @@ -7538,6 +3867,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + [[package]] name = "wasm-bindgen-futures" version = "0.4.40" @@ -7552,9 +3895,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7562,66 +3905,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn 2.0.106", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.11.4", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.11.4", - "semver", -] - -[[package]] -name = "weak-table" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" - [[package]] name = "web-sys" version = "0.3.67" @@ -7694,15 +3997,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -7718,145 +4012,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -7875,24 +4030,6 @@ dependencies = [ "windows-targets 0.52.0", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -7923,32 +4060,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.0", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -7961,12 +4072,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -7979,12 +4084,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -7997,18 +4096,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -8021,12 +4108,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -8039,12 +4120,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -8057,12 +4132,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -8075,12 +4144,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.34" @@ -8099,12 +4162,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" - [[package]] name = "winreg" version = "0.50.0" @@ -8131,109 +4188,6 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.11.4", - "prettyplease", - "syn 2.0.106", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.106", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.11.4", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.11.4", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x25519-dalek" version = "2.0.0" @@ -8252,38 +4206,6 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53be06678ed9e83edb1745eb72efc0bbcd7b5c3c35711a860906aed827a13d61" -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.27" @@ -8304,27 +4226,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", - "synstructure", -] - [[package]] name = "zeroize" version = "1.7.0" @@ -8345,40 +4246,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "zip" version = "0.6.6" @@ -8396,31 +4263,16 @@ dependencies = [ "pbkdf2", "sha1", "time", - "zstd 0.11.2+zstd.1.5.2", + "zstd", ] -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffb3309596d527cfcba7dfc6ed6052f1d39dfbd7c867aa2e865e4a449c10110" -dependencies = [ - "zstd-safe 7.0.0", + "zstd-safe", ] [[package]] @@ -8433,15 +4285,6 @@ dependencies = [ "zstd-sys", ] -[[package]] -name = "zstd-safe" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43747c7422e2924c11144d5229878b98180ef8b06cca4ab5af37afc8a8d8ea3e" -dependencies = [ - "zstd-sys", -] - [[package]] name = "zstd-sys" version = "2.0.9+zstd.1.5.5" diff --git a/burrow-gtk/data/icons/hicolor/128/apps/burrow-gtk.png b/burrow-gtk/data/icons/hicolor/128/apps/burrow-gtk.png new file mode 100644 index 0000000..54b67da Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/128/apps/burrow-gtk.png 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 new file mode 100644 index 0000000..cf418ae Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/16/apps/burrow-gtk.png 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 new file mode 100644 index 0000000..cfe8e33 Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/24/apps/burrow-gtk.png 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 new file mode 100644 index 0000000..561abcb Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/256/apps/burrow-gtk.png 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 new file mode 100644 index 0000000..52e7a25 Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/32/apps/burrow-gtk.png 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 new file mode 100644 index 0000000..68cf88b Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/48/apps/burrow-gtk.png 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 new file mode 100644 index 0000000..c5997ae Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/512/apps/burrow-gtk.png 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 new file mode 100644 index 0000000..46872b4 Binary files /dev/null and b/burrow-gtk/data/icons/hicolor/64/apps/burrow-gtk.png 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 deleted file mode 100644 index a74c4df..0000000 --- a/burrow-gtk/data/icons/hicolor/scalable/apps/burrow-gtk.svg +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - 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 5352e0a..66ca58d 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 +1,11 @@ - + + + + + + + + + + + diff --git a/burrow-gtk/data/meson.build b/burrow-gtk/data/meson.build index 2c3ffd8..4abb3c5 100644 --- a/burrow-gtk/data/meson.build +++ b/burrow-gtk/data/meson.build @@ -77,11 +77,14 @@ if appstream_util.found() ) endif -install_data( - 'icons/hicolor/scalable/apps/' + app_name + '.svg', - install_dir: datadir / 'icons' / 'hicolor' / 'scalable' / 'apps', - rename: app_id + '.svg', -) +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/symbolic/apps/' + app_name + '-symbolic.svg', diff --git a/burrow-gtk/src/components/home_screen.rs b/burrow-gtk/src/components/home_screen.rs index 655cad3..0bfdda2 100644 --- a/burrow-gtk/src/components/home_screen.rs +++ b/burrow-gtk/src/components/home_screen.rs @@ -1,6 +1,6 @@ use super::*; use crate::account_store::{self, AccountKind, AccountRecord}; -use std::{cell::RefCell, rc::Rc, time::Duration}; +use std::time::Duration; pub struct HomeScreen { daemon_banner: adw::Banner, @@ -23,7 +23,6 @@ pub enum HomeScreenMsg { OpenWireGuard, OpenTor, OpenTailnet, - OpenProxySubscription, AddWireGuard { title: String, account: String, @@ -53,14 +52,6 @@ pub enum HomeScreenMsg { hostname: Option, tailnet: Option, }, - PreviewProxySubscription { - url: String, - }, - AddProxySubscription { - url: String, - name: String, - selected_ordinal: Option, - }, } #[relm4::component(pub, async)] @@ -243,7 +234,7 @@ impl AsyncComponent for HomeScreen { configure_add_popover(&widgets.add_button, &sender); let refresh_sender = sender.input_sender().clone(); - gtk::glib::MainContext::default().spawn_local(async move { + relm4::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(5)).await; refresh_sender.emit(HomeScreenMsg::Refresh); @@ -281,7 +272,6 @@ impl AsyncComponent for HomeScreen { HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender), HomeScreenMsg::OpenTor => open_tor_window(root, &sender), HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender), - HomeScreenMsg::OpenProxySubscription => open_proxy_subscription_window(root, &sender), HomeScreenMsg::AddWireGuard { title, account, @@ -314,13 +304,6 @@ impl AsyncComponent for HomeScreen { self.add_tailnet(authority, account, identity, hostname, tailnet) .await; } - HomeScreenMsg::PreviewProxySubscription { url } => { - self.preview_proxy_subscription(url).await; - } - HomeScreenMsg::AddProxySubscription { url, name, selected_ordinal } => { - self.add_proxy_subscription(url, name, selected_ordinal) - .await; - } } } } @@ -684,85 +667,6 @@ impl HomeScreen { } } - async fn preview_proxy_subscription(&mut self, url: String) { - let Ok(url) = daemon_api::require_value(&url, "Subscription URL") else { - self.network_status - .set_label("Enter a subscription URL before previewing."); - return; - }; - - self.network_status - .set_label("Previewing proxy subscription..."); - match daemon_api::preview_proxy_subscription(url).await { - Ok(preview) => { - let first_nodes = preview - .nodes - .iter() - .take(3) - .map(|node| { - let warning = if node.warnings.is_empty() { - String::new() - } else { - format!(" - {}", node.warnings.join(", ")) - }; - format!( - "{}: {} {}:{}{}", - node.protocol, node.name, node.server, node.port, warning - ) - }) - .collect::>() - .join("\n"); - let warnings = if preview.warnings.is_empty() { - String::new() - } else { - format!("\n{}", preview.warnings.join("\n")) - }; - self.network_status.set_label(&format!( - "Found {} compatible nodes and {} rejected entries in {}. Suggested name: {}.{}{}", - preview.compatible_count, - preview.rejected_count, - preview.detected_format, - preview.suggested_name, - warnings, - if first_nodes.is_empty() { - String::new() - } else { - format!("\n{first_nodes}") - } - )); - } - Err(error) => self - .network_status - .set_label(&format!("Proxy subscription preview failed: {error}")), - } - } - - async fn add_proxy_subscription( - &mut self, - url: String, - name: String, - selected_ordinal: Option, - ) { - let Ok(url) = daemon_api::require_value(&url, "Subscription URL") else { - self.network_status - .set_label("Enter a subscription URL before importing."); - return; - }; - - self.network_status - .set_label("Importing proxy subscription..."); - match daemon_api::add_proxy_subscription(url, name, selected_ordinal).await { - Ok(id) => { - self.network_status - .set_label(&format!("Imported proxy subscription network #{id}.")); - self.refresh().await; - } - Err(error) => self - .network_status - .set_label(&format!("Unable to import proxy subscription: {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; @@ -831,10 +735,6 @@ fn configure_add_popover(button: >k::MenuButton, sender: &AsyncComponentSender for (label, msg) in [ ("Add WireGuard Network", HomeScreenMsg::OpenWireGuard), - ( - "Import Proxy Subscription", - HomeScreenMsg::OpenProxySubscription, - ), ("Save Tor Account", HomeScreenMsg::OpenTor), ("Add Tailnet Account", HomeScreenMsg::OpenTailnet), ] { @@ -855,7 +755,6 @@ fn msg_from_template(msg: &HomeScreenMsg) -> HomeScreenMsg { HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard, HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor, HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet, - HomeScreenMsg::OpenProxySubscription => HomeScreenMsg::OpenProxySubscription, _ => unreachable!(), } } @@ -1192,169 +1091,6 @@ fn open_tailnet_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender window.present(); } -fn open_proxy_subscription_window( - root: >k::ScrolledWindow, - sender: &AsyncComponentSender, -) { - let window = sheet_window(root, "Proxy Subscription", 560, 520); - let content = sheet_content( - &window, - "Import Proxy Subscription", - "Import Trojan and Shadowsocks nodes through the Burrow daemon.", - ); - - let url = gtk::Entry::new(); - url.set_placeholder_text(Some("Subscription URL")); - let name = gtk::Entry::new(); - name.set_placeholder_text(Some("Name")); - let node_list = gtk::ListBox::new(); - node_list.set_selection_mode(gtk::SelectionMode::Single); - node_list.set_sensitive(false); - node_list.add_css_class("boxed-list"); - let node_scroll = gtk::ScrolledWindow::builder() - .min_content_height(220) - .vexpand(true) - .child(&node_list) - .build(); - let preview_status = gtk::Label::new(Some("Preview the subscription to choose a node.")); - preview_status.add_css_class("dim-label"); - preview_status.set_xalign(0.0); - preview_status.set_wrap(true); - let selected_ordinal = Rc::new(RefCell::new(None::)); - let node_ordinals = Rc::new(RefCell::new(Vec::::new())); - - content.append(§ion_label("Subscription")); - content.append(&url); - content.append(&name); - content.append(§ion_label("Node")); - content.append(&node_scroll); - content.append(&preview_status); - - let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let preview = gtk::Button::with_label("Preview"); - let import = gtk::Button::with_label("Import"); - import.add_css_class("suggested-action"); - actions.append(&preview); - actions.append(&import); - content.append(&actions); - - let url_for_preview = url.clone(); - let name_for_preview = name.clone(); - let list_for_preview = node_list.clone(); - let status_for_preview = preview_status.clone(); - let selected_for_preview = selected_ordinal.clone(); - let ordinals_for_preview = node_ordinals.clone(); - preview.connect_clicked(move |_| { - let url = url_for_preview.text().to_string(); - let name = name_for_preview.clone(); - let list = list_for_preview.clone(); - let status = status_for_preview.clone(); - let selected = selected_for_preview.clone(); - let ordinals = ordinals_for_preview.clone(); - status.set_label("Previewing proxy subscription..."); - while let Some(child) = list.first_child() { - list.remove(&child); - } - list.set_sensitive(false); - *selected.borrow_mut() = None; - ordinals.borrow_mut().clear(); - gtk::glib::MainContext::default().spawn_local(async move { - match daemon_api::preview_proxy_subscription(url).await { - Ok(preview) => { - if name.text().trim().is_empty() { - name.set_text(&preview.suggested_name); - } - for node in preview.nodes.iter().filter(|node| node.runtime_supported) { - ordinals.borrow_mut().push(node.ordinal); - let row = gtk::ListBoxRow::new(); - row.set_selectable(true); - row.set_activatable(true); - - let row_content = gtk::Box::new(gtk::Orientation::Vertical, 2); - row_content.set_margin_top(8); - row_content.set_margin_bottom(8); - row_content.set_margin_start(10); - row_content.set_margin_end(10); - - let title = gtk::Label::new(Some(&format!( - "{} {}", - node.protocol.to_uppercase(), - node.name - ))); - title.set_xalign(0.0); - title.set_ellipsize(gtk::pango::EllipsizeMode::End); - let endpoint = gtk::Label::new(Some(&format!("{}:{}", node.server, node.port))); - endpoint.add_css_class("dim-label"); - endpoint.set_xalign(0.0); - endpoint.set_ellipsize(gtk::pango::EllipsizeMode::Middle); - - row_content.append(&title); - row_content.append(&endpoint); - row.set_child(Some(&row_content)); - list.append(&row); - } - if let Some(first) = preview.nodes.iter().find(|node| node.runtime_supported) { - if let Some(row) = list.row_at_index(0) { - list.select_row(Some(&row)); - } - list.set_sensitive(true); - *selected.borrow_mut() = Some(first.ordinal); - } - let unsupported = preview - .nodes - .iter() - .filter(|node| !node.runtime_supported) - .count(); - let supported = preview.nodes.len().saturating_sub(unsupported); - let warnings = if preview.warnings.is_empty() { - String::new() - } else { - format!(" {}", preview.warnings.join(" ")) - }; - status.set_label(&format!( - "Found {} runtime-supported nodes, {} unsupported nodes, and {} rejected entries in {}.{}", - supported, - unsupported, - preview.rejected_count, - preview.detected_format, - warnings - )); - } - Err(error) => { - status.set_label(&format!("Proxy subscription preview failed: {error}")); - } - } - }); - }); - - let selected_for_list = selected_ordinal.clone(); - let ordinals_for_list = node_ordinals.clone(); - node_list.connect_row_selected(move |_, row| { - *selected_for_list.borrow_mut() = row.and_then(|row| { - let index = row.index(); - if index < 0 { - return None; - } - ordinals_for_list.borrow().get(index as usize).copied() - }); - }); - - let input = sender.input_sender().clone(); - let window_for_click = window.clone(); - let selected_for_import = selected_ordinal.clone(); - import.connect_clicked(move |_| { - input.emit(HomeScreenMsg::AddProxySubscription { - url: url.text().to_string(), - name: name.text().to_string(), - selected_ordinal: *selected_for_import.borrow(), - }); - window_for_click.close(); - }); - - 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) diff --git a/burrow-gtk/src/components/networks_screen.rs b/burrow-gtk/src/components/networks_screen.rs new file mode 100644 index 0000000..21df982 --- /dev/null +++ b/burrow-gtk/src/components/networks_screen.rs @@ -0,0 +1,515 @@ +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 3817ca6..d04e30f 100644 --- a/burrow-gtk/src/components/settings/daemon_group.rs +++ b/burrow-gtk/src/components/settings/daemon_group.rs @@ -4,12 +4,10 @@ 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, } @@ -30,8 +28,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"), @@ -51,8 +49,7 @@ impl AsyncComponent for DaemonGroup { // Should be impossible to panic here let model = DaemonGroup { system_setup: init.system_setup, - daemon_client: init.daemon_client.clone(), - already_running: init.daemon_client.lock().await.is_some(), + already_running: daemon_api::daemon_available().await, }; let widgets = view_output!(); @@ -104,7 +101,7 @@ mv $TEMP /tmp/burrow-detached-daemon"#, .unwrap(); } DaemonGroupMsg::DaemonStateChange => { - self.already_running = self.daemon_client.lock().await.is_some(); + self.already_running = daemon_api::daemon_available().await; } } } diff --git a/burrow-gtk/src/components/settings/diag_group.rs b/burrow-gtk/src/components/settings/diag_group.rs index a15e0ea..e1e9b78 100644 --- a/burrow-gtk/src/components/settings/diag_group.rs +++ b/burrow-gtk/src/components/settings/diag_group.rs @@ -2,8 +2,6 @@ use super::*; #[derive(Debug)] pub struct DiagGroup { - daemon_client: Arc>>, - system_setup: SystemSetup, service_installed: StatusTernary, socket_installed: StatusTernary, @@ -12,14 +10,13 @@ pub struct DiagGroup { } pub struct DiagGroupInit { - pub daemon_client: Arc>>, pub system_setup: SystemSetup, } impl DiagGroup { - async fn new(daemon_client: Arc>>) -> Result { + async fn new() -> Result { let system_setup = SystemSetup::new(); - let daemon_running = daemon_client.lock().await.is_some(); + let daemon_running = daemon_api::daemon_available().await; Ok(Self { service_installed: system_setup.is_service_installed()?, @@ -27,7 +24,6 @@ impl DiagGroup { socket_enabled: system_setup.is_socket_enabled()?, daemon_running, system_setup, - daemon_client, }) } } @@ -95,7 +91,8 @@ impl AsyncComponent for DiagGroup { sender: AsyncComponentSender, ) -> AsyncComponentParts { // Should be impossible to panic here - let model = DiagGroup::new(init.daemon_client).await.unwrap(); + let mut model = DiagGroup::new().await.unwrap(); + model.system_setup = init.system_setup; let widgets = view_output!(); @@ -111,7 +108,7 @@ impl AsyncComponent for DiagGroup { match msg { DiagGroupMsg::Refresh => { // Should be impossible to panic here - *self = Self::new(Arc::clone(&self.daemon_client)).await.unwrap(); + *self = Self::new().await.unwrap(); } } } diff --git a/burrow-gtk/src/components/settings_screen.rs b/burrow-gtk/src/components/settings_screen.rs index 971f262..9828c31 100644 --- a/burrow-gtk/src/components/settings_screen.rs +++ b/burrow-gtk/src/components/settings_screen.rs @@ -6,10 +6,6 @@ pub struct SettingsScreen { daemon_group: AsyncController, } -pub struct SettingsScreenInit { - pub daemon_client: Arc>>, -} - #[derive(Debug, PartialEq, Eq)] pub enum SettingsScreenMsg { DaemonStateChange, @@ -17,7 +13,7 @@ pub enum SettingsScreenMsg { #[relm4::component(pub)] impl SimpleComponent for SettingsScreen { - type Init = SettingsScreenInit; + type Init = (); type Input = SettingsScreenMsg; type Output = (); @@ -27,26 +23,20 @@ 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, - daemon_client: Arc::clone(&init.daemon_client), - }) + .launch(settings::DiagGroupInit { system_setup }) .forward(sender.input_sender(), |_| { SettingsScreenMsg::DaemonStateChange }); let daemon_group = settings::DaemonGroup::builder() - .launch(settings::DaemonGroupInit { - system_setup, - daemon_client: Arc::clone(&init.daemon_client), - }) + .launch(settings::DaemonGroupInit { system_setup }) .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 f660536..bbc5f82 100644 --- a/burrow-gtk/src/components/switch_screen.rs +++ b/burrow-gtk/src/components/switch_screen.rs @@ -1,27 +1,22 @@ use super::*; pub struct SwitchScreen { - daemon_client: Arc>>, switch: gtk::Switch, switch_screen: gtk::Box, disconnected_banner: adw::Banner, -} - -pub struct SwitchScreenInit { - pub daemon_client: Arc>>, + status_label: gtk::Label, } #[derive(Debug, PartialEq, Eq)] pub enum SwitchScreenMsg { - DaemonReconnect, - DaemonDisconnect, + Refresh, Start, Stop, } #[relm4::component(pub, async)] impl AsyncComponent for SwitchScreen { - type Init = SwitchScreenInit; + type Init = (); type Input = SwitchScreenMsg; type Output = (); type CommandOutput = (); @@ -39,7 +34,7 @@ impl AsyncComponent for SwitchScreen { #[name(setup_banner)] adw::Banner { - set_title: "Burrow is not running!", + set_title: "Burrow daemon is not reachable", }, }, @@ -52,7 +47,12 @@ impl AsyncComponent for SwitchScreen { set_vexpand: true, gtk::Label { - set_label: "Burrow Switch", + set_label: "Burrow Tunnel", + }, + + #[name(status_label)] + gtk::Label { + set_label: "Checking daemon status", }, #[name(switch)] @@ -68,91 +68,76 @@ impl AsyncComponent for SwitchScreen { } async fn init( - init: Self::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!(); - 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, + let mut model = SwitchScreen { 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, - _: AsyncComponentSender, + _sender: AsyncComponentSender, _root: &Self::Root, ) { - let mut disconnected_daemon_client = 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; - } + 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::Input::Stop => { - if let Err(_e) = daemon_client.send_command(DaemonCommand::Stop).await { - disconnected_daemon_client = true; - } - } - _ => {} + 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; + } + } + } +} + +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); } - } 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 index 1a163bf..4ff8bf5 100644 --- a/burrow-gtk/src/daemon_api.rs +++ b/burrow-gtk/src/daemon_api.rs @@ -4,9 +4,7 @@ use burrow::{ grpc_defs::{ Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest, TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest, - ProxySubscriptionApplyRequest, ProxySubscriptionImportRequest, }, - proxy_subscription::ProxySubscriptionPayload, BurrowClient, }; use std::{path::PathBuf, sync::OnceLock}; @@ -56,27 +54,6 @@ pub struct TailnetLoginStatus { pub health: Vec, } -#[derive(Debug, Clone)] -pub struct ProxySubscriptionPreview { - pub suggested_name: String, - pub detected_format: String, - pub compatible_count: i32, - pub rejected_count: i32, - pub nodes: Vec, - pub warnings: Vec, -} - -#[derive(Debug, Clone)] -pub struct ProxyNodePreview { - pub ordinal: i32, - pub name: String, - pub protocol: String, - pub server: String, - pub port: i32, - pub warnings: Vec, - pub runtime_supported: bool, -} - pub fn default_tailnet_authority() -> &'static str { MANAGED_TAILSCALE_AUTHORITY } @@ -239,63 +216,6 @@ pub async fn add_tailnet( add_network(NetworkType::Tailnet, payload).await } -pub async fn preview_proxy_subscription(url: String) -> Result { - let mut client = BurrowClient::from_uds().await?; - let response = timeout( - Duration::from_secs(20), - client - .proxy_subscription_client - .preview_import(ProxySubscriptionImportRequest { url }), - ) - .await - .context("timed out previewing proxy subscription")?? - .into_inner(); - - Ok(ProxySubscriptionPreview { - suggested_name: response.suggested_name, - detected_format: response.detected_format, - compatible_count: response.compatible_count, - rejected_count: response.rejected_count, - nodes: response - .node - .into_iter() - .map(|node| ProxyNodePreview { - ordinal: node.ordinal, - name: node.name, - protocol: node.protocol, - server: node.server, - port: node.port, - warnings: node.warnings, - runtime_supported: node.runtime_supported, - }) - .collect(), - warnings: response.warnings, - }) -} - -pub async fn add_proxy_subscription( - url: String, - name: String, - selected_ordinal: Option, -) -> Result { - let id = next_network_id().await?; - let mut client = BurrowClient::from_uds().await?; - timeout( - Duration::from_secs(25), - client - .proxy_subscription_client - .apply_import(ProxySubscriptionApplyRequest { - id, - url, - name, - selected_ordinal: selected_ordinal.unwrap_or(-1), - }), - ) - .await - .context("timed out importing proxy subscription")??; - Ok(id) -} - pub async fn discover_tailnet(email: String) -> Result { let mut client = BurrowClient::from_uds().await?; let response = timeout( @@ -408,7 +328,6 @@ fn summarize_network(network: &Network) -> NetworkSummary { match network.r#type() { NetworkType::WireGuard => summarize_wireguard(network), NetworkType::Tailnet => summarize_tailnet(network), - NetworkType::ProxySubscription => summarize_proxy_subscription(network), } } @@ -453,21 +372,6 @@ fn summarize_tailnet(network: &Network) -> NetworkSummary { } } -fn summarize_proxy_subscription(network: &Network) -> NetworkSummary { - match serde_json::from_slice::(&network.payload) { - Ok(payload) => NetworkSummary { - id: network.id, - title: payload.name.clone(), - detail: burrow::proxy_subscription::summarize_payload(&payload), - }, - Err(error) => NetworkSummary { - id: network.id, - title: "Proxy Subscription".to_owned(), - detail: format!("Unable to read proxy subscription payload: {error}"), - }, - } -} - fn decode_tailnet_status( response: burrow::grpc_defs::TailnetLoginStatusResponse, ) -> TailnetLoginStatus { diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index a7d5cea..22f3d25 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -31,24 +31,12 @@ tracing-subscriber = { version = "0.3", features = ["std", "env-filter"] } log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1.0" -serde_yaml = "0.9" blake2 = "0.10" -blake3 = "1" -chacha20 = "0.9" chacha20poly1305 = "0.10" rand = "0.8" bytes = "1" rand_core = "0.6" aead = "0.5" -aegis = { version = "0.9.9", default-features = false, features = ["pure-rust"] } -aes = "0.8" -aes-gcm = "0.10" -ccm = "0.5" -deoxys = { version = "0.2.0-rc.3", default-features = false } -lea = "0.5.4" -poly1305 = "0.8" -rabbit = "0.5" -zears = "0.2.1" x25519-dalek = { version = "2.0", features = [ "reusable_secrets", "static_secrets", @@ -56,7 +44,6 @@ x25519-dalek = { version = "2.0", features = [ ring = "0.17" parking_lot = "0.12" hmac = "0.12" -md-5 = "0.10" base64 = "0.21" fehler = "1.0" ip_network_table = "0.2" @@ -70,7 +57,6 @@ arti-client = "0.40.0" hickory-proto = "0.25.2" netstack-smoltcp = "0.2.1" tokio-util = { version = "0.7.18", features = ["compat"] } -tokio_smux = "0.2" tor-rtcompat = "0.40.0" console-subscriber = { version = "0.2.0", optional = true } console = "0.15.8" @@ -92,48 +78,6 @@ hyper-util = "0.1.6" toml = "0.8.15" rust-ini = "0.21.0" subtle = "2.6" -rustls = { version = "0.23", default-features = false, features = [ - "aws_lc_rs", - "logging", - "std", - "tls12", -] } -rustls-pemfile = "2" -sha2 = "0.10" -tokio-rustls = "0.26" -rama-tls-boring = { version = "0.3.0-alpha.4", default-features = false, optional = true } -rama-ua = { version = "0.3.0-alpha.4", default-features = false, optional = true, features = [ - "embed-profiles", - "tls", -] } -rama-net = { version = "0.3.0-alpha.4", default-features = false, optional = true, features = ["tls"] } -webpki-roots = "0.26" -webpki-roots-legacy = { package = "webpki-roots", version = "0.22" } -rust_tokio_kcp = { version = "0.2.5", default-features = false, features = [ - "aes", - "tea", - "xtea", - "simple_xor", - "blowfish", - "cast5", - "triple_des", - "twofish", - "salsa20", - "sm4", - "aes-gcm", -] } -smux_rust = "0.2.1" -tokio-snappy = "0.2.0" -shadowsocks = { version = "1.24.0", default-features = false, features = [ - "aead-cipher", - "aead-cipher-extra", - "aead-cipher-2022", - "aead-cipher-2022-extra", - "stream-cipher", -] } -yamux = "0.13.10" -h2 = "0.4.12" -http = "1.3.1" [target.'cfg(target_os = "linux")'.dependencies] caps = "0.5" @@ -143,11 +87,8 @@ nix = { version = "0.27", features = ["fs", "socket", "uio"] } tracing-journald = "0.3" [target.'cfg(target_vendor = "apple")'.dependencies] -libc = "0.2" -native-tls = { version = "0.2", features = ["alpn"] } nix = { version = "0.27" } rusqlite = { version = "0.38.0", features = ["bundled", "blob"] } -tokio-native-tls = "0.3" [target.'cfg(target_os = "macos")'.dependencies] tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" } @@ -168,11 +109,6 @@ pre_uninstall_script = "../package/rpm/pre_uninstall" [features] tokio-console = ["dep:console-subscriber"] bundled = ["rusqlite/bundled"] -boring-browser-fingerprints = [ - "dep:rama-tls-boring", - "dep:rama-ua", - "dep:rama-net", -] [build-dependencies] diff --git a/burrow/src/auth/server/mod.rs b/burrow/src/auth/server/mod.rs index d14e9a3..fdffce3 100644 --- a/burrow/src/auth/server/mod.rs +++ b/burrow/src/auth/server/mod.rs @@ -147,10 +147,7 @@ pub fn build_router(config: AuthServerConfig) -> Router { .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), - ) + .route("/v1/tailscale/login/:session_id", get(tailscale_login_status)) .with_state(AppState { config, tailscale: tailscale::TailscaleBridgeManager::default(), @@ -250,12 +247,7 @@ async fn tailscale_login_status( .await .map_err(internal_error)? .map(Json) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - "unknown tailscale login session".to_owned(), - ) - }) + .ok_or_else(|| (StatusCode::NOT_FOUND, "unknown tailscale login session".to_owned())) } async fn healthz() -> impl IntoResponse { @@ -427,7 +419,10 @@ mod tests { 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())?) + .oneshot( + Request::get("/v1/tailnet/discover?email=") + .body(Body::empty())?, + ) .await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); Ok(()) diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index 85bb1f2..d08c807 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -291,10 +291,7 @@ impl TailscaleHelperProcess { } async fn shutdown_with_client(&self, client: &Client) -> Result<()> { - let _ = client - .post(format!("{}/shutdown", self.listen_url)) - .send() - .await; + let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await; for _ in 0..10 { let mut child = self.child.lock().await; @@ -405,9 +402,7 @@ fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Res } pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf { - state_root() - .join(session_dir_name(request)) - .join("packet.sock") + state_root().join(session_dir_name(request)).join("packet.sock") } pub(crate) fn state_root() -> PathBuf { @@ -509,10 +504,7 @@ mod tests { control_url: None, packet_socket: None, }; - assert_eq!( - session_dir_name(&request), - "default-apple-tailscale-managed" - ); + assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed"); assert_eq!(default_hostname(&request), "burrow-apple"); let custom_request = TailscaleLoginStartRequest { diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index b86aedb..d044a62 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -92,13 +92,8 @@ pub async fn probe_tailnet_authority(authority: &str) -> Result Result Ok(None), - status => Err(anyhow!( - "tailnet well-known lookup failed with HTTP {status}" - )), + status => Err(anyhow!("tailnet well-known lookup failed with HTTP {status}")), } } -async fn discover_webfinger( - client: &Client, - email: &str, - base_url: &Url, -) -> Result> { +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")?; @@ -233,9 +222,7 @@ async fn discover_webfinger( .filter(|href| !href.trim().is_empty())) } StatusCode::NOT_FOUND => Ok(None), - status => Err(anyhow!( - "tailnet webfinger lookup failed with HTTP {status}" - )), + status => Err(anyhow!("tailnet webfinger lookup failed with HTTP {status}")), } } @@ -287,9 +274,7 @@ mod tests { #[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://controlplane.tailscale.com/")); assert!(!is_managed_tailscale_authority("https://ts.burrow.net")); } diff --git a/burrow/src/daemon/apple.rs b/burrow/src/daemon/apple.rs index 45a2047..f369ea9 100644 --- a/burrow/src/daemon/apple.rs +++ b/burrow/src/daemon/apple.rs @@ -1,5 +1,3 @@ -#[cfg(unix)] -use std::os::unix::net::UnixStream; use std::{ ffi::{c_char, CStr}, path::PathBuf, @@ -36,17 +34,12 @@ pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c } pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Option) { - configure_in_process_logging(db_path_buf.as_ref()); crate::tracing::initialize(); let _guard = BURROW_SPAWN_LOCK.lock().unwrap(); if BURROW_READY.get().is_some() { return; } - if socket_is_reachable(path_buf.as_ref()) { - let _ = BURROW_READY.set(()); - return; - } let notify = Arc::new(Notify::new()); let handle = BURROW_HANDLE.get_or_init(|| { @@ -81,28 +74,3 @@ pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Optio handle.block_on(async move { receiver.notified().await }); let _ = BURROW_READY.set(()); } - -fn configure_in_process_logging(db_path_buf: Option<&PathBuf>) { - if std::env::var_os("BURROW_LOG_FILE").is_none() { - if let Some(log_dir) = db_path_buf.and_then(|path| path.parent()) { - std::env::set_var("BURROW_LOG_FILE", log_dir.join("burrow-runtime.log")); - } - } - - if std::env::var_os("BURROW_LOG").is_none() && std::env::var_os("RUST_LOG").is_none() { - std::env::set_var( - "BURROW_LOG", - "burrow=debug,h2=warn,hyper=warn,tower=warn,tonic=warn", - ); - } -} - -#[cfg(unix)] -fn socket_is_reachable(path: Option<&PathBuf>) -> bool { - path.is_some_and(|path| UnixStream::connect(path).is_ok()) -} - -#[cfg(not(unix))] -fn socket_is_reachable(_path: Option<&PathBuf>) -> bool { - false -} diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index b1d5084..9b2e138 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -8,20 +8,16 @@ use rusqlite::Connection; use tokio::sync::{mpsc, watch, RwLock}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status as RspStatus}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, proxy_subscriptions_server::ProxySubscriptions, - tailnet_control_server::TailnetControl, tunnel_server::Tunnel, Empty, Network, - NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, NetworkType, - ProxySubscriptionApplyRequest, ProxySubscriptionApplyResponse, - ProxySubscriptionImportRequest, ProxySubscriptionNodePreview, - ProxySubscriptionPreviewResponse, ProxySubscriptionRejectedEntry, - ProxySubscriptionSelectRequest, ProxySubscriptionSelectResponse, State as RPCTunnelState, - TailnetDiscoverRequest, TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, - TunnelConfigurationResponse, TunnelPacket, TunnelStatusResponse, + 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, }, runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel}, }; @@ -32,11 +28,7 @@ use crate::{ }, control::discovery, daemon::rpc::ServerConfig, - database::{ - add_network, delete_network, get_connection, list_networks, proxy_subscription_payload, - reorder_network, select_proxy_subscription_node, upsert_network, - }, - proxy_subscription, + database::{add_network, delete_network, get_connection, list_networks, reorder_network}, }; #[derive(Debug, Clone)] @@ -97,7 +89,9 @@ impl DaemonRPCServer { 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()) + active + .as_ref() + .map(|tunnel| tunnel.server_config().clone()) }; let config = match config { Some(config) => config, @@ -110,22 +104,6 @@ impl DaemonRPCServer { Ok(configuration_rsp(config)) } - async fn update_active_proxy_subscription_runtime( - &self, - id: i32, - payload: &proxy_subscription::ProxySubscriptionPayload, - ) -> Result<(), RspStatus> { - let mut active = self.active_tunnel.write().await; - let Some(active) = active.as_mut() else { - return Ok(()); - }; - active - .apply_proxy_subscription_payload(id, payload) - .await - .map_err(proc_err)?; - Ok(()) - } - async fn stop_active_tunnel(&self) -> Result { let current = { self.active_tunnel.write().await.take() }; let Some(current) = current else { @@ -240,7 +218,9 @@ impl Tunnel for DaemonRPCServer { return Err(RspStatus::failed_precondition("no active tunnel")); }; active.packet_stream().ok_or_else(|| { - RspStatus::failed_precondition("active tunnel does not support packet streaming") + RspStatus::failed_precondition( + "active tunnel does not support packet streaming", + ) })? }; @@ -393,98 +373,6 @@ impl Networks for DaemonRPCServer { } } -#[tonic::async_trait] -impl ProxySubscriptions for DaemonRPCServer { - async fn preview_import( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - let body = proxy_subscription::fetch_subscription(&request.url) - .await - .map_err(proc_err)?; - let preview = proxy_subscription::parse_subscription_body(&body, Some(&request.url)); - Ok(Response::new(proxy_subscription_preview_rsp(preview))) - } - - async fn apply_import( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - let body = proxy_subscription::fetch_subscription(&request.url) - .await - .map_err(proc_err)?; - let preview = proxy_subscription::parse_subscription_body(&body, Some(&request.url)); - if preview.nodes.is_empty() { - return Err(RspStatus::invalid_argument( - "subscription contains no compatible Trojan or Shadowsocks nodes", - )); - } - let conn = self.get_connection()?; - let previous_payload = proxy_subscription_payload(&conn, request.id).map_err(proc_err)?; - let selected_ordinal = if let Some(previous_payload) = previous_payload.as_ref() { - let previous_name = selected_proxy_node_name(previous_payload); - supported_selected_name(&preview.nodes, previous_name.as_deref()) - .or_else(|| first_supported_ordinal(&preview.nodes)) - } else { - supported_selected_ordinal(&preview.nodes, request.selected_ordinal) - .or_else(|| first_supported_ordinal(&preview.nodes)) - }; - let payload = proxy_subscription::build_payload( - preview, - &request.url, - (!request.name.trim().is_empty()).then_some(request.name), - selected_ordinal, - ); - crate::proxy_runtime::proxy_runtime_config(&payload) - .map_err(|err| RspStatus::invalid_argument(err.to_string()))?; - let imported_count = payload.nodes.len() as i32; - let selected_ordinal = payload - .selected_ordinal - .or_else(|| payload.nodes.first().map(|node| node.ordinal)) - .unwrap_or_default() as i32; - let network = Network { - id: request.id, - r#type: NetworkType::ProxySubscription.into(), - payload: serde_json::to_vec_pretty(&payload).map_err(proc_err)?, - }; - upsert_network(&conn, &network).map_err(proc_err)?; - self.update_active_proxy_subscription_runtime(request.id, &payload) - .await?; - self.notify_network_update().await?; - #[cfg(not(target_vendor = "apple"))] - self.reconcile_runtime().await?; - Ok(Response::new(ProxySubscriptionApplyResponse { - id: request.id, - imported_count, - selected_ordinal, - })) - } - - async fn select_node( - &self, - request: Request, - ) -> Result, RspStatus> { - let request = request.into_inner(); - let conn = self.get_connection()?; - let selected_ordinal = - select_proxy_subscription_node(&conn, request.id, request.selected_ordinal) - .map_err(proc_err)?; - if let Some(payload) = proxy_subscription_payload(&conn, request.id).map_err(proc_err)? { - self.update_active_proxy_subscription_runtime(request.id, &payload) - .await?; - } - self.notify_network_update().await?; - #[cfg(not(target_vendor = "apple"))] - self.reconcile_runtime().await?; - Ok(Response::new(ProxySubscriptionSelectResponse { - id: request.id, - selected_ordinal, - })) - } -} - #[tonic::async_trait] impl TailnetControl for DaemonRPCServer { async fn discover( @@ -612,9 +500,7 @@ impl TailnetControl for DaemonRPCServer { } fn proc_err(err: impl ToString) -> RspStatus { - let message = err.to_string(); - error!(error = %message, "daemon RPC failed"); - RspStatus::internal(message) + RspStatus::internal(err.to_string()) } fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { @@ -622,7 +508,6 @@ fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { addresses: config.address, mtu: config.mtu.unwrap_or(1000), routes: config.routes, - excluded_routes: config.excluded_routes, dns_servers: config.dns_servers, search_domains: config.search_domains, include_default_route: config.include_default_route, @@ -636,107 +521,6 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse { } } -fn proxy_subscription_preview_rsp( - preview: proxy_subscription::ProxySubscriptionPreview, -) -> ProxySubscriptionPreviewResponse { - ProxySubscriptionPreviewResponse { - suggested_name: preview.suggested_name, - detected_format: preview.detected_format.as_str().to_owned(), - compatible_count: preview.nodes.len() as i32, - rejected_count: preview.rejected.len() as i32, - node: preview - .nodes - .into_iter() - .map(|node| { - let runtime_error = crate::proxy_runtime::runtime_support_error(&node); - let mut warnings = node.warnings; - if let Some(error) = runtime_error.as_deref() { - warnings.push(error.to_owned()); - } - ProxySubscriptionNodePreview { - ordinal: node.ordinal as i32, - name: node.name, - protocol: node.protocol.as_str().to_owned(), - server: node.server, - port: node.port.into(), - warnings, - runtime_supported: runtime_error.is_none(), - } - }) - .collect(), - rejected: preview - .rejected - .into_iter() - .map(|entry| ProxySubscriptionRejectedEntry { - ordinal: entry.ordinal as i32, - reason: entry.reason, - scheme: entry.scheme.unwrap_or_default(), - }) - .collect(), - warnings: preview.warnings, - } -} - -fn supported_selected_ordinal( - nodes: &[proxy_subscription::ProxyNode], - selected_ordinal: i32, -) -> Option { - if selected_ordinal < 0 { - return None; - } - let selected_ordinal = selected_ordinal as usize; - nodes - .iter() - .find(|node| { - node.ordinal == selected_ordinal - && crate::proxy_runtime::runtime_support_error(node).is_none() - }) - .map(|node| node.ordinal) -} - -fn supported_selected_name( - nodes: &[proxy_subscription::ProxyNode], - selected_name: Option<&str>, -) -> Option { - let selected_name = selected_name?; - nodes - .iter() - .find(|node| { - node.name == selected_name - && crate::proxy_runtime::runtime_support_error(node).is_none() - }) - .map(|node| node.ordinal) -} - -fn first_supported_ordinal(nodes: &[proxy_subscription::ProxyNode]) -> Option { - nodes - .iter() - .find(|node| crate::proxy_runtime::runtime_support_error(node).is_none()) - .map(|node| node.ordinal) -} - -fn selected_proxy_node_name( - payload: &proxy_subscription::ProxySubscriptionPayload, -) -> Option { - payload - .selected_name - .clone() - .or_else(|| { - payload.selected_ordinal.and_then(|ordinal| { - payload - .nodes - .iter() - .find(|node| node.ordinal == ordinal) - .map(|node| node.name.clone()) - }) - }) - .or_else(|| { - crate::proxy_runtime::selected_node(payload) - .ok() - .map(|node| node.name.clone()) - }) -} - fn tailnet_login_rsp( session_id: String, status: TailscaleLoginStatus, @@ -754,54 +538,3 @@ fn tailnet_login_rsp( health: status.health, } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn supported_selected_name_survives_subscription_reorder() { - let preview = proxy_subscription::parse_subscription_body( - r#" -proxies: - - name: fallback - type: ss - server: fallback.example.net - port: 8388 - cipher: aes-128-gcm - password: secret - - name: selected - type: ss - server: selected.example.net - port: 8388 - cipher: aes-128-gcm - password: secret -"#, - Some("https://example.com/sub"), - ); - - assert_eq!( - supported_selected_name(&preview.nodes, Some("selected")), - Some(1) - ); - } - - #[test] - fn selected_proxy_node_name_reads_legacy_ordinal_payloads() { - let preview = proxy_subscription::parse_subscription_body( - "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388#SS%20Node\n", - Some("https://example.com/sub"), - ); - let payload = proxy_subscription::build_payload( - preview, - "https://example.com/sub", - Some("Proxy Subscription".to_owned()), - Some(0), - ); - - assert_eq!( - selected_proxy_node_name(&payload).as_deref(), - Some("SS Node") - ); - } -} diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index ccb9b86..724e3bb 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -1,4 +1,4 @@ -use std::{io, os::unix::net::UnixStream as StdUnixStream, path::Path, sync::Arc}; +use std::{path::Path, sync::Arc}; pub mod apple; mod instance; @@ -17,8 +17,8 @@ use tracing::info; use crate::{ daemon::rpc::grpc_defs::{ - networks_server::NetworksServer, proxy_subscriptions_server::ProxySubscriptionsServer, - tailnet_control_server::TailnetControlServer, tunnel_server::TunnelServer, + networks_server::NetworksServer, tailnet_control_server::TailnetControlServer, + tunnel_server::TunnelServer, }, database::get_connection, }; @@ -33,23 +33,16 @@ pub async fn daemon_main( let spp = socket_path.clone(); let tmp = get_socket_path(); let sock_path = spp.unwrap_or(Path::new(tmp.as_str())); - let uds = match bind_daemon_socket(sock_path)? { - Some(uds) => uds, - None => { - if let Some(n) = notify_ready { - n.notify_one(); - } - info!(path = %sock_path.display(), "daemon socket already served by another process"); - return Ok(()); - } - }; + if sock_path.exists() { + std::fs::remove_file(sock_path)?; + } + 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.clone())) - .add_service(ProxySubscriptionsServer::new(burrow_server.clone())) + .add_service(NetworksServer::new(burrow_server)) .add_service(TailnetControlServer::new(tailnet_server)) .serve_with_incoming(uds_stream) .await?; @@ -67,24 +60,6 @@ pub async fn daemon_main( .map_err(|e| e.into()) } -fn bind_daemon_socket(sock_path: &Path) -> Result> { - if let Some(parent) = sock_path.parent() { - std::fs::create_dir_all(parent)?; - } - - match UnixListener::bind(sock_path) { - Ok(listener) => Ok(Some(listener)), - Err(error) if error.kind() == io::ErrorKind::AddrInUse => { - if StdUnixStream::connect(sock_path).is_ok() { - return Ok(None); - } - std::fs::remove_file(sock_path)?; - Ok(Some(UnixListener::bind(sock_path)?)) - } - Err(error) => Err(error.into()), - } -} - #[cfg(test)] mod tests { use std::{ diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index 1f3cfa2..aa84c64 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -6,14 +6,13 @@ use tonic::transport::{Endpoint, Uri}; use tower::service_fn; use super::grpc_defs::{ - networks_client::NetworksClient, proxy_subscriptions_client::ProxySubscriptionsClient, - tailnet_control_client::TailnetControlClient, tunnel_client::TunnelClient, + networks_client::NetworksClient, tailnet_control_client::TailnetControlClient, + tunnel_client::TunnelClient, }; use crate::daemon::get_socket_path; pub struct BurrowClient { pub networks_client: NetworksClient, - pub proxy_subscription_client: ProxySubscriptionsClient, pub tailnet_client: TailnetControlClient, pub tunnel_client: TunnelClient, } @@ -36,12 +35,10 @@ impl BurrowClient { })) .await?; let nw_client = NetworksClient::new(channel.clone()); - let proxy_subscription_client = ProxySubscriptionsClient::new(channel.clone()); let tailnet_client = TailnetControlClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone()); Ok(BurrowClient { networks_client: nw_client, - proxy_subscription_client, tailnet_client, tunnel_client: tun_client, }) diff --git a/burrow/src/daemon/rpc/response.rs b/burrow/src/daemon/rpc/response.rs index d72fcd3..6d03581 100644 --- a/burrow/src/daemon/rpc/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -71,8 +71,6 @@ pub struct ServerConfig { #[serde(default)] pub routes: Vec, #[serde(default)] - pub excluded_routes: Vec, - #[serde(default)] pub dns_servers: Vec, #[serde(default)] pub search_domains: Vec, @@ -93,7 +91,6 @@ impl TryFrom<&Config> for ServerConfig { .iter() .flat_map(|peer| peer.allowed_ips.iter().cloned()) .collect(), - excluded_routes: Vec::new(), dns_servers: config.interface.dns.clone(), search_domains: Vec::new(), include_default_route: false, @@ -108,7 +105,6 @@ impl Default for ServerConfig { Self { address: vec!["10.13.13.2".to_string()], // Dummy remote address routes: Vec::new(), - excluded_routes: Vec::new(), dns_servers: Vec::new(), search_domains: Vec::new(), include_default_route: false, 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 6bc3ce1..68b4195 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":[],"excluded_routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} +{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index ae5fa99..31821a2 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -20,8 +20,6 @@ use crate::{ TailscaleLoginStartRequest, TailscaleLoginStatus, }, control::{discovery, TailnetConfig}, - proxy_runtime, - proxy_subscription::ProxySubscriptionPayload, wireguard::{Config, Interface as WireGuardInterface}, }; @@ -48,10 +46,6 @@ pub enum ResolvedTunnel { identity: RuntimeIdentity, config: Config, }, - ProxySubscription { - identity: RuntimeIdentity, - payload: ProxySubscriptionPayload, - }, } impl ResolvedTunnel { @@ -79,12 +73,6 @@ impl ResolvedTunnel { let config = Config::from_content_fmt(&payload, "ini")?; Ok(Self::WireGuard { identity, config }) } - NetworkType::ProxySubscription => { - let payload: ProxySubscriptionPayload = serde_json::from_slice(&network.payload) - .context("proxy subscription payload must be valid JSON")?; - crate::proxy_subscription::validate_payload(&payload)?; - Ok(Self::ProxySubscription { identity, payload }) - } } } @@ -92,8 +80,7 @@ impl ResolvedTunnel { match self { Self::Passthrough { identity } | Self::Tailnet { identity, .. } - | Self::WireGuard { identity, .. } - | Self::ProxySubscription { identity, .. } => identity, + | Self::WireGuard { identity, .. } => identity, } } @@ -102,7 +89,6 @@ impl ResolvedTunnel { Self::Passthrough { .. } => Ok(ServerConfig { address: Vec::new(), routes: Vec::new(), - excluded_routes: Vec::new(), dns_servers: Vec::new(), search_domains: Vec::new(), include_default_route: false, @@ -112,7 +98,6 @@ impl ResolvedTunnel { Self::Tailnet { .. } => Ok(ServerConfig { address: Vec::new(), routes: tailnet_routes(), - excluded_routes: Vec::new(), dns_servers: tailnet_dns_servers(), search_domains: Vec::new(), include_default_route: false, @@ -120,7 +105,6 @@ impl ResolvedTunnel { mtu: Some(1280), }), Self::WireGuard { config, .. } => ServerConfig::try_from(config), - Self::ProxySubscription { payload, .. } => proxy_subscription_server_config(payload), } } @@ -135,7 +119,6 @@ impl ResolvedTunnel { server_config: ServerConfig { address: Vec::new(), routes: Vec::new(), - excluded_routes: Vec::new(), dns_servers: Vec::new(), search_domains: Vec::new(), include_default_route: false, @@ -154,9 +137,10 @@ impl ResolvedTunnel { }; 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_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; @@ -198,61 +182,10 @@ impl ResolvedTunnel { } } } - Self::ProxySubscription { identity, payload } => { - let runtime_config = proxy_runtime::proxy_runtime_config(&payload)?; - let server_config = proxy_subscription_server_config_for_selected( - &payload, - &runtime_config.selected_name, - )?; - let packet_bridge = - proxy_runtime::start_proxy_packet_bridge(runtime_config.outbound); - #[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(proxy_runtime::run_proxy_tun_bridge( - tun_interface.clone(), - packet_bridge.outbound_sender(), - packet_bridge.subscribe(), - ))) - }; - Ok(ActiveTunnel::ProxySubscription { - identity, - server_config, - packet_bridge, - tun_task, - }) - } } } } -fn proxy_subscription_server_config(payload: &ProxySubscriptionPayload) -> Result { - let runtime_config = proxy_runtime::proxy_runtime_config(payload)?; - proxy_subscription_server_config_for_selected(payload, &runtime_config.selected_name) -} - -fn proxy_subscription_server_config_for_selected( - payload: &ProxySubscriptionPayload, - selected_name: &str, -) -> Result { - let dns_servers = proxy_runtime::proxy_dns_servers(); - let mut excluded_routes = proxy_runtime::proxy_excluded_routes(payload)?; - excluded_routes.extend(proxy_runtime::proxy_dns_excluded_routes(&dns_servers)); - Ok(ServerConfig { - address: proxy_runtime::proxy_server_addresses(), - routes: Vec::new(), - excluded_routes, - dns_servers, - search_domains: Vec::new(), - include_default_route: true, - name: Some(format!("{} ({})", payload.name, selected_name)), - mtu: Some(1500), - }) -} - pub enum ActiveTunnel { Passthrough { identity: RuntimeIdentity, @@ -272,12 +205,6 @@ pub enum ActiveTunnel { interface: Arc>, task: JoinHandle>, }, - ProxySubscription { - identity: RuntimeIdentity, - server_config: ServerConfig, - packet_bridge: proxy_runtime::ProxyPacketBridge, - tun_task: Option>>, - }, } impl ActiveTunnel { @@ -285,8 +212,7 @@ impl ActiveTunnel { match self { Self::Passthrough { identity, .. } | Self::Tailnet { identity, .. } - | Self::WireGuard { identity, .. } - | Self::ProxySubscription { identity, .. } => identity, + | Self::WireGuard { identity, .. } => identity, } } @@ -294,54 +220,22 @@ impl ActiveTunnel { match self { Self::Passthrough { server_config, .. } | Self::Tailnet { server_config, .. } - | Self::WireGuard { server_config, .. } - | Self::ProxySubscription { server_config, .. } => server_config, + | Self::WireGuard { server_config, .. } => server_config, } } - pub fn packet_stream(&self) -> Option<(mpsc::Sender>, broadcast::Receiver>)> { + pub fn packet_stream( + &self, + ) -> Option<(mpsc::Sender>, broadcast::Receiver>)> { match self { - Self::Tailnet { packet_bridge, .. } => { - Some((packet_bridge.outbound_sender(), packet_bridge.subscribe())) - } - Self::ProxySubscription { packet_bridge, .. } => { - Some((packet_bridge.outbound_sender(), packet_bridge.subscribe())) - } + Self::Tailnet { packet_bridge, .. } => Some(( + packet_bridge.outbound_sender(), + packet_bridge.subscribe(), + )), _ => None, } } - pub async fn apply_proxy_subscription_payload( - &mut self, - network_id: i32, - payload: &ProxySubscriptionPayload, - ) -> Result { - let Self::ProxySubscription { - identity, - server_config, - packet_bridge, - .. - } = self - else { - return Ok(false); - }; - - let RuntimeIdentity::Network { id, network_type, .. } = identity else { - return Ok(false); - }; - if *id != network_id || *network_type != NetworkType::ProxySubscription { - return Ok(false); - } - - let runtime_config = proxy_runtime::proxy_runtime_config(payload)?; - *server_config = - proxy_subscription_server_config_for_selected(payload, &runtime_config.selected_name)?; - packet_bridge - .set_proxy_outbound(runtime_config.outbound) - .await; - Ok(true) - } - pub async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { match self { Self::Passthrough { .. } => Ok(()), @@ -374,28 +268,17 @@ impl ActiveTunnel { } Ok(()) } - Self::WireGuard { interface, task, .. } => { + Self::WireGuard { + interface, + task, + .. + } => { interface.read().await.remove_tun().await; let task_result = task.await; tun_interface.write().await.take(); task_result??; Ok(()) } - Self::ProxySubscription { 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.abort(); - packet_bridge.join().await?; - tun_interface.write().await.take(); - Ok(()) - } } } } @@ -513,7 +396,6 @@ fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig { .map(|ip| tailnet_cidr(ip)) .collect(), routes: tailnet_routes(), - excluded_routes: Vec::new(), dns_servers: tailnet_dns_servers(), search_domains, include_default_route: false, @@ -723,10 +605,7 @@ mod tests { 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(), - ], + tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()], ..Default::default() }; let config = tailnet_server_config(&status); diff --git a/burrow/src/database.rs b/burrow/src/database.rs index 4b2350a..fe9a3c7 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -1,6 +1,6 @@ use std::path::Path; -use anyhow::{anyhow, Result}; +use anyhow::Result; use rusqlite::{params, Connection}; use crate::{ @@ -8,7 +8,6 @@ use crate::{ daemon::rpc::grpc_defs::{ Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType, }, - proxy_subscription::ProxySubscriptionPayload, wireguard::config::{Config, Interface, Peer}, }; @@ -139,18 +138,6 @@ pub fn add_network(conn: &Connection, network: &RPCNetwork) -> Result<()> { Ok(()) } -pub fn upsert_network(conn: &Connection, network: &RPCNetwork) -> Result<()> { - validate_network_payload(network)?; - let updated = conn.execute( - "UPDATE network SET type = ?, payload = ? WHERE id = ?", - params![network.r#type().as_str_name(), &network.payload, network.id], - )?; - if updated == 0 { - add_network(conn, network)?; - } - Ok(()) -} - pub fn list_networks(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx, id")?; let networks: Vec = stmt @@ -196,70 +183,6 @@ pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<() renumber_networks(conn, &ordered_ids) } -pub fn proxy_subscription_payload( - conn: &Connection, - id: i32, -) -> Result> { - let row = conn.query_row( - "SELECT type, payload FROM network WHERE id = ?", - params![id], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)), - ); - let (network_type, payload) = match row { - Ok(row) => row, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(error) => return Err(error.into()), - }; - let network_type = NetworkType::from_str_name(network_type.as_str()) - .ok_or_else(|| anyhow!("stored network type {network_type} is not recognized"))?; - if network_type != NetworkType::ProxySubscription { - return Ok(None); - } - Ok(Some(serde_json::from_slice(&payload)?)) -} - -pub fn select_proxy_subscription_node( - conn: &Connection, - id: i32, - selected_ordinal: i32, -) -> Result { - if selected_ordinal < 0 { - return Err(anyhow!("selected proxy node ordinal must be non-negative")); - } - - let (network_type, payload): (String, Vec) = conn.query_row( - "SELECT type, payload FROM network WHERE id = ?", - params![id], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - let network_type = NetworkType::from_str_name(network_type.as_str()) - .ok_or_else(|| anyhow!("stored network type {network_type} is not recognized"))?; - if network_type != NetworkType::ProxySubscription { - return Err(anyhow!("network {id} is not a proxy subscription")); - } - - let mut payload: ProxySubscriptionPayload = serde_json::from_slice(&payload)?; - let selected_ordinal = selected_ordinal as usize; - let node = payload - .nodes - .iter() - .find(|node| node.ordinal == selected_ordinal) - .ok_or_else(|| anyhow!("selected proxy node ordinal {selected_ordinal} was not found"))?; - if let Some(error) = crate::proxy_runtime::runtime_support_error(node) { - return Err(anyhow!(error)); - } - - let selected_name = node.name.clone(); - payload.selected_ordinal = Some(selected_ordinal); - payload.selected_name = Some(selected_name); - crate::proxy_subscription::validate_payload(&payload)?; - conn.execute( - "UPDATE network SET payload = ? WHERE id = ?", - params![serde_json::to_vec_pretty(&payload)?, id], - )?; - Ok(selected_ordinal as i32) -} - fn parse_lst(s: &str) -> Vec { if s.is_empty() { return vec![]; @@ -283,10 +206,6 @@ fn validate_network_payload(network: &RPCNetwork) -> Result<()> { NetworkType::Tailnet => { TailnetConfig::from_slice(&network.payload)?; } - NetworkType::ProxySubscription => { - let payload: ProxySubscriptionPayload = serde_json::from_slice(&network.payload)?; - crate::proxy_subscription::validate_payload(&payload)?; - } } Ok(()) } @@ -359,79 +278,6 @@ Endpoint = wg.burrow.rs:51820 .to_vec() } - fn sample_proxy_subscription_payload() -> Vec { - br#"{ - "name":"example proxy subscription", - "source":{"url":"https://example.com/sub"}, - "detected_format":"uri-list", - "selected_ordinal":0, - "nodes":[{ - "ordinal":0, - "name":"example trojan", - "protocol":"trojan", - "server":"example.com", - "port":443, - "warnings":[], - "config":{ - "type":"trojan", - "password":"secret", - "sni":"front.example", - "alpn":[], - "skip_cert_verify":false, - "network":"tcp", - "client_fingerprint":"chrome", - "fingerprint":null - } - }], - "warnings":[] -}"# - .to_vec() - } - - fn sample_proxy_subscription_payload_with_two_nodes() -> Vec { - br#"{ - "name":"example proxy subscription", - "source":{"url":"https://example.com/sub"}, - "detected_format":"uri-list", - "selected_ordinal":0, - "nodes":[{ - "ordinal":0, - "name":"example trojan", - "protocol":"trojan", - "server":"example.com", - "port":443, - "warnings":[], - "config":{ - "type":"trojan", - "password":"secret", - "sni":"front.example", - "alpn":[], - "skip_cert_verify":false, - "network":"tcp", - "client_fingerprint":"chrome", - "fingerprint":null - } - },{ - "ordinal":1, - "name":"example shadowsocks", - "protocol":"shadowsocks", - "server":"ss.example.com", - "port":8388, - "warnings":[], - "config":{ - "type":"shadowsocks", - "cipher":"aes-256-gcm", - "password":"secret", - "udp":true, - "udp_over_tcp":false, - "plugin":null - } - }], - "warnings":[] -}"# - .to_vec() - } - #[test] fn test_db() { let conn = Connection::open_in_memory().unwrap(); @@ -471,16 +317,6 @@ Endpoint = wg.burrow.rs:51820 &conn, &RPCNetwork { id: 3, - r#type: NetworkType::ProxySubscription.into(), - payload: sample_proxy_subscription_payload(), - }, - ) - .unwrap(); - - add_network( - &conn, - &RPCNetwork { - id: 4, r#type: NetworkType::WireGuard.into(), payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380), }, @@ -490,7 +326,7 @@ Endpoint = wg.burrow.rs:51820 assert!(add_network( &conn, &RPCNetwork { - id: 5, + id: 4, r#type: NetworkType::WireGuard.into(), payload: b"not-a-config".to_vec(), }, @@ -500,29 +336,19 @@ Endpoint = wg.burrow.rs:51820 assert!(add_network( &conn, &RPCNetwork { - id: 6, + id: 5, r#type: NetworkType::Tailnet.into(), payload: b"not-a-tailnet-config".to_vec(), }, ) .is_err()); - assert!(add_network( - &conn, - &RPCNetwork { - id: 7, - r#type: NetworkType::ProxySubscription.into(), - payload: b"not-a-trojan-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, 4]); + assert_eq!(ids, vec![1, 2, 3]); } #[test] @@ -563,87 +389,6 @@ Endpoint = wg.burrow.rs:51820 assert_eq!(ids, vec![3, 2]); } - #[test] - fn upsert_network_preserves_priority() { - 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_with_address("10.42.0.2/32", 1380), - }, - ) - .unwrap(); - add_network( - &conn, - &RPCNetwork { - id: 2, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload_with_address("10.42.0.3/32", 1381), - }, - ) - .unwrap(); - - upsert_network( - &conn, - &RPCNetwork { - id: 1, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload_with_address("10.99.0.2/32", 1400), - }, - ) - .unwrap(); - - let networks = list_networks(&conn).unwrap(); - let ids: Vec = networks.iter().map(|n| n.id).collect(); - assert_eq!(ids, vec![1, 2]); - - let updated = String::from_utf8(networks[0].payload.clone()).unwrap(); - assert!(updated.contains("10.99.0.2/32")); - } - - #[test] - fn select_proxy_subscription_node_updates_payload_without_reorder() { - let conn = Connection::open_in_memory().unwrap(); - initialize_tables(&conn).unwrap(); - - add_network( - &conn, - &RPCNetwork { - id: 1, - r#type: NetworkType::ProxySubscription.into(), - payload: sample_proxy_subscription_payload_with_two_nodes(), - }, - ) - .unwrap(); - add_network( - &conn, - &RPCNetwork { - id: 2, - r#type: NetworkType::WireGuard.into(), - payload: sample_wireguard_payload_with_address("10.42.0.3/32", 1381), - }, - ) - .unwrap(); - - assert_eq!(select_proxy_subscription_node(&conn, 1, 1).unwrap(), 1); - - let networks = list_networks(&conn).unwrap(); - let ids: Vec = networks.iter().map(|n| n.id).collect(); - assert_eq!(ids, vec![1, 2]); - - let payload: ProxySubscriptionPayload = - serde_json::from_slice(&networks[0].payload).unwrap(); - assert_eq!(payload.selected_ordinal, Some(1)); - assert_eq!( - payload.selected_name.as_deref(), - Some("example shadowsocks") - ); - } - #[test] fn get_connection_does_not_seed_a_default_interface() { let dir = tempdir().unwrap(); diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index f0b24d5..7867d18 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -1,9 +1,6 @@ #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod control; -pub mod proxy_runtime; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -pub mod proxy_subscription; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod wireguard; diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 39a4af1..30c5960 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,9 +5,6 @@ use clap::{Args, Parser, Subcommand}; mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; -mod proxy_runtime; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -mod proxy_subscription; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; @@ -38,8 +35,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://github.com/hackclub/burrow -Spotted a bug? Please open an issue! https://github.com/hackclub/burrow/issues/new" +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." )] struct Cli { @@ -83,16 +80,6 @@ enum Commands { TailnetPing(TailnetPingArgs), /// Send a UDP echo probe through the active Tailnet tunnel over daemon packet streaming TailnetUdpEcho(TailnetUdpEchoArgs), - /// Send an HTTP probe through the active proxy tunnel over daemon packet streaming - ProxyTcpProbe(ProxyTcpProbeArgs), - /// Send a UDP echo probe through the active proxy tunnel over daemon packet streaming - ProxyUdpEcho(ProxyUdpEchoArgs), - /// Send an HTTPS probe through the active proxy tunnel over daemon packet streaming - ProxyHttpsProbe(ProxyHttpsProbeArgs), - /// Select a proxy subscription node through the daemon - ProxySubscriptionSelect(ProxySubscriptionSelectArgs), - /// Preview proxy nodes from a subscription URL - ProxySubscriptionPreview(ProxySubscriptionPreviewArgs), #[cfg(target_os = "linux")] /// Run a command in an unshared Linux namespace using a Burrow backend Exec(ExecArgs), @@ -161,59 +148,6 @@ struct TailnetUdpEchoArgs { timeout_ms: u64, } -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[derive(Args)] -struct ProxyTcpProbeArgs { - #[arg(long, default_value = "1.1.1.1:80")] - remote: String, - #[arg(long, default_value = "one.one.one.one")] - host: String, - #[arg(long)] - dns_name: Option, - #[arg(long, default_value = "100.64.0.2:53")] - dns_server: String, - #[arg(long, default_value_t = 8000)] - timeout_ms: u64, -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[derive(Args)] -struct ProxyUdpEchoArgs { - remote: String, - #[arg(long, default_value = "burrow-proxy-udp-smoke")] - message: String, - #[arg(long, default_value_t = 5000)] - timeout_ms: u64, -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[derive(Args)] -struct ProxyHttpsProbeArgs { - #[arg(long, default_value = "1.1.1.1:443")] - remote: String, - #[arg(long)] - host: Option, - #[arg(long)] - dns_name: Option, - #[arg(long, default_value = "100.64.0.2:53")] - dns_server: String, - #[arg(long, default_value_t = 15000)] - timeout_ms: u64, -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[derive(Args)] -struct ProxySubscriptionSelectArgs { - id: i32, - selected_ordinal: i32, -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[derive(Args)] -struct ProxySubscriptionPreviewArgs { - url: String, -} - #[cfg(target_os = "linux")] #[derive(Args)] struct TorExecArgs { @@ -455,21 +389,6 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul #[cfg(any(target_os = "linux", target_vendor = "apple"))] async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> { - try_packet_udp_echo("Tailnet", remote, message, timeout_ms).await -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_proxy_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> { - try_packet_udp_echo("Proxy", remote, message, timeout_ms).await -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_packet_udp_echo( - label: &str, - remote: &str, - message: &str, - timeout_ms: u64, -) -> Result<()> { use std::net::SocketAddr; use anyhow::{bail, Context}; @@ -486,7 +405,6 @@ async fn try_packet_udp_echo( let remote_addr: SocketAddr = remote .parse() .with_context(|| format!("invalid remote socket address {remote}"))?; - let label = label.to_owned(); let mut client = BurrowClient::from_uds().await?; client.tunnel_client.tunnel_start(Empty {}).await?; @@ -506,11 +424,9 @@ async fn try_packet_udp_echo( .enable_udp(true) .enable_tcp(true) .build() - .with_context(|| format!("failed to build userspace {label} UDP stack"))?; - let runner = - runner.with_context(|| format!("userspace {label} UDP stack runner unavailable"))?; - let udp_socket = - udp_socket.with_context(|| format!("userspace {label} UDP stack socket unavailable"))?; + .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(); @@ -521,21 +437,18 @@ async fn try_packet_udp_echo( .await? .into_inner(); - let ingress_label = label.clone(); let ingress_task = tokio::spawn(async move { loop { match tunnel_packets.message().await? { Some(packet) => { log::debug!( - "{} UDP echo received {} bytes from daemon packet stream", - ingress_label, + "tailnet udp echo received {} bytes from daemon packet stream", packet.payload.len() ); - stack_sink.send(packet.payload).await.with_context(|| { - format!( - "failed to feed inbound {ingress_label} packet into userspace stack" - ) - })?; + stack_sink + .send(packet.payload) + .await + .context("failed to feed inbound tailnet packet into userspace stack")?; } None => break, } @@ -543,21 +456,17 @@ async fn try_packet_udp_echo( Result::<()>::Ok(()) }); - let egress_label = label.clone(); 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!( - "{} UDP echo sending {} bytes into daemon packet stream", - egress_label, + "tailnet udp echo sending {} bytes into daemon packet stream", payload.len() ); outbound_tx .send(TunnelPacket { payload }) .await - .with_context(|| { - format!("failed to forward outbound {egress_label} packet to daemon") - })?; + .context("failed to forward outbound tailnet packet to daemon")?; } Result::<()>::Ok(()) }); @@ -567,13 +476,13 @@ async fn try_packet_udp_echo( udp_writer .send((message.as_bytes().to_vec(), local_addr, remote_addr)) .await - .with_context(|| format!("failed to send {label} UDP echo probe into userspace stack"))?; - log::debug!("{label} UDP echo probe queued from {local_addr} to {remote_addr}"); + .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 {label} UDP echo from {remote_addr}"))? - .with_context(|| format!("userspace {label} UDP stack ended before returning a reply"))?; + .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); @@ -591,691 +500,9 @@ async fn try_packet_udp_echo( bail!("UDP echo payload mismatch"); } - println!("{label} UDP Echo Source: {reply_source}"); - println!("{label} UDP Echo Destination: {reply_destination}"); - println!("{label} UDP Echo Payload: {response_text}"); - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_proxy_subscription_preview(url: &str) -> Result<()> { - let body = proxy_subscription::fetch_subscription(url).await?; - let preview = proxy_subscription::parse_subscription_body(&body, Some(url)); - - println!("Proxy subscription nodes: {}", preview.nodes.len()); - println!("Rejected entries: {}", preview.rejected.len()); - println!("Detected format: {}", preview.detected_format.as_str()); - for node in preview.nodes.iter() { - println!( - "{}: {} {}:{} ({})", - node.ordinal, - node.name, - node.server, - node.port, - node.protocol.as_str() - ); - } - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn proxy_dns_probe( - dns_name: &str, - dns_server: &str, - timeout_ms: u64, -) -> Result { - use std::net::{IpAddr, SocketAddr}; - - use anyhow::{bail, Context}; - use futures::{SinkExt, StreamExt}; - use netstack_smoltcp::StackBuilder; - use rand::Rng; - use tokio::{ - sync::mpsc, - time::{timeout, Duration}, - }; - use tokio_stream::wrappers::ReceiverStream; - - use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; - - let dns_server_addr: SocketAddr = dns_server - .parse() - .with_context(|| format!("invalid DNS server socket address {dns_server}"))?; - - let mut query = proxy_runtime::build_dns_query(dns_name, 1)?; - let query_id = rand::thread_rng().gen::(); - query[0..2].copy_from_slice(&query_id.to_be_bytes()); - - 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, dns_server_addr.ip())?; - - let (stack, runner, udp_socket, _) = StackBuilder::default() - .enable_udp(true) - .enable_tcp(true) - .build() - .context("failed to build userspace DNS probe stack")?; - let runner = runner.context("userspace DNS probe stack runner unavailable")?; - let udp_socket = udp_socket.context("userspace DNS probe 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 { - while let Some(packet) = tunnel_packets.message().await? { - stack_sink - .send(packet.payload) - .await - .context("failed to feed inbound DNS probe packet into userspace stack")?; - } - 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 DNS probe packet")?; - outbound_tx - .send(TunnelPacket { payload }) - .await - .context("failed to forward outbound DNS probe packet to daemon")?; - } - Result::<()>::Ok(()) - }); - let runner_task = tokio::spawn(async move { runner.await.map_err(anyhow::Error::from) }); - - udp_writer - .send((query, local_addr, dns_server_addr)) - .await - .context("failed to send DNS probe query")?; - - let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) - .await - .with_context(|| format!("timed out waiting for DNS response from {dns_server_addr}"))? - .context("userspace DNS probe stack ended before returning a reply")?; - let (payload, reply_source, _reply_destination) = response; - - ingress_task.abort(); - egress_task.abort(); - runner_task.abort(); - - if reply_source != dns_server_addr { - bail!("received DNS response from unexpected source {reply_source}"); - } - for ip in proxy_runtime::parse_dns_response(&payload, query_id)? { - if let IpAddr::V4(ip) = ip { - return Ok(ip); - } - } - bail!("DNS response for {dns_name} did not include an A record") -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_proxy_tcp_probe( - remote: &str, - host: &str, - dns_name: Option<&str>, - dns_server: &str, - timeout_ms: u64, -) -> Result<()> { - use std::net::{IpAddr, SocketAddr}; - - use anyhow::{bail, 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 mut remote_addr: SocketAddr = remote - .parse() - .with_context(|| format!("invalid remote socket address {remote}"))?; - let mut http_host = host.to_owned(); - if let Some(dns_name) = dns_name { - let resolved_ip = proxy_dns_probe(dns_name, dns_server, timeout_ms).await?; - remote_addr = SocketAddr::new(IpAddr::V4(resolved_ip), remote_addr.port()); - if host == "one.one.one.one" { - http_host = dns_name.to_owned(); - } - println!("Proxy TCP Probe DNS Name: {dns_name}"); - println!("Proxy TCP Probe DNS Resolved: {resolved_ip}"); - } - let remote_ip = match remote_addr.ip() { - IpAddr::V4(ip) => ip, - IpAddr::V6(_) => bail!("proxy tcp probe currently supports IPv4 targets only"), - }; - - 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 local_ip = match local_addr.ip() { - IpAddr::V4(ip) => ip, - IpAddr::V6(_) => bail!("proxy tcp probe selected an IPv6 local address"), - }; - let local_port = local_addr.port(); - - 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 initial_seq = rand::thread_rng().gen::(); - let syn = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - initial_seq, - 0, - TCP_SYN, - &[], - ); - outbound_tx - .send(TunnelPacket { payload: syn }) - .await - .context("failed to send proxy tcp SYN into daemon packet stream")?; - - let syn_ack = 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 SYN-ACK")?; - if let Some(segment) = parse_tcp_ipv4_segment( - &packet.payload, - local_ip, - remote_ip, - local_port, - remote_addr.port(), - )? { - if segment.flags & TCP_SYN != 0 && segment.flags & TCP_ACK != 0 { - break Ok::<_, anyhow::Error>(segment); - } - if segment.flags & TCP_RST != 0 { - bail!("proxy tcp probe received RST before SYN-ACK"); - } - } - } - }) - .await - .with_context(|| format!("timed out waiting for proxy tcp SYN-ACK from {remote_addr}"))??; - - let mut client_seq = initial_seq.wrapping_add(1); - let mut peer_ack = syn_ack.sequence.wrapping_add(1); - let ack = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - client_seq, - peer_ack, - TCP_ACK, - &[], - ); - outbound_tx - .send(TunnelPacket { payload: ack }) - .await - .context("failed to send proxy tcp handshake ACK")?; - - let request = format!( - "GET / HTTP/1.1\r\nHost: {http_host}\r\nConnection: close\r\nUser-Agent: burrow-proxy-tcp-probe\r\n\r\n" - ) - .into_bytes(); - let request_packet = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - client_seq, - peer_ack, - TCP_PSH | TCP_ACK, - &request, - ); - outbound_tx - .send(TunnelPacket { payload: request_packet }) - .await - .context("failed to send proxy tcp HTTP request")?; - client_seq = client_seq.wrapping_add(request.len() as u32); - - let response = 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 HTTP response")?; - let Some(segment) = parse_tcp_ipv4_segment( - &packet.payload, - local_ip, - remote_ip, - local_port, - remote_addr.port(), - )? - else { - continue; - }; - - if segment.flags & TCP_RST != 0 { - bail!("proxy tcp probe received RST before HTTP response"); - } - if !segment.payload.is_empty() { - peer_ack = segment.sequence.wrapping_add(segment.payload.len() as u32); - let ack = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - client_seq, - peer_ack, - TCP_ACK, - &[], - ); - outbound_tx - .send(TunnelPacket { payload: ack }) - .await - .context("failed to ACK proxy tcp response")?; - break Ok::<_, anyhow::Error>(segment.payload); - } - if segment.flags & TCP_FIN != 0 { - bail!("proxy tcp probe received FIN before HTTP response bytes"); - } - } - }) - .await - .with_context(|| { - format!("timed out waiting for HTTP response through proxy to {remote_addr}") - })??; - - let first_line = String::from_utf8_lossy(&response) - .lines() - .next() - .unwrap_or("") - .to_owned(); - println!("Proxy TCP Probe Local: {local_addr}"); - println!("Proxy TCP Probe Remote: {remote_addr}"); - println!("Proxy TCP Probe Response Bytes: {}", response.len()); - println!("Proxy TCP Probe First Line: {first_line}"); - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn open_proxy_packet_tcp_stream( - remote_addr: std::net::SocketAddr, - timeout_ms: u64, -) -> Result<(tokio::io::DuplexStream, std::net::SocketAddr)> { - use std::net::IpAddr; - - use anyhow::{bail, Context}; - use rand::Rng; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - sync::mpsc, - time::{timeout, Duration}, - }; - use tokio_stream::wrappers::ReceiverStream; - - use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; - - let remote_ip = match remote_addr.ip() { - IpAddr::V4(ip) => ip, - IpAddr::V6(_) => bail!("proxy packet tcp stream currently supports IPv4 targets only"), - }; - - 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 local_ip = match local_addr.ip() { - IpAddr::V4(ip) => ip, - IpAddr::V6(_) => bail!("proxy packet tcp stream selected an IPv6 local address"), - }; - let local_port = local_addr.port(); - - 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 initial_seq = rand::thread_rng().gen::(); - let syn = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - initial_seq, - 0, - TCP_SYN, - &[], - ); - outbound_tx - .send(TunnelPacket { payload: syn }) - .await - .context("failed to send proxy packet TCP SYN into daemon packet stream")?; - - let syn_ack = 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 SYN-ACK")?; - if let Some(segment) = parse_tcp_ipv4_segment( - &packet.payload, - local_ip, - remote_ip, - local_port, - remote_addr.port(), - )? { - if segment.flags & TCP_SYN != 0 && segment.flags & TCP_ACK != 0 { - break Ok::<_, anyhow::Error>(segment); - } - if segment.flags & TCP_RST != 0 { - bail!("proxy packet TCP stream received RST before SYN-ACK"); - } - } - } - }) - .await - .with_context(|| format!("timed out waiting for proxy TCP SYN-ACK from {remote_addr}"))??; - - let mut client_seq = initial_seq.wrapping_add(1); - let mut peer_ack = syn_ack.sequence.wrapping_add(1); - let ack = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - client_seq, - peer_ack, - TCP_ACK, - &[], - ); - outbound_tx - .send(TunnelPacket { payload: ack }) - .await - .context("failed to send proxy packet TCP handshake ACK")?; - - let (client_stream, manager_stream) = tokio::io::duplex(128 * 1024); - let (mut app_reader, mut app_writer) = tokio::io::split(manager_stream); - tokio::spawn(async move { - let mut app_buf = vec![0u8; 8192]; - loop { - tokio::select! { - read = app_reader.read(&mut app_buf) => { - let read = match read { - Ok(read) => read, - Err(error) => { - ::tracing::debug!(%error, "proxy packet TCP stream app read failed"); - break; - } - }; - if read == 0 { - let fin = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - client_seq, - peer_ack, - TCP_FIN | TCP_ACK, - &[], - ); - let _ = outbound_tx.send(TunnelPacket { payload: fin }).await; - break; - } - - for chunk in app_buf[..read].chunks(1200) { - let packet = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - client_seq, - peer_ack, - TCP_PSH | TCP_ACK, - chunk, - ); - if let Err(error) = outbound_tx.send(TunnelPacket { payload: packet }).await { - ::tracing::debug!(%error, "proxy packet TCP stream outbound send failed"); - return; - } - client_seq = client_seq.wrapping_add(chunk.len() as u32); - } - } - packet = tunnel_packets.message() => { - let packet = match packet { - Ok(Some(packet)) => packet, - Ok(None) => break, - Err(error) => { - ::tracing::debug!(%error, "proxy packet TCP stream inbound read failed"); - break; - } - }; - let segment = match parse_tcp_ipv4_segment( - &packet.payload, - local_ip, - remote_ip, - local_port, - remote_addr.port(), - ) { - Ok(Some(segment)) => segment, - Ok(None) => continue, - Err(error) => { - ::tracing::debug!(%error, "proxy packet TCP stream segment parse failed"); - break; - } - }; - if segment.flags & TCP_RST != 0 { - ::tracing::debug!("proxy packet TCP stream received RST"); - break; - } - - let mut ack_increment = segment.payload.len() as u32; - if segment.flags & TCP_FIN != 0 { - ack_increment = ack_increment.wrapping_add(1); - } - if ack_increment > 0 { - peer_ack = segment.sequence.wrapping_add(ack_increment); - let ack = build_tcp_ipv4_packet( - local_ip, - remote_ip, - local_port, - remote_addr.port(), - client_seq, - peer_ack, - TCP_ACK, - &[], - ); - if let Err(error) = outbound_tx.send(TunnelPacket { payload: ack }).await { - ::tracing::debug!(%error, "proxy packet TCP stream ACK send failed"); - return; - } - } - - if !segment.payload.is_empty() { - if let Err(error) = app_writer.write_all(&segment.payload).await { - ::tracing::debug!(%error, "proxy packet TCP stream app write failed"); - break; - } - } - if segment.flags & TCP_FIN != 0 { - let _ = app_writer.shutdown().await; - break; - } - } - } - } - }); - - Ok((client_stream, local_addr)) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_proxy_https_probe( - remote: &str, - host: Option<&str>, - dns_name: Option<&str>, - dns_server: &str, - timeout_ms: u64, -) -> Result<()> { - use std::{ - net::{IpAddr, SocketAddr}, - sync::{Arc, Once}, - }; - - use anyhow::{bail, Context}; - use rustls::{pki_types::ServerName, ClientConfig, RootCertStore}; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - time::{timeout, Duration}, - }; - use tokio_rustls::TlsConnector; - - static INSTALL_RUSTLS_PROVIDER: Once = Once::new(); - INSTALL_RUSTLS_PROVIDER.call_once(|| { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - }); - - let mut remote_addr: SocketAddr = remote - .parse() - .with_context(|| format!("invalid remote socket address {remote}"))?; - let tls_host = host - .or(dns_name) - .context("proxy HTTPS probe requires --host or --dns-name for TLS SNI")? - .to_owned(); - if let Some(dns_name) = dns_name { - let resolved_ip = proxy_dns_probe(dns_name, dns_server, timeout_ms).await?; - remote_addr = SocketAddr::new(IpAddr::V4(resolved_ip), remote_addr.port()); - println!("Proxy HTTPS Probe DNS Name: {dns_name}"); - println!("Proxy HTTPS Probe DNS Resolved: {resolved_ip}"); - } - - if !remote_addr.is_ipv4() { - bail!("proxy HTTPS probe currently supports IPv4 targets only"); - } - - let mut root_store = RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let mut tls_config = ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); - tls_config.alpn_protocols = vec![b"http/1.1".to_vec()]; - - let connector = TlsConnector::from(Arc::new(tls_config)); - let server_name = ServerName::try_from(tls_host.clone()) - .with_context(|| format!("invalid TLS server name {tls_host}"))?; - - let (stream, local_addr) = open_proxy_packet_tcp_stream(remote_addr, timeout_ms).await?; - let mut tls_stream = timeout( - Duration::from_millis(timeout_ms), - connector.connect(server_name, stream), - ) - .await - .with_context(|| format!("timed out waiting for TLS handshake with {tls_host}"))? - .with_context(|| format!("TLS handshake with {tls_host} failed"))?; - - let request = format!( - "GET / HTTP/1.1\r\nHost: {tls_host}\r\nConnection: close\r\nUser-Agent: burrow-proxy-https-probe\r\n\r\n" - ); - timeout( - Duration::from_millis(timeout_ms), - tls_stream.write_all(request.as_bytes()), - ) - .await - .with_context(|| format!("timed out sending HTTPS request to {tls_host}"))? - .with_context(|| format!("failed to send HTTPS request to {tls_host}"))?; - - let response = timeout(Duration::from_millis(timeout_ms), async { - let mut response = Vec::new(); - let mut buf = [0u8; 8192]; - loop { - let read = tls_stream - .read(&mut buf) - .await - .context("failed to read HTTPS response")?; - if read == 0 { - break; - } - response.extend_from_slice(&buf[..read]); - if response.iter().any(|byte| *byte == b'\n') { - break; - } - } - if response.is_empty() { - bail!("HTTPS response ended before returning bytes"); - } - Ok::<_, anyhow::Error>(response) - }) - .await - .with_context(|| format!("timed out waiting for HTTPS response from {tls_host}"))??; - - let first_line = String::from_utf8_lossy(&response) - .lines() - .next() - .unwrap_or("") - .to_owned(); - println!("Proxy HTTPS Probe Local: {local_addr}"); - println!("Proxy HTTPS Probe Remote: {remote_addr}"); - println!("Proxy HTTPS Probe Host: {tls_host}"); - println!("Proxy HTTPS Probe Response Bytes: {}", response.len()); - println!("Proxy HTTPS Probe First Line: {first_line}"); - Ok(()) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_proxy_subscription_select(id: i32, selected_ordinal: i32) -> Result<()> { - use crate::daemon::rpc::grpc_defs::ProxySubscriptionSelectRequest; - - let mut client = BurrowClient::from_uds().await?; - let response = client - .proxy_subscription_client - .select_node(ProxySubscriptionSelectRequest { id, selected_ordinal }) - .await? - .into_inner(); - println!( - "Proxy Subscription Selected: id={} selected_ordinal={}", - response.id, response.selected_ordinal - ); + println!("Tailnet UDP Echo Source: {reply_source}"); + println!("Tailnet UDP Echo Destination: {reply_destination}"); + println!("Tailnet UDP Echo Payload: {response_text}"); Ok(()) } @@ -1320,24 +547,6 @@ struct IcmpEchoReply { payload: Vec, } -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -const TCP_FIN: u16 = 0x01; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -const TCP_SYN: u16 = 0x02; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -const TCP_RST: u16 = 0x04; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -const TCP_PSH: u16 = 0x08; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -const TCP_ACK: u16 = 0x10; - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -struct TcpSegment { - sequence: u32, - flags: u16, - payload: Vec, -} - #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn build_icmp_echo_request( source: std::net::IpAddr, @@ -1434,113 +643,6 @@ fn parse_icmp_echo_reply( })) } -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn build_tcp_ipv4_packet( - source: std::net::Ipv4Addr, - destination: std::net::Ipv4Addr, - source_port: u16, - destination_port: u16, - sequence: u32, - ack: u32, - flags: u16, - payload: &[u8], -) -> Vec { - let tcp_len = 20 + payload.len(); - let total_len = 20 + tcp_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(&0x4000u16.to_be_bytes()); - packet.push(64); - packet.push(6); - 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()); - - let tcp_start = packet.len(); - packet.extend_from_slice(&source_port.to_be_bytes()); - packet.extend_from_slice(&destination_port.to_be_bytes()); - packet.extend_from_slice(&sequence.to_be_bytes()); - packet.extend_from_slice(&ack.to_be_bytes()); - packet.push(5 << 4); - packet.push((flags & 0xff) as u8); - packet.extend_from_slice(&64240u16.to_be_bytes()); - packet.extend_from_slice(&[0, 0]); - packet.extend_from_slice(&0u16.to_be_bytes()); - packet.extend_from_slice(payload); - - let checksum = tcp_ipv4_checksum(source, destination, &packet[tcp_start..]); - packet[tcp_start + 16..tcp_start + 18].copy_from_slice(&checksum.to_be_bytes()); - packet -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn parse_tcp_ipv4_segment( - packet: &[u8], - local_ip: std::net::Ipv4Addr, - remote_ip: std::net::Ipv4Addr, - local_port: u16, - remote_port: u16, -) -> Result> { - use anyhow::bail; - - if packet.len() < 40 { - 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 + 20 || packet[9] != 6 { - 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 tcp = &packet[ihl..]; - let source_port = u16::from_be_bytes([tcp[0], tcp[1]]); - let destination_port = u16::from_be_bytes([tcp[2], tcp[3]]); - if source_port != remote_port || destination_port != local_port { - return Ok(None); - } - let data_offset = ((tcp[12] >> 4) as usize) * 4; - if data_offset < 20 || tcp.len() < data_offset { - bail!("invalid TCP data offset in proxy tcp probe response"); - } - let sequence = u32::from_be_bytes([tcp[4], tcp[5], tcp[6], tcp[7]]); - let flags = (tcp[13] as u16) & 0x3f; - Ok(Some(TcpSegment { - sequence, - flags, - payload: tcp[data_offset..].to_vec(), - })) -} - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn tcp_ipv4_checksum( - source: std::net::Ipv4Addr, - destination: std::net::Ipv4Addr, - tcp_segment: &[u8], -) -> u16 { - let mut pseudo = Vec::with_capacity(12 + tcp_segment.len()); - pseudo.extend_from_slice(&source.octets()); - pseudo.extend_from_slice(&destination.octets()); - pseudo.push(0); - pseudo.push(6); - pseudo.extend_from_slice(&(tcp_segment.len() as u16).to_be_bytes()); - pseudo.extend_from_slice(tcp_segment); - internet_checksum(&pseudo) -} - #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn internet_checksum(bytes: &[u8]) -> u16 { let mut sum = 0u32; @@ -1673,35 +775,6 @@ async fn main() -> Result<()> { Commands::TailnetUdpEcho(args) => { try_tailnet_udp_echo(&args.remote, &args.message, args.timeout_ms).await? } - Commands::ProxyTcpProbe(args) => { - try_proxy_tcp_probe( - &args.remote, - &args.host, - args.dns_name.as_deref(), - &args.dns_server, - args.timeout_ms, - ) - .await? - } - Commands::ProxyUdpEcho(args) => { - try_proxy_udp_echo(&args.remote, &args.message, args.timeout_ms).await? - } - Commands::ProxyHttpsProbe(args) => { - try_proxy_https_probe( - &args.remote, - args.host.as_deref(), - args.dns_name.as_deref(), - &args.dns_server, - args.timeout_ms, - ) - .await? - } - Commands::ProxySubscriptionSelect(args) => { - try_proxy_subscription_select(args.id, args.selected_ordinal).await? - } - Commands::ProxySubscriptionPreview(args) => { - try_proxy_subscription_preview(&args.url).await? - } #[cfg(target_os = "linux")] Commands::Exec(args) => { try_exec( diff --git a/burrow/src/proxy_runtime.rs b/burrow/src/proxy_runtime.rs deleted file mode 100644 index 7eacdda..0000000 --- a/burrow/src/proxy_runtime.rs +++ /dev/null @@ -1,8346 +0,0 @@ -#[cfg(target_vendor = "apple")] -use std::{cmp::Reverse, ffi::CStr, process::Command}; -use std::{ - collections::{HashMap, VecDeque}, - fs, - future::Future, - io, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}, - num::NonZeroU32, - os::fd::AsRawFd, - pin::Pin, - str::FromStr, - sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - Arc, Mutex as StdMutex, Once, - }, - task::{Context as TaskContext, Poll}, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, -}; - -use aead::{ - generic_array::{ - typenum::{U12, U16, U24}, - GenericArray, - }, - AeadInPlace, KeyInit, -}; -use aegis::{aegis128l::Aegis128L, aegis256::Aegis256}; -use aes::{ - cipher::{generic_array::GenericArray as AesGenericArray, BlockDecrypt, BlockEncrypt}, - Aes128, Aes192, Aes256, -}; -use aes_gcm::AesGcm; -use anyhow::{anyhow, bail, Context, Result}; -use base64::{ - engine::general_purpose::{ - STANDARD as BASE64_STANDARD, URL_SAFE as BASE64_URL_SAFE, - URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD, - }, - Engine as _, -}; -use bytes::Bytes; -use ccm::Ccm; -use chacha20::{ - cipher::{KeyIvInit as ChachaKeyIvInit, StreamCipher as ChachaStreamCipher}, - ChaCha20Legacy, XChaCha20, -}; -use chacha20poly1305::{ChaCha8Poly1305, XChaCha8Poly1305}; -use deoxys::{ - aead::{array::Array as DeoxysArray, AeadInOut as DeoxysAeadInOut, KeyInit as DeoxysKeyInit}, - DeoxysII256, -}; -use futures::{SinkExt, StreamExt}; -use http::{Method, Request, StatusCode}; -use lea::{ - cipher::{ - generic_array::GenericArray as LeaGenericArray, BlockEncrypt as LeaBlockEncrypt, - NewBlockCipher as LeaNewBlockCipher, - }, - Lea128, Lea192, Lea256, -}; -use md5::Md5; -#[cfg(target_vendor = "apple")] -use native_tls::{Identity as NativeTlsIdentity, TlsConnector as NativeTlsConnector}; -use netstack_smoltcp::{ - StackBuilder, TcpListener as StackTcpListener, TcpStream as StackTcpStream, - UdpSocket as StackUdpSocket, -}; -use poly1305::{universal_hash::UniversalHash, Poly1305}; -use rabbit::{ - cipher::{KeyIvInit as RabbitKeyIvInit, StreamCipher as RabbitStreamCipher}, - Rabbit, -}; -#[cfg(feature = "boring-browser-fingerprints")] -use rama_net::{ - address::Domain as RamaDomain, - tls::{ - client::{ - ClientHelloExtension as RamaClientHelloExtension, - ServerVerifyMode as RamaServerVerifyMode, - }, - SupportedGroup as RamaSupportedGroup, - }, -}; -#[cfg(feature = "boring-browser-fingerprints")] -use rama_tls_boring::{ - client::{ - ConnectorConfigClientAuth as BoringConnectorConfigClientAuth, - TlsConnectorDataBuilder as BoringTlsConnectorDataBuilder, - }, - core::{ - hash::MessageDigest as BoringMessageDigest, - pkey::PKey as BoringPKey, - x509::{X509Ref as BoringX509Ref, X509 as BoringX509}, - }, -}; -#[cfg(feature = "boring-browser-fingerprints")] -use rama_ua::{ - profile::{try_load_embedded_profiles, TlsProfile}, - PlatformKind, UserAgentKind, -}; -use rand::{rngs::OsRng, Rng, RngCore}; -use ring::{hmac, pbkdf2}; -use rust_tokio_kcp::{ - Aes128BlockCrypt as KcpAes128BlockCrypt, Aes192BlockCrypt as KcpAes192BlockCrypt, - Aes256BlockCrypt as KcpAes256BlockCrypt, AesGcmBlockCrypt as KcpAesGcmBlockCrypt, - BlockCrypt as KcpBlockCrypt, BlowfishBlockCrypt as KcpBlowfishBlockCrypt, - Cast5BlockCrypt as KcpCast5BlockCrypt, KcpConfig, KcpNoDelayConfig, KcpStream, - NoneBlockCrypt as KcpNoneBlockCrypt, Salsa20BlockCrypt as KcpSalsa20BlockCrypt, - SimpleXorBlockCrypt as KcpSimpleXorBlockCrypt, Sm4BlockCrypt as KcpSm4BlockCrypt, - TeaBlockCrypt as KcpTeaBlockCrypt, TripleDesBlockCrypt as KcpTripleDesBlockCrypt, - TwofishBlockCrypt as KcpTwofishBlockCrypt, XteaBlockCrypt as KcpXteaBlockCrypt, -}; -use rustls::{ - client::{ - danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, - EchConfig, EchMode, - }, - pki_types::{CertificateDer, EchConfigListBytes, PrivateKeyDer, ServerName, UnixTime}, - version, ClientConfig, DigitallySignedStruct, Error as RustlsError, RootCertStore, - SignatureScheme, -}; -use sha2::{Digest, Sha224, Sha256}; -use shadowsocks::{ - config::{ - ServerAddr as SsServerAddr, ServerConfig as SsServerConfig, ServerType as SsServerType, - }, - context::{Context as SsContext, SharedContext as SsSharedContext}, - crypto::CipherKind as SsCipherKind, - relay::{ - socks5::Address as SsAddress, - udprelay::{ - proxy_socket::UdpSocketType as SsUdpSocketType, DatagramReceive, DatagramSend, - DatagramSocket, ProxySocket as SsProxySocket, - }, - }, - ProxyClientStream as SsProxyClientStream, -}; -use subtle::ConstantTimeEq; -use tokio::{ - io::{split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}, - net::{TcpSocket, TcpStream, UdpSocket}, - sync::{broadcast, mpsc, oneshot, Mutex, RwLock}, - task::{JoinHandle, JoinSet}, - time::timeout, -}; -#[cfg(target_vendor = "apple")] -use tokio_native_tls::TlsConnector as TokioNativeTlsConnector; -use tokio_rustls::TlsConnector; -use tokio_snappy::SnappyIO; -use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; -use tun::tokio::TunInterface; - -use crate::proxy_subscription::{ - ProxyNode, ProxyNodeConfig, ProxySubscriptionPayload, ShadowsocksNode, ShadowsocksPlugin, - ShadowsocksSmux, TrojanNetwork, TrojanNode, -}; - -const PROXY_TUN_V4: &str = "100.64.0.2/24"; -const PROXY_TUN_V6: &str = "fd00:64::2/64"; -const PROXY_DNS_V4: &str = "100.64.0.2"; -const UDP_IDLE_TIMEOUT: Duration = Duration::from_secs(30); -const TROJAN_MAX_UDP_PAYLOAD: usize = 8192; -const MIHOMO_TROJAN_TCP_DEFAULT_ALPN: &[&str] = &["h2", "http/1.1"]; -const SS_INFO: &[u8] = b"ss-subkey"; -const UOT_VERSION: u8 = 2; -#[allow(dead_code)] -const TLS_RECORD_HEADER_LEN: usize = 5; -#[allow(dead_code)] -const TLS_APPLICATION_DATA_RECORD_TYPE: u8 = 0x17; -#[allow(dead_code)] -const TLS12_RECORD_VERSION: [u8; 2] = [0x03, 0x03]; -const UOT_LEGACY_VERSION: u8 = 1; -const UOT_MAGIC_ADDRESS: &str = "sp.v2.udp-over-tcp.arpa"; -const UOT_LEGACY_MAGIC_ADDRESS: &str = "sp.udp-over-tcp.arpa"; -const SING_MUX_ADDRESS: &str = "sp.mux.sing-box.arpa"; -const SING_MUX_PORT: u16 = 444; -const SING_MUX_PROTOCOL_SMUX: u8 = 0; -const SING_MUX_PROTOCOL_YAMUX: u8 = 1; -const SING_MUX_PROTOCOL_H2MUX: u8 = 2; -const SING_MUX_VERSION_0: u8 = 0; -const SING_MUX_VERSION_1: u8 = 1; -const SING_MUX_PADDING_FRAMES: usize = 16; -const SING_MUX_MIN_PADDING_LEN: usize = 256; -const SING_MUX_PADDING_LEN_RANGE: usize = 512; -const SING_MUX_STREAM_FLAG_UDP: u16 = 1; -const SING_MUX_STREAM_STATUS_SUCCESS: u8 = 0; -const SING_MUX_STREAM_STATUS_ERROR: u8 = 1; -const SING_MUX_BRUTAL_EXCHANGE_DOMAIN: &str = "_BrutalBwExchange"; -const AEAD2022_SESSION_SUBKEY_CONTEXT: &str = "shadowsocks 2022 session subkey"; -const AEAD2022_IDENTITY_SUBKEY_CONTEXT: &str = "shadowsocks 2022 identity subkey"; -const AEAD2022_HEADER_TYPE_CLIENT: u8 = 0; -const AEAD2022_HEADER_TYPE_SERVER: u8 = 1; -const AEAD2022_TIMESTAMP_MAX_DIFF: u64 = 30; -const PUBLIC_DNS_SERVERS: &[&str] = &["1.1.1.1", "8.8.8.8"]; -const SHADOW_TLS_MAX_RECORD_PAYLOAD: usize = 16 * 1024; -const SHADOW_TLS_V3_HMAC_SIZE: usize = 4; -const SHADOW_TLS_V3_TLS_HEADER_SIZE: usize = 5; -const SHADOW_TLS_V3_TLS_RANDOM_SIZE: usize = 32; -const SHADOW_TLS_V3_SESSION_ID_SIZE: usize = 32; -const SHADOW_TLS_V3_SESSION_ID_START: usize = 1 + 3 + 2 + SHADOW_TLS_V3_TLS_RANDOM_SIZE + 1; -const SHADOW_TLS_V3_SERVER_RANDOM_INDEX: usize = SHADOW_TLS_V3_TLS_HEADER_SIZE + 1 + 3 + 2; -const SHADOW_TLS_V3_SESSION_ID_LENGTH_INDEX: usize = - SHADOW_TLS_V3_SERVER_RANDOM_INDEX + SHADOW_TLS_V3_TLS_RANDOM_SIZE; -const SHADOW_TLS_V3_HMAC_HEADER_SIZE: usize = - SHADOW_TLS_V3_TLS_HEADER_SIZE + SHADOW_TLS_V3_HMAC_SIZE; -const KCPTUN_RATE_LIMIT_BURST_BYTES: u64 = 64 * 1500; -const KCPTUN_SMUX_DEFAULT_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(30); -const RESTLS_DEFAULT_SCRIPT: &str = "250?100<1,350~100<1,600~100,300~200,300~100"; -#[allow(dead_code)] -const RESTLS_APP_DATA_MAC_LEN: usize = 8; -#[allow(dead_code)] -const RESTLS_CMD_LEN: usize = 2; -#[allow(dead_code)] -const RESTLS_MASK_LEN: usize = RESTLS_CMD_LEN + 2; -#[allow(dead_code)] -const RESTLS_APP_DATA_AUTH_HEADER_LEN: usize = RESTLS_APP_DATA_MAC_LEN + RESTLS_MASK_LEN; -#[allow(dead_code)] -const RESTLS_APP_DATA_OFFSET: usize = RESTLS_APP_DATA_AUTH_HEADER_LEN; -#[allow(dead_code)] -const RESTLS_APP_DATA_LEN_OFFSET: usize = RESTLS_APP_DATA_MAC_LEN; -#[allow(dead_code)] -const RESTLS_MAX_PLAINTEXT: usize = 16_384; -#[allow(dead_code)] -const RESTLS_RANDOM_RESPONSE_MAGIC: &[u8] = b"restls-random-response"; -#[allow(dead_code)] -const RESTLS_HANDSHAKE_MAC_LEN: usize = 16; -#[allow(dead_code)] -const RESTLS_TLS12_CLIENT_AUTH_LAYOUT_3: [usize; 4] = [0, 11, 22, 32]; -#[allow(dead_code)] -const RESTLS_TLS12_CLIENT_AUTH_LAYOUT_4: [usize; 5] = [0, 8, 16, 24, 32]; -const DIRECT_DNS_TIMEOUT: Duration = Duration::from_secs(2); -const DNS_TYPE_HTTPS: u16 = 65; -const HTTPS_SVC_PARAM_ECH: u16 = 5; -const FAKE_DNS_BASE: u32 = u32::from_be_bytes([198, 18, 64, 1]); - -type Aes192Gcm = AesGcm; -type Aes128Ccm = Ccm; -type Aes192Ccm = Ccm; -type Aes256Ccm = Ccm; - -pub struct ProxyRuntimeConfig { - pub selected_name: String, - pub outbound: ProxyOutbound, -} - -pub struct ProxyPacketBridge { - outbound: mpsc::Sender>, - inbound: broadcast::Sender>, - proxy_outbound: Arc>, - task: JoinHandle>, -} - -impl ProxyPacketBridge { - pub fn outbound_sender(&self) -> mpsc::Sender> { - self.outbound.clone() - } - - pub fn subscribe(&self) -> broadcast::Receiver> { - self.inbound.subscribe() - } - - pub async fn set_proxy_outbound(&self, outbound: ProxyOutbound) { - *self.proxy_outbound.write().await = outbound; - } - - pub fn abort(&self) { - self.task.abort(); - } - - pub async fn join(self) -> Result<()> { - match self.task.await { - Ok(Ok(())) => Ok(()), - Ok(Err(err)) => Err(err), - Err(err) if err.is_cancelled() => Ok(()), - Err(err) => Err(err.into()), - } - } -} - -#[derive(Clone)] -pub enum ProxyOutbound { - Trojan(TrojanOutbound), - Shadowsocks(ShadowsocksOutbound), -} - -type DnsHostnameCache = Arc>; - -#[derive(Debug)] -struct DnsHostnameCacheState { - reverse: HashMap, - fake_by_hostname: HashMap, - next_fake_offset: u32, -} - -impl DnsHostnameCacheState { - fn new() -> Self { - Self { - reverse: HashMap::new(), - fake_by_hostname: HashMap::new(), - next_fake_offset: 0, - } - } - - fn lookup_hostname(&self, ip: IpAddr) -> Option { - self.reverse.get(&ip).cloned() - } - - fn insert_mapping(&mut self, ip: IpAddr, hostname: String) { - self.reverse.insert(ip, hostname); - } - - fn fake_ip_for_hostname(&mut self, hostname: &str) -> Ipv4Addr { - if let Some(ip) = self.fake_by_hostname.get(hostname) { - return *ip; - } - - let raw = FAKE_DNS_BASE.wrapping_add(self.next_fake_offset); - self.next_fake_offset = self.next_fake_offset.wrapping_add(1); - let ip = Ipv4Addr::from(raw); - self.fake_by_hostname.insert(hostname.to_owned(), ip); - self.reverse.insert(IpAddr::V4(ip), hostname.to_owned()); - ip - } -} - -#[derive(Debug, Clone)] -struct ProxyTcpTarget { - address: SocketAddr, - hostname: Option, -} - -impl ProxyOutbound { - async fn bridge_tcp(&self, inbound: StackTcpStream, target: ProxyTcpTarget) -> Result<()> { - match self { - Self::Trojan(outbound) => outbound.bridge_tcp(inbound, target).await, - Self::Shadowsocks(outbound) => outbound.bridge_tcp(inbound, target).await, - } - } - - async fn start_udp_session( - &self, - key: UdpFlowKey, - outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - ) -> Result<()> { - match self { - Self::Trojan(outbound) => outbound.run_udp_session(key, outbound_rx, reply_tx).await, - Self::Shadowsocks(outbound) => { - outbound.run_udp_session(key, outbound_rx, reply_tx).await - } - } - } -} - -#[derive(Clone)] -pub struct TrojanOutbound { - endpoint: ProxyServerEndpoint, - password: String, - sni: String, - alpn: Vec, - skip_cert_verify: bool, - udp_enabled: bool, - certificate_fingerprint: Option, -} - -#[derive(Clone)] -pub struct ShadowsocksOutbound { - endpoint: ProxyServerEndpoint, - protocol: ShadowsocksProtocol, - plugin: Option, - kcptun_pool: Option>, - sing_mux: Option, - sing_mux_sessions: Arc>>, - udp_enabled: bool, - udp_over_tcp: bool, - udp_over_tcp_version: u8, -} - -#[derive(Clone)] -enum ShadowsocksProtocol { - None, - Standard { - server_config: Arc, - context: SsSharedContext, - }, - CustomAead { - kind: CustomSsAeadKind, - master_key: Arc<[u8]>, - }, - CustomStream { - kind: CustomSsStreamKind, - key: Arc<[u8]>, - }, - Aead2022AesCcm { - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ShadowsocksPluginTransport { - SimpleObfs { mode: SimpleObfsMode, host: String }, - WebSocket(ShadowsocksWebSocketTransport), - ShadowTls(ShadowsocksShadowTlsTransport), - Kcptun(ShadowsocksKcptunTransport), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShadowsocksSingMuxTransport { - protocol: ShadowsocksSingMuxProtocol, - max_connections: usize, - min_streams: usize, - max_streams: usize, - padding: bool, - udp: bool, - brutal: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ShadowsocksSingMuxBrutalTransport { - send_bps: u64, - receive_bps: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShadowsocksSingMuxProtocol { - H2Mux, - Smux, - Yamux, -} - -impl ShadowsocksSingMuxProtocol { - fn name(self) -> &'static str { - match self { - Self::H2Mux => "h2mux", - Self::Smux => "smux", - Self::Yamux => "yamux", - } - } - - fn wire_id(self) -> u8 { - match self { - Self::H2Mux => SING_MUX_PROTOCOL_H2MUX, - Self::Smux => SING_MUX_PROTOCOL_SMUX, - Self::Yamux => SING_MUX_PROTOCOL_YAMUX, - } - } -} - -#[derive(Clone)] -enum ShadowsocksSingMuxSession { - H2Mux(ShadowsocksH2MuxSession), - Smux(Arc), - Yamux(ShadowsocksYamuxSession), -} - -#[derive(Clone)] -struct ShadowsocksH2MuxSession { - send_request: h2::client::SendRequest, - active_streams: Arc, - closed: Arc, -} - -#[derive(Clone)] -struct ShadowsocksYamuxSession { - open_tx: mpsc::Sender, - active_streams: Arc, - closed: Arc, -} - -struct YamuxCommand { - response: oneshot::Sender>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SimpleObfsMode { - Http, - Tls, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind, - host: String, - path: String, - headers: Vec<(String, String)>, - tls: bool, - ech: Option, - skip_cert_verify: bool, - certificate_fingerprint: Option, - client_identity: Option, - mux: ShadowsocksWebSocketMux, - v2ray_http_upgrade: bool, - v2ray_http_upgrade_fast_open: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShadowsocksWebSocketKind { - V2ray, - Gost, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShadowsocksWebSocketMux { - None, - V2ray, - Gost, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShadowsocksEchConfig { - config_list: Option>, - query_server_name: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShadowsocksShadowTlsTransport { - host: String, - password: String, - version: u8, - alpn: Vec, - skip_cert_verify: bool, - certificate_fingerprint: Option, - client_identity: Option, - client_fingerprint: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct TlsClientIdentity { - certificate: String, - private_key: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShadowsocksKcptunTransport { - key: String, - crypt: String, - mode: String, - conn: usize, - auto_expire: u64, - scavenge_ttl: u64, - mtu: usize, - rate_limit: u32, - sndwnd: u16, - rcvwnd: u16, - data_shard: usize, - parity_shard: usize, - dscp: u32, - no_comp: bool, - ack_nodelay: bool, - no_delay: i32, - interval: i32, - resend: i32, - no_congestion: bool, - sockbuf: usize, - smux_ver: u8, - smux_buf: usize, - frame_size: usize, - stream_buf: usize, - keep_alive: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShadowsocksRestlsTransport { - host: String, - password: String, - version_hint: RestlsVersionHint, - script: Vec, - client_fingerprint: RestlsClientFingerprint, - session_tickets_disabled: bool, - secret: [u8; 32], -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RestlsVersionHint { - Tls12, - Tls13, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RestlsClientFingerprint { - Chrome, - Firefox, - Safari, - Ios, -} - -impl RestlsClientFingerprint { - #[cfg(feature = "boring-browser-fingerprints")] - fn browser_profile(self) -> MihomoClientFingerprint { - match self { - Self::Chrome => MihomoClientFingerprint::Chrome, - Self::Firefox => MihomoClientFingerprint::Firefox, - Self::Safari => MihomoClientFingerprint::Safari, - Self::Ios => MihomoClientFingerprint::Ios, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MihomoClientFingerprint { - Chrome, - Firefox, - Safari, - Ios, - Android, - Edge, - Browser360, - Qq, - Random, - Chrome120, - Firefox120, - Safari16, - ChromePsk, - ChromePskShuffle, - ChromePaddingPskShuffle, - ChromePq, - ChromePqPsk, - Randomized, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RestlsScriptLine { - target_len: RestlsTargetLength, - command: RestlsCommand, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct RestlsTargetLength { - base: u16, - random_range: u16, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RestlsCommand { - Noop, - Response(u16), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -enum RestlsRecordDirection { - ToServer, - ToClient, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] -struct RestlsApplicationFrame { - data: Vec, - command: RestlsCommand, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] -struct RestlsWriteResult { - record: Vec, - consumed: usize, - command: RestlsCommand, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -struct RestlsApplicationCodec { - secret: [u8; 32], - server_random: [u8; 32], - to_server_counter: u64, - to_client_counter: u64, - tls12_gcm: bool, - tls12_gcm_to_client_disable_counter: bool, - client_finished_auth: Option>, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -struct RestlsStreamState { - codec: RestlsApplicationCodec, - script: Vec, - send_buffer: Vec, - write_pending: bool, -} - -#[allow(dead_code)] -struct RestlsStream { - inner: S, - state: RestlsStreamState, - read_stage: RestlsRecordReadStage, - read_buf: Vec, - read_offset: usize, - write_buf: Vec, - write_offset: usize, -} - -#[allow(dead_code)] -enum RestlsRecordReadStage { - Header { - bytes: [u8; TLS_RECORD_HEADER_LEN], - filled: usize, - }, - Payload { - header: [u8; TLS_RECORD_HEADER_LEN], - bytes: Vec, - filled: usize, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] -struct RestlsStreamWriteResult { - accepted: usize, - records: Vec>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] -struct RestlsStreamReadResult { - data: Vec, - records: Vec>, - resumed_pending_write: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] -struct RestlsServerAuthRecord { - record: Vec, - tls12_gcm_server_disable_counter: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] -struct RestlsTls13ClientHelloAuthMaterial { - key_shares: Vec<(u16, Vec)>, - psk_labels: Vec>, -} - -struct KcptunSessionPool { - endpoint: ProxyServerEndpoint, - config: ShadowsocksKcptunTransport, - state: Mutex, -} - -struct KcptunSessionPoolState { - sessions: Vec>, - rr: usize, -} - -struct KcptunTimedSession { - session: Arc, - expiry_at: Option, -} - -impl Default for ShadowsocksKcptunTransport { - fn default() -> Self { - let mut config = Self::empty(); - config.fill_defaults(); - config - } -} - -impl ShadowsocksKcptunTransport { - fn empty() -> Self { - Self { - key: String::new(), - crypt: String::new(), - mode: String::new(), - conn: 0, - auto_expire: 0, - scavenge_ttl: 0, - mtu: 0, - rate_limit: 0, - sndwnd: 0, - rcvwnd: 0, - data_shard: 0, - parity_shard: 0, - dscp: 0, - no_comp: false, - ack_nodelay: false, - no_delay: 0, - interval: 0, - resend: 0, - no_congestion: false, - sockbuf: 0, - smux_ver: 0, - smux_buf: 0, - frame_size: 0, - stream_buf: 0, - keep_alive: 0, - } - } - - fn fill_defaults(&mut self) { - if self.key.is_empty() { - self.key = "it's a secrect".to_owned(); - } - if self.crypt.is_empty() { - self.crypt = "aes".to_owned(); - } - if self.mode.is_empty() { - self.mode = "fast".to_owned(); - } - if self.conn == 0 { - self.conn = 1; - } - if self.scavenge_ttl == 0 { - self.scavenge_ttl = 600; - } - if self.mtu == 0 { - self.mtu = 1350; - } - if self.sndwnd == 0 { - self.sndwnd = 128; - } - if self.rcvwnd == 0 { - self.rcvwnd = 512; - } - if self.data_shard == 0 { - self.data_shard = 10; - } - if self.parity_shard == 0 { - self.parity_shard = 3; - } - if self.interval == 0 { - self.interval = 50; - } - if self.sockbuf == 0 { - self.sockbuf = 4_194_304; - } - if self.smux_ver == 0 { - self.smux_ver = 1; - } - if self.smux_buf == 0 { - self.smux_buf = 4_194_304; - } - if self.frame_size == 0 { - self.frame_size = 8192; - } - if self.stream_buf == 0 { - self.stream_buf = 2_097_152; - } - if self.keep_alive == 0 { - self.keep_alive = 10; - } - match self.mode.trim().to_ascii_lowercase().as_str() { - "normal" => { - self.no_delay = 0; - self.interval = 40; - self.resend = 2; - self.no_congestion = true; - } - "fast" => { - self.no_delay = 0; - self.interval = 30; - self.resend = 2; - self.no_congestion = true; - } - "fast2" => { - self.no_delay = 1; - self.interval = 20; - self.resend = 2; - self.no_congestion = true; - } - "fast3" => { - self.no_delay = 1; - self.interval = 10; - self.resend = 2; - self.no_congestion = true; - } - _ => {} - } - if self.smux_ver > 2 { - self.smux_ver = 2; - } - } -} - -impl KcptunSessionPool { - fn new(endpoint: ProxyServerEndpoint, config: ShadowsocksKcptunTransport) -> Self { - let conn = config.conn.max(1); - Self { - endpoint, - config, - state: Mutex::new(KcptunSessionPoolState { - sessions: std::iter::repeat_with(|| None).take(conn).collect(), - rr: 0, - }), - } - } - - async fn open_stream(&self) -> Result<(Box, SocketAddr)> { - let address = *self - .endpoint - .addresses - .first() - .ok_or_else(|| anyhow!("Shadowsocks kcptun endpoint has no resolved address"))?; - for attempt in 0..2 { - let session = self.session_for_next_slot(address).await?; - match kcptun_open_smux_stream(session.clone()).await { - Ok(stream) => return Ok((stream, address)), - Err(err) if attempt == 0 => { - tracing::debug!( - error = %err, - server = %self.endpoint.host, - port = self.endpoint.port, - "discarding failed Shadowsocks kcptun smux session and retrying" - ); - self.invalidate_session(&session).await; - } - Err(err) => return Err(err), - } - } - unreachable!("kcptun retry loop has fixed attempts") - } - - async fn session_for_next_slot(&self, address: SocketAddr) -> Result> { - let mut state = self.state.lock().await; - if state.sessions.is_empty() { - state.sessions.push(None); - } - let index = state.rr % state.sessions.len(); - state.rr = state.rr.wrapping_add(1); - let now = Instant::now(); - let expired = state.sessions[index] - .as_ref() - .and_then(|session| session.expiry_at) - .is_some_and(|expiry| now >= expiry); - let needs_reconnect = state.sessions[index] - .as_ref() - .map(|session| session.session.is_closed() || expired) - .unwrap_or(true); - if needs_reconnect { - if let Some(previous) = state.sessions[index].take() { - if expired && !previous.session.is_closed() { - schedule_kcptun_session_close(previous.session, self.config.scavenge_ttl); - } - } - let session = kcptun_open_session(&self.endpoint, address, &self.config).await?; - let expiry_at = if self.config.auto_expire == 0 { - None - } else { - Some(Instant::now() + Duration::from_secs(self.config.auto_expire)) - }; - state.sessions[index] = Some(KcptunTimedSession { - session: session.clone(), - expiry_at, - }); - return Ok(session); - } - Ok(state.sessions[index] - .as_ref() - .expect("kcptun session exists when reconnect is not needed") - .session - .clone()) - } - - async fn invalidate_session(&self, session: &Arc) { - let mut state = self.state.lock().await; - let stale = state - .sessions - .iter_mut() - .find(|slot| { - slot.as_ref() - .map(|candidate| Arc::ptr_eq(&candidate.session, session)) - .unwrap_or(false) - }) - .and_then(Option::take); - drop(state); - if let Some(stale) = stale { - let _ = stale.session.close().await; - } - } -} - -#[derive(Clone)] -struct ProxyServerEndpoint { - host: String, - port: u16, - addresses: Arc<[SocketAddr]>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CustomSsAeadKind { - Aes192Gcm, - Aes192Ccm, - Chacha8IetfPoly1305, - XChacha8IetfPoly1305, - Rabbit128Poly1305, - Aegis128L, - Aegis256, - Aez384, - DeoxysII256128, - Ascon128, - Ascon128A, - Lea128Gcm, - Lea192Gcm, - Lea256Gcm, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CustomSsStreamKind { - Chacha20, - XChacha20, -} - -impl CustomSsStreamKind { - fn from_name(name: &str) -> Option { - match name.trim().to_ascii_lowercase().as_str() { - "chacha20" => Some(Self::Chacha20), - "xchacha20" => Some(Self::XChacha20), - _ => None, - } - } - - fn name(self) -> &'static str { - match self { - Self::Chacha20 => "chacha20", - Self::XChacha20 => "xchacha20", - } - } - - fn key_len(self) -> usize { - 32 - } - - fn salt_len(self) -> usize { - match self { - Self::Chacha20 => 8, - Self::XChacha20 => 24, - } - } -} - -impl CustomSsAeadKind { - fn from_name(name: &str) -> Option { - match name.trim().to_ascii_lowercase().as_str() { - "aes-192-gcm" => Some(Self::Aes192Gcm), - "aes-192-ccm" => Some(Self::Aes192Ccm), - "chacha8-ietf-poly1305" => Some(Self::Chacha8IetfPoly1305), - "xchacha8-ietf-poly1305" => Some(Self::XChacha8IetfPoly1305), - "rabbit128-poly1305" => Some(Self::Rabbit128Poly1305), - "aegis-128l" => Some(Self::Aegis128L), - "aegis-256" => Some(Self::Aegis256), - "aez-384" => Some(Self::Aez384), - "deoxys-ii-256-128" => Some(Self::DeoxysII256128), - "ascon128" => Some(Self::Ascon128), - "ascon128a" => Some(Self::Ascon128A), - "lea-128-gcm" => Some(Self::Lea128Gcm), - "lea-192-gcm" => Some(Self::Lea192Gcm), - "lea-256-gcm" => Some(Self::Lea256Gcm), - _ => None, - } - } - - fn key_len(self) -> usize { - match self { - Self::Aes192Gcm | Self::Aes192Ccm | Self::Lea192Gcm => 24, - Self::Chacha8IetfPoly1305 | Self::XChacha8IetfPoly1305 => 32, - Self::Rabbit128Poly1305 | Self::Aegis128L | Self::Lea128Gcm => 16, - Self::Aegis256 | Self::DeoxysII256128 | Self::Lea256Gcm => 32, - Self::Aez384 => 48, - Self::Ascon128 | Self::Ascon128A => 16, - } - } - - fn salt_len(self) -> usize { - self.key_len() - } - - fn tag_len(self) -> usize { - 16 - } - - fn nonce_len(self) -> usize { - match self { - Self::Aes192Gcm - | Self::Aes192Ccm - | Self::Lea128Gcm - | Self::Lea192Gcm - | Self::Lea256Gcm => 12, - Self::Chacha8IetfPoly1305 => 12, - Self::XChacha8IetfPoly1305 => 24, - Self::Rabbit128Poly1305 => 8, - Self::Aegis128L => 16, - Self::Aegis256 => 32, - Self::Aez384 => 16, - Self::DeoxysII256128 => 15, - Self::Ascon128 | Self::Ascon128A => 16, - } - } - - fn name(self) -> &'static str { - match self { - Self::Aes192Gcm => "aes-192-gcm", - Self::Aes192Ccm => "aes-192-ccm", - Self::Chacha8IetfPoly1305 => "chacha8-ietf-poly1305", - Self::XChacha8IetfPoly1305 => "xchacha8-ietf-poly1305", - Self::Rabbit128Poly1305 => "rabbit128-poly1305", - Self::Aegis128L => "aegis-128l", - Self::Aegis256 => "aegis-256", - Self::Aez384 => "aez-384", - Self::DeoxysII256128 => "deoxys-ii-256-128", - Self::Ascon128 => "ascon128", - Self::Ascon128A => "ascon128a", - Self::Lea128Gcm => "lea-128-gcm", - Self::Lea192Gcm => "lea-192-gcm", - Self::Lea256Gcm => "lea-256-gcm", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Aead2022AesCcmKind { - Aes128, - Aes256, -} - -impl Aead2022AesCcmKind { - fn from_name(name: &str) -> Option { - match name.trim().to_ascii_lowercase().as_str() { - "2022-blake3-aes-128-ccm" => Some(Self::Aes128), - "2022-blake3-aes-256-ccm" => Some(Self::Aes256), - _ => None, - } - } - - fn name(self) -> &'static str { - match self { - Self::Aes128 => "2022-blake3-aes-128-ccm", - Self::Aes256 => "2022-blake3-aes-256-ccm", - } - } - - fn key_len(self) -> usize { - match self { - Self::Aes128 => 16, - Self::Aes256 => 32, - } - } - - fn tag_len(self) -> usize { - 16 - } - - fn nonce_len(self) -> usize { - 12 - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CertificateFingerprint([u8; 32]); - -#[derive(Debug)] -struct UdpReply { - payload: Vec, - source: SocketAddr, - destination: SocketAddr, -} - -#[derive(Debug, Clone, Eq, Hash, PartialEq)] -struct UdpFlowKey { - local: SocketAddr, - remote: SocketAddr, -} - -pub fn proxy_server_config_name(payload: &ProxySubscriptionPayload) -> String { - match selected_node(payload) { - Ok(node) => format!("{} ({})", payload.name, node.name), - Err(_) => payload.name.clone(), - } -} - -pub fn proxy_server_addresses() -> Vec { - vec![PROXY_TUN_V4.to_owned(), PROXY_TUN_V6.to_owned()] -} - -pub fn proxy_dns_servers() -> Vec { - let servers = vec![PROXY_DNS_V4.to_owned()]; - tracing::info!( - dns_servers = ?servers, - "configured proxy subscription DNS servers from daemon fake-IP resolver" - ); - servers -} - -pub fn proxy_dns_excluded_routes(dns_servers: &[String]) -> Vec { - let mut routes = Vec::new(); - for server in dns_servers { - let Ok(ip) = server.parse::() else { - continue; - }; - if ip == IpAddr::V4(Ipv4Addr::new(100, 64, 0, 2)) { - continue; - } - let route = host_route_for_ip(ip); - if !routes.contains(&route) { - routes.push(route); - } - } - tracing::info!( - excluded_routes = ?routes, - "configured proxy DNS excluded routes" - ); - routes -} - -pub fn proxy_excluded_routes(payload: &ProxySubscriptionPayload) -> Result> { - let node = selected_node(payload)?; - let routes = excluded_routes_for_server(&node.server, node.port)?; - tracing::info!( - server = %node.server, - port = node.port, - excluded_routes = ?routes, - "configured proxy server excluded routes" - ); - Ok(routes) -} - -pub fn proxy_runtime_config(payload: &ProxySubscriptionPayload) -> Result { - let node = selected_node(payload)?; - tracing::info!( - subscription = %payload.name, - node = %node.name, - protocol = %node.protocol.as_str(), - server = %node.server, - port = node.port, - "selected proxy subscription node" - ); - let outbound = match &node.config { - ProxyNodeConfig::Trojan(config) => { - ProxyOutbound::Trojan(TrojanOutbound::from_node(node, config)?) - } - ProxyNodeConfig::Shadowsocks(config) => { - ProxyOutbound::Shadowsocks(ShadowsocksOutbound::from_node(node, config)?) - } - }; - Ok(ProxyRuntimeConfig { - selected_name: node.name.clone(), - outbound, - }) -} - -pub fn selected_node(payload: &ProxySubscriptionPayload) -> Result<&ProxyNode> { - if let Some(name) = payload.selected_name.as_deref() { - let node = payload - .nodes - .iter() - .find(|node| node.name == name) - .ok_or_else(|| anyhow!("selected proxy subscription node {name} was not found"))?; - validate_runtime_supported_node(node)?; - return Ok(node); - } - - if let Some(ordinal) = payload.selected_ordinal { - let node = payload - .nodes - .iter() - .find(|node| node.ordinal == ordinal) - .ok_or_else(|| anyhow!("selected proxy subscription node {ordinal} was not found"))?; - validate_runtime_supported_node(node)?; - return Ok(node); - } - - payload - .nodes - .iter() - .find(|node| validate_runtime_supported_node(node).is_ok()) - .ok_or_else(|| { - anyhow!("proxy subscription contains no runtime-supported Trojan or Shadowsocks nodes") - }) -} - -fn validate_runtime_supported_node(node: &ProxyNode) -> Result<()> { - match &node.config { - ProxyNodeConfig::Trojan(config) => { - if !matches!(config.network, TrojanNetwork::Tcp) { - bail!( - "Trojan node {} uses {:?}; only tcp transport is supported by the packet runtime", - node.name, - config.network - ); - } - } - ProxyNodeConfig::Shadowsocks(config) => { - shadowsocks_plugin_transport( - config.plugin.as_ref(), - config.client_fingerprint.as_deref(), - )?; - if config.udp_over_tcp { - normalize_uot_version(config.udp_over_tcp_version)?; - } - validate_shadowsocks_cipher(&config.cipher)?; - let protocol = shadowsocks_protocol_for_node(node, config)?; - shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol)?; - } - } - Ok(()) -} - -pub fn runtime_support_error(node: &ProxyNode) -> Option { - validate_runtime_supported_node(node) - .err() - .map(|err| err.to_string()) -} - -pub fn start_proxy_packet_bridge(outbound: ProxyOutbound) -> ProxyPacketBridge { - let (outbound_tx, outbound_rx) = mpsc::channel(128); - let (inbound_tx, _) = broadcast::channel(128); - let proxy_outbound = Arc::new(RwLock::new(outbound)); - let task = tokio::spawn(run_proxy_packet_stack( - outbound_rx, - inbound_tx.clone(), - proxy_outbound.clone(), - )); - ProxyPacketBridge { - outbound: outbound_tx, - inbound: inbound_tx, - proxy_outbound, - task, - } -} - -pub async fn run_proxy_tun_bridge( - tun: Arc>>, - outbound_tx: mpsc::Sender>, - mut inbound_rx: broadcast::Receiver>, -) -> Result<()> { - let inbound_tun = tun.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!("proxy subscription tun interface unavailable"); - }; - tun.send(&packet) - .await - .context("failed to write proxy packet to tun")?; - } - Result::<()>::Ok(()) - }); - - let outbound_tun = tun.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!("proxy subscription tun interface unavailable"); - }; - tun.recv(&mut buf) - .await - .context("failed to read proxy packet from tun")? - }; - outbound_tx - .send(buf[..len].to_vec()) - .await - .context("failed to forward packet to proxy stack")?; - } - #[allow(unreachable_code)] - Result::<()>::Ok(()) - }); - - let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; - inbound_result?; - outbound_result?; - Ok(()) -} - -async fn run_proxy_packet_stack( - mut packet_rx: mpsc::Receiver>, - packet_tx: broadcast::Sender>, - outbound: Arc>, -) -> Result<()> { - 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 proxy 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) }); - } - - tasks.spawn(async move { - while let Some(packet) = packet_rx.recv().await { - stack_sink - .send(packet) - .await - .context("failed to send tunnel packet into proxy netstack")?; - } - Result::<()>::Ok(()) - }); - - tasks.spawn(async move { - while let Some(packet) = stack_stream.next().await { - let packet = packet.context("failed to receive packet from proxy netstack")?; - let _ = packet_tx.send(packet); - } - Result::<()>::Ok(()) - }); - - if let Some(tcp_listener) = tcp_listener { - let tcp_outbound = outbound.clone(); - let dns_cache = Arc::new(RwLock::new(DnsHostnameCacheState::new())); - let tcp_dns_cache = dns_cache.clone(); - tasks.spawn( - async move { tcp_dispatch_loop(tcp_listener, tcp_outbound, tcp_dns_cache).await }, - ); - - if let Some(udp_socket) = udp_socket { - tasks.spawn(async move { udp_dispatch_loop(udp_socket, outbound, dns_cache).await }); - } - } else if let Some(udp_socket) = udp_socket { - let dns_cache = Arc::new(RwLock::new(DnsHostnameCacheState::new())); - tasks.spawn(async move { udp_dispatch_loop(udp_socket, outbound, dns_cache).await }); - } - - while let Some(result) = tasks.join_next().await { - match result { - Ok(Ok(())) => {} - Ok(Err(err)) => return Err(err), - Err(err) if err.is_cancelled() => {} - Err(err) => return Err(err.into()), - } - } - Ok(()) -} - -async fn tcp_dispatch_loop( - mut listener: StackTcpListener, - outbound: Arc>, - dns_cache: DnsHostnameCache, -) -> 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)) => tracing::warn!(?err, "proxy tcp bridge task failed"), - Err(err) if err.is_cancelled() => {} - Err(err) => tracing::warn!(?err, "proxy tcp bridge task panicked"), - } - } - next = listener.next() => match next { - Some((stream, local_addr, remote_addr)) => { - let hostname = dns_cache.read().await.lookup_hostname(remote_addr.ip()); - tracing::debug!( - %local_addr, - %remote_addr, - hostname = hostname.as_deref(), - "accepted proxy tcp stream" - ); - let outbound = outbound.read().await.clone(); - let target = ProxyTcpTarget { - address: remote_addr, - hostname, - }; - tasks.spawn(async move { - outbound.bridge_tcp(stream, target).await - }); - } - None => break, - } - } - } - - tasks.abort_all(); - Ok(()) -} - -async fn udp_dispatch_loop( - socket: StackUdpSocket, - outbound: Arc>, - dns_cache: DnsHostnameCache, -) -> Result<()> { - let (mut udp_reader, mut udp_writer) = socket.split(); - let (reply_tx, mut reply_rx) = mpsc::channel::(128); - let 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)) => tracing::warn!(?err, "proxy udp session failed"), - Err(err) if err.is_cancelled() => {} - Err(err) => tracing::warn!(?err, "proxy udp session panicked"), - } - } - maybe_reply = reply_rx.recv() => match maybe_reply { - Some(reply) => { - record_dns_mappings(&dns_cache, &reply.payload).await; - udp_writer - .send((reply.payload, reply.source, reply.destination)) - .await - .context("failed to write udp reply into proxy netstack")?; - } - None => break, - }, - maybe_datagram = udp_reader.next() => match maybe_datagram { - Some((payload, local_addr, remote_addr)) => { - if is_proxy_dns_request(remote_addr) { - if let Some(response) = handle_fake_dns_request(&dns_cache, &payload).await { - udp_writer - .send((response, remote_addr, local_addr)) - .await - .context("failed to write fake DNS reply into proxy netstack")?; - continue; - } - } - dispatch_udp( - outbound.clone(), - payload, - local_addr, - remote_addr, - reply_tx.clone(), - sessions.clone(), - &mut session_tasks, - ).await?; - } - None => break, - } - } - } - - session_tasks.abort_all(); - Ok(()) -} - -fn is_proxy_dns_request(remote_addr: SocketAddr) -> bool { - remote_addr.port() == 53 && remote_addr.ip() == IpAddr::V4(Ipv4Addr::new(100, 64, 0, 2)) -} - -async fn handle_fake_dns_request(cache: &DnsHostnameCache, payload: &[u8]) -> Option> { - match build_fake_dns_response(cache, payload).await { - Ok(response) => Some(response), - Err(err) => { - tracing::debug!(error = ?err, "failed to answer fake proxy DNS request"); - None - } - } -} - -async fn build_fake_dns_response(cache: &DnsHostnameCache, query: &[u8]) -> Result> { - let questions = parse_dns_query(query)?; - let mut answers = Vec::new(); - { - let mut guard = cache.write().await; - for question in &questions { - if question.class != 1 || question.name.is_empty() { - continue; - } - if question.record_type == 1 { - let ip = guard.fake_ip_for_hostname(&question.name); - tracing::debug!( - hostname = %question.name, - %ip, - "answered proxy fake DNS A query" - ); - answers.push((question.name.clone(), ip)); - } - } - } - Ok(encode_fake_dns_response(query, &questions, &answers)) -} - -#[derive(Debug)] -struct DnsQuestion { - name: String, - record_type: u16, - class: u16, -} - -fn parse_dns_query(query: &[u8]) -> Result> { - if query.len() < 12 { - bail!("truncated DNS query"); - } - let flags = u16::from_be_bytes([query[2], query[3]]); - if flags & 0x8000 != 0 { - bail!("DNS packet is not a query"); - } - let question_count = u16::from_be_bytes([query[4], query[5]]) as usize; - let mut offset = 12; - let mut questions = Vec::new(); - for _ in 0..question_count { - let (name, next_offset) = read_dns_name(query, offset)?; - offset = next_offset; - if query.len() < offset + 4 { - bail!("truncated DNS question"); - } - let record_type = u16::from_be_bytes([query[offset], query[offset + 1]]); - let class = u16::from_be_bytes([query[offset + 2], query[offset + 3]]); - offset += 4; - questions.push(DnsQuestion { name, record_type, class }); - } - Ok(questions) -} - -fn encode_fake_dns_response( - query: &[u8], - questions: &[DnsQuestion], - answers: &[(String, Ipv4Addr)], -) -> Vec { - let question_end = dns_question_section_end(query).unwrap_or(query.len()); - let mut response = Vec::with_capacity(question_end + answers.len() * 32); - response.extend_from_slice(&query[..2]); - response.extend_from_slice(&0x8180u16.to_be_bytes()); - response.extend_from_slice(&(questions.len() as u16).to_be_bytes()); - response.extend_from_slice(&(answers.len() as u16).to_be_bytes()); - response.extend_from_slice(&0u16.to_be_bytes()); - response.extend_from_slice(&0u16.to_be_bytes()); - response.extend_from_slice(&query[12..question_end]); - - for (hostname, ip) in answers { - encode_dns_name(&mut response, hostname); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&60u32.to_be_bytes()); - response.extend_from_slice(&4u16.to_be_bytes()); - response.extend_from_slice(&ip.octets()); - } - response -} - -fn dns_question_section_end(query: &[u8]) -> Result { - if query.len() < 12 { - bail!("truncated DNS query"); - } - let question_count = u16::from_be_bytes([query[4], query[5]]) as usize; - let mut offset = 12; - for _ in 0..question_count { - offset = skip_dns_name(query, offset)?; - if query.len() < offset + 4 { - bail!("truncated DNS question"); - } - offset += 4; - } - Ok(offset) -} - -fn encode_dns_name(out: &mut Vec, hostname: &str) { - for label in hostname.trim_end_matches('.').split('.') { - out.push(label.len().min(63) as u8); - out.extend_from_slice( - label - .as_bytes() - .get(..label.len().min(63)) - .unwrap_or_default(), - ); - } - out.push(0); -} - -async fn dispatch_udp( - outbound: Arc>, - 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 proxy udp payload")?; - sessions.lock().await.insert(key.clone(), tx); - - session_tasks.spawn(async move { - let outbound = outbound.read().await.clone(); - let result = outbound - .start_udp_session(key.clone(), rx, reply_tx) - .await - .with_context(|| format!("proxy udp session {} -> {} failed", key.local, key.remote)); - sessions.lock().await.remove(&key); - result - }); - Ok(()) -} - -impl TrojanOutbound { - fn from_node(node: &ProxyNode, config: &TrojanNode) -> Result { - if !matches!(config.network, TrojanNetwork::Tcp) { - bail!( - "Trojan node {} uses {:?}; only tcp transport is supported by the packet runtime", - node.name, - config.network - ); - } - let sni = config.sni.clone().unwrap_or_else(|| node.server.clone()); - let certificate_fingerprint = config - .fingerprint - .as_deref() - .map(parse_certificate_fingerprint) - .transpose()?; - let endpoint = ProxyServerEndpoint::resolve(&node.server, node.port) - .with_context(|| format!("failed to resolve Trojan server for node {}", node.name))?; - if let Some(client_fingerprint) = unsupported_client_fingerprint(config) { - tracing::warn!( - node = %node.name, - client_fingerprint, - "Trojan client-fingerprint is stored but uTLS ClientHello impersonation is not implemented; using platform TLS ClientHello" - ); - } - tracing::info!( - node = %node.name, - server = %endpoint.host, - port = endpoint.port, - sni = %sni, - alpn = ?trojan_alpn(config), - alpn_present = config.alpn_present, - skip_cert_verify = config.skip_cert_verify, - udp = config.udp, - certificate_fingerprint = config.fingerprint.is_some(), - resolved_addresses = ?endpoint.addresses, - "configured Trojan proxy outbound" - ); - Ok(Self { - endpoint, - password: config.password.clone(), - sni, - alpn: trojan_alpn(config), - skip_cert_verify: config.skip_cert_verify, - udp_enabled: config.udp, - certificate_fingerprint, - }) - } - - async fn bridge_tcp(&self, inbound: StackTcpStream, target: ProxyTcpTarget) -> Result<()> { - let remote_addr = target.address; - let mut outbound = self.connect_tls().await?; - let socks_target = socks_addr_for_target(&target)?; - write_trojan_header(&mut outbound, &self.password, 0x01, &socks_target).await?; - tracing::debug!( - %remote_addr, - hostname = target.hostname.as_deref(), - "wrote Trojan TCP request header" - ); - bridge_tcp_streams(inbound, outbound, remote_addr, target.hostname).await?; - Ok(()) - } - - async fn run_udp_session( - &self, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - ) -> Result<()> { - if !self.udp_enabled { - bail!("Trojan UDP is disabled for this node"); - } - let mut stream = self.connect_tls().await?; - write_trojan_header(&mut stream, &self.password, 0x03, &socks_addr(key.remote)?).await?; - let (mut reader, mut writer) = split(stream); - let remote_socks = socks_addr(key.remote)?; - - let mut write_task = tokio::spawn(async move { - while let Some(payload) = outbound_rx.recv().await { - write_trojan_udp_packet(&mut writer, &remote_socks, &payload).await?; - } - Result::<()>::Ok(()) - }); - - let mut read_task = tokio::spawn(async move { - let mut buf = vec![0u8; 65_535]; - loop { - let (source, payload) = read_trojan_udp_packet(&mut reader, &mut buf).await?; - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue Trojan udp reply")?; - } - #[allow(unreachable_code)] - Result::<()>::Ok(()) - }); - - tokio::select! { - result = &mut write_task => { - read_task.abort(); - result??; - } - result = &mut read_task => { - write_task.abort(); - result??; - } - } - Ok(()) - } - - async fn connect_tls(&self) -> Result { - let (tcp, address) = connect_proxy_tcp(&self.endpoint).await.with_context(|| { - format!( - "failed to connect Trojan server {}:{}", - self.endpoint.host, self.endpoint.port - ) - })?; - #[cfg(target_vendor = "apple")] - { - return self.connect_native_tls(tcp, address).await; - } - #[cfg(not(target_vendor = "apple"))] - { - self.connect_rustls(tcp, address).await - } - } - - #[cfg(not(target_vendor = "apple"))] - async fn connect_rustls(&self, tcp: TcpStream, address: SocketAddr) -> Result { - let config = trojan_tls_config( - &self.alpn, - self.skip_cert_verify, - self.certificate_fingerprint.as_ref(), - )?; - let connector = TlsConnector::from(Arc::new(config)); - let name = ServerName::try_from(self.sni.clone()) - .with_context(|| format!("invalid Trojan SNI {}", self.sni))?; - tracing::debug!( - server = %self.endpoint.host, - port = self.endpoint.port, - %address, - sni = %self.sni, - alpn = ?self.alpn, - skip_cert_verify = self.skip_cert_verify, - "starting Trojan TLS handshake" - ); - let tls = match connector.connect(name, tcp).await { - Ok(tls) => { - let negotiated_alpn = tls - .get_ref() - .1 - .alpn_protocol() - .map(|protocol| String::from_utf8_lossy(protocol).into_owned()); - tracing::debug!( - server = %self.endpoint.host, - port = self.endpoint.port, - %address, - sni = %self.sni, - alpn = ?negotiated_alpn, - "completed Trojan TLS handshake" - ); - tls - } - Err(err) => { - tracing::warn!( - server = %self.endpoint.host, - port = self.endpoint.port, - %address, - sni = %self.sni, - error = %err, - "failed Trojan TLS handshake" - ); - return Err(err).with_context(|| { - format!("failed to complete Trojan TLS handshake with {}", self.sni) - }); - } - }; - Ok(Box::new(tls)) - } - - #[cfg(target_vendor = "apple")] - async fn connect_native_tls( - &self, - tcp: TcpStream, - address: SocketAddr, - ) -> Result { - let mut builder = NativeTlsConnector::builder(); - if self.skip_cert_verify || self.certificate_fingerprint.is_some() { - builder.danger_accept_invalid_certs(true); - } - let alpn = self.alpn.iter().map(String::as_str).collect::>(); - if !alpn.is_empty() { - builder.request_alpns(&alpn); - } - let connector = TokioNativeTlsConnector::from( - builder - .build() - .context("failed to build native TLS connector for Trojan")?, - ); - tracing::debug!( - server = %self.endpoint.host, - port = self.endpoint.port, - %address, - sni = %self.sni, - alpn = ?self.alpn, - skip_cert_verify = self.skip_cert_verify, - certificate_fingerprint = self.certificate_fingerprint.is_some(), - "starting Trojan native TLS handshake" - ); - let tls = match connector.connect(&self.sni, tcp).await { - Ok(tls) => { - if let Some(fingerprint) = self.certificate_fingerprint.as_ref() { - verify_native_tls_peer_certificate(&tls, fingerprint, "Trojan")?; - } - let negotiated_alpn = tls - .get_ref() - .negotiated_alpn() - .ok() - .flatten() - .map(|protocol| String::from_utf8_lossy(&protocol).into_owned()); - tracing::debug!( - server = %self.endpoint.host, - port = self.endpoint.port, - %address, - sni = %self.sni, - alpn = ?negotiated_alpn, - "completed Trojan native TLS handshake" - ); - tls - } - Err(err) => { - tracing::warn!( - server = %self.endpoint.host, - port = self.endpoint.port, - %address, - sni = %self.sni, - error = %err, - "failed Trojan native TLS handshake" - ); - return Err(err).with_context(|| { - format!( - "failed to complete Trojan native TLS handshake with {}", - self.sni - ) - }); - } - }; - Ok(Box::new(tls)) - } -} - -async fn bridge_tcp_streams( - inbound: StackTcpStream, - outbound: TrojanStream, - remote_addr: SocketAddr, - hostname: Option, -) -> Result<()> { - let (inbound_reader, inbound_writer) = split(inbound); - let (outbound_reader, outbound_writer) = split(outbound); - let hostname_for_client = hostname.clone(); - let mut client_to_proxy = tokio::spawn(async move { - copy_tcp_direction( - "client_to_proxy", - inbound_reader, - outbound_writer, - remote_addr, - hostname_for_client, - ) - .await - }); - let mut proxy_to_client = tokio::spawn(async move { - copy_tcp_direction( - "proxy_to_client", - outbound_reader, - inbound_writer, - remote_addr, - hostname, - ) - .await - }); - - tokio::select! { - result = &mut client_to_proxy => { - proxy_to_client.abort(); - let bytes = result.context("client-to-proxy bridge task panicked")??; - tracing::debug!(%remote_addr, bytes, "Trojan tcp client-to-proxy bridge completed first"); - } - result = &mut proxy_to_client => { - client_to_proxy.abort(); - let bytes = result.context("proxy-to-client bridge task panicked")??; - tracing::debug!(%remote_addr, bytes, "Trojan tcp proxy-to-client bridge completed first"); - } - } - Ok(()) -} - -async fn copy_tcp_direction( - direction: &'static str, - mut reader: R, - mut writer: W, - remote_addr: SocketAddr, - hostname: Option, -) -> Result -where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, -{ - let mut total = 0u64; - let mut logged_first_chunk = false; - let mut buf = vec![0u8; 16 * 1024]; - loop { - let len = reader - .read(&mut buf) - .await - .with_context(|| format!("failed to read {direction} proxy tcp bytes"))?; - if len == 0 { - tracing::debug!( - %remote_addr, - hostname = hostname.as_deref(), - direction, - total, - "proxy tcp direction reached EOF" - ); - writer - .shutdown() - .await - .with_context(|| format!("failed to shutdown {direction} proxy tcp writer"))?; - return Ok(total); - } - if !logged_first_chunk { - logged_first_chunk = true; - tracing::debug!( - %remote_addr, - hostname = hostname.as_deref(), - direction, - len, - "proxy tcp direction forwarding first bytes" - ); - } - writer - .write_all(&buf[..len]) - .await - .with_context(|| format!("failed to write {direction} proxy tcp bytes"))?; - total += len as u64; - } -} - -fn trojan_alpn(config: &TrojanNode) -> Vec { - if config.alpn_present { - config.alpn.clone() - } else { - MIHOMO_TROJAN_TCP_DEFAULT_ALPN - .iter() - .map(|value| (*value).to_owned()) - .collect() - } -} - -async fn bridge_streams(inbound: StackTcpStream, stream: S) -> Result<()> -where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - let (mut inbound_reader, mut inbound_writer) = split(inbound); - let (mut stream_reader, mut stream_writer) = split(stream); - let mut upload = tokio::spawn(async move { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = inbound_reader - .read(&mut buf) - .await - .context("failed to read tcp payload from proxy netstack")?; - if n == 0 { - break; - } - stream_writer.write_all(&buf[..n]).await?; - } - stream_writer - .shutdown() - .await - .context("failed to shutdown proxy tcp upload")?; - Result::<()>::Ok(()) - }); - - let mut download = tokio::spawn(async move { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = stream_reader - .read(&mut buf) - .await - .context("failed to read proxy tcp payload")?; - if n == 0 { - break; - } - inbound_writer - .write_all(&buf[..n]) - .await - .context("failed to write tcp payload to proxy netstack")?; - } - inbound_writer - .shutdown() - .await - .context("failed to shutdown proxy netstack tcp download")?; - Result::<()>::Ok(()) - }); - - tokio::select! { - result = &mut upload => { - download.abort(); - result??; - } - result = &mut download => { - upload.abort(); - result??; - } - } - Ok(()) -} - -impl ShadowsocksOutbound { - fn from_node(node: &ProxyNode, config: &ShadowsocksNode) -> Result { - let plugin = shadowsocks_plugin_transport( - config.plugin.as_ref(), - config.client_fingerprint.as_deref(), - ) - .with_context(|| { - format!( - "Shadowsocks node {} uses an unsupported plugin transport", - node.name - ) - })?; - let force_udp_over_tcp = matches!(plugin, Some(ShadowsocksPluginTransport::Kcptun(_))); - let udp_over_tcp = config.udp_over_tcp || force_udp_over_tcp; - let udp_over_tcp_version = if udp_over_tcp { - normalize_uot_version(config.udp_over_tcp_version)? - } else { - UOT_LEGACY_VERSION - }; - let endpoint = - ProxyServerEndpoint::resolve(&node.server, node.port).with_context(|| { - format!( - "failed to resolve Shadowsocks server for node {}", - node.name - ) - })?; - let protocol = shadowsocks_protocol_for_node(node, config)?; - let sing_mux = shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol) - .with_context(|| { - format!( - "Shadowsocks node {} uses an unsupported top-level sing-mux mode", - node.name - ) - })?; - let kcptun_pool = match plugin.as_ref() { - Some(ShadowsocksPluginTransport::Kcptun(config)) => Some(Arc::new( - KcptunSessionPool::new(endpoint.clone(), config.clone()), - )), - _ => None, - }; - tracing::info!( - node = %node.name, - server = %endpoint.host, - port = endpoint.port, - cipher = %config.cipher, - udp = config.udp, - udp_over_tcp, - udp_over_tcp_version, - plugin = ?plugin, - sing_mux = ?sing_mux, - resolved_addresses = ?endpoint.addresses, - "configured Shadowsocks proxy outbound" - ); - Ok(Self { - endpoint, - protocol, - plugin, - kcptun_pool, - udp_enabled: config.udp - || udp_over_tcp - || sing_mux - .as_ref() - .map(|sing_mux| sing_mux.udp) - .unwrap_or(false), - sing_mux, - sing_mux_sessions: Arc::new(Mutex::new(Vec::new())), - udp_over_tcp, - udp_over_tcp_version, - }) - } - - async fn connect_tcp(&self) -> Result<(Box, SocketAddr)> { - if let Some(pool) = self.kcptun_pool.as_ref() { - return pool.open_stream().await; - } - let (tcp, address) = connect_proxy_tcp(&self.endpoint).await.with_context(|| { - format!( - "failed to connect Shadowsocks server {}:{}", - self.endpoint.host, self.endpoint.port - ) - })?; - let stream: Box = match self.plugin.as_ref() { - Some(ShadowsocksPluginTransport::SimpleObfs { mode, host }) => Box::new( - SimpleObfsStream::new(tcp, *mode, host.clone(), self.endpoint.port), - ), - Some(ShadowsocksPluginTransport::WebSocket(config)) => { - let stream = - websocket_plugin_connect(Box::new(tcp), config, self.endpoint.port).await?; - match config.mux { - ShadowsocksWebSocketMux::None => stream, - ShadowsocksWebSocketMux::V2ray => Box::new(V2rayMuxStream::new(stream)), - ShadowsocksWebSocketMux::Gost => gost_smux_stream(stream).await?, - } - } - Some(ShadowsocksPluginTransport::ShadowTls(config)) => { - shadow_tls_connect(Box::new(tcp), config).await? - } - Some(ShadowsocksPluginTransport::Kcptun(_)) => { - bail!("internal Shadowsocks kcptun transport dispatch error") - } - None => Box::new(tcp), - }; - Ok((stream, address)) - } - - async fn connect_tcp_to_target( - &self, - target: &ProxyTcpTarget, - ) -> Result<(Box, SocketAddr)> { - let (mut tcp, address) = self.connect_tcp().await?; - match &self.protocol { - ShadowsocksProtocol::None => { - let socks_target = socks_addr_for_target(target)?; - tcp.write_all(&socks_target).await.with_context(|| { - format!( - "failed to write Shadowsocks none target address for {}", - target.address - ) - })?; - tcp.flush() - .await - .context("failed to flush Shadowsocks none target address")?; - Ok((tcp, address)) - } - ShadowsocksProtocol::Standard { server_config, context } => { - let stream = SsProxyClientStream::from_stream( - context.clone(), - tcp, - server_config, - shadowsocks_addr_for_target(target)?, - ); - Ok((Box::new(stream), address)) - } - ShadowsocksProtocol::CustomAead { kind, master_key } => { - let stream = - custom_aead_plain_tcp_stream(tcp, target, *kind, master_key.clone()).await?; - Ok((stream, address)) - } - ShadowsocksProtocol::CustomStream { kind, key } => { - let stream = - custom_stream_plain_tcp_stream(tcp, target, *kind, key.clone()).await?; - Ok((stream, address)) - } - ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { - let stream = aead2022_aes_ccm_plain_tcp_stream( - tcp, - target, - *kind, - user_key.clone(), - identity_keys.clone(), - ) - .await?; - Ok((stream, address)) - } - } - } - - async fn open_sing_mux_tcp_stream(&self, target: &ProxyTcpTarget) -> Result> { - let session = self.sing_mux_session().await?; - let mut stream = session.open_stream().await?; - write_sing_mux_tcp_request(&mut stream, target).await?; - Ok(Box::new(SingMuxTcpStream::new(stream))) - } - - async fn sing_mux_session(&self) -> Result { - let config = self - .sing_mux - .as_ref() - .context("Shadowsocks sing-mux is not configured")?; - let mut sessions = self.sing_mux_sessions.lock().await; - sessions.retain(|session| !session.is_closed()); - - if config.brutal.is_some() { - if let Some(session) = sessions.first() { - return Ok(session.clone()); - } - let session = self.connect_sing_mux_session().await?; - sessions.push(session.clone()); - return Ok(session); - } - - let mut best = None; - for (index, session) in sessions.iter().enumerate() { - let streams = session.num_streams().await; - match best { - None => best = Some((index, streams)), - Some((_, current)) if streams < current => best = Some((index, streams)), - _ => {} - } - } - - if let Some((index, streams)) = best { - if streams == 0 - || (config.max_connections > 0 - && (sessions.len() >= config.max_connections || streams < config.min_streams)) - || (config.max_connections == 0 - && config.max_streams > 0 - && streams < config.max_streams) - { - return Ok(sessions[index].clone()); - } - } - - let session = self.connect_sing_mux_session().await?; - sessions.push(session.clone()); - Ok(session) - } - - async fn connect_sing_mux_session(&self) -> Result { - let target = ProxyTcpTarget { - address: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), SING_MUX_PORT), - hostname: Some(SING_MUX_ADDRESS.to_owned()), - }; - let sing_mux = self - .sing_mux - .as_ref() - .context("Shadowsocks sing-mux session requested without sing-mux config")?; - let (mut stream, address) = self.connect_tcp_to_target(&target).await?; - write_sing_mux_session_preface(&mut stream, sing_mux.protocol, sing_mux.padding).await?; - let stream = if sing_mux.padding { - sing_mux_padding_stream(stream) - } else { - stream - }; - - let session = match sing_mux.protocol { - ShadowsocksSingMuxProtocol::H2Mux => start_sing_mux_h2mux_session(stream).await?, - ShadowsocksSingMuxProtocol::Smux => { - let mut config = smux_rust::Config::default(); - config.keep_alive_disabled = true; - let session = - smux_rust::client(Box::new(ProxyIoAdapter::new(stream)), Some(config)) - .await - .map_err(|err| { - anyhow!("failed to start Shadowsocks sing-mux smux session: {err}") - })?; - ShadowsocksSingMuxSession::Smux(session) - } - ShadowsocksSingMuxProtocol::Yamux => start_sing_mux_yamux_session(stream).await?, - }; - if let Some(brutal) = sing_mux.brutal { - sing_mux_brutal_exchange(&session, brutal).await?; - } - tracing::debug!( - %address, - mux_host = SING_MUX_ADDRESS, - mux_port = SING_MUX_PORT, - protocol = sing_mux.protocol.name(), - brutal = sing_mux.brutal.is_some(), - "opened Shadowsocks sing-mux session" - ); - Ok(session) - } - - async fn bridge_tcp(&self, inbound: StackTcpStream, target: ProxyTcpTarget) -> Result<()> { - if self.sing_mux.is_some() { - let stream = self.open_sing_mux_tcp_stream(&target).await?; - return bridge_streams(inbound, stream).await; - } - let (tcp, _) = self.connect_tcp().await?; - match &self.protocol { - ShadowsocksProtocol::None => self.bridge_none_tcp(inbound, tcp, target).await, - ShadowsocksProtocol::Standard { server_config, context } => { - let stream = SsProxyClientStream::from_stream( - context.clone(), - tcp, - server_config, - shadowsocks_addr_for_target(&target)?, - ); - bridge_streams(inbound, stream).await - } - ShadowsocksProtocol::CustomAead { kind, master_key } => { - self.bridge_custom_aead_tcp(inbound, tcp, target, *kind, master_key.clone()) - .await - } - ShadowsocksProtocol::CustomStream { kind, key } => { - self.bridge_custom_stream_tcp(inbound, tcp, target, *kind, key.clone()) - .await - } - ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { - self.bridge_aead2022_aes_ccm_tcp( - inbound, - tcp, - target, - *kind, - user_key.clone(), - identity_keys.clone(), - ) - .await - } - } - } - - async fn bridge_none_tcp( - &self, - inbound: StackTcpStream, - mut tcp: S, - target: ProxyTcpTarget, - ) -> Result<()> - where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, - { - let remote_addr = target.address; - let socks_target = socks_addr_for_target(&target)?; - tcp.write_all(&socks_target).await.with_context(|| { - format!("failed to write Shadowsocks none target address for {remote_addr}") - })?; - tcp.flush() - .await - .context("failed to flush Shadowsocks none target address")?; - bridge_streams(inbound, tcp).await - } - - async fn bridge_custom_aead_tcp( - &self, - inbound: StackTcpStream, - tcp: S, - target: ProxyTcpTarget, - kind: CustomSsAeadKind, - master_key: Arc<[u8]>, - ) -> Result<()> - where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, - { - let remote_addr = target.address; - let (reader, writer) = split(tcp); - let mut ss_reader = CustomSsAeadReader::new(reader, kind, master_key.clone()); - let mut ss_writer = CustomSsAeadWriter::new(writer, kind, master_key) - .await - .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; - let socks_target = socks_addr_for_target(&target)?; - ss_writer - .write_chunk(&socks_target) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks target address for {remote_addr} with {}", - kind.name() - ) - })?; - let (mut inbound_reader, mut inbound_writer) = split(inbound); - let mut upload = tokio::spawn(async move { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = inbound_reader - .read(&mut buf) - .await - .context("failed to read tcp payload from proxy netstack")?; - if n == 0 { - break; - } - ss_writer.write_chunk(&buf[..n]).await?; - } - ss_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks tcp upload")?; - Result::<()>::Ok(()) - }); - - let mut download = tokio::spawn(async move { - let mut buf = Vec::new(); - loop { - buf.clear(); - match ss_reader.read_chunk(&mut buf).await { - Ok(0) => break, - Ok(_) => { - inbound_writer - .write_all(&buf) - .await - .context("failed to write tcp payload to proxy netstack")?; - } - Err(err) => return Err(err), - } - } - inbound_writer - .shutdown() - .await - .context("failed to shutdown proxy netstack tcp download")?; - Result::<()>::Ok(()) - }); - - tokio::select! { - result = &mut upload => { - download.abort(); - result??; - } - result = &mut download => { - upload.abort(); - result??; - } - } - Ok(()) - } - - async fn bridge_custom_stream_tcp( - &self, - inbound: StackTcpStream, - tcp: S, - target: ProxyTcpTarget, - kind: CustomSsStreamKind, - key: Arc<[u8]>, - ) -> Result<()> - where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, - { - let remote_addr = target.address; - let (reader, writer) = split(tcp); - let mut ss_reader = CustomSsStreamReader::new(reader, kind, key.clone()); - let mut ss_writer = CustomSsStreamWriter::new(writer, kind, key) - .await - .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; - let socks_target = socks_addr_for_target(&target)?; - ss_writer - .write_all_encrypted(&socks_target) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks target address for {remote_addr} with {}", - kind.name() - ) - })?; - let (mut inbound_reader, mut inbound_writer) = split(inbound); - let mut upload = tokio::spawn(async move { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = inbound_reader - .read(&mut buf) - .await - .context("failed to read tcp payload from proxy netstack")?; - if n == 0 { - break; - } - ss_writer.write_all_encrypted(&buf[..n]).await?; - } - ss_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks tcp upload")?; - Result::<()>::Ok(()) - }); - - let mut download = tokio::spawn(async move { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = ss_reader.read_decrypted(&mut buf).await?; - if n == 0 { - break; - } - inbound_writer - .write_all(&buf[..n]) - .await - .context("failed to write tcp payload to proxy netstack")?; - } - inbound_writer - .shutdown() - .await - .context("failed to shutdown proxy netstack tcp download")?; - Result::<()>::Ok(()) - }); - - tokio::select! { - result = &mut upload => { - download.abort(); - result??; - } - result = &mut download => { - upload.abort(); - result??; - } - } - Ok(()) - } - - async fn bridge_aead2022_aes_ccm_tcp( - &self, - inbound: StackTcpStream, - tcp: S, - target: ProxyTcpTarget, - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, - ) -> Result<()> - where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, - { - let remote_addr = target.address; - let (reader, writer) = split(tcp); - let mut ss_writer = - Aead2022AesCcmWriter::new(writer, kind, user_key.clone(), identity_keys) - .await - .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; - let request_salt = ss_writer - .write_request(&socks_addr_for_target(&target)?, 1) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks 2022 target address for {remote_addr} with {}", - kind.name() - ) - })?; - let mut ss_reader = Aead2022AesCcmReader::new(reader, kind, user_key, request_salt); - let (mut inbound_reader, mut inbound_writer) = split(inbound); - let mut upload = tokio::spawn(async move { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = inbound_reader - .read(&mut buf) - .await - .context("failed to read tcp payload from proxy netstack")?; - if n == 0 { - break; - } - ss_writer.write_chunk(&buf[..n]).await?; - } - ss_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks 2022 tcp upload")?; - Result::<()>::Ok(()) - }); - - let mut download = tokio::spawn(async move { - let mut buf = Vec::new(); - loop { - buf.clear(); - match ss_reader.read_chunk(&mut buf).await { - Ok(0) => break, - Ok(_) => { - inbound_writer - .write_all(&buf) - .await - .context("failed to write tcp payload to proxy netstack")?; - } - Err(err) => return Err(err), - } - } - inbound_writer - .shutdown() - .await - .context("failed to shutdown proxy netstack tcp download")?; - Result::<()>::Ok(()) - }); - - tokio::select! { - result = &mut upload => { - download.abort(); - result??; - } - result = &mut download => { - upload.abort(); - result??; - } - } - Ok(()) - } - - async fn run_udp_session( - &self, - key: UdpFlowKey, - outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - ) -> Result<()> { - if self - .sing_mux - .as_ref() - .map(|sing_mux| sing_mux.udp) - .unwrap_or(false) - { - return self - .run_sing_mux_udp_session(key, outbound_rx, reply_tx) - .await; - } - if self.udp_over_tcp { - return self - .run_udp_over_tcp_session(key, outbound_rx, reply_tx) - .await; - } - if !self.udp_enabled { - bail!("Shadowsocks UDP is disabled for this node"); - } - let (socket, server) = connect_proxy_udp(&self.endpoint).await.with_context(|| { - format!( - "failed to connect Shadowsocks udp socket to {}:{}", - self.endpoint.host, self.endpoint.port - ) - })?; - socket.connect(server).await.with_context(|| { - format!("failed to connect udp socket to Shadowsocks server {server}") - })?; - match &self.protocol { - ShadowsocksProtocol::None => { - self.run_none_udp_loop(socket, server, key, outbound_rx, reply_tx) - .await - } - ShadowsocksProtocol::Standard { server_config, context } => { - let socket = SsProxySocket::from_socket( - SsUdpSocketType::Client, - context.clone(), - server_config, - BurrowDatagramSocket(socket), - ); - self.run_standard_udp_loop(socket, server, key, outbound_rx, reply_tx) - .await - } - ShadowsocksProtocol::CustomAead { kind, master_key } => { - self.run_custom_aead_udp_loop( - socket, - server, - key, - outbound_rx, - reply_tx, - *kind, - master_key.clone(), - ) - .await - } - ShadowsocksProtocol::CustomStream { kind, key: stream_key } => { - self.run_custom_stream_udp_loop( - socket, - server, - key, - outbound_rx, - reply_tx, - *kind, - stream_key.clone(), - ) - .await - } - ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { - self.run_aead2022_aes_ccm_udp_loop( - socket, - server, - key, - outbound_rx, - reply_tx, - *kind, - user_key.clone(), - identity_keys.clone(), - ) - .await - } - } - } - - async fn run_sing_mux_udp_session( - &self, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - ) -> Result<()> { - let session = self.sing_mux_session().await?; - let mut stream = session.open_stream().await?; - let mut request_written = false; - let mut response_read = false; - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - if !request_written { - write_sing_mux_udp_request(&mut stream, key.remote, &payload).await?; - request_written = true; - } else { - write_sing_mux_udp_payload(&mut stream, &payload).await?; - } - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, read_sing_mux_udp_payload(&mut stream, &mut response_read)), if request_written => match recv { - Ok(Ok(payload)) => { - reply_tx - .send(UdpReply { - payload, - source: key.remote, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks sing-mux udp reply")?; - } - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks sing-mux udp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_udp_over_tcp_session( - &self, - key: UdpFlowKey, - outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - ) -> Result<()> { - let (tcp, server) = self.connect_tcp().await.with_context(|| { - format!( - "failed to connect Shadowsocks tcp socket to {}:{} for udp-over-tcp", - self.endpoint.host, self.endpoint.port - ) - })?; - match &self.protocol { - ShadowsocksProtocol::None => { - let mut stream = tcp; - let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; - stream.write_all(&uot_target).await.with_context(|| { - "failed to write Shadowsocks none udp-over-tcp target".to_owned() - })?; - stream - .flush() - .await - .context("failed to flush Shadowsocks none udp-over-tcp target")?; - self.run_standard_uot_loop( - stream, - server, - key, - outbound_rx, - reply_tx, - self.udp_over_tcp_version, - ) - .await - } - ShadowsocksProtocol::Standard { server_config, context } => { - let stream = SsProxyClientStream::from_stream( - context.clone(), - tcp, - server_config, - uot_magic_shadowsocks_addr(self.udp_over_tcp_version), - ); - self.run_standard_uot_loop( - stream, - server, - key, - outbound_rx, - reply_tx, - self.udp_over_tcp_version, - ) - .await - } - ShadowsocksProtocol::CustomAead { kind, master_key } => { - let (reader, writer) = split(tcp); - let mut ss_writer = CustomSsAeadWriter::new(writer, *kind, master_key.clone()) - .await - .with_context(|| { - format!("failed to initialize {} udp-over-tcp stream", kind.name()) - })?; - let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; - ss_writer.write_chunk(&uot_target).await.with_context(|| { - format!( - "failed to write Shadowsocks udp-over-tcp target for {}", - kind.name() - ) - })?; - let ss_reader = CustomSsAeadReader::new(reader, *kind, master_key.clone()); - self.run_custom_aead_uot_loop( - CustomSsAeadByteReader::new(ss_reader), - ss_writer, - server, - key, - outbound_rx, - reply_tx, - self.udp_over_tcp_version, - ) - .await - } - ShadowsocksProtocol::CustomStream { kind, key: stream_key } => { - let (reader, writer) = split(tcp); - let mut ss_writer = CustomSsStreamWriter::new(writer, *kind, stream_key.clone()) - .await - .with_context(|| { - format!("failed to initialize {} udp-over-tcp stream", kind.name()) - })?; - let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; - ss_writer - .write_all_encrypted(&uot_target) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks udp-over-tcp target for {}", - kind.name() - ) - })?; - let ss_reader = CustomSsStreamReader::new(reader, *kind, stream_key.clone()); - self.run_custom_stream_uot_loop( - CustomSsStreamByteReader::new(ss_reader), - ss_writer, - server, - key, - outbound_rx, - reply_tx, - self.udp_over_tcp_version, - ) - .await - } - ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { - let (reader, writer) = split(tcp); - let mut ss_writer = Aead2022AesCcmWriter::new( - writer, - *kind, - user_key.clone(), - identity_keys.clone(), - ) - .await - .with_context(|| { - format!("failed to initialize {} udp-over-tcp stream", kind.name()) - })?; - let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; - let request_salt = - ss_writer - .write_request(&uot_target, 1) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks 2022 udp-over-tcp target for {}", - kind.name() - ) - })?; - let ss_reader = - Aead2022AesCcmReader::new(reader, *kind, user_key.clone(), request_salt); - self.run_aead2022_aes_ccm_uot_loop( - Aead2022AesCcmByteReader::new(ss_reader), - ss_writer, - server, - key, - outbound_rx, - reply_tx, - self.udp_over_tcp_version, - ) - .await - } - } - } - - async fn run_none_udp_loop( - &self, - socket: UdpSocket, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - ) -> Result<()> { - let remote = socks_addr(key.remote)?; - let mut buf = vec![0u8; 65_535]; - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - let mut packet = Vec::with_capacity(remote.len() + payload.len()); - packet.extend_from_slice(&remote); - packet.extend_from_slice(&payload); - socket - .send(&packet) - .await - .with_context(|| format!("failed to send Shadowsocks none udp payload to {server}"))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { - Ok(Ok(packet_len)) => { - let (source, used) = parse_socks_addr(&buf[..packet_len]) - .context("failed to parse Shadowsocks none udp response source")?; - reply_tx - .send(UdpReply { - payload: buf[used..packet_len].to_vec(), - source, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks none udp reply")?; - } - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks none udp response"), - Err(_) => break, - }, - } - } - Ok(()) - } - - async fn run_standard_udp_loop( - &self, - socket: SsProxySocket, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - ) -> Result<()> { - let remote = SsAddress::SocketAddress(key.remote); - let mut buf = vec![0u8; 65_535]; - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - socket - .send(&remote, &payload) - .await - .with_context(|| format!("failed to send Shadowsocks udp payload to {server}"))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { - Ok(Ok((payload_len, source, _packet_len))) => { - let source = shadowsocks_addr_to_socket_addr(source)?; - reply_tx - .send(UdpReply { - payload: buf[..payload_len].to_vec(), - source, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks udp reply")?; - } - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_standard_uot_loop( - &self, - mut stream: S, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - version: u8, - ) -> Result<()> - where - S: AsyncRead + AsyncWrite + Unpin, - { - if version == UOT_VERSION { - write_uot_request(&mut stream, key.remote).await?; - } - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - write_uot_frame(&mut stream, key.remote, &payload) - .await - .with_context(|| format!("failed to send Shadowsocks udp-over-tcp payload to {server}"))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, read_uot_frame(&mut stream)) => match recv { - Ok(Ok((source, payload))) => { - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks udp-over-tcp reply")?; - } - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp-over-tcp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_custom_aead_udp_loop( - &self, - socket: UdpSocket, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - kind: CustomSsAeadKind, - master_key: Arc<[u8]>, - ) -> Result<()> { - let mut buf = vec![0u8; 65_535]; - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - let packet = encrypt_custom_ss_udp_packet(kind, &master_key, key.remote, &payload)?; - socket - .send(&packet) - .await - .with_context(|| format!("failed to send {} udp payload to {server}", kind.name()))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { - Ok(Ok(len)) => { - let (source, payload) = - decrypt_custom_ss_udp_packet(kind, &master_key, &buf[..len])?; - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks udp reply")?; - } - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_custom_aead_uot_loop( - &self, - mut reader: CustomSsAeadByteReader, - mut writer: CustomSsAeadWriter, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - version: u8, - ) -> Result<()> - where - W: AsyncWrite + Unpin, - R: AsyncRead + Unpin, - { - if version == UOT_VERSION { - writer.write_chunk(&uot_request(key.remote)?).await?; - } - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - let frame = uot_frame(key.remote, &payload)?; - writer.write_chunk(&frame) - .await - .with_context(|| format!("failed to send custom Shadowsocks udp-over-tcp payload to {server}"))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, reader.read_frame()) => match recv { - Ok(Ok(Some((source, payload)))) => { - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue custom Shadowsocks udp-over-tcp reply")?; - } - Ok(Ok(None)) => break, - Ok(Err(err)) => return Err(err).context("failed to receive custom Shadowsocks udp-over-tcp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_custom_stream_udp_loop( - &self, - socket: UdpSocket, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - kind: CustomSsStreamKind, - stream_key: Arc<[u8]>, - ) -> Result<()> { - let mut buf = vec![0u8; 65_535]; - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - let packet = encrypt_custom_ss_stream_udp_packet(kind, &stream_key, key.remote, &payload)?; - socket - .send(&packet) - .await - .with_context(|| format!("failed to send {} udp payload to {server}", kind.name()))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { - Ok(Ok(len)) => { - let (source, payload) = - decrypt_custom_ss_stream_udp_packet(kind, &stream_key, &buf[..len])?; - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks udp reply")?; - } - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_custom_stream_uot_loop( - &self, - mut reader: CustomSsStreamByteReader, - mut writer: CustomSsStreamWriter, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - version: u8, - ) -> Result<()> - where - W: AsyncWrite + Unpin, - R: AsyncRead + Unpin, - { - if version == UOT_VERSION { - writer - .write_all_encrypted(&uot_request(key.remote)?) - .await?; - } - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - let frame = uot_frame(key.remote, &payload)?; - writer.write_all_encrypted(&frame) - .await - .with_context(|| format!("failed to send custom Shadowsocks udp-over-tcp payload to {server}"))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, reader.read_frame()) => match recv { - Ok(Ok(Some((source, payload)))) => { - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue custom Shadowsocks udp-over-tcp reply")?; - } - Ok(Ok(None)) => break, - Ok(Err(err)) => return Err(err).context("failed to receive custom Shadowsocks udp-over-tcp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_aead2022_aes_ccm_udp_loop( - &self, - socket: UdpSocket, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, - ) -> Result<()> { - let mut session = Aead2022AesCcmUdpSession::new(kind, user_key, identity_keys)?; - let mut buf = vec![0u8; 65_535]; - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - let packet = session.encrypt_client_packet(key.remote, &payload)?; - socket - .send(&packet) - .await - .with_context(|| format!("failed to send {} udp payload to {server}", kind.name()))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { - Ok(Ok(len)) => { - let (source, payload) = session.decrypt_server_packet(&buf[..len])?; - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks 2022 udp reply")?; - } - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks 2022 udp response"), - Err(_) => break, - } - } - } - Ok(()) - } - - async fn run_aead2022_aes_ccm_uot_loop( - &self, - mut reader: Aead2022AesCcmByteReader, - mut writer: Aead2022AesCcmWriter, - server: SocketAddr, - key: UdpFlowKey, - mut outbound_rx: mpsc::Receiver>, - reply_tx: mpsc::Sender, - version: u8, - ) -> Result<()> - where - W: AsyncWrite + Unpin, - R: AsyncRead + Unpin, - { - if version == UOT_VERSION { - writer.write_chunk(&uot_request(key.remote)?).await?; - } - loop { - tokio::select! { - maybe_payload = outbound_rx.recv() => match maybe_payload { - Some(payload) => { - let frame = uot_frame(key.remote, &payload)?; - writer.write_chunk(&frame) - .await - .with_context(|| format!("failed to send Shadowsocks 2022 udp-over-tcp payload to {server}"))?; - } - None => break, - }, - recv = timeout(UDP_IDLE_TIMEOUT, reader.read_frame()) => match recv { - Ok(Ok(Some((source, payload)))) => { - reply_tx - .send(UdpReply { - payload, - source, - destination: key.local, - }) - .await - .context("failed to enqueue Shadowsocks 2022 udp-over-tcp reply")?; - } - Ok(Ok(None)) => break, - Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks 2022 udp-over-tcp response"), - Err(_) => break, - } - } - } - Ok(()) - } -} - -trait ProxyIo: AsyncRead + AsyncWrite + Unpin + Send {} - -impl ProxyIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {} - -impl ShadowsocksSingMuxSession { - fn is_closed(&self) -> bool { - match self { - Self::H2Mux(session) => session.closed.load(Ordering::Relaxed), - Self::Smux(session) => session.is_closed(), - Self::Yamux(session) => session.closed.load(Ordering::Relaxed), - } - } - - async fn num_streams(&self) -> usize { - match self { - Self::H2Mux(session) => session.active_streams.load(Ordering::Relaxed), - Self::Smux(session) => session.num_streams().await, - Self::Yamux(session) => session.active_streams.load(Ordering::Relaxed), - } - } - - async fn open_stream(&self) -> Result> { - match self { - Self::H2Mux(session) => session.open_stream().await, - Self::Smux(session) => { - let stream = session.open_stream().await.map_err(|err| { - anyhow!("failed to open Shadowsocks sing-mux smux stream: {err}") - })?; - Ok(Box::new(stream)) - } - Self::Yamux(session) => { - let (response, stream_rx) = oneshot::channel(); - session - .open_tx - .send(YamuxCommand { response }) - .await - .context("Shadowsocks sing-mux yamux session is closed")?; - let stream = stream_rx - .await - .context("Shadowsocks sing-mux yamux session stopped before opening stream")? - .map_err(|err| { - anyhow!("failed to open Shadowsocks sing-mux yamux stream: {err}") - })?; - session.active_streams.fetch_add(1, Ordering::Relaxed); - Ok(Box::new(CountingProxyIo::new( - stream.compat(), - session.active_streams.clone(), - ))) - } - } - } -} - -impl ShadowsocksH2MuxSession { - async fn open_stream(&self) -> Result> { - let mut send_request = self - .send_request - .clone() - .ready() - .await - .context("Shadowsocks sing-mux h2mux session is not ready")?; - let request = Request::builder() - .method(Method::CONNECT) - .uri("https://localhost") - .body(()) - .context("failed to build Shadowsocks sing-mux h2mux CONNECT request")?; - let (response, send_stream) = send_request - .send_request(request, false) - .context("failed to open Shadowsocks sing-mux h2mux stream")?; - self.active_streams.fetch_add(1, Ordering::Relaxed); - Ok(Box::new(H2MuxStream::new( - Box::pin(response), - send_stream, - self.active_streams.clone(), - ))) - } -} - -async fn start_sing_mux_h2mux_session( - stream: Box, -) -> Result { - let (send_request, connection) = h2::client::handshake(ProxyIoAdapter::new(stream)) - .await - .context("failed to start Shadowsocks sing-mux h2mux session")?; - let active_streams = Arc::new(AtomicUsize::new(0)); - let closed = Arc::new(AtomicBool::new(false)); - let driver_closed = closed.clone(); - - tokio::spawn(async move { - if let Err(err) = connection.await { - tracing::debug!(?err, "Shadowsocks sing-mux h2mux session stopped"); - } - driver_closed.store(true, Ordering::Relaxed); - }); - - Ok(ShadowsocksSingMuxSession::H2Mux(ShadowsocksH2MuxSession { - send_request, - active_streams, - closed, - })) -} - -async fn start_sing_mux_yamux_session( - stream: Box, -) -> Result { - let connection = yamux::Connection::new( - ProxyIoAdapter::new(stream).compat(), - yamux::Config::default(), - yamux::Mode::Client, - ); - let (open_tx, open_rx) = mpsc::channel(32); - let active_streams = Arc::new(AtomicUsize::new(0)); - let closed = Arc::new(AtomicBool::new(false)); - let driver_closed = closed.clone(); - - tokio::spawn(async move { - YamuxDriver::new(connection, open_rx).await; - driver_closed.store(true, Ordering::Relaxed); - }); - - Ok(ShadowsocksSingMuxSession::Yamux(ShadowsocksYamuxSession { - open_tx, - active_streams, - closed, - })) -} - -struct YamuxDriver { - connection: yamux::Connection, - open_rx: mpsc::Receiver, - pending_open: VecDeque>>, -} - -impl YamuxDriver { - fn new(connection: yamux::Connection, open_rx: mpsc::Receiver) -> Self { - Self { - connection, - open_rx, - pending_open: VecDeque::new(), - } - } -} - -impl Future for YamuxDriver -where - T: futures::io::AsyncRead + futures::io::AsyncWrite + Unpin, -{ - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll { - loop { - let mut progressed = false; - - while let Poll::Ready(command) = self.open_rx.poll_recv(cx) { - let Some(command) = command else { - break; - }; - self.pending_open.push_back(command.response); - progressed = true; - } - - while let Some(response) = self.pending_open.pop_front() { - match self.connection.poll_new_outbound(cx) { - Poll::Ready(Ok(stream)) => { - let _ = response.send(Ok(stream)); - progressed = true; - } - Poll::Ready(Err(err)) => { - let _ = response.send(Err(err.to_string())); - progressed = true; - } - Poll::Pending => { - self.pending_open.push_front(response); - break; - } - } - } - - match self.connection.poll_next_inbound(cx) { - Poll::Ready(Some(Ok(_stream))) => { - tracing::debug!("discarding unexpected server-opened Shadowsocks yamux stream"); - progressed = true; - } - Poll::Ready(Some(Err(err))) => { - tracing::debug!(?err, "Shadowsocks sing-mux yamux session stopped"); - return Poll::Ready(()); - } - Poll::Ready(None) => return Poll::Ready(()), - Poll::Pending => {} - } - - if !progressed { - return Poll::Pending; - } - } - } -} - -type H2MuxResponseFuture = Pin< - Box, h2::Error>> + Send>, ->; - -struct H2MuxStream { - response: Option, - recv: Option, - send: h2::SendStream, - read_buf: Bytes, - active_streams: Arc, - send_closed: bool, -} - -impl H2MuxStream { - fn new( - response: H2MuxResponseFuture, - send: h2::SendStream, - active_streams: Arc, - ) -> Self { - Self { - response: Some(response), - recv: None, - send, - read_buf: Bytes::new(), - active_streams, - send_closed: false, - } - } -} - -impl Drop for H2MuxStream { - fn drop(&mut self) { - if !self.send_closed { - self.send.send_reset(h2::Reason::CANCEL); - } - self.active_streams.fetch_sub(1, Ordering::Relaxed); - } -} - -impl AsyncRead for H2MuxStream { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - if !self.read_buf.is_empty() { - let n = buf.remaining().min(self.read_buf.len()); - buf.put_slice(&self.read_buf[..n]); - let _ = self.read_buf.split_to(n); - return Poll::Ready(Ok(())); - } - - if self.recv.is_none() { - let Some(response) = self.response.as_mut() else { - return Poll::Ready(Ok(())); - }; - let response = match response.as_mut().poll(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Ok(response)) => response, - Poll::Ready(Err(err)) => return Poll::Ready(Err(io::Error::other(err))), - }; - if response.status() != StatusCode::OK { - return Poll::Ready(Err(io::Error::other(format!( - "unexpected Shadowsocks sing-mux h2mux status {}", - response.status() - )))); - } - self.recv = Some(response.into_body()); - self.response = None; - } - - let recv = self.recv.as_mut().expect("h2mux response body initialized"); - match recv.poll_data(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Some(Ok(bytes))) => { - if bytes.is_empty() { - continue; - } - let len = bytes.len(); - if let Err(err) = recv.flow_control().release_capacity(len) { - return Poll::Ready(Err(io::Error::other(err))); - } - self.read_buf = bytes; - } - Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(io::Error::other(err))), - Poll::Ready(None) => return Poll::Ready(Ok(())), - } - } - } -} - -impl AsyncWrite for H2MuxStream { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - if self.send_closed { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "Shadowsocks sing-mux h2mux stream is closed", - ))); - } - if buf.is_empty() { - return Poll::Ready(Ok(0)); - } - self.send - .send_data(Bytes::copy_from_slice(buf), false) - .map_err(io::Error::other)?; - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - if !self.send_closed { - self.send - .send_data(Bytes::new(), true) - .map_err(io::Error::other)?; - self.send_closed = true; - } - Poll::Ready(Ok(())) - } -} - -struct CountingProxyIo { - inner: S, - active_streams: Arc, -} - -impl CountingProxyIo { - fn new(inner: S, active_streams: Arc) -> Self { - Self { inner, active_streams } - } -} - -impl Drop for CountingProxyIo { - fn drop(&mut self) { - self.active_streams.fetch_sub(1, Ordering::Relaxed); - } -} - -impl AsyncRead for CountingProxyIo -where - S: AsyncRead + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -impl AsyncWrite for CountingProxyIo -where - S: AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -struct SingMuxTcpStream { - inner: S, - response_read: bool, -} - -impl SingMuxTcpStream { - fn new(inner: S) -> Self { - Self { inner, response_read: false } - } -} - -impl AsyncRead for SingMuxTcpStream -where - S: AsyncRead + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if !self.response_read { - let mut status = [0u8; 1]; - let mut status_buf = ReadBuf::new(&mut status); - match Pin::new(&mut self.inner).poll_read(cx, &mut status_buf) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if status_buf.filled().is_empty() => { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "Shadowsocks sing-mux stream closed before response status", - ))); - } - Poll::Ready(Ok(())) => match status[0] { - SING_MUX_STREAM_STATUS_SUCCESS => self.response_read = true, - SING_MUX_STREAM_STATUS_ERROR => { - return Poll::Ready(Err(io::Error::other( - "Shadowsocks sing-mux remote error", - ))); - } - other => { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unknown Shadowsocks sing-mux stream response status {other}"), - ))); - } - }, - } - } - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -impl AsyncWrite for SingMuxTcpStream -where - S: AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -struct ProxyIoAdapter { - inner: Box, -} - -impl ProxyIoAdapter { - fn new(inner: Box) -> Self { - Self { inner } - } -} - -impl AsyncRead for ProxyIoAdapter { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -impl AsyncWrite for ProxyIoAdapter { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -async fn custom_aead_plain_tcp_stream( - tcp: S, - target: &ProxyTcpTarget, - kind: CustomSsAeadKind, - master_key: Arc<[u8]>, -) -> Result> -where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - let remote_addr = target.address; - let (reader, writer) = split(tcp); - let mut ss_reader = CustomSsAeadReader::new(reader, kind, master_key.clone()); - let mut ss_writer = CustomSsAeadWriter::new(writer, kind, master_key) - .await - .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; - let socks_target = socks_addr_for_target(target)?; - ss_writer - .write_chunk(&socks_target) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks target address for {remote_addr} with {}", - kind.name() - ) - })?; - - let (plain, bridge) = tokio::io::duplex(64 * 1024); - let (mut plain_reader, mut plain_writer) = split(bridge); - let _upload = tokio::spawn(async move { - let result = async { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = plain_reader - .read(&mut buf) - .await - .context("failed to read plaintext payload for Shadowsocks tcp upload")?; - if n == 0 { - break; - } - ss_writer.write_chunk(&buf[..n]).await?; - } - ss_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks tcp upload")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!( - ?err, - "Shadowsocks custom AEAD plaintext upload task stopped" - ); - } - }); - - let _download = tokio::spawn(async move { - let result = async { - let mut buf = Vec::new(); - loop { - buf.clear(); - match ss_reader.read_chunk(&mut buf).await { - Ok(0) => break, - Ok(_) => { - plain_writer - .write_all(&buf) - .await - .context("failed to write plaintext payload from Shadowsocks tcp")?; - } - Err(err) => return Err(err), - } - } - plain_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks tcp download")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!( - ?err, - "Shadowsocks custom AEAD plaintext download task stopped" - ); - } - }); - - Ok(Box::new(plain)) -} - -async fn custom_stream_plain_tcp_stream( - tcp: S, - target: &ProxyTcpTarget, - kind: CustomSsStreamKind, - key: Arc<[u8]>, -) -> Result> -where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - let remote_addr = target.address; - let (reader, writer) = split(tcp); - let mut ss_reader = CustomSsStreamReader::new(reader, kind, key.clone()); - let mut ss_writer = CustomSsStreamWriter::new(writer, kind, key) - .await - .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; - let socks_target = socks_addr_for_target(target)?; - ss_writer - .write_all_encrypted(&socks_target) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks target address for {remote_addr} with {}", - kind.name() - ) - })?; - - let (plain, bridge) = tokio::io::duplex(64 * 1024); - let (mut plain_reader, mut plain_writer) = split(bridge); - let _upload = tokio::spawn(async move { - let result = async { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = plain_reader - .read(&mut buf) - .await - .context("failed to read plaintext payload for Shadowsocks tcp upload")?; - if n == 0 { - break; - } - ss_writer.write_all_encrypted(&buf[..n]).await?; - } - ss_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks tcp upload")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!( - ?err, - "Shadowsocks custom stream plaintext upload task stopped" - ); - } - }); - - let _download = tokio::spawn(async move { - let result = async { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = ss_reader.read_decrypted(&mut buf).await?; - if n == 0 { - break; - } - plain_writer - .write_all(&buf[..n]) - .await - .context("failed to write plaintext payload from Shadowsocks tcp")?; - } - plain_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks tcp download")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!( - ?err, - "Shadowsocks custom stream plaintext download task stopped" - ); - } - }); - - Ok(Box::new(plain)) -} - -async fn aead2022_aes_ccm_plain_tcp_stream( - tcp: S, - target: &ProxyTcpTarget, - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, -) -> Result> -where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - let remote_addr = target.address; - let (reader, writer) = split(tcp); - let mut ss_writer = Aead2022AesCcmWriter::new(writer, kind, user_key.clone(), identity_keys) - .await - .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; - let request_salt = ss_writer - .write_request(&socks_addr_for_target(target)?, 1) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks 2022 target address for {remote_addr} with {}", - kind.name() - ) - })?; - let mut ss_reader = Aead2022AesCcmReader::new(reader, kind, user_key, request_salt); - - let (plain, bridge) = tokio::io::duplex(64 * 1024); - let (mut plain_reader, mut plain_writer) = split(bridge); - let _upload = tokio::spawn(async move { - let result = async { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = plain_reader - .read(&mut buf) - .await - .context("failed to read plaintext payload for Shadowsocks 2022 tcp upload")?; - if n == 0 { - break; - } - ss_writer.write_chunk(&buf[..n]).await?; - } - ss_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks 2022 tcp upload")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!(?err, "Shadowsocks 2022 plaintext upload task stopped"); - } - }); - - let _download = tokio::spawn(async move { - let result = async { - let mut buf = Vec::new(); - loop { - buf.clear(); - match ss_reader.read_chunk(&mut buf).await { - Ok(0) => break, - Ok(_) => { - plain_writer.write_all(&buf).await.context( - "failed to write plaintext payload from Shadowsocks 2022 tcp", - )?; - } - Err(err) => return Err(err), - } - } - plain_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks 2022 tcp download")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!(?err, "Shadowsocks 2022 plaintext download task stopped"); - } - }); - - Ok(Box::new(plain)) -} - -type TrojanStream = Box; - -struct SimpleObfsStream { - inner: S, - mode: SimpleObfsMode, - host: String, - port: u16, - first_write: bool, - first_read: bool, - write_buf: Vec, - write_offset: usize, - read_buf: Vec, - read_offset: usize, - tls_read_stage: Option, -} - -enum SimpleObfsTlsReadStage { - Discard { remaining: usize }, - Length { bytes: [u8; 2], filled: usize }, - Payload { bytes: Vec, filled: usize }, -} - -impl SimpleObfsStream { - fn new(inner: S, mode: SimpleObfsMode, host: String, port: u16) -> Self { - Self { - inner, - mode, - host, - port, - first_write: true, - first_read: true, - write_buf: Vec::new(), - write_offset: 0, - read_buf: Vec::new(), - read_offset: 0, - tls_read_stage: None, - } - } - - fn compact_write_buffer(&mut self) { - if self.write_offset == self.write_buf.len() { - self.write_buf.clear(); - self.write_offset = 0; - } - } - - fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - return false; - } - let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); - buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); - self.read_offset += len; - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - } - true - } -} - -impl AsyncRead for SimpleObfsStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - match self.mode { - SimpleObfsMode::Http => self.poll_read_http(cx, buf), - SimpleObfsMode::Tls => self.poll_read_tls(cx, buf), - } - } -} - -impl SimpleObfsStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read_http( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if !self.first_read { - return Pin::new(&mut self.inner).poll_read(cx, buf); - } - let mut response = vec![0u8; 16 * 1024]; - let mut response_buf = ReadBuf::new(&mut response); - match Pin::new(&mut self.inner).poll_read(cx, &mut response_buf) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => { - let filled = response_buf.filled().len(); - let Some(header_end) = response[..filled] - .windows(4) - .position(|window| window == b"\r\n\r\n") - .map(|index| index + 4) - else { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "simple-obfs HTTP response header was incomplete", - ))); - }; - self.first_read = false; - self.read_buf - .extend_from_slice(&response[header_end..filled]); - self.copy_read_buffer(buf); - Poll::Ready(Ok(())) - } - } - } - - fn poll_read_tls( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - if self.tls_read_stage.is_none() { - let discard = if self.first_read { 105 } else { 3 }; - self.first_read = false; - self.tls_read_stage = Some(SimpleObfsTlsReadStage::Discard { remaining: discard }); - } - - match self.tls_read_stage.take().expect("stage initialized") { - SimpleObfsTlsReadStage::Discard { mut remaining } => { - while remaining > 0 { - let mut scratch = [0u8; 128]; - let read_len = remaining.min(scratch.len()); - let mut discard_buf = ReadBuf::new(&mut scratch[..read_len]); - match Pin::new(&mut self.inner).poll_read(cx, &mut discard_buf) { - Poll::Pending => { - self.tls_read_stage = - Some(SimpleObfsTlsReadStage::Discard { remaining }); - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if discard_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - remaining -= discard_buf.filled().len(); - } - } - } - self.tls_read_stage = - Some(SimpleObfsTlsReadStage::Length { bytes: [0u8; 2], filled: 0 }); - } - SimpleObfsTlsReadStage::Length { mut bytes, mut filled } => { - while filled < 2 { - let mut len_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut len_buf) { - Poll::Pending => { - self.tls_read_stage = - Some(SimpleObfsTlsReadStage::Length { bytes, filled }); - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if len_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - filled += len_buf.filled().len(); - } - } - } - let len = u16::from_be_bytes(bytes) as usize; - self.tls_read_stage = Some(SimpleObfsTlsReadStage::Payload { - bytes: vec![0u8; len], - filled: 0, - }); - } - SimpleObfsTlsReadStage::Payload { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { - Poll::Pending => { - self.tls_read_stage = - Some(SimpleObfsTlsReadStage::Payload { bytes, filled }); - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - filled += payload_buf.filled().len(); - } - } - } - self.tls_read_stage = None; - self.read_buf = bytes; - } - } - } - } -} - -impl AsyncWrite for SimpleObfsStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => { - return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); - } - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - - if self.first_write { - self.first_write = false; - self.write_buf = match self.mode { - SimpleObfsMode::Http => simple_obfs_http_request(&self.host, self.port, buf), - SimpleObfsMode::Tls => simple_obfs_tls_client_hello(&self.host, buf)?, - }; - self.write_offset = 0; - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Ready(Ok(buf.len())), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => { - return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); - } - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - return Poll::Ready(Ok(buf.len())); - } - - match self.mode { - SimpleObfsMode::Http => Pin::new(&mut self.inner).poll_write(cx, buf), - SimpleObfsMode::Tls => { - self.write_buf = simple_obfs_tls_record(buf)?; - self.write_offset = 0; - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Ready(Ok(buf.len())), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => { - return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); - } - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Poll::Ready(Ok(buf.len())) - } - } - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -struct HmacReadStream { - inner: S, - hmac: hmac::Context, -} - -#[cfg(feature = "boring-browser-fingerprints")] -struct DetachableStream { - inner: Option, -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl DetachableStream { - fn new(inner: S) -> Self { - Self { inner: Some(inner) } - } - - fn take(&mut self) -> io::Result { - self.inner.take().ok_or_else(|| { - io::Error::new( - io::ErrorKind::BrokenPipe, - "detachable TLS stream already taken", - ) - }) - } - - fn inner_mut(&mut self) -> io::Result<&mut S> { - self.inner.as_mut().ok_or_else(|| { - io::Error::new( - io::ErrorKind::BrokenPipe, - "detachable TLS stream already taken", - ) - }) - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl AsyncRead for DetachableStream -where - S: AsyncRead + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - let inner = match self.inner_mut() { - Ok(inner) => inner, - Err(err) => return Poll::Ready(Err(err)), - }; - Pin::new(inner).poll_read(cx, buf) - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl AsyncWrite for DetachableStream -where - S: AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - let inner = match self.inner_mut() { - Ok(inner) => inner, - Err(err) => return Poll::Ready(Err(err)), - }; - Pin::new(inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - let inner = match self.inner_mut() { - Ok(inner) => inner, - Err(err) => return Poll::Ready(Err(err)), - }; - Pin::new(inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - let inner = match self.inner_mut() { - Ok(inner) => inner, - Err(err) => return Poll::Ready(Err(err)), - }; - Pin::new(inner).poll_shutdown(cx) - } -} - -impl HmacReadStream { - fn new(inner: S, password: &str) -> Self { - Self { - inner, - hmac: hmac::Context::with_key(&hmac::Key::new( - hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, - password.as_bytes(), - )), - } - } - - fn finish(self) -> (S, [u8; 8]) { - let digest = self.hmac.sign(); - let mut prefix = [0u8; 8]; - prefix.copy_from_slice(&digest.as_ref()[..8]); - (self.inner, prefix) - } -} - -impl AsyncRead for HmacReadStream -where - S: AsyncRead + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - let before = buf.filled().len(); - match Pin::new(&mut self.inner).poll_read(cx, buf) { - Poll::Ready(Ok(())) => { - let filled = buf.filled(); - if filled.len() > before { - self.hmac.update(&filled[before..]); - } - Poll::Ready(Ok(())) - } - other => other, - } - } -} - -impl AsyncWrite for HmacReadStream -where - S: AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -struct ShadowTlsV2Stream { - inner: S, - first_write_hmac: Option<[u8; 8]>, - read_stage: ShadowTlsReadStage, - write_buf: Vec, - write_offset: usize, -} - -enum ShadowTlsReadStage { - Header { bytes: [u8; 5], filled: usize }, - Payload { remaining: usize }, -} - -impl ShadowTlsV2Stream { - fn new(inner: S, first_write_hmac: [u8; 8]) -> Self { - Self { - inner, - first_write_hmac: Some(first_write_hmac), - read_stage: ShadowTlsReadStage::Header { bytes: [0u8; 5], filled: 0 }, - write_buf: Vec::new(), - write_offset: 0, - } - } - - fn compact_write_buffer(&mut self) { - if self.write_offset == self.write_buf.len() { - self.write_buf.clear(); - self.write_offset = 0; - } - } -} - -impl AsyncRead for ShadowTlsV2Stream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - match self.read_stage { - ShadowTlsReadStage::Header { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut header_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { - Poll::Pending => { - self.read_stage = ShadowTlsReadStage::Header { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { - return Poll::Ready(Ok(())); - } - Poll::Ready(Ok(())) => { - filled += header_buf.filled().len(); - } - } - } - if bytes[0] != 23 { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unexpected ShadowTLS record type {}", bytes[0]), - ))); - } - let remaining = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; - self.read_stage = ShadowTlsReadStage::Payload { remaining }; - } - ShadowTlsReadStage::Payload { remaining } => { - if remaining == 0 { - self.read_stage = ShadowTlsReadStage::Header { bytes: [0u8; 5], filled: 0 }; - continue; - } - let before = buf.filled().len(); - let read_len = remaining.min(buf.remaining()); - if read_len == 0 { - return Poll::Ready(Ok(())); - } - let initialized = buf.initialize_unfilled_to(read_len); - let mut payload_buf = ReadBuf::new(initialized); - match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { - Poll::Pending => { - self.read_stage = ShadowTlsReadStage::Payload { remaining }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - let read = payload_buf.filled().len(); - buf.advance(read); - let remaining = remaining - read; - self.read_stage = if remaining == 0 { - ShadowTlsReadStage::Header { bytes: [0u8; 5], filled: 0 } - } else { - ShadowTlsReadStage::Payload { remaining } - }; - if buf.filled().len() > before { - return Poll::Ready(Ok(())); - } - } - } - } - } - } - } -} - -impl AsyncWrite for ShadowTlsV2Stream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - - let write_len = buf.len().min(SHADOW_TLS_MAX_RECORD_PAYLOAD); - self.write_buf = shadow_tls_v2_record(buf, write_len, self.first_write_hmac.take())?; - self.write_offset = 0; - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Ready(Ok(write_len)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Poll::Ready(Ok(write_len)) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -struct ShadowTlsV3HandshakeStream { - inner: S, - password: String, - read_stage: ShadowTlsV3ReadStage, - read_buf: Vec, - read_offset: usize, - server_random: Option<[u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]>, - read_hmac: Option, - read_hmac_key: Option<[u8; 32]>, - is_tls13: bool, - authorized: bool, -} - -#[cfg(feature = "boring-browser-fingerprints")] -struct ShadowTlsV3SignedClientHelloStream { - inner: ShadowTlsV3HandshakeStream, - password: String, - client_hello_signed: bool, - client_hello_buf: Vec, - write_buf: Vec, - write_offset: usize, -} - -enum ShadowTlsV3ReadStage { - Header { - bytes: [u8; 5], - filled: usize, - }, - Payload { - header: [u8; 5], - bytes: Vec, - filled: usize, - }, -} - -impl ShadowTlsV3HandshakeStream { - fn new(inner: S, password: &str) -> Self { - Self { - inner, - password: password.to_owned(), - read_stage: ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, - read_buf: Vec::new(), - read_offset: 0, - server_random: None, - read_hmac: None, - read_hmac_key: None, - is_tls13: false, - authorized: false, - } - } - - fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - return false; - } - let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); - buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); - self.read_offset += len; - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - } - true - } - - fn process_frame(&mut self, mut frame: Vec) -> io::Result<()> { - match frame.first().copied() { - Some(0x16) => { - if self.read_hmac.is_none() - && frame.len() - >= SHADOW_TLS_V3_SERVER_RANDOM_INDEX + SHADOW_TLS_V3_TLS_RANDOM_SIZE - && frame[SHADOW_TLS_V3_TLS_HEADER_SIZE] == 0x02 - { - let mut server_random = [0u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]; - server_random.copy_from_slice( - &frame[SHADOW_TLS_V3_SERVER_RANDOM_INDEX - ..SHADOW_TLS_V3_SERVER_RANDOM_INDEX + SHADOW_TLS_V3_TLS_RANDOM_SIZE], - ); - let read_hmac = shadow_tls_v3_hmac(&self.password, &server_random, b""); - let read_hmac_key = shadow_tls_v3_kdf(&self.password, &server_random); - self.is_tls13 = shadow_tls_v3_server_hello_supports_tls13(&frame); - if !self.is_tls13 { - self.authorized = true; - } - self.server_random = Some(server_random); - self.read_hmac_key = Some(read_hmac_key); - self.read_hmac = Some(read_hmac); - } - self.read_buf = frame; - } - Some(0x17) => { - self.authorized = false; - if frame.len() > SHADOW_TLS_V3_HMAC_HEADER_SIZE { - if let (Some(read_hmac), Some(read_hmac_key)) = - (self.read_hmac.as_mut(), self.read_hmac_key.as_ref()) - { - read_hmac.update(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); - let expected = - shadow_tls_v3_hmac_prefix(read_hmac, SHADOW_TLS_V3_HMAC_SIZE); - if expected[..] - == frame[SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE] - { - shadow_tls_v3_xor( - &mut frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..], - read_hmac_key, - ); - let payload_len = frame.len() - SHADOW_TLS_V3_HMAC_HEADER_SIZE; - let mut rewritten = - Vec::with_capacity(SHADOW_TLS_V3_TLS_HEADER_SIZE + payload_len); - rewritten.extend_from_slice(&frame[..SHADOW_TLS_V3_TLS_HEADER_SIZE]); - rewritten[3..5].copy_from_slice(&(payload_len as u16).to_be_bytes()); - rewritten.extend_from_slice(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); - self.read_buf = rewritten; - self.authorized = true; - } else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ShadowTLS v3 handshake HMAC mismatch", - )); - } - } else { - self.read_buf = frame; - } - } else { - self.read_buf = frame; - } - } - _ => { - self.read_buf = frame; - } - } - self.read_offset = 0; - Ok(()) - } - - fn into_inner(self) -> (S, ShadowTlsV3HandshakeState) { - ( - self.inner, - ShadowTlsV3HandshakeState { - server_random: self.server_random, - read_hmac: self.read_hmac, - is_tls13: self.is_tls13, - authorized: self.authorized, - }, - ) - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl ShadowTlsV3SignedClientHelloStream { - fn new(inner: ShadowTlsV3HandshakeStream, password: &str) -> Self { - Self { - inner, - password: password.to_owned(), - client_hello_signed: false, - client_hello_buf: Vec::new(), - write_buf: Vec::new(), - write_offset: 0, - } - } - - fn into_inner(self) -> (ShadowTlsV3HandshakeStream, bool) { - (self.inner, self.client_hello_signed) - } - - fn queue_write(&mut self, buf: &[u8]) -> io::Result<()> { - if self.client_hello_signed { - self.write_buf.extend_from_slice(buf); - return Ok(()); - } - self.client_hello_buf.extend_from_slice(buf); - if let Some(signed) = - shadow_tls_v3_sign_client_hello_record(&self.password, &self.client_hello_buf)? - { - self.write_buf.extend_from_slice(&signed); - self.client_hello_buf.clear(); - self.client_hello_signed = true; - } - Ok(()) - } - - fn poll_write_pending(&mut self, cx: &mut TaskContext<'_>) -> Poll> - where - S: AsyncRead + AsyncWrite + Unpin, - { - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - } - } - } - self.write_buf.clear(); - self.write_offset = 0; - Poll::Ready(Ok(())) - } -} - -struct ShadowTlsV3HandshakeState { - server_random: Option<[u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]>, - read_hmac: Option, - is_tls13: bool, - authorized: bool, -} - -#[allow(dead_code)] -struct RestlsHandshakeStream { - inner: S, - secret: [u8; 32], - tls12_gcm: bool, - read_stage: RestlsHandshakeReadStage, - read_buf: Vec, - read_offset: usize, - write_buf: Vec, - write_offset: usize, - write_capture_buf: Vec, - server_random: Option<[u8; 32]>, - client_finished_auth: Option>, - server_auth_unmasked: bool, - tls12_gcm_server_disable_counter: bool, -} - -#[cfg(feature = "boring-browser-fingerprints")] -struct RestlsSignedClientHelloStream { - inner: RestlsHandshakeStream, - secret: [u8; 32], - client_hello_signed: bool, - client_hello_buf: Vec, - write_buf: Vec, - write_offset: usize, -} - -#[allow(dead_code)] -enum RestlsHandshakeReadStage { - Header { - bytes: [u8; TLS_RECORD_HEADER_LEN], - filled: usize, - }, - Payload { - header: [u8; TLS_RECORD_HEADER_LEN], - bytes: Vec, - filled: usize, - }, -} - -#[allow(dead_code)] -struct RestlsHandshakeState { - server_random: Option<[u8; 32]>, - client_finished_auth: Option>, - server_auth_unmasked: bool, - tls12_gcm_server_disable_counter: bool, -} - -#[allow(dead_code)] -impl RestlsHandshakeStream { - fn new(inner: S, secret: [u8; 32], tls12_gcm: bool) -> Self { - Self { - inner, - secret, - tls12_gcm, - read_stage: RestlsHandshakeReadStage::Header { - bytes: [0u8; TLS_RECORD_HEADER_LEN], - filled: 0, - }, - read_buf: Vec::new(), - read_offset: 0, - write_buf: Vec::new(), - write_offset: 0, - write_capture_buf: Vec::new(), - server_random: None, - client_finished_auth: None, - server_auth_unmasked: false, - tls12_gcm_server_disable_counter: false, - } - } - - fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - return false; - } - let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); - buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); - self.read_offset += len; - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - } - true - } - - fn compact_write_buffer(&mut self) { - if self.write_offset == self.write_buf.len() { - self.write_buf.clear(); - self.write_offset = 0; - } - } - - fn process_read_frame(&mut self, frame: Vec) -> io::Result<()> { - if let Some(server_random) = restls_server_hello_random(&frame) { - self.server_random = Some(server_random); - self.read_buf = frame; - } else if !self.server_auth_unmasked && frame.first().copied() == Some(0x17) { - let server_random = self.server_random.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Restls server-auth record arrived before ServerHello random", - ) - })?; - let unmasked = restls_unmask_server_auth_record( - &self.secret, - &server_random, - &frame, - self.tls12_gcm, - ) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - self.tls12_gcm_server_disable_counter = unmasked.tls12_gcm_server_disable_counter; - self.server_auth_unmasked = true; - self.read_buf = unmasked.record; - } else { - self.read_buf = frame; - } - self.read_offset = 0; - Ok(()) - } - - fn process_write_bytes(&mut self, buf: &[u8]) { - if self.client_finished_auth.is_some() { - return; - } - self.write_capture_buf.extend_from_slice(buf); - loop { - if self.write_capture_buf.len() < TLS_RECORD_HEADER_LEN { - return; - } - let payload_len = usize::from(u16::from_be_bytes([ - self.write_capture_buf[3], - self.write_capture_buf[4], - ])); - let record_len = TLS_RECORD_HEADER_LEN + payload_len; - if self.write_capture_buf.len() < record_len { - return; - } - let record = self.write_capture_buf[..record_len].to_vec(); - self.write_capture_buf.drain(..record_len); - if record.first().copied() == Some(TLS_APPLICATION_DATA_RECORD_TYPE) { - self.client_finished_auth = Some(record); - self.write_capture_buf.clear(); - return; - } - } - } - - fn into_inner(self) -> (S, RestlsHandshakeState) { - ( - self.inner, - RestlsHandshakeState { - server_random: self.server_random, - client_finished_auth: self.client_finished_auth, - server_auth_unmasked: self.server_auth_unmasked, - tls12_gcm_server_disable_counter: self.tls12_gcm_server_disable_counter, - }, - ) - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl RestlsSignedClientHelloStream { - fn new(inner: RestlsHandshakeStream, secret: [u8; 32]) -> Self { - Self { - inner, - secret, - client_hello_signed: false, - client_hello_buf: Vec::new(), - write_buf: Vec::new(), - write_offset: 0, - } - } - - fn into_inner(self) -> (RestlsHandshakeStream, bool) { - (self.inner, self.client_hello_signed) - } - - fn queue_write(&mut self, buf: &[u8]) -> io::Result<()> { - if self.client_hello_signed { - self.write_buf.extend_from_slice(buf); - return Ok(()); - } - self.client_hello_buf.extend_from_slice(buf); - if let Some(signed) = - restls_tls13_sign_client_hello_record(&self.secret, &self.client_hello_buf)? - { - self.write_buf.extend_from_slice(&signed); - self.client_hello_buf.clear(); - self.client_hello_signed = true; - } - Ok(()) - } - - fn poll_write_pending(&mut self, cx: &mut TaskContext<'_>) -> Poll> - where - S: AsyncRead + AsyncWrite + Unpin, - { - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - } - } - } - self.write_buf.clear(); - self.write_offset = 0; - Poll::Ready(Ok(())) - } -} - -impl AsyncRead for RestlsHandshakeStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - loop { - match std::mem::replace( - &mut self.read_stage, - RestlsHandshakeReadStage::Header { - bytes: [0u8; TLS_RECORD_HEADER_LEN], - filled: 0, - }, - ) { - RestlsHandshakeReadStage::Header { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut header_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { - Poll::Pending => { - self.read_stage = - RestlsHandshakeReadStage::Header { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { - self.read_stage = - RestlsHandshakeReadStage::Header { bytes, filled }; - return Poll::Ready(Ok(())); - } - Poll::Ready(Ok(())) => { - filled += header_buf.filled().len(); - } - } - } - let remaining = usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); - self.read_stage = RestlsHandshakeReadStage::Payload { - header: bytes, - bytes: vec![0u8; remaining], - filled: 0, - }; - } - RestlsHandshakeReadStage::Payload { header, mut bytes, mut filled } => { - while filled < bytes.len() { - let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { - Poll::Pending => { - self.read_stage = - RestlsHandshakeReadStage::Payload { header, bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - filled += payload_buf.filled().len(); - } - } - } - let mut frame = Vec::with_capacity(TLS_RECORD_HEADER_LEN + bytes.len()); - frame.extend_from_slice(&header); - frame.extend_from_slice(&bytes); - self.process_read_frame(frame)?; - self.read_stage = RestlsHandshakeReadStage::Header { - bytes: [0u8; TLS_RECORD_HEADER_LEN], - filled: 0, - }; - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - } - } - } - } -} - -impl AsyncWrite for RestlsHandshakeStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - self.process_write_bytes(buf); - match Pin::new(&mut self.inner).poll_write(cx, buf) { - Poll::Pending => Poll::Pending, - Poll::Ready(result) => Poll::Ready(result), - } - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl AsyncRead for RestlsSignedClientHelloStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - match self.poll_write_pending(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => {} - } - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl AsyncWrite for RestlsSignedClientHelloStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - if let Err(err) = self.queue_write(buf) { - return Poll::Ready(Err(err)); - } - match self.poll_write_pending(cx) { - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Pending | Poll::Ready(Ok(())) => Poll::Ready(Ok(buf.len())), - } - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - if !self.client_hello_signed && !self.client_hello_buf.is_empty() { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "incomplete Restls ClientHello before flush", - ))); - } - match self.poll_write_pending(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_flush(cx), - } - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -#[allow(dead_code)] -impl RestlsStream { - fn new(inner: S, state: RestlsStreamState) -> Self { - Self { - inner, - state, - read_stage: RestlsRecordReadStage::Header { - bytes: [0u8; TLS_RECORD_HEADER_LEN], - filled: 0, - }, - read_buf: Vec::new(), - read_offset: 0, - write_buf: Vec::new(), - write_offset: 0, - } - } - - fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - return false; - } - let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); - buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); - self.read_offset += len; - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - } - true - } - - fn queue_records(&mut self, records: Vec>) { - for record in records { - self.write_buf.extend_from_slice(&record); - } - } - - fn compact_write_buffer(&mut self) { - if self.write_offset == self.write_buf.len() { - self.write_buf.clear(); - self.write_offset = 0; - } - } - - fn process_read_frame(&mut self, frame: Vec) -> io::Result<()> { - let read = self - .state - .read_to_client_record(&frame) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - self.queue_records(read.records); - self.read_buf = read.data; - self.read_offset = 0; - Ok(()) - } -} - -impl AsyncRead for RestlsStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - match std::mem::replace( - &mut self.read_stage, - RestlsRecordReadStage::Header { - bytes: [0u8; TLS_RECORD_HEADER_LEN], - filled: 0, - }, - ) { - RestlsRecordReadStage::Header { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut header_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { - Poll::Pending => { - self.read_stage = RestlsRecordReadStage::Header { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { - self.read_stage = RestlsRecordReadStage::Header { bytes, filled }; - return Poll::Ready(Ok(())); - } - Poll::Ready(Ok(())) => { - filled += header_buf.filled().len(); - } - } - } - let remaining = usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); - self.read_stage = RestlsRecordReadStage::Payload { - header: bytes, - bytes: vec![0u8; remaining], - filled: 0, - }; - } - RestlsRecordReadStage::Payload { header, mut bytes, mut filled } => { - while filled < bytes.len() { - let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { - Poll::Pending => { - self.read_stage = - RestlsRecordReadStage::Payload { header, bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - filled += payload_buf.filled().len(); - } - } - } - let mut frame = Vec::with_capacity(TLS_RECORD_HEADER_LEN + bytes.len()); - frame.extend_from_slice(&header); - frame.extend_from_slice(&bytes); - self.process_read_frame(frame)?; - self.read_stage = RestlsRecordReadStage::Header { - bytes: [0u8; TLS_RECORD_HEADER_LEN], - filled: 0, - }; - } - } - } - } -} - -impl AsyncWrite for RestlsStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - let write = self - .state - .write(buf) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - let accepted = write.accepted; - self.queue_records(write.records); - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Ready(Ok(accepted)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Poll::Ready(Ok(accepted)) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -impl AsyncRead for ShadowTlsV3HandshakeStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - match std::mem::replace( - &mut self.read_stage, - ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, - ) { - ShadowTlsV3ReadStage::Header { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut header_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { - Poll::Pending => { - self.read_stage = ShadowTlsV3ReadStage::Header { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { - return Poll::Ready(Ok(())); - } - Poll::Ready(Ok(())) => { - filled += header_buf.filled().len(); - } - } - } - let len = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; - self.read_stage = ShadowTlsV3ReadStage::Payload { - header: bytes, - bytes: vec![0u8; len], - filled: 0, - }; - } - ShadowTlsV3ReadStage::Payload { header, mut bytes, mut filled } => { - while filled < bytes.len() { - let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { - Poll::Pending => { - self.read_stage = - ShadowTlsV3ReadStage::Payload { header, bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - filled += payload_buf.filled().len(); - } - } - } - let mut frame = Vec::with_capacity(header.len() + bytes.len()); - frame.extend_from_slice(&header); - frame.extend_from_slice(&bytes); - if let Err(err) = self.process_frame(frame) { - return Poll::Ready(Err(err)); - } - self.read_stage = ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }; - } - } - } - } -} - -impl AsyncWrite for ShadowTlsV3HandshakeStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl AsyncRead for ShadowTlsV3SignedClientHelloStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - match self.poll_write_pending(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => {} - } - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -impl AsyncWrite for ShadowTlsV3SignedClientHelloStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - if let Err(err) = self.queue_write(buf) { - return Poll::Ready(Err(err)); - } - match self.poll_write_pending(cx) { - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Pending | Poll::Ready(Ok(())) => Poll::Ready(Ok(buf.len())), - } - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - if !self.client_hello_signed && !self.client_hello_buf.is_empty() { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "incomplete ShadowTLS v3 ClientHello before flush", - ))); - } - match self.poll_write_pending(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_flush(cx), - } - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -struct ShadowTlsV3Stream { - inner: S, - hmac_add: hmac::Context, - hmac_verify: hmac::Context, - hmac_ignore: Option, - read_buf: Vec, - read_offset: usize, - read_stage: ShadowTlsV3ReadStage, - write_buf: Vec, - write_offset: usize, -} - -impl ShadowTlsV3Stream { - fn new( - inner: S, - password: &str, - server_random: [u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE], - hmac_ignore: Option, - ) -> Self { - Self { - inner, - hmac_add: shadow_tls_v3_hmac(password, &server_random, b"C"), - hmac_verify: shadow_tls_v3_hmac(password, &server_random, b"S"), - hmac_ignore, - read_buf: Vec::new(), - read_offset: 0, - read_stage: ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, - write_buf: Vec::new(), - write_offset: 0, - } - } - - fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - return false; - } - let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); - buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); - self.read_offset += len; - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - } - true - } - - fn process_frame(&mut self, frame: Vec) -> io::Result { - match frame.first().copied() { - Some(0x15) => Err(io::Error::new( - io::ErrorKind::ConnectionAborted, - "ShadowTLS v3 remote alert", - )), - Some(0x17) => { - if let Some(hmac_ignore) = self.hmac_ignore.as_ref() { - if shadow_tls_v3_application_data_hmac(&frame, hmac_ignore).is_some_and( - |expected| { - expected[..] - == frame - [SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE] - }, - ) { - return Ok(false); - } - } - self.hmac_ignore = None; - let Some(expected) = shadow_tls_v3_application_data_hmac(&frame, &self.hmac_verify) - else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ShadowTLS v3 application data frame was invalid", - )); - }; - if expected[..] - != frame[SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE] - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ShadowTLS v3 application data verification failed", - )); - } - self.hmac_verify - .update(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); - self.hmac_verify.update(&expected); - self.read_buf = frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..].to_vec(); - self.read_offset = 0; - Ok(true) - } - Some(other) => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unexpected ShadowTLS v3 record type {other}"), - )), - None => Ok(false), - } - } - - fn compact_write_buffer(&mut self) { - if self.write_offset == self.write_buf.len() { - self.write_buf.clear(); - self.write_offset = 0; - } - } -} - -impl AsyncRead for ShadowTlsV3Stream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - match std::mem::replace( - &mut self.read_stage, - ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, - ) { - ShadowTlsV3ReadStage::Header { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut header_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { - Poll::Pending => { - self.read_stage = ShadowTlsV3ReadStage::Header { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { - return Poll::Ready(Ok(())); - } - Poll::Ready(Ok(())) => filled += header_buf.filled().len(), - } - } - let len = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; - self.read_stage = ShadowTlsV3ReadStage::Payload { - header: bytes, - bytes: vec![0u8; len], - filled: 0, - }; - } - ShadowTlsV3ReadStage::Payload { header, mut bytes, mut filled } => { - while filled < bytes.len() { - let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { - Poll::Pending => { - self.read_stage = - ShadowTlsV3ReadStage::Payload { header, bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += payload_buf.filled().len(), - } - } - let mut frame = Vec::with_capacity(header.len() + bytes.len()); - frame.extend_from_slice(&header); - frame.extend_from_slice(&bytes); - match self.process_frame(frame) { - Ok(true) => { - self.read_stage = - ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }; - continue; - } - Ok(false) => { - self.read_stage = - ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }; - continue; - } - Err(err) => return Poll::Ready(Err(err)), - } - } - } - } - } -} - -impl AsyncWrite for ShadowTlsV3Stream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - let write_len = buf.len().min(SHADOW_TLS_MAX_RECORD_PAYLOAD); - self.write_buf = shadow_tls_v3_record(buf, write_len, &mut self.hmac_add)?; - self.write_offset = 0; - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Ready(Ok(write_len)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Poll::Ready(Ok(write_len)) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - while self.write_offset < self.write_buf.len() { - let offset = self.write_offset; - let pending = self.write_buf[offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -struct RateLimitedStream { - inner: S, - bytes_per_second: u64, - burst_capacity: u64, - available: u64, - last_refill: Instant, - sleep: Option>>, -} - -impl RateLimitedStream { - fn new(inner: S, bytes_per_second: u32) -> Self { - let burst_capacity = KCPTUN_RATE_LIMIT_BURST_BYTES; - Self { - inner, - bytes_per_second: u64::from(bytes_per_second), - burst_capacity, - available: burst_capacity, - last_refill: Instant::now(), - sleep: None, - } - } - - fn refill(&mut self) { - if self.bytes_per_second == 0 { - self.available = self.burst_capacity; - self.last_refill = Instant::now(); - return; - } - let now = Instant::now(); - let elapsed = now.saturating_duration_since(self.last_refill); - let tokens = (elapsed - .as_nanos() - .saturating_mul(u128::from(self.bytes_per_second)) - / 1_000_000_000) as u64; - if tokens > 0 { - self.available = self - .available - .saturating_add(tokens) - .min(self.burst_capacity); - self.last_refill = now; - } - } - - fn wait_duration(&self) -> Duration { - if self.bytes_per_second == 0 { - return Duration::ZERO; - } - let nanos = - 1_000_000_000_u64.saturating_add(self.bytes_per_second - 1) / self.bytes_per_second; - Duration::from_nanos(nanos.max(1)) - } -} - -impl AsyncRead for RateLimitedStream -where - S: AsyncRead + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -impl AsyncWrite for RateLimitedStream -where - S: AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - if buf.is_empty() || self.bytes_per_second == 0 { - return Pin::new(&mut self.inner).poll_write(cx, buf); - } - - loop { - self.refill(); - if self.available > 0 { - break; - } - if self.sleep.is_none() { - self.sleep = Some(Box::pin(tokio::time::sleep(self.wait_duration()))); - } - let sleep = self.sleep.as_mut().expect("rate limit sleep exists"); - match Future::poll(sleep.as_mut(), cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(()) => { - self.sleep = None; - self.refill(); - if self.available == 0 { - self.available = 1; - } - } - } - } - - let write_len = buf.len().min(self.available as usize); - match Pin::new(&mut self.inner).poll_write(cx, &buf[..write_len]) { - Poll::Ready(Ok(written)) => { - self.available = self.available.saturating_sub(written as u64); - Poll::Ready(Ok(written)) - } - other => other, - } - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -struct WebSocketStream { - inner: S, - read_stage: WebSocketReadStage, - read_buf: Vec, - read_offset: usize, - write_buf: Vec, - write_offset: usize, -} - -enum WebSocketReadStage { - Header { - bytes: [u8; 2], - filled: usize, - }, - ExtendedLength { - header: [u8; 2], - bytes: [u8; 8], - needed: usize, - filled: usize, - }, - Mask { - opcode: u8, - len: usize, - bytes: [u8; 4], - filled: usize, - }, - Payload { - opcode: u8, - len: usize, - mask: Option<[u8; 4]>, - bytes: Vec, - filled: usize, - }, -} - -impl WebSocketStream { - fn new(inner: S) -> Self { - Self { - inner, - read_stage: WebSocketReadStage::Header { bytes: [0u8; 2], filled: 0 }, - read_buf: Vec::new(), - read_offset: 0, - write_buf: Vec::new(), - write_offset: 0, - } - } - - fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - return false; - } - let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); - buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); - self.read_offset += len; - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - } - true - } - - fn compact_write_buffer(&mut self) { - if self.write_offset == self.write_buf.len() { - self.write_buf.clear(); - self.write_offset = 0; - } - } -} - -impl AsyncRead for WebSocketStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - - loop { - let stage = std::mem::replace( - &mut self.read_stage, - WebSocketReadStage::Header { bytes: [0u8; 2], filled: 0 }, - ); - match stage { - WebSocketReadStage::Header { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut read_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = WebSocketReadStage::Header { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - if filled == 0 { - return Poll::Ready(Ok(())); - } - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - let len_marker = bytes[1] & 0x7f; - if len_marker < 126 { - let len = usize::from(len_marker); - self.read_stage = if bytes[1] & 0x80 == 0 { - WebSocketReadStage::Payload { - opcode: bytes[0] & 0x0f, - len, - mask: None, - bytes: vec![0u8; len], - filled: 0, - } - } else { - WebSocketReadStage::Mask { - opcode: bytes[0] & 0x0f, - len, - bytes: [0u8; 4], - filled: 0, - } - }; - } else { - let needed = if len_marker == 126 { 2 } else { 8 }; - self.read_stage = WebSocketReadStage::ExtendedLength { - header: bytes, - bytes: [0u8; 8], - needed, - filled: 0, - }; - } - } - WebSocketReadStage::ExtendedLength { - header, - mut bytes, - needed, - mut filled, - } => { - while filled < needed { - let mut read_buf = ReadBuf::new(&mut bytes[filled..needed]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = WebSocketReadStage::ExtendedLength { - header, - bytes, - needed, - filled, - }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - let len = if needed == 2 { - u16::from_be_bytes([bytes[0], bytes[1]]) as usize - } else { - let len = u64::from_be_bytes(bytes); - match usize::try_from(len) { - Ok(len) => len, - Err(_) => { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "websocket frame is too large", - ))); - } - } - }; - if len > 16 * 1024 * 1024 { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "websocket frame exceeds Burrow frame limit", - ))); - } - self.read_stage = if header[1] & 0x80 == 0 { - WebSocketReadStage::Payload { - opcode: header[0] & 0x0f, - len, - mask: None, - bytes: vec![0u8; len], - filled: 0, - } - } else { - WebSocketReadStage::Mask { - opcode: header[0] & 0x0f, - len, - bytes: [0u8; 4], - filled: 0, - } - }; - } - WebSocketReadStage::Mask { - opcode, - len, - mut bytes, - mut filled, - } => { - while filled < bytes.len() { - let mut read_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = - WebSocketReadStage::Mask { opcode, len, bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - self.read_stage = WebSocketReadStage::Payload { - opcode, - len, - mask: Some(bytes), - bytes: vec![0u8; len], - filled: 0, - }; - } - WebSocketReadStage::Payload { - opcode, - len, - mask, - mut bytes, - mut filled, - } => { - while filled < len { - let mut read_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = WebSocketReadStage::Payload { - opcode, - len, - mask, - bytes, - filled, - }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - if let Some(mask) = mask { - apply_websocket_mask(&mut bytes, mask); - } - self.read_stage = WebSocketReadStage::Header { bytes: [0u8; 2], filled: 0 }; - match opcode { - 0x0 | 0x1 | 0x2 => { - self.read_buf = bytes; - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - } - 0x8 => return Poll::Ready(Ok(())), - _ => {} - } - } - } - } - } -} - -impl AsyncWrite for WebSocketStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - - self.write_buf = websocket_binary_frame(buf)?; - self.write_offset = 0; - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Ready(Ok(buf.len())), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -struct HttpUpgradeFastOpenStream { - inner: Box, - response: Vec, - validated: bool, -} - -impl HttpUpgradeFastOpenStream { - fn new(inner: Box) -> Self { - Self { - inner, - response: Vec::with_capacity(1024), - validated: false, - } - } -} - -impl AsyncRead for HttpUpgradeFastOpenStream { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - while !self.validated { - if self.response.len() >= 16 * 1024 { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "websocket plugin response headers exceeded Burrow limit", - ))); - } - - let mut byte = [0u8; 1]; - let mut read_buf = ReadBuf::new(&mut byte); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => { - self.response.push(read_buf.filled()[0]); - if self.response.ends_with(b"\r\n\r\n") { - let response = match std::str::from_utf8(&self.response) { - Ok(response) => response, - Err(_) => { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "websocket plugin response headers were not valid UTF-8", - ))); - } - }; - if let Err(err) = validate_websocket_upgrade_response(response, true, None) - { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - err.to_string(), - ))); - } - self.validated = true; - self.response.clear(); - } - } - } - } - - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -impl AsyncWrite for HttpUpgradeFastOpenStream { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -struct WebSocketEarlyDataStream { - inner: WebSocketEarlyDataInner, - config: ShadowsocksWebSocketTransport, - path: String, - port: u16, - max_early_data: usize, - request: Vec, - request_offset: usize, - request_flushed: bool, - response: Vec, - first_write_consumed: Option, - websocket_key: Option, -} - -enum WebSocketEarlyDataInner { - Pending(Box), - Raw(Box), - Framed(WebSocketStream>), - Empty, -} - -impl WebSocketEarlyDataStream { - fn new( - inner: Box, - config: ShadowsocksWebSocketTransport, - port: u16, - path: String, - max_early_data: usize, - ) -> Self { - Self { - inner: WebSocketEarlyDataInner::Pending(inner), - config, - path, - port, - max_early_data, - request: Vec::new(), - request_offset: 0, - request_flushed: false, - response: Vec::with_capacity(1024), - first_write_consumed: None, - websocket_key: None, - } - } - - fn start_handshake(&mut self, payload: &[u8]) -> io::Result<()> { - if self.first_write_consumed.is_some() { - return Ok(()); - } - let consumed = payload.len().min(self.max_early_data); - let (request, websocket_key) = websocket_plugin_request( - &self.config, - self.port, - &self.path, - (consumed > 0).then_some(&payload[..consumed]), - ) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))?; - self.request = request; - self.websocket_key = websocket_key; - self.first_write_consumed = Some(consumed); - Ok(()) - } - - fn poll_handshake(&mut self, cx: &mut TaskContext<'_>) -> Poll> { - loop { - let WebSocketEarlyDataInner::Pending(stream) = &mut self.inner else { - return Poll::Ready(Ok(())); - }; - while self.request_offset < self.request.len() { - let pending = self.request[self.request_offset..].to_vec(); - match Pin::new(&mut **stream).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => { - return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); - } - Poll::Ready(Ok(written)) => self.request_offset += written, - } - } - - if !self.request_flushed { - match Pin::new(&mut **stream).poll_flush(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => self.request_flushed = true, - } - } - - if self.config.v2ray_http_upgrade && self.config.v2ray_http_upgrade_fast_open { - let stream = - match std::mem::replace(&mut self.inner, WebSocketEarlyDataInner::Empty) { - WebSocketEarlyDataInner::Pending(stream) => stream, - _ => unreachable!("early-data fast-open stream must be pending"), - }; - self.inner = - WebSocketEarlyDataInner::Raw(Box::new(HttpUpgradeFastOpenStream::new(stream))); - self.request.clear(); - self.response.clear(); - return Poll::Ready(Ok(())); - } - - while !self.response.ends_with(b"\r\n\r\n") { - if self.response.len() >= 16 * 1024 { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "websocket plugin response headers exceeded Burrow limit", - ))); - } - let mut byte = [0u8; 1]; - let mut read_buf = ReadBuf::new(&mut byte); - match Pin::new(&mut **stream).poll_read(cx, &mut read_buf) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => self.response.push(read_buf.filled()[0]), - } - } - - let response = match std::str::from_utf8(&self.response) { - Ok(response) => response, - Err(_) => { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "websocket plugin response headers were not valid UTF-8", - ))); - } - }; - validate_websocket_upgrade_response( - response, - self.config.v2ray_http_upgrade, - self.websocket_key.as_deref(), - ) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; - let stream = match std::mem::replace(&mut self.inner, WebSocketEarlyDataInner::Empty) { - WebSocketEarlyDataInner::Pending(stream) => stream, - _ => unreachable!("early-data stream must be pending before validation"), - }; - self.inner = if self.config.v2ray_http_upgrade { - WebSocketEarlyDataInner::Raw(stream) - } else { - WebSocketEarlyDataInner::Framed(WebSocketStream::new(stream)) - }; - self.request.clear(); - self.response.clear(); - return Poll::Ready(Ok(())); - } - } -} - -impl AsyncRead for WebSocketEarlyDataStream { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if matches!(self.inner, WebSocketEarlyDataInner::Pending(_)) { - if let Err(err) = self.start_handshake(&[]) { - return Poll::Ready(Err(err)); - } - match self.poll_handshake(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => {} - } - } - - match &mut self.inner { - WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_read(cx, buf), - WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_read(cx, buf), - WebSocketEarlyDataInner::Pending(_) => { - unreachable!("pending early-data stream handled") - } - WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), - } - } -} - -impl AsyncWrite for WebSocketEarlyDataStream { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - if matches!(self.inner, WebSocketEarlyDataInner::Pending(_)) { - if let Err(err) = self.start_handshake(buf) { - return Poll::Ready(Err(err)); - } - match self.poll_handshake(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => { - return Poll::Ready(Ok(self.first_write_consumed.take().unwrap_or(0))); - } - } - } - - match &mut self.inner { - WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_write(cx, buf), - WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_write(cx, buf), - WebSocketEarlyDataInner::Pending(_) => { - unreachable!("pending early-data stream handled") - } - WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), - } - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match &mut self.inner { - WebSocketEarlyDataInner::Pending(stream) => Pin::new(&mut **stream).poll_flush(cx), - WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_flush(cx), - WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_flush(cx), - WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), - } - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match &mut self.inner { - WebSocketEarlyDataInner::Pending(stream) => Pin::new(&mut **stream).poll_shutdown(cx), - WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_shutdown(cx), - WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_shutdown(cx), - WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), - } - } -} - -struct V2rayMuxStream { - inner: S, - read_stage: V2rayMuxReadStage, - read_buf: Vec, - read_offset: usize, - write_buf: Vec, - write_offset: usize, - wrote_open: bool, -} - -enum V2rayMuxReadStage { - MetadataLength { bytes: [u8; 2], filled: usize }, - Metadata { bytes: Vec, filled: usize }, - DataLength { bytes: [u8; 2], filled: usize }, - Data { bytes: Vec, filled: usize }, -} - -impl V2rayMuxStream { - fn new(inner: S) -> Self { - Self { - inner, - read_stage: V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }, - read_buf: Vec::new(), - read_offset: 0, - write_buf: Vec::new(), - write_offset: 0, - wrote_open: false, - } - } - - fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - return false; - } - let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); - buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); - self.read_offset += len; - if self.read_offset == self.read_buf.len() { - self.read_buf.clear(); - self.read_offset = 0; - } - true - } - - fn compact_write_buffer(&mut self) { - if self.write_offset == self.write_buf.len() { - self.write_buf.clear(); - self.write_offset = 0; - } - } -} - -impl AsyncRead for V2rayMuxStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - - loop { - let stage = std::mem::replace( - &mut self.read_stage, - V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }, - ); - match stage { - V2rayMuxReadStage::MetadataLength { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut read_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = - V2rayMuxReadStage::MetadataLength { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - if filled == 0 { - return Poll::Ready(Ok(())); - } - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - let len = u16::from_be_bytes(bytes) as usize; - if len > 512 { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "v2ray-plugin mux metadata is too large", - ))); - } - self.read_stage = V2rayMuxReadStage::Metadata { - bytes: vec![0u8; len], - filled: 0, - }; - } - V2rayMuxReadStage::Metadata { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut read_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = V2rayMuxReadStage::Metadata { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - if bytes.len() < 4 { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "v2ray-plugin mux metadata is incomplete", - ))); - } - let status = bytes[2]; - let option = bytes[3]; - if status == 0x04 || option != 0x01 { - self.read_stage = - V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }; - } else { - self.read_stage = - V2rayMuxReadStage::DataLength { bytes: [0u8; 2], filled: 0 }; - } - } - V2rayMuxReadStage::DataLength { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut read_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = V2rayMuxReadStage::DataLength { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - let len = u16::from_be_bytes(bytes) as usize; - self.read_stage = V2rayMuxReadStage::Data { - bytes: vec![0u8; len], - filled: 0, - }; - } - V2rayMuxReadStage::Data { mut bytes, mut filled } => { - while filled < bytes.len() { - let mut read_buf = ReadBuf::new(&mut bytes[filled..]); - match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { - Poll::Pending => { - self.read_stage = V2rayMuxReadStage::Data { bytes, filled }; - return Poll::Pending; - } - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { - return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); - } - Poll::Ready(Ok(())) => filled += read_buf.filled().len(), - } - } - self.read_stage = - V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }; - self.read_buf = bytes; - if self.copy_read_buffer(buf) { - return Poll::Ready(Ok(())); - } - } - } - } - } -} - -impl AsyncWrite for V2rayMuxStream -where - S: AsyncRead + AsyncWrite + Unpin, -{ - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - - let payload_len = buf.len().min(u16::MAX as usize); - let payload = &buf[..payload_len]; - self.write_buf = if self.wrote_open { - v2ray_mux_data_frame(payload)? - } else { - self.wrote_open = true; - let mut frame = v2ray_mux_open_frame(); - frame.extend_from_slice(&v2ray_mux_data_frame(payload)?); - frame - }; - self.write_offset = 0; - - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Ready(Ok(payload_len)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Poll::Ready(Ok(payload_len)) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - while self.write_offset < self.write_buf.len() { - let pending = self.write_buf[self.write_offset..].to_vec(); - match Pin::new(&mut self.inner).poll_write(cx, &pending) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), - Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), - Poll::Ready(Ok(written)) => { - self.write_offset += written; - self.compact_write_buffer(); - } - } - } - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), - } - } -} - -#[cfg(target_vendor = "apple")] -fn verify_native_tls_peer_certificate( - tls: &tokio_native_tls::TlsStream, - fingerprint: &CertificateFingerprint, - protocol: &str, -) -> Result<()> { - let cert = tls - .get_ref() - .peer_certificate() - .with_context(|| format!("failed to read {protocol} native TLS peer certificate"))? - .ok_or_else(|| anyhow!("{protocol} native TLS peer did not provide a certificate"))?; - let digest = Sha256::digest( - cert.to_der() - .with_context(|| format!("failed to encode {protocol} native TLS peer certificate"))?, - ); - if digest.as_slice() != fingerprint.0 { - bail!("{protocol} certificate fingerprint mismatch"); - } - Ok(()) -} - -#[cfg(not(target_vendor = "apple"))] -fn trojan_tls_config( - alpn: &[String], - skip_cert_verify: bool, - certificate_fingerprint: Option<&CertificateFingerprint>, -) -> Result { - rustls_tls_config(alpn, skip_cert_verify, certificate_fingerprint, None, None) -} - -fn rustls_tls_config( - alpn: &[String], - skip_cert_verify: bool, - certificate_fingerprint: Option<&CertificateFingerprint>, - client_identity: Option<&TlsClientIdentity>, - ech_config_list: Option<&[u8]>, -) -> Result { - install_rustls_crypto_provider(); - let mut root_store = RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let builder = if let Some(ech_config_list) = ech_config_list { - let ech_config = rustls_ech_config(ech_config_list)?; - ClientConfig::builder_with_provider(Arc::new(rustls::crypto::aws_lc_rs::default_provider())) - .with_ech(EchMode::Enable(ech_config)) - .context("failed to enable ECH for Shadowsocks websocket plugin")? - } else { - ClientConfig::builder() - }; - let builder = builder.with_root_certificates(root_store); - let mut config = if let Some(identity) = client_identity { - let (cert_chain, private_key) = load_rustls_client_identity(identity)?; - builder - .with_client_auth_cert(cert_chain, private_key) - .context("failed to configure TLS client certificate")? - } else { - builder.with_no_client_auth() - }; - config.alpn_protocols = alpn.iter().map(|value| value.as_bytes().to_vec()).collect(); - if let Some(fingerprint) = certificate_fingerprint { - config - .dangerous() - .set_certificate_verifier(Arc::new(PinnedCertificateVerifier { - fingerprint: fingerprint.clone(), - })); - } else if skip_cert_verify { - config - .dangerous() - .set_certificate_verifier(Arc::new(NoCertificateVerifier)); - } - Ok(config) -} - -fn rustls_ech_config(config_list: &[u8]) -> Result { - EchConfig::new( - EchConfigListBytes::from(config_list), - rustls::crypto::aws_lc_rs::hpke::ALL_SUPPORTED_SUITES, - ) - .context("failed to parse Shadowsocks websocket ECH config list") -} - -fn install_rustls_crypto_provider() { - static INSTALL: Once = Once::new(); - INSTALL.call_once( - || match rustls::crypto::aws_lc_rs::default_provider().install_default() { - Ok(()) => tracing::debug!("installed aws-lc-rs rustls crypto provider"), - Err(_) => tracing::debug!("rustls crypto provider was already installed"), - }, - ); -} - -fn parse_certificate_fingerprint(value: &str) -> Result { - let normalized = value - .chars() - .filter(|ch| *ch != ':' && *ch != '-' && !ch.is_whitespace()) - .collect::(); - if matches!( - normalized.to_ascii_lowercase().as_str(), - "chrome" | "firefox" | "safari" | "ios" | "android" | "edge" | "360" | "qq" - ) { - bail!( - "`fingerprint` is for TLS certificate pinning; use `client-fingerprint` for browser fingerprints" - ); - } - if normalized.len() != 64 { - bail!("TLS certificate fingerprint must be a SHA-256 hex digest"); - } - let mut bytes = [0u8; 32]; - for (idx, chunk) in normalized.as_bytes().chunks_exact(2).enumerate() { - let hi = hex_nibble(chunk[0]).ok_or_else(|| anyhow!("invalid certificate fingerprint"))?; - let lo = hex_nibble(chunk[1]).ok_or_else(|| anyhow!("invalid certificate fingerprint"))?; - bytes[idx] = (hi << 4) | lo; - } - Ok(CertificateFingerprint(bytes)) -} - -fn tls_material_bytes(value: &str, label: &str) -> Result> { - let trimmed = value.trim(); - if trimmed.contains("-----BEGIN ") { - return Ok(trimmed.as_bytes().to_vec()); - } - fs::read(trimmed).with_context(|| format!("failed to read TLS {label} from {trimmed}")) -} - -fn load_rustls_client_identity( - identity: &TlsClientIdentity, -) -> Result<(Vec>, PrivateKeyDer<'static>)> { - let certificate = tls_material_bytes(&identity.certificate, "client certificate")?; - let private_key = tls_material_bytes(&identity.private_key, "client private key")?; - - let mut certificate_reader = io::Cursor::new(certificate); - let certificate_chain = rustls_pemfile::certs(&mut certificate_reader) - .collect::, _>>() - .context("failed to parse TLS client certificate PEM")?; - if certificate_chain.is_empty() { - bail!("TLS client certificate PEM did not contain any certificates"); - } - - let mut private_key_reader = io::Cursor::new(private_key); - let private_key = rustls_pemfile::private_key(&mut private_key_reader) - .context("failed to parse TLS client private key PEM")? - .ok_or_else(|| anyhow!("TLS client private key PEM did not contain a private key"))?; - - Ok((certificate_chain, private_key)) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn load_boring_client_identity( - identity: &TlsClientIdentity, -) -> Result { - let certificate = tls_material_bytes(&identity.certificate, "client certificate")?; - let private_key = tls_material_bytes(&identity.private_key, "client private key")?; - - let mut certificate_reader = io::Cursor::new(certificate); - let certificate_chain = rustls_pemfile::certs(&mut certificate_reader) - .collect::, _>>() - .context("failed to parse TLS client certificate PEM")? - .into_iter() - .map(|certificate| { - BoringX509::from_der(certificate.as_ref()) - .context("failed to parse TLS client certificate as X.509") - }) - .collect::>>()?; - if certificate_chain.is_empty() { - bail!("TLS client certificate PEM did not contain any certificates"); - } - - let mut private_key_reader = io::Cursor::new(private_key); - let private_key = rustls_pemfile::private_key(&mut private_key_reader) - .context("failed to parse TLS client private key PEM")? - .ok_or_else(|| anyhow!("TLS client private key PEM did not contain a private key"))?; - let private_key = BoringPKey::private_key_from_der(private_key.secret_der()) - .context("failed to parse TLS client private key for BoringSSL")?; - - Ok(BoringConnectorConfigClientAuth { - cert_chain: certificate_chain, - private_key, - }) -} - -#[cfg(target_vendor = "apple")] -fn load_native_tls_identity(identity: &TlsClientIdentity) -> Result { - let certificate = tls_material_bytes(&identity.certificate, "client certificate")?; - let private_key = tls_material_bytes(&identity.private_key, "client private key")?; - NativeTlsIdentity::from_pkcs8(&certificate, &private_key) - .context("failed to configure TLS client certificate") -} - -fn unsupported_client_fingerprint(config: &TrojanNode) -> Option<&str> { - unsupported_client_fingerprint_value(config.client_fingerprint.as_deref()) -} - -fn unsupported_client_fingerprint_value(value: Option<&str>) -> Option<&str> { - let value = value?.trim(); - if value.is_empty() || value.eq_ignore_ascii_case("none") { - return None; - } - MihomoClientFingerprint::from_mihomo_name(value) - .is_some() - .then_some(value) -} - -impl MihomoClientFingerprint { - fn from_mihomo_name(value: &str) -> Option { - Some(match value.trim().to_ascii_lowercase().as_str() { - "chrome" => Self::Chrome, - "firefox" => Self::Firefox, - "safari" => Self::Safari, - "ios" => Self::Ios, - "android" => Self::Android, - "edge" => Self::Edge, - "360" => Self::Browser360, - "qq" => Self::Qq, - "random" => Self::Random, - "chrome120" => Self::Chrome120, - "firefox120" => Self::Firefox120, - "safari16" => Self::Safari16, - "chrome_psk" => Self::ChromePsk, - "chrome_psk_shuffle" => Self::ChromePskShuffle, - "chrome_padding_psk_shuffle" => Self::ChromePaddingPskShuffle, - "chrome_pq" => Self::ChromePq, - "chrome_pq_psk" => Self::ChromePqPsk, - "randomized" => Self::Randomized, - _ => return None, - }) - } - - fn mihomo_name(self) -> &'static str { - match self { - Self::Chrome => "chrome", - Self::Firefox => "firefox", - Self::Safari => "safari", - Self::Ios => "ios", - Self::Android => "android", - Self::Edge => "edge", - Self::Browser360 => "360", - Self::Qq => "qq", - Self::Random => "random", - Self::Chrome120 => "chrome120", - Self::Firefox120 => "firefox120", - Self::Safari16 => "safari16", - Self::ChromePsk => "chrome_psk", - Self::ChromePskShuffle => "chrome_psk_shuffle", - Self::ChromePaddingPskShuffle => "chrome_padding_psk_shuffle", - Self::ChromePq => "chrome_pq", - Self::ChromePqPsk => "chrome_pq_psk", - Self::Randomized => "randomized", - } - } - - fn shadow_tls_v2_boring_supported(self) -> bool { - #[cfg(not(feature = "boring-browser-fingerprints"))] - { - let _ = self; - false - } - #[cfg(feature = "boring-browser-fingerprints")] - matches!( - self, - Self::Chrome - | Self::Firefox - | Self::Safari - | Self::Ios - | Self::Android - | Self::Edge - | Self::Random - | Self::Safari16 - ) - } - -} - -fn hex_nibble(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -#[derive(Debug)] -struct PinnedCertificateVerifier { - fingerprint: CertificateFingerprint, -} - -impl ServerCertVerifier for PinnedCertificateVerifier { - fn verify_server_cert( - &self, - end_entity: &CertificateDer<'_>, - intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> Result { - let mut certificates = std::iter::once(end_entity).chain(intermediates.iter()); - if certificates - .any(|certificate| certificate_fingerprint_matches(certificate, &self.fingerprint)) - { - Ok(ServerCertVerified::assertion()) - } else { - Err(RustlsError::General( - "certificate chain does not match pinned fingerprint".to_owned(), - )) - } - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - supported_verify_schemes() - } -} - -fn certificate_fingerprint_matches( - certificate: &CertificateDer<'_>, - fingerprint: &CertificateFingerprint, -) -> bool { - let digest = Sha256::digest(certificate.as_ref()); - digest[..] == fingerprint.0[..] -} - -#[derive(Debug)] -struct NoCertificateVerifier; - -impl ServerCertVerifier for NoCertificateVerifier { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> Result { - Ok(ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - supported_verify_schemes() - } -} - -fn supported_verify_schemes() -> Vec { - vec![ - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ECDSA_NISTP384_SHA384, - SignatureScheme::ED25519, - SignatureScheme::RSA_PSS_SHA256, - SignatureScheme::RSA_PSS_SHA384, - SignatureScheme::RSA_PSS_SHA512, - SignatureScheme::RSA_PKCS1_SHA256, - SignatureScheme::RSA_PKCS1_SHA384, - SignatureScheme::RSA_PKCS1_SHA512, - ] -} - -async fn write_trojan_header( - writer: &mut W, - password: &str, - command: u8, - socks_address: &[u8], -) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - let password_hash = trojan_password_hash(password); - let mut header = Vec::with_capacity(password_hash.len() + 2 + 1 + socks_address.len() + 2); - header.extend_from_slice(password_hash.as_bytes()); - header.extend_from_slice(b"\r\n"); - header.push(command); - header.extend_from_slice(socks_address); - header.extend_from_slice(b"\r\n"); - writer.write_all(&header).await?; - writer.flush().await?; - Ok(()) -} - -fn trojan_password_hash(password: &str) -> String { - let digest = Sha224::digest(password.as_bytes()); - hex_lower(&digest) -} - -async fn write_trojan_udp_packet( - writer: &mut W, - socks_address: &[u8], - payload: &[u8], -) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - if payload.len() <= TROJAN_MAX_UDP_PAYLOAD { - write_trojan_udp_frame(writer, socks_address, payload).await?; - } else { - for chunk in payload.chunks(TROJAN_MAX_UDP_PAYLOAD) { - write_trojan_udp_frame(writer, socks_address, chunk).await?; - } - } - writer.flush().await?; - Ok(()) -} - -async fn write_trojan_udp_frame( - writer: &mut W, - socks_address: &[u8], - payload: &[u8], -) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - writer.write_all(socks_address).await?; - writer - .write_all(&(payload.len() as u16).to_be_bytes()) - .await?; - writer.write_all(b"\r\n").await?; - writer.write_all(payload).await?; - Ok(()) -} - -async fn read_trojan_udp_packet(reader: &mut R, buf: &mut [u8]) -> Result<(SocketAddr, Vec)> -where - R: AsyncRead + Unpin, -{ - let source = read_socks_addr_async(reader).await?; - let mut len = [0u8; 2]; - reader.read_exact(&mut len).await?; - let payload_len = u16::from_be_bytes(len) as usize; - let mut crlf = [0u8; 2]; - reader.read_exact(&mut crlf).await?; - if crlf != *b"\r\n" { - bail!("invalid Trojan UDP packet delimiter"); - } - if payload_len > buf.len() { - bail!("Trojan UDP packet too large"); - } - reader.read_exact(&mut buf[..payload_len]).await?; - Ok((source, buf[..payload_len].to_vec())) -} - -include!("proxy_runtime/shadowsocks_crypto.rs"); - -include!("proxy_runtime/shadowsocks_plugins.rs"); - -fn validate_shadowsocks_cipher(name: &str) -> Result<()> { - if shadowsocks_cipher_kind(name).is_ok() - || CustomSsAeadKind::from_name(name).is_some() - || CustomSsStreamKind::from_name(name).is_some() - || Aead2022AesCcmKind::from_name(name).is_some() - { - return Ok(()); - } - bail!("unsupported Shadowsocks cipher {name}") -} - -fn normalize_uot_version(version: u8) -> Result { - match version { - 0 | UOT_LEGACY_VERSION => Ok(UOT_LEGACY_VERSION), - UOT_VERSION => Ok(UOT_VERSION), - other => bail!("unsupported Shadowsocks udp-over-tcp version {other}"), - } -} - -fn shadowsocks_sing_mux_transport( - smux: Option<&ShadowsocksSmux>, - _config: &ShadowsocksNode, - protocol: &ShadowsocksProtocol, -) -> Result> { - let Some(smux) = smux.filter(|smux| smux.enabled) else { - return Ok(None); - }; - let protocol_name = smux.protocol.as_deref().unwrap_or("h2mux"); - let sing_mux_protocol = if protocol_name.eq_ignore_ascii_case("h2mux") { - ShadowsocksSingMuxProtocol::H2Mux - } else if protocol_name.eq_ignore_ascii_case("smux") { - ShadowsocksSingMuxProtocol::Smux - } else if protocol_name.eq_ignore_ascii_case("yamux") { - ShadowsocksSingMuxProtocol::Yamux - } else { - bail!("top-level Shadowsocks sing-mux protocol {protocol_name} is not supported yet"); - }; - let brutal = smux - .brutal - .as_ref() - .filter(|brutal| brutal.enabled) - .map(|brutal| ShadowsocksSingMuxBrutalTransport { - send_bps: mihomo_string_to_bps(brutal.up.as_deref().unwrap_or("")), - receive_bps: mihomo_string_to_bps(brutal.down.as_deref().unwrap_or("")), - }); - match protocol { - ShadowsocksProtocol::None - | ShadowsocksProtocol::Standard { .. } - | ShadowsocksProtocol::CustomAead { .. } - | ShadowsocksProtocol::CustomStream { .. } - | ShadowsocksProtocol::Aead2022AesCcm { .. } => {} - } - Ok(Some(ShadowsocksSingMuxTransport { - protocol: sing_mux_protocol, - max_connections: smux.max_connections.unwrap_or(0) as usize, - min_streams: smux.min_streams.unwrap_or(0) as usize, - max_streams: smux.max_streams.unwrap_or(0) as usize, - padding: smux.padding, - udp: !smux.only_tcp, - brutal, - })) -} - -fn mihomo_string_to_bps(value: &str) -> u64 { - let value = value.trim(); - if value.is_empty() { - return 0; - } - if let Ok(mbps) = value.parse::() { - return mbps.saturating_mul(1_000_000 / 8); - } - - let digit_len = value - .as_bytes() - .iter() - .take_while(|byte| byte.is_ascii_digit()) - .count(); - if digit_len == 0 { - return 0; - } - let Ok(amount) = value[..digit_len].parse::() else { - return 0; - }; - let unit = value[digit_len..].trim(); - let Some(suffix) = unit.strip_suffix("ps") else { - return 0; - }; - let (scale, bits) = match suffix { - "b" => (1, true), - "B" => (1, false), - "Kb" => (1_000, true), - "KB" => (1_000, false), - "Mb" => (1_000_000, true), - "MB" => (1_000_000, false), - "Gb" => (1_000_000_000, true), - "GB" => (1_000_000_000, false), - "Tb" => (1_000_000_000_000, true), - "TB" => (1_000_000_000_000, false), - _ => return 0, - }; - let bytes = amount.saturating_mul(scale); - if bits { - bytes / 8 - } else { - bytes - } -} - -fn shadowsocks_protocol_for_node( - node: &ProxyNode, - config: &ShadowsocksNode, -) -> Result { - if config.cipher.trim().eq_ignore_ascii_case("none") { - return Ok(ShadowsocksProtocol::None); - } - - if let Some(kind) = Aead2022AesCcmKind::from_name(&config.cipher) { - let (user_key, identity_keys) = parse_aead2022_keys(kind, &config.password)?; - return Ok(ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys }); - } - - if let Ok(cipher) = shadowsocks_cipher_kind(&config.cipher) { - validate_standard_aead2022_mihomo_password(cipher, &config.password)?; - let server_config = SsServerConfig::new( - SsServerAddr::DomainName(node.server.clone(), node.port), - config.password.clone(), - cipher, - ) - .with_context(|| { - format!( - "failed to configure Shadowsocks cipher {} for node {}", - config.cipher, node.name - ) - })?; - return Ok(ShadowsocksProtocol::Standard { - server_config: Arc::new(server_config), - context: SsContext::new_shared(SsServerType::Local), - }); - } - - if let Some(kind) = CustomSsAeadKind::from_name(&config.cipher) { - let master_key = evp_bytes_to_key(config.password.as_bytes(), kind.key_len()); - return Ok(ShadowsocksProtocol::CustomAead { - kind, - master_key: Arc::from(master_key), - }); - } - - if let Some(kind) = CustomSsStreamKind::from_name(&config.cipher) { - let key = evp_bytes_to_key(config.password.as_bytes(), kind.key_len()); - return Ok(ShadowsocksProtocol::CustomStream { kind, key: Arc::from(key) }); - } - - bail!("unsupported Shadowsocks cipher {}", config.cipher) -} - -fn shadowsocks_cipher_kind(name: &str) -> Result { - let canonical = canonical_shadowsocks_cipher_name(name); - SsCipherKind::from_str(&canonical).map_err(|_| anyhow!("unsupported Shadowsocks cipher {name}")) -} - -fn validate_standard_aead2022_mihomo_password(cipher: SsCipherKind, password: &str) -> Result<()> { - if !cipher.is_aead_2022() { - return Ok(()); - } - - if password.is_empty() { - bail!("Shadowsocks 2022 password is missing"); - } - - let supports_eih = matches!( - cipher, - SsCipherKind::AEAD2022_BLAKE3_AES_128_GCM | SsCipherKind::AEAD2022_BLAKE3_AES_256_GCM - ); - let segments = password.split(':').collect::>(); - if segments.len() > 1 && !supports_eih { - bail!("Shadowsocks 2022 EIH support only available in AES ciphers"); - } - - for segment in segments { - let decoded = BASE64_STANDARD - .decode(segment) - .context("failed to decode Shadowsocks 2022 base64 key")?; - if decoded.len() != cipher.key_len() { - bail!( - "Shadowsocks 2022 key length mismatch: required {}, got {}", - cipher.key_len(), - decoded.len() - ); - } - } - - Ok(()) -} - -fn canonical_shadowsocks_cipher_name(name: &str) -> String { - match name.trim().to_ascii_lowercase().as_str() { - "aead_aes_128_gcm" => "aes-128-gcm".to_owned(), - "aead_aes_256_gcm" => "aes-256-gcm".to_owned(), - "chacha20-poly1305" | "aead_chacha20_poly1305" => "chacha20-ietf-poly1305".to_owned(), - other => other.to_owned(), - } -} - -fn shadowsocks_addr_for_target(target: &ProxyTcpTarget) -> Result { - if let Some(hostname) = target.hostname.as_deref() { - let hostname = hostname.trim_end_matches('.'); - if !hostname.is_empty() && hostname.len() <= u8::MAX as usize { - return Ok(SsAddress::DomainNameAddress( - hostname.to_owned(), - target.address.port(), - )); - } - } - Ok(SsAddress::SocketAddress(target.address)) -} - -fn shadowsocks_addr_to_socket_addr(addr: SsAddress) -> Result { - match addr { - SsAddress::SocketAddress(addr) => Ok(addr), - SsAddress::DomainNameAddress(domain, port) => resolve_server(&domain, port), - } -} - -fn socks_addr(addr: SocketAddr) -> Result> { - let mut encoded = Vec::with_capacity(1 + 16 + 2); - match addr.ip() { - IpAddr::V4(ip) => { - encoded.push(1); - encoded.extend_from_slice(&ip.octets()); - } - IpAddr::V6(ip) => { - encoded.push(4); - encoded.extend_from_slice(&ip.octets()); - } - } - encoded.extend_from_slice(&addr.port().to_be_bytes()); - Ok(encoded) -} - -fn socks_domain_addr(domain: &str, port: u16) -> Result> { - let domain = domain.trim_end_matches('.'); - if domain.is_empty() || domain.len() > u8::MAX as usize { - bail!("invalid SOCKS domain length"); - } - let mut encoded = Vec::with_capacity(1 + 1 + domain.len() + 2); - encoded.push(3); - encoded.push(domain.len() as u8); - encoded.extend_from_slice(domain.as_bytes()); - encoded.extend_from_slice(&port.to_be_bytes()); - Ok(encoded) -} - -fn socks_addr_for_target(target: &ProxyTcpTarget) -> Result> { - if let Some(hostname) = target.hostname.as_deref() { - let hostname = hostname.trim_end_matches('.'); - if !hostname.is_empty() && hostname.len() <= u8::MAX as usize { - return socks_domain_addr(hostname, target.address.port()); - } - } - socks_addr(target.address) -} - -async fn sing_mux_brutal_exchange( - session: &ShadowsocksSingMuxSession, - brutal: ShadowsocksSingMuxBrutalTransport, -) -> Result<()> { - let mut stream = session.open_stream().await?; - write_sing_mux_brutal_request(&mut stream, brutal.receive_bps).await?; - let server_receive_bps = read_sing_mux_brutal_response(&mut stream).await?; - let send_bps = if server_receive_bps < brutal.send_bps { - server_receive_bps - } else { - brutal.send_bps - }; - tracing::debug!( - send_bps, - receive_bps = brutal.receive_bps, - server_receive_bps, - "completed Shadowsocks sing-mux brutal bandwidth exchange" - ); - Ok(()) -} - -async fn write_sing_mux_brutal_request(writer: &mut W, receive_bps: u64) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - writer - .write_all(&0u16.to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux brutal tcp flags")?; - writer - .write_all(&socks_domain_addr(SING_MUX_BRUTAL_EXCHANGE_DOMAIN, 0)?) - .await - .context("failed to write Shadowsocks sing-mux brutal exchange destination")?; - writer - .write_all(&receive_bps.to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux brutal receive bps")?; - writer - .flush() - .await - .context("failed to flush Shadowsocks sing-mux brutal request")?; - Ok(()) -} - -async fn read_sing_mux_brutal_response(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, -{ - read_sing_mux_stream_response(reader).await?; - let ok = reader - .read_u8() - .await - .context("failed to read Shadowsocks sing-mux brutal response status")?; - match ok { - 0 => { - let len = read_sing_mux_uvarint(reader).await?; - if len > 4096 { - bail!("Shadowsocks sing-mux brutal remote error message is too long"); - } - let mut message = vec![0u8; len as usize]; - reader - .read_exact(&mut message) - .await - .context("failed to read Shadowsocks sing-mux brutal remote error")?; - let message = String::from_utf8_lossy(&message); - bail!("Shadowsocks sing-mux brutal remote error: {message}"); - } - 1 => reader - .read_u64() - .await - .context("failed to read Shadowsocks sing-mux brutal server receive bps"), - other => bail!("unknown Shadowsocks sing-mux brutal response status {other}"), - } -} - -async fn write_sing_mux_tcp_request(writer: &mut W, target: &ProxyTcpTarget) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - writer - .write_all(&0u16.to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux tcp flags")?; - writer - .write_all(&socks_addr_for_target(target)?) - .await - .context("failed to write Shadowsocks sing-mux tcp destination")?; - writer - .flush() - .await - .context("failed to flush Shadowsocks sing-mux tcp request")?; - Ok(()) -} - -async fn write_sing_mux_udp_request( - writer: &mut W, - destination: SocketAddr, - payload: &[u8], -) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - writer - .write_all(&SING_MUX_STREAM_FLAG_UDP.to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux udp flags")?; - writer - .write_all(&socks_addr(destination)?) - .await - .context("failed to write Shadowsocks sing-mux udp destination")?; - if !payload.is_empty() { - write_sing_mux_udp_payload(writer, payload).await?; - } else { - writer - .flush() - .await - .context("failed to flush Shadowsocks sing-mux udp request")?; - } - Ok(()) -} - -async fn write_sing_mux_udp_payload(writer: &mut W, payload: &[u8]) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - if payload.len() > u16::MAX as usize { - bail!("Shadowsocks sing-mux udp payload too large"); - } - writer - .write_all(&(payload.len() as u16).to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux udp payload length")?; - writer - .write_all(payload) - .await - .context("failed to write Shadowsocks sing-mux udp payload")?; - writer - .flush() - .await - .context("failed to flush Shadowsocks sing-mux udp payload")?; - Ok(()) -} - -async fn read_sing_mux_udp_payload(reader: &mut R, response_read: &mut bool) -> Result> -where - R: AsyncRead + Unpin, -{ - if !*response_read { - read_sing_mux_stream_response(reader).await?; - *response_read = true; - } - let payload_len = reader - .read_u16() - .await - .context("failed to read Shadowsocks sing-mux udp payload length")? - as usize; - let mut payload = vec![0u8; payload_len]; - reader - .read_exact(&mut payload) - .await - .context("failed to read Shadowsocks sing-mux udp payload")?; - Ok(payload) -} - -fn sing_mux_padding_len() -> usize { - SING_MUX_MIN_PADDING_LEN + (OsRng.next_u32() as usize % SING_MUX_PADDING_LEN_RANGE) -} - -async fn write_sing_mux_session_preface( - writer: &mut W, - protocol: ShadowsocksSingMuxProtocol, - padding: bool, -) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - if !padding { - writer - .write_all(&[SING_MUX_VERSION_0, protocol.wire_id()]) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks sing-mux {} preface", - protocol.name() - ) - })?; - writer.flush().await.with_context(|| { - format!( - "failed to flush Shadowsocks sing-mux {} preface", - protocol.name() - ) - })?; - return Ok(()); - } - - let padding_len = sing_mux_padding_len(); - writer - .write_all(&[SING_MUX_VERSION_1, protocol.wire_id(), 1]) - .await - .with_context(|| { - format!( - "failed to write Shadowsocks sing-mux padded {} preface", - protocol.name() - ) - })?; - writer - .write_all(&(padding_len as u16).to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux preface padding length")?; - writer - .write_all(&vec![0u8; padding_len]) - .await - .context("failed to write Shadowsocks sing-mux preface padding")?; - writer - .flush() - .await - .context("failed to flush Shadowsocks sing-mux padded preface")?; - Ok(()) -} - -fn sing_mux_padding_stream(stream: Box) -> Box { - let (plain, bridge) = tokio::io::duplex(64 * 1024); - let (mut plain_reader, mut plain_writer) = split(bridge); - let (mut stream_reader, mut stream_writer) = split(stream); - - let _upload = tokio::spawn(async move { - let result = async { - let mut buf = vec![0u8; 16 * 1024]; - let mut frames = 0usize; - loop { - let n = plain_reader - .read(&mut buf) - .await - .context("failed to read Shadowsocks sing-mux padded upload payload")?; - if n == 0 { - break; - } - if frames < SING_MUX_PADDING_FRAMES { - let padding_len = sing_mux_padding_len(); - stream_writer - .write_all(&(n as u16).to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux padded upload length")?; - stream_writer - .write_all(&(padding_len as u16).to_be_bytes()) - .await - .context("failed to write Shadowsocks sing-mux upload padding length")?; - stream_writer - .write_all(&buf[..n]) - .await - .context("failed to write Shadowsocks sing-mux padded upload payload")?; - stream_writer - .write_all(&vec![0u8; padding_len]) - .await - .context("failed to write Shadowsocks sing-mux upload padding")?; - frames += 1; - } else { - stream_writer - .write_all(&buf[..n]) - .await - .context("failed to write Shadowsocks sing-mux upload payload")?; - } - } - stream_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks sing-mux padded upload")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!(?err, "Shadowsocks sing-mux padded upload task stopped"); - } - }); - - let _download = tokio::spawn(async move { - let result = async { - let mut header = [0u8; 4]; - let mut padding = vec![0u8; SING_MUX_MIN_PADDING_LEN + SING_MUX_PADDING_LEN_RANGE - 1]; - let mut frames = 0usize; - while frames < SING_MUX_PADDING_FRAMES { - stream_reader - .read_exact(&mut header) - .await - .context("failed to read Shadowsocks sing-mux padded download header")?; - let payload_len = u16::from_be_bytes([header[0], header[1]]) as usize; - let padding_len = u16::from_be_bytes([header[2], header[3]]) as usize; - let mut payload = vec![0u8; payload_len]; - stream_reader - .read_exact(&mut payload) - .await - .context("failed to read Shadowsocks sing-mux padded download payload")?; - let mut remaining_padding = padding_len; - while remaining_padding > 0 { - let read_len = remaining_padding.min(padding.len()); - stream_reader - .read_exact(&mut padding[..read_len]) - .await - .context("failed to read Shadowsocks sing-mux download padding")?; - remaining_padding -= read_len; - } - plain_writer - .write_all(&payload) - .await - .context("failed to write Shadowsocks sing-mux padded download payload")?; - frames += 1; - } - - let mut buf = vec![0u8; 16 * 1024]; - loop { - let n = stream_reader - .read(&mut buf) - .await - .context("failed to read Shadowsocks sing-mux download payload")?; - if n == 0 { - break; - } - plain_writer - .write_all(&buf[..n]) - .await - .context("failed to write Shadowsocks sing-mux download payload")?; - } - plain_writer - .shutdown() - .await - .context("failed to shutdown Shadowsocks sing-mux padded download")?; - Result::<()>::Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!(?err, "Shadowsocks sing-mux padded download task stopped"); - } - }); - - Box::new(plain) -} - -async fn read_sing_mux_stream_response(reader: &mut R) -> Result<()> -where - R: AsyncRead + Unpin, -{ - let mut status = [0u8; 1]; - reader - .read_exact(&mut status) - .await - .context("failed to read Shadowsocks sing-mux stream response")?; - match status[0] { - SING_MUX_STREAM_STATUS_SUCCESS => Ok(()), - SING_MUX_STREAM_STATUS_ERROR => { - let len = read_sing_mux_uvarint(reader).await?; - if len > 4096 { - bail!("Shadowsocks sing-mux remote error message is too long"); - } - let mut message = vec![0u8; len as usize]; - reader - .read_exact(&mut message) - .await - .context("failed to read Shadowsocks sing-mux remote error message")?; - bail!( - "Shadowsocks sing-mux remote error: {}", - String::from_utf8_lossy(&message) - ) - } - other => bail!("unknown Shadowsocks sing-mux stream response status {other}"), - } -} - -async fn read_sing_mux_uvarint(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, -{ - let mut value = 0u64; - for shift in (0..64).step_by(7) { - let mut byte = [0u8; 1]; - reader - .read_exact(&mut byte) - .await - .context("failed to read Shadowsocks sing-mux uvarint")?; - value |= u64::from(byte[0] & 0x7f) << shift; - if byte[0] < 0x80 { - return Ok(value); - } - } - bail!("Shadowsocks sing-mux uvarint is too large") -} - -fn uot_magic_domain(version: u8) -> &'static str { - match version { - UOT_VERSION => UOT_MAGIC_ADDRESS, - _ => UOT_LEGACY_MAGIC_ADDRESS, - } -} - -fn uot_magic_shadowsocks_addr(version: u8) -> SsAddress { - SsAddress::DomainNameAddress(uot_magic_domain(version).to_owned(), 0) -} - -fn uot_request(destination: SocketAddr) -> Result> { - let mut request = Vec::with_capacity(1 + 1 + 16 + 2); - request.push(0); - request.extend_from_slice(&socks_addr(destination)?); - Ok(request) -} - -async fn write_uot_request(writer: &mut W, destination: SocketAddr) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - writer.write_all(&uot_request(destination)?).await?; - writer.flush().await?; - Ok(()) -} - -fn uot_frame(destination: SocketAddr, payload: &[u8]) -> Result> { - if payload.len() > u16::MAX as usize { - bail!("udp-over-tcp payload too large"); - } - let mut frame = uot_addr(destination); - frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - frame.extend_from_slice(payload); - Ok(frame) -} - -async fn write_uot_frame(writer: &mut W, destination: SocketAddr, payload: &[u8]) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - writer.write_all(&uot_frame(destination, payload)?).await?; - writer.flush().await?; - Ok(()) -} - -fn uot_addr(addr: SocketAddr) -> Vec { - let mut encoded = Vec::with_capacity(1 + 16 + 2); - match addr.ip() { - IpAddr::V4(ip) => { - encoded.push(0x00); - encoded.extend_from_slice(&ip.octets()); - } - IpAddr::V6(ip) => { - encoded.push(0x01); - encoded.extend_from_slice(&ip.octets()); - } - } - encoded.extend_from_slice(&addr.port().to_be_bytes()); - encoded -} - -async fn read_uot_frame(reader: &mut R) -> Result<(SocketAddr, Vec)> -where - R: AsyncRead + Unpin, -{ - let source = read_uot_addr_async(reader).await?; - let payload_len = reader.read_u16().await? as usize; - let mut payload = vec![0u8; payload_len]; - reader.read_exact(&mut payload).await?; - Ok((source, payload)) -} - -async fn read_uot_addr_async(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, -{ - let atyp = reader.read_u8().await?; - match atyp { - 0x00 => { - let mut ip = [0u8; 4]; - reader.read_exact(&mut ip).await?; - let port = reader.read_u16().await?; - Ok(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port)) - } - 0x01 => { - let mut ip = [0u8; 16]; - reader.read_exact(&mut ip).await?; - let port = reader.read_u16().await?; - Ok(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port)) - } - 0x02 => { - let len = reader.read_u8().await? as usize; - let mut domain = vec![0u8; len]; - reader.read_exact(&mut domain).await?; - let port = reader.read_u16().await?; - let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; - resolve_server(&domain, port) - } - other => bail!("unsupported udp-over-tcp address type {other}"), - } -} - -async fn read_socks_addr_async(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, -{ - let atyp = reader.read_u8().await?; - match atyp { - 1 => { - let mut ip = [0u8; 4]; - reader.read_exact(&mut ip).await?; - let port = reader.read_u16().await?; - Ok(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port)) - } - 3 => { - let len = reader.read_u8().await? as usize; - let mut domain = vec![0u8; len]; - reader.read_exact(&mut domain).await?; - let port = reader.read_u16().await?; - let domain = String::from_utf8(domain).context("invalid SOCKS domain")?; - resolve_server(&domain, port) - } - 4 => { - let mut ip = [0u8; 16]; - reader.read_exact(&mut ip).await?; - let port = reader.read_u16().await?; - Ok(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port)) - } - other => bail!("unsupported SOCKS address type {other}"), - } -} - -fn parse_socks_addr(buf: &[u8]) -> Result<(SocketAddr, usize)> { - if buf.is_empty() { - bail!("missing SOCKS address"); - } - match buf[0] { - 1 => { - if buf.len() < 7 { - bail!("truncated IPv4 SOCKS address"); - } - let ip = Ipv4Addr::new(buf[1], buf[2], buf[3], buf[4]); - let port = u16::from_be_bytes([buf[5], buf[6]]); - Ok((SocketAddr::new(IpAddr::V4(ip), port), 7)) - } - 3 => { - if buf.len() < 2 { - bail!("truncated domain SOCKS address"); - } - let len = buf[1] as usize; - if buf.len() < 2 + len + 2 { - bail!("truncated domain SOCKS address"); - } - let domain = std::str::from_utf8(&buf[2..2 + len]).context("invalid SOCKS domain")?; - let port = u16::from_be_bytes([buf[2 + len], buf[3 + len]]); - Ok((resolve_server(domain, port)?, 2 + len + 2)) - } - 4 => { - if buf.len() < 19 { - bail!("truncated IPv6 SOCKS address"); - } - let mut ip = [0u8; 16]; - ip.copy_from_slice(&buf[1..17]); - let port = u16::from_be_bytes([buf[17], buf[18]]); - Ok((SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port), 19)) - } - other => bail!("unsupported SOCKS address type {other}"), - } -} - -fn resolve_server(host: &str, port: u16) -> Result { - (host, port) - .to_socket_addrs() - .with_context(|| format!("failed to resolve {host}:{port}"))? - .next() - .ok_or_else(|| anyhow!("no addresses resolved for {host}:{port}")) -} - -include!("proxy_runtime/networking.rs"); - -fn hex_lower(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); - } - out -} - -#[cfg(test)] -include!("proxy_runtime/tests.rs"); diff --git a/burrow/src/proxy_runtime/networking.rs b/burrow/src/proxy_runtime/networking.rs deleted file mode 100644 index f186543..0000000 --- a/burrow/src/proxy_runtime/networking.rs +++ /dev/null @@ -1,961 +0,0 @@ -impl ProxyServerEndpoint { - fn resolve(host: &str, port: u16) -> Result { - let addresses = resolve_server_addresses(host, port)?; - let endpoint = Self { - host: host.to_owned(), - port, - addresses: Arc::from(addresses), - }; - if endpoint - .addresses - .iter() - .any(|address| is_fake_or_benchmark_ip(address.ip())) - { - tracing::warn!( - server = %endpoint.host, - port = endpoint.port, - resolved_addresses = ?endpoint.addresses, - "proxy server resolved to a fake-IP or benchmarking range; outbound dial may recurse or fail" - ); - } else { - tracing::debug!( - server = %endpoint.host, - port = endpoint.port, - resolved_addresses = ?endpoint.addresses, - "resolved proxy server addresses" - ); - } - Ok(endpoint) - } -} - -async fn connect_proxy_tcp(endpoint: &ProxyServerEndpoint) -> Result<(TcpStream, SocketAddr)> { - let mut last_error = None; - for address in endpoint.addresses.iter().copied() { - tracing::debug!( - server = %endpoint.host, - port = endpoint.port, - %address, - "dialing proxy tcp server" - ); - let socket = match address { - SocketAddr::V4(_) => TcpSocket::new_v4(), - SocketAddr::V6(_) => TcpSocket::new_v6(), - } - .with_context(|| format!("failed to create tcp socket for {address}"))?; - - if let Err(err) = bind_proxy_outbound_socket(socket.as_raw_fd(), address.ip()) { - tracing::warn!( - server = %endpoint.host, - port = endpoint.port, - %address, - error = ?err, - "failed to bind proxy tcp socket to physical interface" - ); - last_error = Some(err); - continue; - } - - match socket.connect(address).await { - Ok(stream) => { - tracing::debug!( - server = %endpoint.host, - port = endpoint.port, - %address, - "connected proxy tcp server" - ); - return Ok((stream, address)); - } - Err(err) => { - tracing::warn!( - server = %endpoint.host, - port = endpoint.port, - %address, - error = %err, - "failed to connect proxy tcp server" - ); - last_error = Some(anyhow!(err).context(format!("failed to connect {address}"))); - } - } - } - - Err(last_error.unwrap_or_else(|| { - anyhow!( - "no cached addresses available for {}:{}", - endpoint.host, - endpoint.port - ) - })) -} - -async fn connect_proxy_udp(endpoint: &ProxyServerEndpoint) -> Result<(UdpSocket, SocketAddr)> { - let mut last_error = None; - for address in endpoint.addresses.iter().copied() { - tracing::debug!( - server = %endpoint.host, - port = endpoint.port, - %address, - "dialing proxy udp server" - ); - let bind_addr = match address { - SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), - SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), - }; - let socket = match std::net::UdpSocket::bind(bind_addr) { - Ok(socket) => socket, - Err(err) => { - tracing::warn!( - server = %endpoint.host, - port = endpoint.port, - %address, - error = %err, - "failed to bind proxy udp socket" - ); - last_error = - Some(anyhow!(err).context(format!("failed to bind udp socket for {address}"))); - continue; - } - }; - - if let Err(err) = bind_proxy_outbound_socket(socket.as_raw_fd(), address.ip()) { - tracing::warn!( - server = %endpoint.host, - port = endpoint.port, - %address, - error = ?err, - "failed to bind proxy udp socket to physical interface" - ); - last_error = Some(err); - continue; - } - if let Err(err) = socket.set_nonblocking(true) { - tracing::warn!( - server = %endpoint.host, - port = endpoint.port, - %address, - error = %err, - "failed to make proxy udp socket nonblocking" - ); - last_error = Some(anyhow!(err).context("failed to make proxy udp socket nonblocking")); - continue; - } - - match UdpSocket::from_std(socket) { - Ok(socket) => { - tracing::debug!( - server = %endpoint.host, - port = endpoint.port, - %address, - "connected proxy udp server" - ); - return Ok((socket, address)); - } - Err(err) => { - tracing::warn!( - server = %endpoint.host, - port = endpoint.port, - %address, - error = %err, - "failed to wrap proxy udp socket" - ); - last_error = Some(anyhow!(err).context("failed to wrap proxy udp socket")); - } - } - } - - Err(last_error.unwrap_or_else(|| { - anyhow!( - "no cached addresses available for {}:{}", - endpoint.host, - endpoint.port - ) - })) -} - -fn resolve_server_addresses(host: &str, port: u16) -> Result> { - let addresses: Vec<_> = (host, port) - .to_socket_addrs() - .with_context(|| format!("failed to resolve {host}:{port}"))? - .collect(); - if addresses.is_empty() { - bail!("no addresses resolved for {host}:{port}"); - } - if addresses - .iter() - .all(|address| is_fake_or_benchmark_ip(address.ip())) - { - tracing::warn!( - server = %host, - port, - resolved_addresses = ?addresses, - "system DNS returned only fake-IP proxy server addresses; trying direct DNS" - ); - let direct_addresses = resolve_server_addresses_direct(host, port)?; - tracing::info!( - server = %host, - port, - resolved_addresses = ?direct_addresses, - "direct DNS resolved proxy server addresses" - ); - return Ok(direct_addresses); - } - Ok(addresses) -} - -fn resolve_server_addresses_direct(host: &str, port: u16) -> Result> { - if let Ok(ip) = host.parse::() { - return Ok(vec![SocketAddr::new(ip, port)]); - } - - let mut dns_servers = Vec::new(); - for server in platform_proxy_dns_servers() - .into_iter() - .chain(PUBLIC_DNS_SERVERS.iter().map(|server| (*server).to_owned())) - { - if dns_servers.iter().any(|existing| existing == &server) { - continue; - } - let Ok(ip) = server.parse::() else { - continue; - }; - if is_usable_proxy_dns_ip(ip) { - dns_servers.push(server); - } - } - - let mut addresses = Vec::new(); - let mut last_error = None; - for dns_server in dns_servers { - let Ok(ip) = dns_server.parse::() else { - continue; - }; - let dns_addr = SocketAddr::new(ip, 53); - match direct_dns_lookup(host, dns_addr) { - Ok(resolved) => { - for ip in resolved { - if is_fake_or_benchmark_ip(ip) { - continue; - } - let address = SocketAddr::new(ip, port); - if !addresses.contains(&address) { - addresses.push(address); - } - } - if !addresses.is_empty() { - break; - } - } - Err(err) => { - tracing::debug!( - server = %host, - %dns_addr, - error = ?err, - "direct DNS lookup failed" - ); - last_error = Some(err); - } - } - } - - if addresses.is_empty() { - return Err(last_error.unwrap_or_else(|| { - anyhow!("direct DNS did not resolve any usable addresses for {host}:{port}") - })); - } - Ok(addresses) -} - -fn direct_dns_lookup(host: &str, dns_server: SocketAddr) -> Result> { - let mut addresses = Vec::new(); - let mut last_error = None; - for query_type in [1u16, 28u16] { - let mut query = build_dns_query(host, query_type)?; - let id = (OsRng.next_u32() & 0xffff) as u16; - query[0..2].copy_from_slice(&id.to_be_bytes()); - - let bind_addr = match dns_server { - SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), - SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), - }; - let socket = std::net::UdpSocket::bind(bind_addr) - .with_context(|| format!("failed to bind DNS socket for {dns_server}"))?; - bind_proxy_outbound_socket(socket.as_raw_fd(), dns_server.ip()).with_context(|| { - format!("failed to bind DNS socket to physical interface for {dns_server}") - })?; - socket - .set_read_timeout(Some(DIRECT_DNS_TIMEOUT)) - .context("failed to set DNS read timeout")?; - socket - .set_write_timeout(Some(DIRECT_DNS_TIMEOUT)) - .context("failed to set DNS write timeout")?; - if let Err(err) = socket.send_to(&query, dns_server) { - last_error = - Some(anyhow!(err).context(format!("failed to send DNS query to {dns_server}"))); - continue; - } - - let mut response = [0u8; 1500]; - match socket.recv_from(&mut response) { - Ok((len, _)) => match parse_dns_response(&response[..len], id) - .with_context(|| format!("failed to parse DNS response from {dns_server}")) - { - Ok(mut resolved) => addresses.append(&mut resolved), - Err(err) => last_error = Some(err), - }, - Err(err) => { - last_error = Some( - anyhow!(err) - .context(format!("failed to receive DNS response from {dns_server}")), - ); - } - } - } - if addresses.is_empty() { - return Err(last_error.unwrap_or_else(|| anyhow!("DNS lookup returned no answers"))); - } - Ok(addresses) -} - -fn direct_dns_https_ech_config_list(host: &str, dns_server: SocketAddr) -> Result>> { - let mut query = build_dns_query(host, DNS_TYPE_HTTPS)?; - let id = (OsRng.next_u32() & 0xffff) as u16; - query[0..2].copy_from_slice(&id.to_be_bytes()); - - let bind_addr = match dns_server { - SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), - SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), - }; - let socket = std::net::UdpSocket::bind(bind_addr) - .with_context(|| format!("failed to bind DNS socket for {dns_server}"))?; - bind_proxy_outbound_socket(socket.as_raw_fd(), dns_server.ip()).with_context(|| { - format!("failed to bind DNS socket to physical interface for {dns_server}") - })?; - socket - .set_read_timeout(Some(DIRECT_DNS_TIMEOUT)) - .context("failed to set DNS read timeout")?; - socket - .set_write_timeout(Some(DIRECT_DNS_TIMEOUT)) - .context("failed to set DNS write timeout")?; - socket - .send_to(&query, dns_server) - .with_context(|| format!("failed to send DNS HTTPS query to {dns_server}"))?; - - let mut response = [0u8; 1500]; - let (len, _) = socket - .recv_from(&mut response) - .with_context(|| format!("failed to receive DNS HTTPS response from {dns_server}"))?; - parse_dns_https_ech_config_list(&response[..len], id) - .with_context(|| format!("failed to parse DNS HTTPS response from {dns_server}")) -} - -pub(crate) fn build_dns_query(host: &str, query_type: u16) -> Result> { - let mut query = Vec::with_capacity(512); - query.extend_from_slice(&0u16.to_be_bytes()); - query.extend_from_slice(&0x0100u16.to_be_bytes()); - query.extend_from_slice(&1u16.to_be_bytes()); - query.extend_from_slice(&0u16.to_be_bytes()); - query.extend_from_slice(&0u16.to_be_bytes()); - query.extend_from_slice(&0u16.to_be_bytes()); - - for label in host.trim_end_matches('.').split('.') { - if label.is_empty() || label.len() > 63 { - bail!("invalid DNS label in {host}"); - } - query.push(label.len() as u8); - query.extend_from_slice(label.as_bytes()); - } - query.push(0); - query.extend_from_slice(&query_type.to_be_bytes()); - query.extend_from_slice(&1u16.to_be_bytes()); - Ok(query) -} - -pub(crate) fn parse_dns_response(response: &[u8], expected_id: u16) -> Result> { - if response.len() < 12 { - bail!("truncated DNS response"); - } - let id = u16::from_be_bytes([response[0], response[1]]); - if id != expected_id { - bail!("DNS response id mismatch"); - } - let flags = u16::from_be_bytes([response[2], response[3]]); - if flags & 0x8000 == 0 { - bail!("DNS response is not marked as a response"); - } - let rcode = flags & 0x000f; - if rcode != 0 { - bail!("DNS response error code {rcode}"); - } - - let question_count = u16::from_be_bytes([response[4], response[5]]) as usize; - let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize; - let mut offset = 12; - for _ in 0..question_count { - offset = skip_dns_name(response, offset)?; - if response.len() < offset + 4 { - bail!("truncated DNS question"); - } - offset += 4; - } - - let mut addresses = Vec::new(); - for _ in 0..answer_count { - offset = skip_dns_name(response, offset)?; - if response.len() < offset + 10 { - bail!("truncated DNS answer"); - } - let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]); - let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]); - let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize; - offset += 10; - if response.len() < offset + data_len { - bail!("truncated DNS answer data"); - } - if record_class == 1 && record_type == 1 && data_len == 4 { - addresses.push(IpAddr::V4(Ipv4Addr::new( - response[offset], - response[offset + 1], - response[offset + 2], - response[offset + 3], - ))); - } else if record_class == 1 && record_type == 28 && data_len == 16 { - let mut octets = [0u8; 16]; - octets.copy_from_slice(&response[offset..offset + 16]); - addresses.push(IpAddr::V6(Ipv6Addr::from(octets))); - } - offset += data_len; - } - Ok(addresses) -} - -fn parse_dns_https_ech_config_list(response: &[u8], expected_id: u16) -> Result>> { - if response.len() < 12 { - bail!("truncated DNS response"); - } - let id = u16::from_be_bytes([response[0], response[1]]); - if id != expected_id { - bail!("DNS response id mismatch"); - } - let flags = u16::from_be_bytes([response[2], response[3]]); - if flags & 0x8000 == 0 { - bail!("DNS response is not marked as a response"); - } - let rcode = flags & 0x000f; - if rcode != 0 { - bail!("DNS response error code {rcode}"); - } - - let question_count = u16::from_be_bytes([response[4], response[5]]) as usize; - let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize; - let mut offset = 12; - for _ in 0..question_count { - offset = skip_dns_name(response, offset)?; - if response.len() < offset + 4 { - bail!("truncated DNS question"); - } - offset += 4; - } - - for _ in 0..answer_count { - offset = skip_dns_name(response, offset)?; - if response.len() < offset + 10 { - bail!("truncated DNS answer"); - } - let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]); - let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]); - let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize; - offset += 10; - if response.len() < offset + data_len { - bail!("truncated DNS answer data"); - } - if record_class == 1 && record_type == DNS_TYPE_HTTPS { - if let Some(config_list) = parse_https_rr_ech_config_list(response, offset, data_len)? { - return Ok(Some(config_list)); - } - } - offset += data_len; - } - Ok(None) -} - -fn parse_https_rr_ech_config_list( - response: &[u8], - offset: usize, - data_len: usize, -) -> Result>> { - let end = offset - .checked_add(data_len) - .ok_or_else(|| anyhow!("DNS HTTPS record offset overflow"))?; - if response.len() < end || data_len < 3 { - bail!("truncated DNS HTTPS record"); - } - let (_target, mut cursor) = read_dns_name(response, offset + 2)?; - if cursor > end { - bail!("DNS HTTPS record target exceeded RDATA"); - } - - while cursor < end { - if response.len() < cursor + 4 || cursor + 4 > end { - bail!("truncated DNS HTTPS service parameter"); - } - let key = u16::from_be_bytes([response[cursor], response[cursor + 1]]); - let value_len = u16::from_be_bytes([response[cursor + 2], response[cursor + 3]]) as usize; - cursor += 4; - let value_end = cursor - .checked_add(value_len) - .ok_or_else(|| anyhow!("DNS HTTPS parameter offset overflow"))?; - if value_end > end { - bail!("truncated DNS HTTPS service parameter value"); - } - if key == HTTPS_SVC_PARAM_ECH { - return Ok(Some(response[cursor..value_end].to_vec())); - } - cursor = value_end; - } - Ok(None) -} - -async fn record_dns_mappings(cache: &DnsHostnameCache, response: &[u8]) { - let mappings = match dns_response_host_mappings(response) { - Ok(mappings) => mappings, - Err(_) => return, - }; - if mappings.is_empty() { - return; - } - - let mut guard = cache.write().await; - for (ip, hostname) in mappings { - tracing::debug!(%ip, %hostname, "cached proxy DNS hostname mapping"); - guard.insert_mapping(ip, hostname); - } -} - -fn dns_response_host_mappings(response: &[u8]) -> Result> { - if response.len() < 12 { - bail!("truncated DNS response"); - } - let flags = u16::from_be_bytes([response[2], response[3]]); - if flags & 0x8000 == 0 { - bail!("DNS packet is not a response"); - } - if flags & 0x000f != 0 { - bail!("DNS response returned an error"); - } - - let question_count = u16::from_be_bytes([response[4], response[5]]) as usize; - let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize; - let mut offset = 12; - let mut question_names = Vec::new(); - for _ in 0..question_count { - let (name, next_offset) = read_dns_name(response, offset)?; - offset = next_offset; - if response.len() < offset + 4 { - bail!("truncated DNS question"); - } - if !name.is_empty() { - question_names.push(name); - } - offset += 4; - } - - let mut mappings = Vec::new(); - for _ in 0..answer_count { - let (answer_name, next_offset) = read_dns_name(response, offset)?; - offset = next_offset; - if response.len() < offset + 10 { - bail!("truncated DNS answer"); - } - let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]); - let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]); - let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize; - offset += 10; - if response.len() < offset + data_len { - bail!("truncated DNS answer data"); - } - - let hostname = question_names - .first() - .cloned() - .filter(|name| !name.is_empty()) - .unwrap_or(answer_name); - if record_class == 1 && record_type == 1 && data_len == 4 { - mappings.push(( - IpAddr::V4(Ipv4Addr::new( - response[offset], - response[offset + 1], - response[offset + 2], - response[offset + 3], - )), - hostname, - )); - } else if record_class == 1 && record_type == 28 && data_len == 16 { - let mut octets = [0u8; 16]; - octets.copy_from_slice(&response[offset..offset + 16]); - mappings.push((IpAddr::V6(Ipv6Addr::from(octets)), hostname)); - } - offset += data_len; - } - Ok(mappings) -} - -fn read_dns_name(response: &[u8], offset: usize) -> Result<(String, usize)> { - let mut labels = Vec::new(); - let mut cursor = offset; - let mut next_offset = None; - let mut jumps = 0usize; - - loop { - if cursor >= response.len() { - bail!("truncated DNS name"); - } - let len = response[cursor]; - if len & 0xc0 == 0xc0 { - if response.len() < cursor + 2 { - bail!("truncated compressed DNS name"); - } - if next_offset.is_none() { - next_offset = Some(cursor + 2); - } - let pointer = (((len & 0x3f) as usize) << 8) | response[cursor + 1] as usize; - cursor = pointer; - jumps += 1; - if jumps > 16 { - bail!("too many compressed DNS name jumps"); - } - continue; - } - if len & 0xc0 != 0 { - bail!("unsupported DNS label encoding"); - } - cursor += 1; - if len == 0 { - return Ok((labels.join("."), next_offset.unwrap_or(cursor))); - } - let end = cursor - .checked_add(len as usize) - .ok_or_else(|| anyhow!("DNS name offset overflow"))?; - if end > response.len() { - bail!("truncated DNS label"); - } - let label = - std::str::from_utf8(&response[cursor..end]).context("DNS label is not valid UTF-8")?; - labels.push(label.to_owned()); - cursor = end; - } -} - -fn skip_dns_name(response: &[u8], mut offset: usize) -> Result { - loop { - if offset >= response.len() { - bail!("truncated DNS name"); - } - let len = response[offset]; - if len & 0xc0 == 0xc0 { - if response.len() < offset + 2 { - bail!("truncated compressed DNS name"); - } - return Ok(offset + 2); - } - if len & 0xc0 != 0 { - bail!("unsupported DNS label encoding"); - } - offset += 1; - if len == 0 { - return Ok(offset); - } - offset = offset - .checked_add(len as usize) - .ok_or_else(|| anyhow!("DNS name offset overflow"))?; - } -} - -fn is_fake_or_benchmark_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(ip) => { - let octets = ip.octets(); - octets[0] == 198 && matches!(octets[1], 18 | 19) - } - IpAddr::V6(_) => false, - } -} - -#[cfg(target_vendor = "apple")] -fn platform_proxy_dns_servers() -> Vec { - match std::fs::read_to_string("/etc/resolv.conf") { - Ok(contents) => { - let servers = parse_resolv_conf_dns_servers(&contents); - if !servers.is_empty() { - return servers; - } - } - Err(err) => { - tracing::warn!(error = %err, "failed to read /etc/resolv.conf"); - } - } - - match Command::new("scutil").arg("--dns").output() { - Ok(output) if output.status.success() => { - parse_apple_physical_dns_servers(&String::from_utf8_lossy(&output.stdout)) - } - Ok(output) => { - tracing::warn!( - status = ?output.status.code(), - stderr = %String::from_utf8_lossy(&output.stderr), - "failed to read macOS DNS configuration" - ); - Vec::new() - } - Err(err) => { - tracing::warn!(error = %err, "failed to run scutil --dns"); - Vec::new() - } - } -} - -#[cfg(not(target_vendor = "apple"))] -fn platform_proxy_dns_servers() -> Vec { - Vec::new() -} - -fn parse_apple_physical_dns_servers(output: &str) -> Vec { - let mut servers = Vec::new(); - let mut resolver_servers = Vec::::new(); - let mut resolver_interface = None::; - - let mut flush_resolver = - |resolver_servers: &mut Vec, resolver_interface: &mut Option| { - let is_tunnel_resolver = resolver_interface - .as_deref() - .is_some_and(should_skip_dns_resolver_interface); - if !is_tunnel_resolver { - for server in resolver_servers.drain(..) { - if !servers.contains(&server) { - servers.push(server); - } - } - } else { - resolver_servers.clear(); - } - *resolver_interface = None; - }; - - for line in output.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("resolver #") { - flush_resolver(&mut resolver_servers, &mut resolver_interface); - continue; - } - if trimmed.starts_with("nameserver[") { - if let Some((_, value)) = trimmed.split_once(':') { - let server = value.trim(); - if let Ok(ip) = server.parse::() { - if is_usable_proxy_dns_ip(ip) { - resolver_servers.push(server.to_owned()); - } - } - } - continue; - } - if trimmed.starts_with("if_index") { - if let (Some(start), Some(end)) = (trimmed.find('('), trimmed.find(')')) { - resolver_interface = Some(trimmed[start + 1..end].to_owned()); - } - } - } - flush_resolver(&mut resolver_servers, &mut resolver_interface); - - servers -} - -fn parse_resolv_conf_dns_servers(contents: &str) -> Vec { - let mut servers = Vec::new(); - for line in contents.lines() { - let line = line.split('#').next().unwrap_or("").trim(); - let mut fields = line.split_whitespace(); - if fields.next() != Some("nameserver") { - continue; - } - let Some(server) = fields.next() else { - continue; - }; - let Ok(ip) = server.parse::() else { - continue; - }; - if is_usable_proxy_dns_ip(ip) && !servers.iter().any(|existing| existing == server) { - servers.push(server.to_owned()); - } - } - servers -} - -fn should_skip_dns_resolver_interface(interface: &str) -> bool { - interface.starts_with("utun") - || interface.starts_with("lo") - || interface.starts_with("awdl") - || interface.starts_with("llw") -} - -fn is_usable_proxy_dns_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(ip) => { - !ip.is_unspecified() - && !ip.is_loopback() - && !ip.is_multicast() - && !is_fake_or_benchmark_ip(IpAddr::V4(ip)) - } - IpAddr::V6(ip) => !ip.is_unspecified() && !ip.is_loopback() && !ip.is_multicast(), - } -} - -#[cfg(target_vendor = "apple")] -fn bind_proxy_outbound_socket(fd: std::os::fd::RawFd, ip: IpAddr) -> Result<()> { - if !should_bind_proxy_outbound_socket(ip) { - return Ok(()); - } - let interface_index = default_physical_interface_index().ok_or_else(|| { - anyhow!("no non-tunnel default interface found for proxy outbound socket") - })?; - let interface_index = interface_index as libc::c_uint; - let (level, option) = match ip { - IpAddr::V4(_) => (libc::IPPROTO_IP, libc::IP_BOUND_IF), - IpAddr::V6(_) => (libc::IPPROTO_IPV6, libc::IPV6_BOUND_IF), - }; - let result = unsafe { - libc::setsockopt( - fd, - level, - option, - (&interface_index as *const libc::c_uint).cast::(), - std::mem::size_of_val(&interface_index) as libc::socklen_t, - ) - }; - if result != 0 { - return Err(std::io::Error::last_os_error()).with_context(|| { - format!("failed to bind proxy outbound socket to interface index {interface_index}") - }); - } - Ok(()) -} - -#[cfg(not(target_vendor = "apple"))] -fn bind_proxy_outbound_socket(_fd: std::os::fd::RawFd, _ip: IpAddr) -> Result<()> { - Ok(()) -} - -fn should_bind_proxy_outbound_socket(ip: IpAddr) -> bool { - !ip.is_loopback() -} - -#[cfg(target_vendor = "apple")] -fn default_physical_interface_index() -> Option { - #[derive(Clone)] - struct Candidate { - name: String, - index: u32, - score: i32, - } - - let mut addrs = std::ptr::null_mut(); - if unsafe { libc::getifaddrs(&mut addrs) } != 0 || addrs.is_null() { - return None; - } - - struct IfAddrs(*mut libc::ifaddrs); - impl Drop for IfAddrs { - fn drop(&mut self) { - unsafe { libc::freeifaddrs(self.0) }; - } - } - let _guard = IfAddrs(addrs); - - let mut candidates = Vec::::new(); - let mut current = addrs; - while !current.is_null() { - let ifa = unsafe { &*current }; - current = ifa.ifa_next; - if ifa.ifa_addr.is_null() || ifa.ifa_name.is_null() { - continue; - } - let flags = ifa.ifa_flags as libc::c_uint; - if flags & libc::IFF_UP as libc::c_uint == 0 - || flags & libc::IFF_RUNNING as libc::c_uint == 0 - || flags & libc::IFF_LOOPBACK as libc::c_uint != 0 - || flags & libc::IFF_POINTOPOINT as libc::c_uint != 0 - { - continue; - } - - let family = unsafe { (*ifa.ifa_addr).sa_family as libc::c_int }; - if family != libc::AF_INET && family != libc::AF_INET6 { - continue; - } - - let name = unsafe { CStr::from_ptr(ifa.ifa_name) } - .to_string_lossy() - .into_owned(); - if should_skip_outbound_interface(&name) { - continue; - } - let index = unsafe { libc::if_nametoindex(ifa.ifa_name) }; - if index == 0 { - continue; - } - - let mut score = if name.starts_with("en") { 100 } else { 10 }; - if family == libc::AF_INET { - score += 20; - } - if name == "en0" { - score += 10; - } - - candidates.push(Candidate { name, index, score }); - } - - candidates.sort_by_key(|candidate| (Reverse(candidate.score), candidate.name.clone())); - candidates.first().map(|candidate| { - tracing::debug!( - interface = %candidate.name, - index = candidate.index, - score = candidate.score, - "selected proxy outbound physical interface" - ); - candidate.index - }) -} - -#[cfg(target_vendor = "apple")] -fn should_skip_outbound_interface(name: &str) -> bool { - name.starts_with("lo") - || name.starts_with("utun") - || name.starts_with("awdl") - || name.starts_with("llw") - || name.starts_with("bridge") - || name.starts_with("gif") - || name.starts_with("stf") - || name.starts_with("p2p") -} - -fn excluded_routes_for_server(host: &str, port: u16) -> Result> { - let mut routes = Vec::new(); - for address in resolve_server_addresses(host, port) - .with_context(|| format!("failed to resolve proxy server {host}:{port}"))? - { - let route = host_route_for_ip(address.ip()); - if !routes.contains(&route) { - routes.push(route); - } - } - if routes.is_empty() { - bail!("no addresses resolved for proxy server {host}:{port}"); - } - Ok(routes) -} - -fn host_route_for_ip(ip: IpAddr) -> String { - match ip { - IpAddr::V4(ip) => format!("{ip}/32"), - IpAddr::V6(ip) => format!("{ip}/128"), - } -} diff --git a/burrow/src/proxy_runtime/shadowsocks_crypto.rs b/burrow/src/proxy_runtime/shadowsocks_crypto.rs deleted file mode 100644 index 773a37d..0000000 --- a/burrow/src/proxy_runtime/shadowsocks_crypto.rs +++ /dev/null @@ -1,2004 +0,0 @@ -struct BurrowDatagramSocket(UdpSocket); - -impl DatagramSocket for BurrowDatagramSocket { - fn local_addr(&self) -> std::io::Result { - self.0.local_addr() - } -} - -impl DatagramReceive for BurrowDatagramSocket { - fn poll_recv( - &self, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - self.0.poll_recv(cx, buf) - } - - fn poll_recv_from( - &self, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - self.0.poll_recv_from(cx, buf) - } - - fn poll_recv_ready(&self, cx: &mut TaskContext<'_>) -> Poll> { - self.0.poll_recv_ready(cx) - } -} - -impl DatagramSend for BurrowDatagramSocket { - fn poll_send(&self, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll> { - self.0.poll_send(cx, buf) - } - - fn poll_send_to( - &self, - cx: &mut TaskContext<'_>, - buf: &[u8], - target: SocketAddr, - ) -> Poll> { - self.0.poll_send_to(cx, buf, target) - } - - fn poll_send_ready(&self, cx: &mut TaskContext<'_>) -> Poll> { - self.0.poll_send_ready(cx) - } -} - -struct CustomSsStreamWriter { - writer: W, - cipher: CustomSsStreamCipher, -} - -impl CustomSsStreamWriter -where - W: AsyncWrite + Unpin, -{ - async fn new(mut writer: W, kind: CustomSsStreamKind, key: Arc<[u8]>) -> Result { - let mut salt = vec![0u8; kind.salt_len()]; - OsRng.fill_bytes(&mut salt); - let cipher = CustomSsStreamCipher::new(kind, &key, &salt)?; - writer - .write_all(&salt) - .await - .context("failed to write Shadowsocks request salt")?; - Ok(Self { writer, cipher }) - } - - async fn write_all_encrypted(&mut self, payload: &[u8]) -> Result<()> { - if payload.is_empty() { - return Ok(()); - } - let mut encrypted = payload.to_vec(); - self.cipher.apply_keystream(&mut encrypted); - self.writer - .write_all(&encrypted) - .await - .context("failed to write Shadowsocks encrypted stream payload")?; - self.writer.flush().await?; - Ok(()) - } - - async fn shutdown(&mut self) -> Result<()> { - self.writer.shutdown().await?; - Ok(()) - } -} - -struct CustomSsStreamReader { - reader: R, - kind: CustomSsStreamKind, - key: Arc<[u8]>, - cipher: Option, -} - -impl CustomSsStreamReader -where - R: AsyncRead + Unpin, -{ - fn new(reader: R, kind: CustomSsStreamKind, key: Arc<[u8]>) -> Self { - Self { - reader, - kind, - key, - cipher: None, - } - } - - async fn read_decrypted(&mut self, output: &mut [u8]) -> Result { - if self.cipher.is_none() { - let mut salt = vec![0u8; self.kind.salt_len()]; - if let Err(err) = self.reader.read_exact(&mut salt).await { - if err.kind() == std::io::ErrorKind::UnexpectedEof { - return Ok(0); - } - return Err(err).context("failed to read Shadowsocks response salt"); - } - self.cipher = Some(CustomSsStreamCipher::new(self.kind, &self.key, &salt)?); - } - let n = self - .reader - .read(output) - .await - .context("failed to read Shadowsocks encrypted stream payload")?; - if n == 0 { - return Ok(0); - } - self.cipher - .as_mut() - .expect("cipher initialized") - .apply_keystream(&mut output[..n]); - Ok(n) - } -} - -struct CustomSsStreamByteReader { - reader: CustomSsStreamReader, - buffer: Vec, - offset: usize, -} - -impl CustomSsStreamByteReader -where - R: AsyncRead + Unpin, -{ - fn new(reader: CustomSsStreamReader) -> Self { - Self { - reader, - buffer: Vec::new(), - offset: 0, - } - } - - async fn read_frame(&mut self) -> Result)>> { - let source = match self.read_uot_addr().await? { - Some(source) => source, - None => return Ok(None), - }; - let Some(payload_len) = self.read_u16().await? else { - bail!("truncated udp-over-tcp payload length"); - }; - let payload = self.read_exact_vec(payload_len as usize).await?; - Ok(Some((source, payload))) - } - - async fn read_uot_addr(&mut self) -> Result> { - let Some(atyp) = self.read_u8().await? else { - return Ok(None); - }; - match atyp { - 0x00 => { - let ip = self.read_exact_array::<4>().await?; - let port = self.read_u16_required().await?; - Ok(Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port))) - } - 0x01 => { - let ip = self.read_exact_array::<16>().await?; - let port = self.read_u16_required().await?; - Ok(Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port))) - } - 0x02 => { - let len = self.read_u8_required().await? as usize; - let domain = self.read_exact_vec(len).await?; - let port = self.read_u16_required().await?; - let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; - Ok(Some(resolve_server(&domain, port)?)) - } - other => bail!("unsupported udp-over-tcp address type {other}"), - } - } - - async fn read_u8_required(&mut self) -> Result { - self.read_u8() - .await? - .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) - } - - async fn read_u16_required(&mut self) -> Result { - self.read_u16() - .await? - .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) - } - - async fn read_u8(&mut self) -> Result> { - if !self.ensure_available(1).await? { - return Ok(None); - } - let value = self.buffer[self.offset]; - self.offset += 1; - self.compact_buffer(); - Ok(Some(value)) - } - - async fn read_u16(&mut self) -> Result> { - if !self.ensure_available(2).await? { - return Ok(None); - } - let value = u16::from_be_bytes([self.buffer[self.offset], self.buffer[self.offset + 1]]); - self.offset += 2; - self.compact_buffer(); - Ok(Some(value)) - } - - async fn read_exact_array(&mut self) -> Result<[u8; N]> { - let bytes = self.read_exact_vec(N).await?; - Ok(bytes - .try_into() - .expect("read_exact_vec returned requested length")) - } - - async fn read_exact_vec(&mut self, len: usize) -> Result> { - if !self.ensure_available(len).await? { - bail!("truncated udp-over-tcp frame"); - } - let value = self.buffer[self.offset..self.offset + len].to_vec(); - self.offset += len; - self.compact_buffer(); - Ok(value) - } - - async fn ensure_available(&mut self, len: usize) -> Result { - while self.buffer.len().saturating_sub(self.offset) < len { - if self.offset > 0 { - self.buffer.drain(..self.offset); - self.offset = 0; - } - let mut chunk = vec![0u8; 16 * 1024]; - let read = self.reader.read_decrypted(&mut chunk).await?; - if read == 0 { - return Ok(false); - } - self.buffer.extend_from_slice(&chunk[..read]); - } - Ok(true) - } - - fn compact_buffer(&mut self) { - if self.offset == self.buffer.len() { - self.buffer.clear(); - self.offset = 0; - } else if self.offset > 4096 && self.offset * 2 > self.buffer.len() { - self.buffer.drain(..self.offset); - self.offset = 0; - } - } -} - -enum CustomSsStreamCipher { - Chacha20(ChaCha20Legacy), - XChacha20(XChaCha20), -} - -impl CustomSsStreamCipher { - fn new(kind: CustomSsStreamKind, key: &[u8], salt: &[u8]) -> Result { - match kind { - CustomSsStreamKind::Chacha20 => Ok(Self::Chacha20( - ChaCha20Legacy::new_from_slices(key, salt) - .map_err(|err| anyhow!("invalid chacha20 stream cipher material: {err}"))?, - )), - CustomSsStreamKind::XChacha20 => Ok(Self::XChacha20( - XChaCha20::new_from_slices(key, salt) - .map_err(|err| anyhow!("invalid xchacha20 stream cipher material: {err}"))?, - )), - } - } - - fn apply_keystream(&mut self, buf: &mut [u8]) { - match self { - Self::Chacha20(cipher) => cipher.apply_keystream(buf), - Self::XChacha20(cipher) => cipher.apply_keystream(buf), - } - } -} - -struct CustomSsAeadWriter { - writer: W, - cipher: CustomSsAeadCipher, - nonce: Vec, -} - -impl CustomSsAeadWriter -where - W: AsyncWrite + Unpin, -{ - async fn new(mut writer: W, kind: CustomSsAeadKind, master_key: Arc<[u8]>) -> Result { - let mut salt = vec![0u8; kind.salt_len()]; - OsRng.fill_bytes(&mut salt); - let cipher = custom_ss_subkey(kind, &master_key, &salt)?; - writer - .write_all(&salt) - .await - .context("failed to write Shadowsocks request salt")?; - Ok(Self { - writer, - cipher, - nonce: vec![0u8; kind.nonce_len()], - }) - } - - async fn write_chunk(&mut self, payload: &[u8]) -> Result<()> { - if payload.is_empty() { - return Ok(()); - } - let mut offset = 0; - while offset < payload.len() { - let end = (offset + 0x3fff).min(payload.len()); - let chunk = &payload[offset..end]; - - let mut encrypted_len = (chunk.len() as u16).to_be_bytes().to_vec(); - self.cipher - .seal_in_place(&mut self.nonce, &mut encrypted_len)?; - self.writer.write_all(&encrypted_len).await?; - - let mut encrypted_payload = chunk.to_vec(); - self.cipher - .seal_in_place(&mut self.nonce, &mut encrypted_payload)?; - self.writer.write_all(&encrypted_payload).await?; - offset = end; - } - self.writer.flush().await?; - Ok(()) - } - - async fn shutdown(&mut self) -> Result<()> { - self.writer.shutdown().await?; - Ok(()) - } -} - -struct CustomSsAeadReader { - reader: R, - kind: CustomSsAeadKind, - master_key: Arc<[u8]>, - cipher: Option, - nonce: Vec, -} - -impl CustomSsAeadReader -where - R: AsyncRead + Unpin, -{ - fn new(reader: R, kind: CustomSsAeadKind, master_key: Arc<[u8]>) -> Self { - Self { - reader, - kind, - master_key, - cipher: None, - nonce: vec![0u8; kind.nonce_len()], - } - } - - async fn read_chunk(&mut self, output: &mut Vec) -> Result { - if self.cipher.is_none() { - let mut salt = vec![0u8; self.kind.salt_len()]; - self.reader - .read_exact(&mut salt) - .await - .context("failed to read Shadowsocks response salt")?; - self.cipher = Some(custom_ss_subkey(self.kind, &self.master_key, &salt)?); - } - let cipher = self.cipher.as_ref().expect("cipher initialized"); - let tag_len = self.kind.tag_len(); - let mut encrypted_len = vec![0u8; 2 + tag_len]; - if let Err(err) = self.reader.read_exact(&mut encrypted_len).await { - if err.kind() == std::io::ErrorKind::UnexpectedEof { - return Ok(0); - } - return Err(err).context("failed to read Shadowsocks encrypted chunk length"); - } - let plaintext_len = cipher.open_in_place(&mut self.nonce, &mut encrypted_len)?; - let payload_len = u16::from_be_bytes([plaintext_len[0], plaintext_len[1]]) as usize; - if payload_len == 0 { - return Ok(0); - } - let mut encrypted_payload = vec![0u8; payload_len + tag_len]; - self.reader - .read_exact(&mut encrypted_payload) - .await - .context("failed to read Shadowsocks encrypted chunk")?; - let plaintext = cipher.open_in_place(&mut self.nonce, &mut encrypted_payload)?; - output.extend_from_slice(plaintext); - Ok(plaintext.len()) - } -} - -struct CustomSsAeadByteReader { - reader: CustomSsAeadReader, - buffer: Vec, - offset: usize, -} - -impl CustomSsAeadByteReader -where - R: AsyncRead + Unpin, -{ - fn new(reader: CustomSsAeadReader) -> Self { - Self { - reader, - buffer: Vec::new(), - offset: 0, - } - } - - async fn read_frame(&mut self) -> Result)>> { - let source = match self.read_uot_addr().await? { - Some(source) => source, - None => return Ok(None), - }; - let Some(payload_len) = self.read_u16().await? else { - bail!("truncated udp-over-tcp payload length"); - }; - let payload = self.read_exact_vec(payload_len as usize).await?; - Ok(Some((source, payload))) - } - - async fn read_uot_addr(&mut self) -> Result> { - let Some(atyp) = self.read_u8().await? else { - return Ok(None); - }; - match atyp { - 0x00 => { - let ip = self.read_exact_array::<4>().await?; - let port = self.read_u16_required().await?; - Ok(Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port))) - } - 0x01 => { - let ip = self.read_exact_array::<16>().await?; - let port = self.read_u16_required().await?; - Ok(Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port))) - } - 0x02 => { - let len = self.read_u8_required().await? as usize; - let domain = self.read_exact_vec(len).await?; - let port = self.read_u16_required().await?; - let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; - Ok(Some(resolve_server(&domain, port)?)) - } - other => bail!("unsupported udp-over-tcp address type {other}"), - } - } - - async fn read_u8_required(&mut self) -> Result { - self.read_u8() - .await? - .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) - } - - async fn read_u16_required(&mut self) -> Result { - self.read_u16() - .await? - .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) - } - - async fn read_u8(&mut self) -> Result> { - if !self.ensure_available(1).await? { - return Ok(None); - } - let value = self.buffer[self.offset]; - self.offset += 1; - self.compact_buffer(); - Ok(Some(value)) - } - - async fn read_u16(&mut self) -> Result> { - if !self.ensure_available(2).await? { - return Ok(None); - } - let value = u16::from_be_bytes([self.buffer[self.offset], self.buffer[self.offset + 1]]); - self.offset += 2; - self.compact_buffer(); - Ok(Some(value)) - } - - async fn read_exact_array(&mut self) -> Result<[u8; N]> { - let bytes = self.read_exact_vec(N).await?; - Ok(bytes - .try_into() - .expect("read_exact_vec returned requested length")) - } - - async fn read_exact_vec(&mut self, len: usize) -> Result> { - if !self.ensure_available(len).await? { - bail!("truncated udp-over-tcp frame"); - } - let value = self.buffer[self.offset..self.offset + len].to_vec(); - self.offset += len; - self.compact_buffer(); - Ok(value) - } - - async fn ensure_available(&mut self, len: usize) -> Result { - while self.buffer.len().saturating_sub(self.offset) < len { - if self.offset > 0 { - self.buffer.drain(..self.offset); - self.offset = 0; - } - let mut chunk = Vec::new(); - let read = self.reader.read_chunk(&mut chunk).await?; - if read == 0 { - return Ok(false); - } - self.buffer.extend_from_slice(&chunk); - } - Ok(true) - } - - fn compact_buffer(&mut self) { - if self.offset == self.buffer.len() { - self.buffer.clear(); - self.offset = 0; - } else if self.offset > 4096 && self.offset * 2 > self.buffer.len() { - self.buffer.drain(..self.offset); - self.offset = 0; - } - } -} - -struct Aead2022AesCcmWriter { - writer: W, - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, - cipher: Option, -} - -impl Aead2022AesCcmWriter -where - W: AsyncWrite + Unpin, -{ - async fn new( - writer: W, - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, - ) -> Result { - Ok(Self { - writer, - kind, - user_key, - identity_keys, - cipher: None, - }) - } - - async fn write_request( - &mut self, - socks_target: &[u8], - padding_len: usize, - ) -> Result> { - if padding_len > u16::MAX as usize { - bail!("Shadowsocks 2022 TCP request padding is too large"); - } - let mut salt = vec![0u8; self.kind.key_len()]; - OsRng.fill_bytes(&mut salt); - - let mut header = salt.clone(); - header.extend_from_slice(&aead2022_eih_blocks( - self.kind, - &self.identity_keys, - &self.user_key, - &salt, - )?); - - let mut cipher = Aead2022TcpCipher::new(self.kind, &self.user_key, &salt)?; - let variable_len = socks_target - .len() - .checked_add(2) - .and_then(|len| len.checked_add(padding_len)) - .ok_or_else(|| anyhow!("Shadowsocks 2022 TCP request header is too large"))?; - if variable_len > u16::MAX as usize { - bail!("Shadowsocks 2022 TCP request header is too large"); - } - - let mut fixed = Vec::with_capacity(1 + 8 + 2); - fixed.push(AEAD2022_HEADER_TYPE_CLIENT); - fixed.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); - fixed.extend_from_slice(&(variable_len as u16).to_be_bytes()); - header.extend_from_slice(&cipher.seal_packet(&fixed)?); - - let mut variable = Vec::with_capacity(variable_len); - variable.extend_from_slice(socks_target); - variable.extend_from_slice(&(padding_len as u16).to_be_bytes()); - variable.resize(variable.len() + padding_len, 0); - header.extend_from_slice(&cipher.seal_packet(&variable)?); - - self.writer - .write_all(&header) - .await - .context("failed to write Shadowsocks 2022 request header")?; - self.writer - .flush() - .await - .context("failed to flush Shadowsocks 2022 request header")?; - self.cipher = Some(cipher); - Ok(Arc::from(salt)) - } - - async fn write_chunk(&mut self, payload: &[u8]) -> Result<()> { - if payload.is_empty() { - return Ok(()); - } - let cipher = self - .cipher - .as_mut() - .ok_or_else(|| anyhow!("Shadowsocks 2022 TCP request was not initialized"))?; - let mut offset = 0; - while offset < payload.len() { - let end = (offset + u16::MAX as usize).min(payload.len()); - let chunk = &payload[offset..end]; - - let encrypted_len = cipher.seal_packet(&(chunk.len() as u16).to_be_bytes())?; - self.writer.write_all(&encrypted_len).await?; - - let encrypted_payload = cipher.seal_packet(chunk)?; - self.writer.write_all(&encrypted_payload).await?; - offset = end; - } - self.writer.flush().await?; - Ok(()) - } - - async fn shutdown(&mut self) -> Result<()> { - self.writer.shutdown().await?; - Ok(()) - } -} - -struct Aead2022AesCcmReader { - reader: R, - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - request_salt: Arc<[u8]>, - cipher: Option, - response_read: bool, -} - -impl Aead2022AesCcmReader -where - R: AsyncRead + Unpin, -{ - fn new( - reader: R, - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - request_salt: Arc<[u8]>, - ) -> Self { - Self { - reader, - kind, - user_key, - request_salt, - cipher: None, - response_read: false, - } - } - - async fn read_response(&mut self) -> Result<()> { - if self.response_read { - return Ok(()); - } - let mut salt = vec![0u8; self.kind.key_len()]; - self.reader - .read_exact(&mut salt) - .await - .context("failed to read Shadowsocks 2022 response salt")?; - let mut cipher = Aead2022TcpCipher::new(self.kind, &self.user_key, &salt)?; - - let fixed_len = 1 + 8 + self.kind.key_len() + 2; - let mut encrypted_fixed = vec![0u8; fixed_len + self.kind.tag_len()]; - self.reader - .read_exact(&mut encrypted_fixed) - .await - .context("failed to read Shadowsocks 2022 response fixed header")?; - let fixed = cipher.open_packet(&mut encrypted_fixed)?; - if fixed[0] != AEAD2022_HEADER_TYPE_SERVER { - bail!( - "unexpected Shadowsocks 2022 response header type {}", - fixed[0] - ); - } - aead2022_validate_timestamp(u64::from_be_bytes( - fixed[1..9].try_into().expect("fixed timestamp"), - ))?; - let response_request_salt = &fixed[9..9 + self.kind.key_len()]; - if !bool::from(response_request_salt.ct_eq(self.request_salt.as_ref())) { - bail!("Shadowsocks 2022 response request salt mismatch"); - } - let padding_len_offset = 9 + self.kind.key_len(); - let padding_len = - u16::from_be_bytes([fixed[padding_len_offset], fixed[padding_len_offset + 1]]) as usize; - if padding_len > 0 { - let mut encrypted_padding = vec![0u8; padding_len + self.kind.tag_len()]; - self.reader - .read_exact(&mut encrypted_padding) - .await - .context("failed to read Shadowsocks 2022 response padding")?; - let padding = cipher.open_packet(&mut encrypted_padding)?; - if padding.len() != padding_len { - bail!("Shadowsocks 2022 response padding length mismatch"); - } - } - - self.cipher = Some(cipher); - self.response_read = true; - Ok(()) - } - - async fn read_chunk(&mut self, output: &mut Vec) -> Result { - self.read_response().await?; - let cipher = self - .cipher - .as_mut() - .ok_or_else(|| anyhow!("Shadowsocks 2022 TCP response was not initialized"))?; - let mut encrypted_len = vec![0u8; 2 + self.kind.tag_len()]; - if let Err(err) = self.reader.read_exact(&mut encrypted_len).await { - if err.kind() == std::io::ErrorKind::UnexpectedEof { - return Ok(0); - } - return Err(err).context("failed to read Shadowsocks 2022 encrypted chunk length"); - } - let plaintext_len = cipher.open_packet(&mut encrypted_len)?; - let payload_len = u16::from_be_bytes([plaintext_len[0], plaintext_len[1]]) as usize; - if payload_len == 0 { - return Ok(0); - } - let mut encrypted_payload = vec![0u8; payload_len + self.kind.tag_len()]; - self.reader - .read_exact(&mut encrypted_payload) - .await - .context("failed to read Shadowsocks 2022 encrypted chunk")?; - let plaintext = cipher.open_packet(&mut encrypted_payload)?; - output.extend_from_slice(plaintext); - Ok(plaintext.len()) - } -} - -struct Aead2022AesCcmByteReader { - reader: Aead2022AesCcmReader, - buffer: Vec, - offset: usize, -} - -impl Aead2022AesCcmByteReader -where - R: AsyncRead + Unpin, -{ - fn new(reader: Aead2022AesCcmReader) -> Self { - Self { - reader, - buffer: Vec::new(), - offset: 0, - } - } - - async fn read_frame(&mut self) -> Result)>> { - let source = match self.read_uot_addr().await? { - Some(source) => source, - None => return Ok(None), - }; - let Some(payload_len) = self.read_u16().await? else { - bail!("truncated udp-over-tcp payload length"); - }; - let payload = self.read_exact_vec(payload_len as usize).await?; - Ok(Some((source, payload))) - } - - async fn read_uot_addr(&mut self) -> Result> { - let Some(atyp) = self.read_u8().await? else { - return Ok(None); - }; - match atyp { - 0x00 => { - let ip = self.read_exact_array::<4>().await?; - let port = self.read_u16_required().await?; - Ok(Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port))) - } - 0x01 => { - let ip = self.read_exact_array::<16>().await?; - let port = self.read_u16_required().await?; - Ok(Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port))) - } - 0x02 => { - let len = self.read_u8_required().await? as usize; - let domain = self.read_exact_vec(len).await?; - let port = self.read_u16_required().await?; - let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; - Ok(Some(resolve_server(&domain, port)?)) - } - other => bail!("unsupported udp-over-tcp address type {other}"), - } - } - - async fn read_u8_required(&mut self) -> Result { - self.read_u8() - .await? - .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) - } - - async fn read_u16_required(&mut self) -> Result { - self.read_u16() - .await? - .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) - } - - async fn read_u8(&mut self) -> Result> { - if !self.ensure_available(1).await? { - return Ok(None); - } - let value = self.buffer[self.offset]; - self.offset += 1; - self.compact_buffer(); - Ok(Some(value)) - } - - async fn read_u16(&mut self) -> Result> { - if !self.ensure_available(2).await? { - return Ok(None); - } - let value = u16::from_be_bytes([self.buffer[self.offset], self.buffer[self.offset + 1]]); - self.offset += 2; - self.compact_buffer(); - Ok(Some(value)) - } - - async fn read_exact_array(&mut self) -> Result<[u8; N]> { - let bytes = self.read_exact_vec(N).await?; - Ok(bytes - .try_into() - .expect("read_exact_vec returned requested length")) - } - - async fn read_exact_vec(&mut self, len: usize) -> Result> { - if !self.ensure_available(len).await? { - bail!("truncated udp-over-tcp frame"); - } - let value = self.buffer[self.offset..self.offset + len].to_vec(); - self.offset += len; - self.compact_buffer(); - Ok(value) - } - - async fn ensure_available(&mut self, len: usize) -> Result { - while self.buffer.len().saturating_sub(self.offset) < len { - if self.offset > 0 { - self.buffer.drain(..self.offset); - self.offset = 0; - } - let mut chunk = Vec::new(); - let read = self.reader.read_chunk(&mut chunk).await?; - if read == 0 { - return Ok(false); - } - self.buffer.extend_from_slice(&chunk); - } - Ok(true) - } - - fn compact_buffer(&mut self) { - if self.offset == self.buffer.len() { - self.buffer.clear(); - self.offset = 0; - } else if self.offset > 4096 && self.offset * 2 > self.buffer.len() { - self.buffer.drain(..self.offset); - self.offset = 0; - } - } -} - -enum Aead2022AesCcmCipher { - Aes128(Aes128Ccm), - Aes256(Aes256Ccm), -} - -impl Aead2022AesCcmCipher { - fn new(kind: Aead2022AesCcmKind, key: &[u8]) -> Result { - match kind { - Aead2022AesCcmKind::Aes128 => Ok(Self::Aes128( - Aes128Ccm::new_from_slice(key) - .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-128-CCM key"))?, - )), - Aead2022AesCcmKind::Aes256 => Ok(Self::Aes256( - Aes256Ccm::new_from_slice(key) - .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-256-CCM key"))?, - )), - } - } - - fn seal_in_place(&self, nonce: &[u8], buf: &mut Vec) -> Result<()> { - let tag = match self { - Self::Aes128(cipher) => cipher - .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) - .map_err(|_| anyhow!("Shadowsocks 2022 AES-128-CCM seal failed"))? - .as_slice() - .to_vec(), - Self::Aes256(cipher) => cipher - .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) - .map_err(|_| anyhow!("Shadowsocks 2022 AES-256-CCM seal failed"))? - .as_slice() - .to_vec(), - }; - buf.extend_from_slice(&tag); - Ok(()) - } - - fn open_in_place<'a>(&self, nonce: &[u8], buf: &'a mut [u8]) -> Result<&'a [u8]> { - if buf.len() < 16 { - bail!("Shadowsocks 2022 AEAD packet missing tag"); - } - let (ciphertext, tag) = buf.split_at_mut(buf.len() - 16); - match self { - Self::Aes128(cipher) => cipher - .decrypt_in_place_detached( - GenericArray::from_slice(nonce), - &[], - ciphertext, - GenericArray::from_slice(tag), - ) - .map_err(|_| anyhow!("Shadowsocks 2022 AES-128-CCM open failed"))?, - Self::Aes256(cipher) => cipher - .decrypt_in_place_detached( - GenericArray::from_slice(nonce), - &[], - ciphertext, - GenericArray::from_slice(tag), - ) - .map_err(|_| anyhow!("Shadowsocks 2022 AES-256-CCM open failed"))?, - }; - Ok(ciphertext) - } -} - -struct Aead2022TcpCipher { - cipher: Aead2022AesCcmCipher, - nonce: Vec, -} - -impl Aead2022TcpCipher { - fn new(kind: Aead2022AesCcmKind, user_key: &[u8], salt: &[u8]) -> Result { - Ok(Self { - cipher: Aead2022AesCcmCipher::new(kind, &aead2022_session_key(kind, user_key, salt)?)?, - nonce: vec![0u8; kind.nonce_len()], - }) - } - - fn seal_packet(&mut self, payload: &[u8]) -> Result> { - let mut packet = payload.to_vec(); - self.cipher.seal_in_place(&self.nonce, &mut packet)?; - increment_nonce(&mut self.nonce); - Ok(packet) - } - - fn open_packet<'a>(&mut self, packet: &'a mut [u8]) -> Result<&'a [u8]> { - let plaintext = self.cipher.open_in_place(&self.nonce, packet)?; - increment_nonce(&mut self.nonce); - Ok(plaintext) - } -} - -struct Aead2022AesCcmUdpSession { - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, - client_session_id: u64, - packet_id: u64, - client_cipher: Aead2022AesCcmCipher, -} - -impl Aead2022AesCcmUdpSession { - fn new( - kind: Aead2022AesCcmKind, - user_key: Arc<[u8]>, - identity_keys: Arc<[Arc<[u8]>]>, - ) -> Result { - let client_session_id = OsRng.next_u64(); - let client_session_id_bytes = client_session_id.to_be_bytes(); - let client_cipher = Aead2022AesCcmCipher::new( - kind, - &aead2022_session_key(kind, &user_key, &client_session_id_bytes)?, - )?; - Ok(Self { - kind, - user_key, - identity_keys, - client_session_id, - packet_id: 0, - client_cipher, - }) - } - - fn encrypt_client_packet( - &mut self, - destination: SocketAddr, - payload: &[u8], - ) -> Result> { - let packet_id = self.packet_id; - self.packet_id = self.packet_id.wrapping_add(1); - - let padding_len = aead2022_udp_padding_len(destination, payload); - let mut packet = Vec::with_capacity( - 16 + self.identity_keys.len() * 16 + 1 + 8 + 2 + padding_len + 19 + payload.len() + 16, - ); - packet.extend_from_slice(&self.client_session_id.to_be_bytes()); - packet.extend_from_slice(&packet_id.to_be_bytes()); - packet.extend_from_slice(&aead2022_udp_eih_blocks( - self.kind, - &self.identity_keys, - &self.user_key, - &packet[..16], - )?); - let data_index = packet.len(); - packet.push(AEAD2022_HEADER_TYPE_CLIENT); - packet.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); - packet.extend_from_slice(&(padding_len as u16).to_be_bytes()); - packet.resize(packet.len() + padding_len, 0); - packet.extend_from_slice(&socks_addr(destination)?); - packet.extend_from_slice(payload); - - let nonce: [u8; 12] = packet[4..16].try_into().expect("fixed packet nonce"); - let mut encrypted_body = packet[data_index..].to_vec(); - self.client_cipher - .seal_in_place(&nonce, &mut encrypted_body)?; - packet.truncate(data_index); - packet.extend_from_slice(&encrypted_body); - aead2022_aes_encrypt_block( - self.kind, - aead2022_udp_header_key(&self.identity_keys, &self.user_key), - &mut packet[..16], - )?; - Ok(packet) - } - - fn decrypt_server_packet(&mut self, packet: &[u8]) -> Result<(SocketAddr, Vec)> { - if packet.len() < 16 + 1 + 8 + 8 + 2 + self.kind.tag_len() { - bail!("Shadowsocks 2022 UDP packet is too short"); - } - let mut buf = packet.to_vec(); - aead2022_aes_decrypt_block(self.kind, &self.user_key, &mut buf[..16])?; - let session_id = u64::from_be_bytes(buf[..8].try_into().expect("fixed session id")); - let session_id_bytes = session_id.to_be_bytes(); - let cipher = Aead2022AesCcmCipher::new( - self.kind, - &aead2022_session_key(self.kind, &self.user_key, &session_id_bytes)?, - )?; - let nonce: [u8; 12] = buf[4..16].try_into().expect("fixed packet nonce"); - let plaintext = cipher.open_in_place(&nonce, &mut buf[16..])?; - if plaintext.len() < 1 + 8 + 8 + 2 { - bail!("Shadowsocks 2022 UDP response is truncated"); - } - if plaintext[0] != AEAD2022_HEADER_TYPE_SERVER { - bail!( - "unexpected Shadowsocks 2022 UDP response header type {}", - plaintext[0] - ); - } - aead2022_validate_timestamp(u64::from_be_bytes( - plaintext[1..9].try_into().expect("fixed timestamp"), - ))?; - let client_session_id = u64::from_be_bytes( - plaintext[9..17] - .try_into() - .expect("fixed client session id"), - ); - if client_session_id != self.client_session_id { - bail!("Shadowsocks 2022 UDP response client session id mismatch"); - } - let padding_len = u16::from_be_bytes([plaintext[17], plaintext[18]]) as usize; - let address_offset = 19usize - .checked_add(padding_len) - .ok_or_else(|| anyhow!("Shadowsocks 2022 UDP response padding is too large"))?; - if plaintext.len() < address_offset { - bail!("Shadowsocks 2022 UDP response padding is truncated"); - } - let (source, used) = parse_socks_addr(&plaintext[address_offset..])?; - Ok((source, plaintext[address_offset + used..].to_vec())) - } -} - -enum CustomSsAeadCipher { - Aes192Gcm(Aes192Gcm), - Aes192Ccm(Aes192Ccm), - Chacha8IetfPoly1305(ChaCha8Poly1305), - XChacha8IetfPoly1305(XChaCha8Poly1305), - Rabbit128Poly1305([u8; 16]), - Aegis128L([u8; 16]), - Aegis256([u8; 32]), - Aez384([u8; 48]), - DeoxysII256128(DeoxysII256), - Ascon128([u8; 16]), - Ascon128A([u8; 16]), - Lea128Gcm(Lea128), - Lea192Gcm(Lea192), - Lea256Gcm(Lea256), -} - -impl CustomSsAeadCipher { - fn new(kind: CustomSsAeadKind, key: &[u8]) -> Result { - match kind { - CustomSsAeadKind::Aes192Gcm => Ok(Self::Aes192Gcm( - Aes192Gcm::new_from_slice(key).map_err(|_| anyhow!("invalid AES-192-GCM key"))?, - )), - CustomSsAeadKind::Aes192Ccm => Ok(Self::Aes192Ccm( - Aes192Ccm::new_from_slice(key).map_err(|_| anyhow!("invalid AES-192-CCM key"))?, - )), - CustomSsAeadKind::Chacha8IetfPoly1305 => Ok(Self::Chacha8IetfPoly1305( - ChaCha8Poly1305::new_from_slice(key) - .map_err(|_| anyhow!("invalid ChaCha8-Poly1305 key"))?, - )), - CustomSsAeadKind::XChacha8IetfPoly1305 => Ok(Self::XChacha8IetfPoly1305( - XChaCha8Poly1305::new_from_slice(key) - .map_err(|_| anyhow!("invalid XChaCha8-Poly1305 key"))?, - )), - CustomSsAeadKind::Rabbit128Poly1305 => Ok(Self::Rabbit128Poly1305( - key.try_into() - .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 key"))?, - )), - CustomSsAeadKind::Aegis128L => Ok(Self::Aegis128L( - key.try_into() - .map_err(|_| anyhow!("invalid AEGIS-128L key"))?, - )), - CustomSsAeadKind::Aegis256 => Ok(Self::Aegis256( - key.try_into() - .map_err(|_| anyhow!("invalid AEGIS-256 key"))?, - )), - CustomSsAeadKind::Aez384 => Ok(Self::Aez384( - key.try_into().map_err(|_| anyhow!("invalid AEZ-384 key"))?, - )), - CustomSsAeadKind::DeoxysII256128 => Ok(Self::DeoxysII256128( - DeoxysII256::new_from_slice(key) - .map_err(|_| anyhow!("invalid Deoxys-II-256-128 key"))?, - )), - CustomSsAeadKind::Ascon128 => Ok(Self::Ascon128( - key.try_into() - .map_err(|_| anyhow!("invalid Ascon128 key"))?, - )), - CustomSsAeadKind::Ascon128A => Ok(Self::Ascon128A( - key.try_into() - .map_err(|_| anyhow!("invalid Ascon128a key"))?, - )), - CustomSsAeadKind::Lea128Gcm => Ok(Self::Lea128Gcm(Lea128::new( - LeaGenericArray::from_slice(key), - ))), - CustomSsAeadKind::Lea192Gcm => Ok(Self::Lea192Gcm(Lea192::new( - LeaGenericArray::from_slice(key), - ))), - CustomSsAeadKind::Lea256Gcm => Ok(Self::Lea256Gcm(Lea256::new( - LeaGenericArray::from_slice(key), - ))), - } - } - - fn seal_in_place(&self, nonce: &mut [u8], buf: &mut Vec) -> Result<()> { - let tag = match self { - Self::Aes192Gcm(cipher) => cipher - .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) - .map_err(|_| anyhow!("AES-192-GCM Shadowsocks seal failed"))? - .as_slice() - .to_vec(), - Self::Aes192Ccm(cipher) => cipher - .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) - .map_err(|_| anyhow!("AES-192-CCM Shadowsocks seal failed"))? - .as_slice() - .to_vec(), - Self::Chacha8IetfPoly1305(cipher) => cipher - .encrypt_in_place_detached(GenericArray::::from_slice(nonce), &[], buf) - .map_err(|_| anyhow!("ChaCha8-Poly1305 Shadowsocks seal failed"))? - .as_slice() - .to_vec(), - Self::XChacha8IetfPoly1305(cipher) => cipher - .encrypt_in_place_detached(GenericArray::::from_slice(nonce), &[], buf) - .map_err(|_| anyhow!("XChaCha8-Poly1305 Shadowsocks seal failed"))? - .as_slice() - .to_vec(), - Self::Rabbit128Poly1305(key) => { - let nonce: [u8; 8] = nonce - .try_into() - .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 nonce"))?; - rabbit_poly1305_seal_in_place(key, &nonce, buf)? - } - Self::Aegis128L(key) => { - let nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid AEGIS-128L nonce"))?; - Aegis128L::<16>::new(key, &nonce) - .encrypt_in_place(buf, &[]) - .to_vec() - } - Self::Aegis256(key) => { - let nonce: [u8; 32] = nonce - .try_into() - .map_err(|_| anyhow!("invalid AEGIS-256 nonce"))?; - Aegis256::<16>::new(key, &nonce) - .encrypt_in_place(buf, &[]) - .to_vec() - } - Self::Aez384(key) => { - let aead_nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid AEZ-384 nonce"))?; - let cipher = zears::Aez::new(key); - cipher.encrypt_vec(&aead_nonce, &[], 16, buf); - increment_nonce(nonce); - return Ok(()); - } - Self::DeoxysII256128(cipher) => cipher - .encrypt_inout_detached( - &DeoxysArray::try_from(&nonce[..]) - .map_err(|_| anyhow!("invalid Deoxys-II-256-128 nonce"))?, - &[], - buf.as_mut_slice().into(), - ) - .map_err(|_| anyhow!("Deoxys-II-256-128 Shadowsocks seal failed"))? - .as_slice() - .to_vec(), - Self::Ascon128(key) => { - let nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid Ascon128 nonce"))?; - ascon_seal_in_place(key, &nonce, AsconMode::Ascon128, buf) - } - Self::Ascon128A(key) => { - let nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid Ascon128a nonce"))?; - ascon_seal_in_place(key, &nonce, AsconMode::Ascon128A, buf) - } - Self::Lea128Gcm(_) | Self::Lea192Gcm(_) | Self::Lea256Gcm(_) => { - let nonce: [u8; 12] = nonce - .try_into() - .map_err(|_| anyhow!("invalid LEA-GCM nonce"))?; - lea_gcm_seal_in_place(self, &nonce, buf) - } - }; - buf.extend_from_slice(&tag); - increment_nonce(nonce); - Ok(()) - } - - fn open_in_place<'a>(&self, nonce: &mut [u8], buf: &'a mut [u8]) -> Result<&'a [u8]> { - if buf.len() < 16 { - bail!("Shadowsocks AEAD packet missing tag"); - } - let (ciphertext, tag) = buf.split_at_mut(buf.len() - 16); - match self { - Self::Aes192Gcm(cipher) => cipher - .decrypt_in_place_detached( - GenericArray::from_slice(nonce), - &[], - ciphertext, - GenericArray::from_slice(tag), - ) - .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, - Self::Aes192Ccm(cipher) => cipher - .decrypt_in_place_detached( - GenericArray::from_slice(nonce), - &[], - ciphertext, - GenericArray::from_slice(tag), - ) - .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, - Self::Chacha8IetfPoly1305(cipher) => cipher - .decrypt_in_place_detached( - GenericArray::::from_slice(nonce), - &[], - ciphertext, - GenericArray::from_slice(tag), - ) - .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, - Self::XChacha8IetfPoly1305(cipher) => cipher - .decrypt_in_place_detached( - GenericArray::::from_slice(nonce), - &[], - ciphertext, - GenericArray::from_slice(tag), - ) - .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, - Self::Rabbit128Poly1305(key) => { - let nonce: [u8; 8] = nonce - .try_into() - .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 nonce"))?; - rabbit_poly1305_open_in_place(key, &nonce, ciphertext, tag)? - } - Self::Aegis128L(key) => { - let nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid AEGIS-128L nonce"))?; - let tag: [u8; 16] = tag - .try_into() - .map_err(|_| anyhow!("invalid AEGIS-128L tag"))?; - Aegis128L::<16>::new(key, &nonce) - .decrypt_in_place(ciphertext, &tag, &[]) - .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))? - } - Self::Aegis256(key) => { - let nonce: [u8; 32] = nonce - .try_into() - .map_err(|_| anyhow!("invalid AEGIS-256 nonce"))?; - let tag: [u8; 16] = tag - .try_into() - .map_err(|_| anyhow!("invalid AEGIS-256 tag"))?; - Aegis256::<16>::new(key, &nonce) - .decrypt_in_place(ciphertext, &tag, &[]) - .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))? - } - Self::Aez384(key) => { - let aead_nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid AEZ-384 nonce"))?; - let cipher = zears::Aez::new(key); - let mut encrypted = Vec::with_capacity(ciphertext.len() + tag.len()); - encrypted.extend_from_slice(ciphertext); - encrypted.extend_from_slice(tag); - let plaintext = cipher - .decrypt(&aead_nonce, &[], 16, &encrypted) - .ok_or_else(|| anyhow!("Shadowsocks AEZ-384 open failed"))?; - ciphertext[..plaintext.len()].copy_from_slice(&plaintext); - increment_nonce(nonce); - return Ok(&ciphertext[..plaintext.len()]); - } - Self::DeoxysII256128(cipher) => cipher - .decrypt_inout_detached( - &DeoxysArray::try_from(&nonce[..]) - .map_err(|_| anyhow!("invalid Deoxys-II-256-128 nonce"))?, - &[], - ciphertext.into(), - &DeoxysArray::try_from(&tag[..]) - .map_err(|_| anyhow!("invalid Deoxys-II-256-128 tag"))?, - ) - .map_err(|_| anyhow!("Shadowsocks Deoxys-II-256-128 open failed"))?, - Self::Ascon128(key) => { - let nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid Ascon128 nonce"))?; - ascon_open_in_place(key, &nonce, AsconMode::Ascon128, ciphertext, tag)? - } - Self::Ascon128A(key) => { - let nonce: [u8; 16] = nonce - .try_into() - .map_err(|_| anyhow!("invalid Ascon128a nonce"))?; - ascon_open_in_place(key, &nonce, AsconMode::Ascon128A, ciphertext, tag)? - } - Self::Lea128Gcm(_) | Self::Lea192Gcm(_) | Self::Lea256Gcm(_) => { - let nonce: [u8; 12] = nonce - .try_into() - .map_err(|_| anyhow!("invalid LEA-GCM nonce"))?; - lea_gcm_open_in_place(self, &nonce, ciphertext, tag)? - } - }; - increment_nonce(nonce); - Ok(ciphertext) - } -} - -fn lea_gcm_seal_in_place( - cipher: &CustomSsAeadCipher, - nonce: &[u8; 12], - plaintext_in_ciphertext_out: &mut [u8], -) -> Vec { - lea_gcm_apply_ctr(cipher, nonce, plaintext_in_ciphertext_out); - lea_gcm_tag(cipher, nonce, plaintext_in_ciphertext_out) -} - -fn lea_gcm_open_in_place( - cipher: &CustomSsAeadCipher, - nonce: &[u8; 12], - ciphertext: &mut [u8], - tag: &[u8], -) -> Result<()> { - let expected = lea_gcm_tag(cipher, nonce, ciphertext); - if !bool::from(expected.as_slice().ct_eq(tag)) { - bail!("Shadowsocks LEA-GCM tag verification failed"); - } - lea_gcm_apply_ctr(cipher, nonce, ciphertext); - Ok(()) -} - -fn lea_gcm_apply_ctr(cipher: &CustomSsAeadCipher, nonce: &[u8; 12], buf: &mut [u8]) { - let mut counter = [0u8; 16]; - counter[..12].copy_from_slice(nonce); - counter[15] = 1; - lea_gcm_increment_counter(&mut counter); - - for chunk in buf.chunks_mut(16) { - let mut key_stream = counter; - lea_encrypt_block(cipher, &mut key_stream); - for (byte, key_stream_byte) in chunk.iter_mut().zip(key_stream) { - *byte ^= key_stream_byte; - } - lea_gcm_increment_counter(&mut counter); - } -} - -fn lea_gcm_tag(cipher: &CustomSsAeadCipher, nonce: &[u8; 12], ciphertext: &[u8]) -> Vec { - let mut h = [0u8; 16]; - lea_encrypt_block(cipher, &mut h); - - let mut tag_mask = [0u8; 16]; - tag_mask[..12].copy_from_slice(nonce); - tag_mask[15] = 1; - lea_encrypt_block(cipher, &mut tag_mask); - - let auth = ghash(&h, &[], ciphertext); - tag_mask - .iter() - .zip(auth) - .map(|(left, right)| left ^ right) - .collect() -} - -fn lea_encrypt_block(cipher: &CustomSsAeadCipher, block: &mut [u8; 16]) { - let block = LeaGenericArray::from_mut_slice(block); - match cipher { - CustomSsAeadCipher::Lea128Gcm(cipher) => cipher.encrypt_block(block), - CustomSsAeadCipher::Lea192Gcm(cipher) => cipher.encrypt_block(block), - CustomSsAeadCipher::Lea256Gcm(cipher) => cipher.encrypt_block(block), - _ => unreachable!("LEA-GCM block encryption called with non-LEA cipher"), - } -} - -fn lea_gcm_increment_counter(counter: &mut [u8; 16]) { - let value = - u32::from_be_bytes(counter[12..16].try_into().expect("fixed GCM counter")).wrapping_add(1); - counter[12..16].copy_from_slice(&value.to_be_bytes()); -} - -fn ghash(h: &[u8; 16], associated_data: &[u8], ciphertext: &[u8]) -> [u8; 16] { - let h = u128::from_be_bytes(*h); - let mut y = 0u128; - ghash_update(&mut y, h, associated_data); - ghash_update(&mut y, h, ciphertext); - let length_block = - ((associated_data.len() as u128) * 8) << 64 | ((ciphertext.len() as u128) * 8); - y = ghash_mul(y ^ length_block, h); - y.to_be_bytes() -} - -fn ghash_update(y: &mut u128, h: u128, mut input: &[u8]) { - while input.len() >= 16 { - *y = ghash_mul( - *y ^ u128::from_be_bytes(input[..16].try_into().expect("full GHASH block")), - h, - ); - input = &input[16..]; - } - if !input.is_empty() { - let mut block = [0u8; 16]; - block[..input.len()].copy_from_slice(input); - *y = ghash_mul(*y ^ u128::from_be_bytes(block), h); - } -} - -fn ghash_mul(mut x: u128, mut y: u128) -> u128 { - let reduction = 0xe1000000000000000000000000000000u128; - let mut z = 0u128; - for _ in 0..128 { - if x & (1u128 << 127) != 0 { - z ^= y; - } - let carry = y & 1 != 0; - y >>= 1; - if carry { - y ^= reduction; - } - x <<= 1; - } - z -} - -#[derive(Debug, Clone, Copy)] -enum AsconMode { - Ascon128, - Ascon128A, -} - -impl AsconMode { - fn block_size(self) -> usize { - match self { - Self::Ascon128 => 8, - Self::Ascon128A => 16, - } - } - - fn perm_b(self) -> usize { - match self { - Self::Ascon128 => 6, - Self::Ascon128A => 8, - } - } -} - -fn ascon_seal_in_place( - key: &[u8; 16], - nonce: &[u8; 16], - mode: AsconMode, - plaintext_in_ciphertext_out: &mut [u8], -) -> Vec { - let mut state = ascon_initialize(key, nonce, mode); - ascon_assoc_data(&mut state, mode, &[]); - ascon_proc_text(&mut state, mode, plaintext_in_ciphertext_out, true); - ascon_finalize(&mut state, mode, key) -} - -fn ascon_open_in_place( - key: &[u8; 16], - nonce: &[u8; 16], - mode: AsconMode, - ciphertext: &mut [u8], - tag: &[u8], -) -> Result<()> { - let mut state = ascon_initialize(key, nonce, mode); - ascon_assoc_data(&mut state, mode, &[]); - ascon_proc_text(&mut state, mode, ciphertext, false); - let expected = ascon_finalize(&mut state, mode, key); - if !bool::from(expected.as_slice().ct_eq(tag)) { - bail!("Shadowsocks Ascon tag verification failed"); - } - Ok(()) -} - -fn ascon_initialize(key: &[u8; 16], nonce: &[u8; 16], mode: AsconMode) -> [u64; 5] { - let k1 = u64::from_be_bytes(key[..8].try_into().expect("fixed key half")); - let k2 = u64::from_be_bytes(key[8..].try_into().expect("fixed key half")); - let block_bits = (mode.block_size() as u64) * 8; - let perm_b = mode.perm_b() as u64; - let n1 = u64::from_be_bytes(nonce[..8].try_into().expect("fixed nonce half")); - let n2 = u64::from_be_bytes(nonce[8..].try_into().expect("fixed nonce half")); - let mut state = [ - (128u64 << 56) | (block_bits << 48) | (12u64 << 40) | (perm_b << 32), - k1, - k2, - n1, - n2, - ]; - ascon_perm(12, &mut state); - state[3] ^= k1; - state[4] ^= k2; - state -} - -fn ascon_assoc_data(state: &mut [u64; 5], mode: AsconMode, mut associated_data: &[u8]) { - let block_size = mode.block_size(); - let perm_b = mode.perm_b(); - if !associated_data.is_empty() { - while associated_data.len() >= block_size { - for index in 0..(block_size / 8) { - let start = index * 8; - state[index] ^= u64::from_be_bytes( - associated_data[start..start + 8] - .try_into() - .expect("full Ascon AD block"), - ); - } - ascon_perm(perm_b, state); - associated_data = &associated_data[block_size..]; - } - for (index, byte) in associated_data.iter().enumerate() { - state[index / 8] ^= (*byte as u64) << (56 - 8 * (index % 8)); - } - state[associated_data.len() / 8] ^= 0x80u64 << (56 - 8 * (associated_data.len() % 8)); - ascon_perm(perm_b, state); - } - state[4] ^= 0x01; -} - -fn ascon_proc_text(state: &mut [u64; 5], mode: AsconMode, buf: &mut [u8], encrypt: bool) { - let block_size = mode.block_size(); - let perm_b = mode.perm_b(); - let mut offset = 0; - while buf.len() - offset >= block_size { - for index in 0..(block_size / 8) { - let start = offset + index * 8; - let input = u64::from_be_bytes(buf[start..start + 8].try_into().expect("full block")); - let output = state[index] ^ input; - buf[start..start + 8].copy_from_slice(&output.to_be_bytes()); - state[index] = if encrypt { output } else { input }; - } - ascon_perm(perm_b, state); - offset += block_size; - } - - let remaining = buf.len() - offset; - for index in 0..remaining { - let state_index = index / 8; - let shift = 56 - 8 * (index % 8); - let state_byte = ((state[state_index] >> shift) & 0xff) as u8; - let input = buf[offset + index]; - let output = state_byte ^ input; - buf[offset + index] = output; - let state_value = if encrypt { output } else { input }; - state[state_index] = - (state[state_index] & !(0xffu64 << shift)) | ((state_value as u64) << shift); - } - state[remaining / 8] ^= 0x80u64 << (56 - 8 * (remaining % 8)); -} - -fn ascon_finalize(state: &mut [u64; 5], mode: AsconMode, key: &[u8; 16]) -> Vec { - let k1 = u64::from_be_bytes(key[..8].try_into().expect("fixed key half")); - let k2 = u64::from_be_bytes(key[8..].try_into().expect("fixed key half")); - let block_words = mode.block_size() / 8; - state[block_words] ^= k1; - state[block_words + 1] ^= k2; - ascon_perm(12, state); - - let mut tag = Vec::with_capacity(16); - tag.extend_from_slice(&(state[3] ^ k1).to_be_bytes()); - tag.extend_from_slice(&(state[4] ^ k2).to_be_bytes()); - tag -} - -fn ascon_perm(rounds: usize, state: &mut [u64; 5]) { - let [mut x0, mut x1, mut x2, mut x3, mut x4] = *state; - for round in (12 - rounds)..12 { - x2 ^= (((0xf - round) << 4) | round) as u64; - - x0 ^= x4; - x4 ^= x3; - x2 ^= x1; - let t0 = x0 & !x4; - let mut t1 = x2 & !x1; - x0 ^= t1; - t1 = x4 & !x3; - x2 ^= t1; - t1 = x1 & !x0; - x4 ^= t1; - t1 = x3 & !x2; - x1 ^= t1; - x3 ^= t0; - x1 ^= x0; - x3 ^= x2; - x0 ^= x4; - x2 = !x2; - - x0 ^= x0.rotate_right(19) ^ x0.rotate_right(28); - x1 ^= x1.rotate_right(61) ^ x1.rotate_right(39); - x2 ^= x2.rotate_right(1) ^ x2.rotate_right(6); - x3 ^= x3.rotate_right(10) ^ x3.rotate_right(17); - x4 ^= x4.rotate_right(7) ^ x4.rotate_right(41); - } - *state = [x0, x1, x2, x3, x4]; -} - -fn rabbit_poly1305_seal_in_place( - key: &[u8; 16], - nonce: &[u8; 8], - plaintext_in_ciphertext_out: &mut [u8], -) -> Result> { - let mut poly_key = [0u8; 32]; - rabbit_apply_keystream(key, nonce, &mut poly_key)?; - rabbit_apply_keystream(key, nonce, plaintext_in_ciphertext_out)?; - Ok(rabbit_poly1305_tag(&poly_key, &[], plaintext_in_ciphertext_out).to_vec()) -} - -fn rabbit_poly1305_open_in_place( - key: &[u8; 16], - nonce: &[u8; 8], - ciphertext: &mut [u8], - tag: &[u8], -) -> Result<()> { - let mut poly_key = [0u8; 32]; - rabbit_apply_keystream(key, nonce, &mut poly_key)?; - let expected = rabbit_poly1305_tag(&poly_key, &[], ciphertext); - if !bool::from(expected.as_slice().ct_eq(tag)) { - bail!("Shadowsocks Rabbit128-Poly1305 tag verification failed"); - } - rabbit_apply_keystream(key, nonce, ciphertext)?; - Ok(()) -} - -fn rabbit_apply_keystream(key: &[u8; 16], nonce: &[u8; 8], buf: &mut [u8]) -> Result<()> { - let mut cipher = Rabbit::new_from_slices(key, nonce) - .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 key or nonce"))?; - cipher.apply_keystream(buf); - Ok(()) -} - -fn rabbit_poly1305_tag(poly_key: &[u8; 32], associated_data: &[u8], ciphertext: &[u8]) -> [u8; 16] { - let mut mac = Poly1305::new(GenericArray::from_slice(poly_key)); - mac.update_padded(associated_data); - mac.update_padded(ciphertext); - let mut lengths = [0u8; 16]; - lengths[..8].copy_from_slice(&(associated_data.len() as u64).to_le_bytes()); - lengths[8..].copy_from_slice(&(ciphertext.len() as u64).to_le_bytes()); - mac.update_padded(&lengths); - mac.finalize().into() -} - -fn encrypt_custom_ss_udp_packet( - kind: CustomSsAeadKind, - master_key: &[u8], - target: SocketAddr, - payload: &[u8], -) -> Result> { - let mut salt = vec![0u8; kind.salt_len()]; - OsRng.fill_bytes(&mut salt); - let cipher = custom_ss_subkey(kind, master_key, &salt)?; - let mut nonce = vec![0u8; kind.nonce_len()]; - let mut plaintext = socks_addr(target)?; - plaintext.extend_from_slice(payload); - cipher.seal_in_place(&mut nonce, &mut plaintext)?; - let mut packet = salt; - packet.extend_from_slice(&plaintext); - Ok(packet) -} - -fn decrypt_custom_ss_udp_packet( - kind: CustomSsAeadKind, - master_key: &[u8], - packet: &[u8], -) -> Result<(SocketAddr, Vec)> { - let salt_len = kind.salt_len(); - if packet.len() <= salt_len { - bail!("Shadowsocks UDP packet missing salt"); - } - let (salt, encrypted) = packet.split_at(salt_len); - let cipher = custom_ss_subkey(kind, master_key, salt)?; - let mut nonce = vec![0u8; kind.nonce_len()]; - let mut buf = encrypted.to_vec(); - let plaintext = cipher.open_in_place(&mut nonce, &mut buf)?; - let (source, used) = parse_socks_addr(plaintext)?; - Ok((source, plaintext[used..].to_vec())) -} - -fn encrypt_custom_ss_stream_udp_packet( - kind: CustomSsStreamKind, - key: &[u8], - target: SocketAddr, - payload: &[u8], -) -> Result> { - let mut salt = vec![0u8; kind.salt_len()]; - OsRng.fill_bytes(&mut salt); - let mut cipher = CustomSsStreamCipher::new(kind, key, &salt)?; - let mut plaintext = socks_addr(target)?; - plaintext.extend_from_slice(payload); - cipher.apply_keystream(&mut plaintext); - let mut packet = salt; - packet.extend_from_slice(&plaintext); - Ok(packet) -} - -fn decrypt_custom_ss_stream_udp_packet( - kind: CustomSsStreamKind, - key: &[u8], - packet: &[u8], -) -> Result<(SocketAddr, Vec)> { - let salt_len = kind.salt_len(); - if packet.len() <= salt_len { - bail!("Shadowsocks UDP packet missing salt"); - } - let (salt, encrypted) = packet.split_at(salt_len); - let mut cipher = CustomSsStreamCipher::new(kind, key, salt)?; - let mut plaintext = encrypted.to_vec(); - cipher.apply_keystream(&mut plaintext); - let (source, used) = parse_socks_addr(&plaintext)?; - Ok((source, plaintext[used..].to_vec())) -} - -fn parse_aead2022_keys( - kind: Aead2022AesCcmKind, - password: &str, -) -> Result<(Arc<[u8]>, Arc<[Arc<[u8]>]>)> { - if password.is_empty() { - bail!("Shadowsocks 2022 password is missing"); - } - let mut keys = Vec::new(); - for segment in password.split(':') { - let decoded = BASE64_STANDARD - .decode(segment) - .context("failed to decode Shadowsocks 2022 base64 key")?; - if decoded.len() != kind.key_len() { - bail!( - "Shadowsocks 2022 key length mismatch: required {}, got {}", - kind.key_len(), - decoded.len() - ); - } - keys.push(Arc::<[u8]>::from(decoded)); - } - let user_key = keys - .pop() - .ok_or_else(|| anyhow!("Shadowsocks 2022 password is missing"))?; - Ok((user_key, Arc::from(keys))) -} - -fn aead2022_session_key(kind: Aead2022AesCcmKind, user_key: &[u8], salt: &[u8]) -> Result> { - if user_key.len() != kind.key_len() || salt.is_empty() { - bail!("invalid Shadowsocks 2022 session key material"); - } - let mut material = Vec::with_capacity(user_key.len() + salt.len()); - material.extend_from_slice(user_key); - material.extend_from_slice(salt); - let derived = blake3::derive_key(AEAD2022_SESSION_SUBKEY_CONTEXT, &material); - Ok(derived[..kind.key_len()].to_vec()) -} - -fn aead2022_identity_subkey( - kind: Aead2022AesCcmKind, - identity_key: &[u8], - salt: &[u8], -) -> Result> { - if identity_key.len() != kind.key_len() || salt.len() != kind.key_len() { - bail!("invalid Shadowsocks 2022 identity key material"); - } - let mut material = Vec::with_capacity(identity_key.len() + salt.len()); - material.extend_from_slice(identity_key); - material.extend_from_slice(salt); - let derived = blake3::derive_key(AEAD2022_IDENTITY_SUBKEY_CONTEXT, &material); - Ok(derived[..kind.key_len()].to_vec()) -} - -fn aead2022_eih_blocks( - kind: Aead2022AesCcmKind, - identity_keys: &[Arc<[u8]>], - user_key: &[u8], - salt: &[u8], -) -> Result> { - let mut blocks = Vec::with_capacity(identity_keys.len() * 16); - for (index, identity_key) in identity_keys.iter().enumerate() { - let next_key = identity_keys - .get(index + 1) - .map(AsRef::as_ref) - .unwrap_or(user_key); - let mut block = aead2022_psk_hash_block(next_key); - let subkey = aead2022_identity_subkey(kind, identity_key, salt)?; - aead2022_aes_encrypt_block(kind, &subkey, &mut block)?; - blocks.extend_from_slice(&block); - } - Ok(blocks) -} - -fn aead2022_udp_eih_blocks( - kind: Aead2022AesCcmKind, - identity_keys: &[Arc<[u8]>], - user_key: &[u8], - packet_header: &[u8], -) -> Result> { - let mut blocks = Vec::with_capacity(identity_keys.len() * 16); - for (index, identity_key) in identity_keys.iter().enumerate() { - let next_key = identity_keys - .get(index + 1) - .map(AsRef::as_ref) - .unwrap_or(user_key); - let mut block = aead2022_psk_hash_block(next_key); - for (left, right) in block.iter_mut().zip(packet_header.iter().copied()) { - *left ^= right; - } - aead2022_aes_encrypt_block(kind, identity_key, &mut block)?; - blocks.extend_from_slice(&block); - } - Ok(blocks) -} - -fn aead2022_psk_hash_block(key: &[u8]) -> [u8; 16] { - let hash = blake3::hash(key); - hash.as_bytes()[..16] - .try_into() - .expect("fixed BLAKE3 block") -} - -fn aead2022_udp_header_key<'a>(identity_keys: &'a [Arc<[u8]>], user_key: &'a [u8]) -> &'a [u8] { - identity_keys.first().map(AsRef::as_ref).unwrap_or(user_key) -} - -fn aead2022_aes_encrypt_block( - kind: Aead2022AesCcmKind, - key: &[u8], - block: &mut [u8], -) -> Result<()> { - if block.len() != 16 { - bail!("Shadowsocks 2022 AES block must be 16 bytes"); - } - match kind { - Aead2022AesCcmKind::Aes128 => { - let cipher = Aes128::new_from_slice(key) - .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-128 block key"))?; - cipher.encrypt_block(AesGenericArray::from_mut_slice(block)); - } - Aead2022AesCcmKind::Aes256 => { - let cipher = Aes256::new_from_slice(key) - .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-256 block key"))?; - cipher.encrypt_block(AesGenericArray::from_mut_slice(block)); - } - } - Ok(()) -} - -fn aead2022_aes_decrypt_block( - kind: Aead2022AesCcmKind, - key: &[u8], - block: &mut [u8], -) -> Result<()> { - if block.len() != 16 { - bail!("Shadowsocks 2022 AES block must be 16 bytes"); - } - match kind { - Aead2022AesCcmKind::Aes128 => { - let cipher = Aes128::new_from_slice(key) - .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-128 block key"))?; - cipher.decrypt_block(AesGenericArray::from_mut_slice(block)); - } - Aead2022AesCcmKind::Aes256 => { - let cipher = Aes256::new_from_slice(key) - .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-256 block key"))?; - cipher.decrypt_block(AesGenericArray::from_mut_slice(block)); - } - } - Ok(()) -} - -fn aead2022_now_timestamp() -> Result { - Ok(SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before unix epoch")? - .as_secs()) -} - -fn aead2022_validate_timestamp(timestamp: u64) -> Result<()> { - let now = aead2022_now_timestamp()?; - if now.abs_diff(timestamp) > AEAD2022_TIMESTAMP_MAX_DIFF { - bail!("Shadowsocks 2022 timestamp is outside the allowed skew"); - } - Ok(()) -} - -fn aead2022_udp_padding_len(destination: SocketAddr, payload: &[u8]) -> usize { - if destination.port() == 53 && payload.len() < 900 { - (OsRng.next_u32() as usize % (900 - payload.len())) + 1 - } else { - 0 - } -} - -fn custom_ss_subkey( - kind: CustomSsAeadKind, - master_key: &[u8], - salt: &[u8], -) -> Result { - let prk = hmac::sign( - &hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, salt), - master_key, - ); - let prk = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, prk.as_ref()); - let mut okm = Vec::with_capacity(kind.key_len()); - let mut previous = Vec::new(); - let mut counter = 1u8; - while okm.len() < kind.key_len() { - let mut input = Vec::with_capacity(previous.len() + SS_INFO.len() + 1); - input.extend_from_slice(&previous); - input.extend_from_slice(SS_INFO); - input.push(counter); - previous = hmac::sign(&prk, &input).as_ref().to_vec(); - okm.extend_from_slice(&previous); - counter = counter - .checked_add(1) - .ok_or_else(|| anyhow!("Shadowsocks HKDF output too long"))?; - } - okm.truncate(kind.key_len()); - CustomSsAeadCipher::new(kind, &okm) -} - -fn evp_bytes_to_key(password: &[u8], key_len: usize) -> Vec { - let mut out = Vec::with_capacity(key_len); - let mut previous = Vec::new(); - while out.len() < key_len { - let mut hasher = Md5::new(); - if !previous.is_empty() { - hasher.update(&previous); - } - hasher.update(password); - previous = hasher.finalize().to_vec(); - out.extend_from_slice(&previous); - } - out.truncate(key_len); - out -} - -fn increment_nonce(nonce: &mut [u8]) { - for byte in nonce { - let (next, overflow) = byte.overflowing_add(1); - *byte = next; - if !overflow { - break; - } - } -} diff --git a/burrow/src/proxy_runtime/shadowsocks_plugins.rs b/burrow/src/proxy_runtime/shadowsocks_plugins.rs deleted file mode 100644 index 7f765d9..0000000 --- a/burrow/src/proxy_runtime/shadowsocks_plugins.rs +++ /dev/null @@ -1,2735 +0,0 @@ -async fn websocket_plugin_connect( - mut stream: Box, - config: &ShadowsocksWebSocketTransport, - port: u16, -) -> Result> { - if config.tls { - stream = websocket_plugin_tls_connect( - stream, - config.websocket_host_header(), - config.skip_cert_verify, - config.certificate_fingerprint.as_ref(), - config.client_identity.as_ref(), - config.ech.as_ref(), - ) - .await?; - } - let (path, max_early_data) = websocket_path_and_early_data(&config.path); - tracing::debug!( - kind = ?config.kind, - host = %config.host, - port, - path = %path, - tls = config.tls, - mux = ?config.mux, - v2ray_http_upgrade = config.v2ray_http_upgrade, - v2ray_http_upgrade_fast_open = config.v2ray_http_upgrade_fast_open, - "starting Shadowsocks websocket plugin handshake" - ); - if let Some(max_early_data) = max_early_data { - return Ok(Box::new(WebSocketEarlyDataStream::new( - stream, - config.clone(), - port, - path, - max_early_data, - ))); - } - let (request, websocket_key) = websocket_plugin_request(config, port, &path, None)?; - stream.write_all(&request).await?; - stream.flush().await?; - if config.v2ray_http_upgrade && config.v2ray_http_upgrade_fast_open { - return Ok(Box::new(HttpUpgradeFastOpenStream::new(stream))); - } - let response = read_http_upgrade_response(&mut stream).await?; - validate_websocket_upgrade_response( - &response, - config.v2ray_http_upgrade, - websocket_key.as_deref(), - )?; - tracing::debug!( - kind = ?config.kind, - host = %config.host, - port, - path = %path, - "completed Shadowsocks websocket plugin handshake" - ); - - if config.v2ray_http_upgrade { - Ok(stream) - } else { - Ok(Box::new(WebSocketStream::new(stream))) - } -} - -async fn gost_smux_stream(stream: Box) -> Result> { - let mut smux_config = tokio_smux::SmuxConfig::default(); - smux_config.keep_alive_disable = true; - let mut session = tokio_smux::Session::client(stream, smux_config) - .map_err(|err| anyhow!("failed to start gost-plugin smux session: {err}"))?; - let mut smux_stream = session - .open_stream() - .await - .map_err(|err| anyhow!("failed to open gost-plugin smux stream: {err}"))?; - let (client, bridge) = tokio::io::duplex(64 * 1024); - let (mut bridge_read, mut bridge_write) = split(bridge); - let (upload_tx, mut upload_rx) = mpsc::channel::>(32); - - let upload = tokio::spawn(async move { - let mut buf = vec![0u8; 16 * 1024]; - loop { - let read = bridge_read.read(&mut buf).await?; - if read == 0 { - break; - } - if upload_tx.send(buf[..read].to_vec()).await.is_err() { - break; - } - } - Result::<()>::Ok(()) - }); - - tokio::spawn(async move { - let _session = session; - let result: Result<()> = async { - loop { - tokio::select! { - outbound = upload_rx.recv() => { - let Some(outbound) = outbound else { - break; - }; - smux_stream - .send_message(outbound) - .await - .map_err(|err| anyhow!("failed to write gost-plugin smux stream: {err}"))?; - } - inbound = smux_stream.recv_message() => { - let Some(inbound) = inbound - .map_err(|err| anyhow!("failed to read gost-plugin smux stream: {err}"))? - else { - break; - }; - bridge_write.write_all(&inbound).await?; - bridge_write.flush().await?; - } - } - } - Ok(()) - } - .await; - if let Err(err) = result { - tracing::debug!(error = %err, "gost-plugin smux bridge stopped"); - } - upload.abort(); - }); - - Ok(Box::new(client)) -} - -async fn kcptun_open_session( - endpoint: &ProxyServerEndpoint, - address: SocketAddr, - config: &ShadowsocksKcptunTransport, -) -> Result> { - let kcp_config = KcpConfig { - mtu: config.mtu, - nodelay: KcpNoDelayConfig { - nodelay: config.no_delay != 0, - interval: config.interval, - resend: config.resend, - nc: config.no_congestion, - }, - wnd_size: (config.sndwnd, config.rcvwnd), - flush_write: false, - flush_acks_input: config.ack_nodelay, - stream: true, - fec_data_shards: config.data_shard, - fec_parity_shards: config.parity_shard, - crypt: kcptun_block_crypt(&config.key, &config.crypt)?, - ..Default::default() - }; - let udp = match address.ip() { - IpAddr::V4(_) => UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?, - IpAddr::V6(_) => UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0)).await?, - }; - bind_proxy_outbound_socket(udp.as_raw_fd(), address.ip())?; - configure_kcptun_udp_socket(udp.as_raw_fd(), address.ip(), config); - let kcp = KcpStream::connect_with_socket(&kcp_config, udp, address) - .await - .with_context(|| { - format!( - "failed to connect Shadowsocks kcptun transport {}:{}", - endpoint.host, endpoint.port - ) - })?; - let keep_alive = if config.keep_alive == 0 { - 10 - } else { - config.keep_alive - }; - let keep_alive_interval = Duration::from_secs(keep_alive); - let smux_config = smux_rust::Config { - version: config.smux_ver, - keep_alive_disabled: false, - keep_alive_interval, - keep_alive_timeout: kcptun_smux_keep_alive_timeout(keep_alive_interval), - max_frame_size: config.frame_size, - max_receive_buffer: config.smux_buf, - max_stream_buffer: config.stream_buf, - }; - let session = match (config.no_comp, config.rate_limit) { - (true, 0) => kcptun_open_smux_session(kcp, smux_config).await?, - (true, rate_limit) => { - kcptun_open_smux_session(RateLimitedStream::new(kcp, rate_limit), smux_config).await? - } - (false, 0) => kcptun_open_smux_session(SnappyIO::new(kcp), smux_config).await?, - (false, rate_limit) => { - kcptun_open_smux_session( - SnappyIO::new(RateLimitedStream::new(kcp, rate_limit)), - smux_config, - ) - .await? - } - }; - tracing::debug!( - server = %endpoint.host, - port = endpoint.port, - crypt = %config.crypt, - mode = %config.mode, - rate_limit = config.rate_limit, - no_comp = config.no_comp, - smux_ver = config.smux_ver, - "opened Shadowsocks kcptun plugin session" - ); - Ok(session) -} - -async fn kcptun_open_smux_session( - transport: T, - smux_config: smux_rust::Config, -) -> Result> -where - T: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - smux_config - .verify() - .map_err(|err| anyhow!("invalid Shadowsocks kcptun smux config: {err}"))?; - let session = smux_rust::client(Box::new(transport), Some(smux_config)) - .await - .map_err(|err| anyhow!("failed to start Shadowsocks kcptun smux session: {err}"))?; - Ok(session) -} - -async fn kcptun_open_smux_stream(session: Arc) -> Result> { - let stream = session - .open_stream() - .await - .map_err(|err| anyhow!("failed to open Shadowsocks kcptun smux stream: {err}"))?; - Ok(Box::new(stream)) -} - -fn kcptun_smux_keep_alive_timeout(keep_alive_interval: Duration) -> Duration { - if keep_alive_interval >= KCPTUN_SMUX_DEFAULT_KEEP_ALIVE_TIMEOUT { - keep_alive_interval.saturating_mul(3) - } else { - KCPTUN_SMUX_DEFAULT_KEEP_ALIVE_TIMEOUT - } -} - -fn schedule_kcptun_session_close(session: Arc, scavenge_ttl: u64) { - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(scavenge_ttl)).await; - if !session.is_closed() { - let _ = session.close().await; - } - }); -} - -fn configure_kcptun_udp_socket( - fd: std::os::fd::RawFd, - ip: IpAddr, - config: &ShadowsocksKcptunTransport, -) { - if config.dscp > 0 { - if let Err(err) = set_kcptun_dscp(fd, ip, config.dscp) { - tracing::debug!(dscp = config.dscp, error = %err, "failed to set Shadowsocks kcptun DSCP"); - } - } - if config.sockbuf > 0 { - let sockbuf = kcptun_socket_int_value(config.sockbuf as u64); - if let Err(err) = set_socket_int_option(fd, libc::SOL_SOCKET, libc::SO_RCVBUF, sockbuf) { - tracing::debug!(sockbuf = config.sockbuf, error = %err, "failed to set Shadowsocks kcptun receive buffer"); - } - if let Err(err) = set_socket_int_option(fd, libc::SOL_SOCKET, libc::SO_SNDBUF, sockbuf) { - tracing::debug!(sockbuf = config.sockbuf, error = %err, "failed to set Shadowsocks kcptun send buffer"); - } - } -} - -fn set_kcptun_dscp(fd: std::os::fd::RawFd, ip: IpAddr, dscp: u32) -> io::Result<()> { - match ip { - IpAddr::V4(_) => set_socket_int_option( - fd, - libc::IPPROTO_IP, - libc::IP_TOS, - kcptun_ipv4_dscp_tos(dscp), - ), - IpAddr::V6(_) => set_socket_int_option( - fd, - libc::IPPROTO_IPV6, - libc::IPV6_TCLASS, - kcptun_socket_int_value(dscp as u64), - ), - } -} - -fn kcptun_ipv4_dscp_tos(dscp: u32) -> libc::c_int { - dscp.saturating_mul(4).min(u8::MAX as u32) as libc::c_int -} - -fn kcptun_socket_int_value(value: u64) -> libc::c_int { - value.min(libc::c_int::MAX as u64) as libc::c_int -} - -fn set_socket_int_option( - fd: std::os::fd::RawFd, - level: libc::c_int, - option: libc::c_int, - value: libc::c_int, -) -> io::Result<()> { - let result = unsafe { - libc::setsockopt( - fd, - level, - option, - (&value as *const libc::c_int).cast::(), - std::mem::size_of_val(&value) as libc::socklen_t, - ) - }; - if result != 0 { - return Err(io::Error::last_os_error()); - } - Ok(()) -} - -fn kcptun_block_crypt(key: &str, crypt: &str) -> Result>> { - let mut derived = [0u8; 32]; - pbkdf2::derive( - pbkdf2::PBKDF2_HMAC_SHA1, - NonZeroU32::new(4096).expect("non-zero PBKDF2 iterations"), - b"kcp-go", - key.as_bytes(), - &mut derived, - ); - let crypt = crypt.trim().to_ascii_lowercase(); - let block: Arc = match crypt.as_str() { - "null" => return Ok(None), - "none" => KcpNoneBlockCrypt::new(&derived) - .map_err(|err| anyhow!("invalid kcptun none crypt key: {err}"))?, - "tea" => Arc::new( - KcpTeaBlockCrypt::new(&derived[..16]) - .map_err(|err| anyhow!("invalid kcptun tea crypt key: {err}"))?, - ), - "xor" | "simple_xor" => Arc::new( - KcpSimpleXorBlockCrypt::new(&derived) - .map_err(|err| anyhow!("invalid kcptun xor crypt key: {err}"))?, - ), - "aes" | "aes-256" => Arc::new( - KcpAes256BlockCrypt::new(&derived) - .map_err(|err| anyhow!("invalid kcptun aes crypt key: {err}"))?, - ), - "aes-128" => Arc::new( - KcpAes128BlockCrypt::new(&derived[..16]) - .map_err(|err| anyhow!("invalid kcptun aes-128 crypt key: {err}"))?, - ), - "aes-192" => Arc::new( - KcpAes192BlockCrypt::new(&derived[..24]) - .map_err(|err| anyhow!("invalid kcptun aes-192 crypt key: {err}"))?, - ), - "blowfish" => Arc::new( - KcpBlowfishBlockCrypt::new(&derived) - .map_err(|err| anyhow!("invalid kcptun blowfish crypt key: {err}"))?, - ), - "cast5" => Arc::new( - KcpCast5BlockCrypt::new(&derived[..16]) - .map_err(|err| anyhow!("invalid kcptun cast5 crypt key: {err}"))?, - ), - "3des" | "triple_des" => Arc::new( - KcpTripleDesBlockCrypt::new(&derived[..24]) - .map_err(|err| anyhow!("invalid kcptun 3des crypt key: {err}"))?, - ), - "twofish" => Arc::new( - KcpTwofishBlockCrypt::new(&derived) - .map_err(|err| anyhow!("invalid kcptun twofish crypt key: {err}"))?, - ), - "xtea" => Arc::new( - KcpXteaBlockCrypt::new(&derived[..16]) - .map_err(|err| anyhow!("invalid kcptun xtea crypt key: {err}"))?, - ), - "salsa20" => Arc::new( - KcpSalsa20BlockCrypt::new(&derived) - .map_err(|err| anyhow!("invalid kcptun salsa20 crypt key: {err}"))?, - ), - "sm4" => Arc::new( - KcpSm4BlockCrypt::new(&derived[..16]) - .map_err(|err| anyhow!("invalid kcptun sm4 crypt key: {err}"))?, - ), - "aes-128-gcm" => Arc::new( - KcpAesGcmBlockCrypt::new(&derived[..16]) - .map_err(|err| anyhow!("invalid kcptun aes-128-gcm crypt key: {err}"))?, - ), - _ => Arc::new( - KcpAes256BlockCrypt::new(&derived) - .map_err(|err| anyhow!("invalid kcptun fallback aes crypt key: {err}"))?, - ), - }; - Ok(Some(block)) -} - -async fn websocket_plugin_tls_connect( - stream: Box, - server_name: &str, - skip_cert_verify: bool, - certificate_fingerprint: Option<&CertificateFingerprint>, - client_identity: Option<&TlsClientIdentity>, - ech: Option<&ShadowsocksEchConfig>, -) -> Result> { - #[cfg(target_vendor = "apple")] - { - if ech.is_none() { - return websocket_plugin_native_tls_connect( - stream, - server_name, - skip_cert_verify, - certificate_fingerprint, - client_identity, - ) - .await; - } - } - websocket_plugin_rustls_tls_connect( - stream, - server_name, - skip_cert_verify, - certificate_fingerprint, - client_identity, - ech, - ) - .await -} - -async fn websocket_plugin_rustls_tls_connect( - stream: Box, - server_name: &str, - skip_cert_verify: bool, - certificate_fingerprint: Option<&CertificateFingerprint>, - client_identity: Option<&TlsClientIdentity>, - ech: Option<&ShadowsocksEchConfig>, -) -> Result> { - let ech_config_list = resolve_shadowsocks_ech_config_list(server_name, ech)?; - let config = rustls_tls_config( - &["http/1.1".to_owned()], - skip_cert_verify, - certificate_fingerprint, - client_identity, - ech_config_list.as_deref(), - )?; - let connector = TlsConnector::from(Arc::new(config)); - let name = ServerName::try_from(server_name.to_owned()) - .with_context(|| format!("invalid websocket plugin SNI {server_name}"))?; - Ok(Box::new(connector.connect(name, stream).await?)) -} - -fn resolve_shadowsocks_ech_config_list( - server_name: &str, - ech: Option<&ShadowsocksEchConfig>, -) -> Result>> { - let Some(ech) = ech else { - return Ok(None); - }; - if let Some(config_list) = ech.config_list.as_ref() { - return Ok(Some(config_list.clone())); - } - - let query_name = ech.query_server_name.as_deref().unwrap_or(server_name); - let mut last_error = None; - for dns_server in platform_proxy_dns_servers() { - let Ok(ip) = dns_server.parse::() else { - continue; - }; - let dns_addr = SocketAddr::new(ip, 53); - match direct_dns_https_ech_config_list(query_name, dns_addr) { - Ok(Some(config_list)) => return Ok(Some(config_list)), - Ok(None) => { - last_error = Some(anyhow!( - "DNS HTTPS response for {query_name} did not include an ECH config" - )); - } - Err(err) => last_error = Some(err), - } - } - Err(last_error.unwrap_or_else(|| { - anyhow!("failed to resolve ECH config for Shadowsocks websocket host {query_name}") - })) -} - -#[cfg(target_vendor = "apple")] -async fn websocket_plugin_native_tls_connect( - stream: Box, - server_name: &str, - skip_cert_verify: bool, - certificate_fingerprint: Option<&CertificateFingerprint>, - client_identity: Option<&TlsClientIdentity>, -) -> Result> { - let mut builder = NativeTlsConnector::builder(); - if skip_cert_verify || certificate_fingerprint.is_some() { - builder.danger_accept_invalid_certs(true); - } - if let Some(identity) = client_identity { - builder.identity(load_native_tls_identity(identity)?); - } - builder.request_alpns(&["http/1.1"]); - let connector = TokioNativeTlsConnector::from( - builder - .build() - .context("failed to build native TLS connector for Shadowsocks websocket plugin")?, - ); - let tls = connector.connect(server_name, stream).await?; - if let Some(fingerprint) = certificate_fingerprint { - verify_native_tls_peer_certificate(&tls, fingerprint, "Shadowsocks websocket plugin")?; - } - Ok(Box::new(tls)) -} - -async fn shadow_tls_connect( - stream: Box, - config: &ShadowsocksShadowTlsTransport, -) -> Result> { - match config.version { - 1 => { - let tls_config = shadow_tls_client_config(config)?; - let connector = TlsConnector::from(Arc::new(tls_config)); - let name = ServerName::try_from(config.host.clone()) - .with_context(|| format!("invalid ShadowTLS host {}", config.host))?; - let tls = connector - .connect(name, stream) - .await - .with_context(|| format!("ShadowTLS v1 handshake failed for {}", config.host))?; - let (stream, _) = tls.into_inner(); - Ok(stream) - } - 2 => { - if config.client_fingerprint.is_some() { - #[cfg(feature = "boring-browser-fingerprints")] - return shadow_tls_v2_boring_connect(stream, config).await; - #[cfg(not(feature = "boring-browser-fingerprints"))] - bail!( - "unsupported ShadowTLS v2 client-fingerprint: browser TLS profiles require the boring-browser-fingerprints feature" - ); - } - let tls_config = shadow_tls_client_config(config)?; - let connector = TlsConnector::from(Arc::new(tls_config)); - let name = ServerName::try_from(config.host.clone()) - .with_context(|| format!("invalid ShadowTLS host {}", config.host))?; - let hashed = HmacReadStream::new(stream, &config.password); - let tls = connector - .connect(name, hashed) - .await - .with_context(|| format!("ShadowTLS v2 handshake failed for {}", config.host))?; - let (hashed, _) = tls.into_inner(); - let (stream, prefix) = hashed.finish(); - Ok(Box::new(ShadowTlsV2Stream::new(stream, prefix))) - } - version => bail!("unsupported ShadowTLS version {version}"), - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -async fn shadow_tls_v2_boring_connect( - stream: Box, - config: &ShadowsocksShadowTlsTransport, -) -> Result> { - let fingerprint = config - .client_fingerprint - .ok_or_else(|| anyhow!("missing ShadowTLS v2 client fingerprint"))?; - let profile = shadow_tls_v2_boring_profile(fingerprint)?; - let domain = RamaDomain::try_from(config.host.clone()) - .with_context(|| format!("invalid ShadowTLS host {}", config.host))?; - let mut builder = BoringTlsConnectorDataBuilder::try_from(profile.client_config.as_ref()) - .with_context(|| { - format!( - "failed to build browser TLS profile for client-fingerprint {}", - fingerprint.mihomo_name() - ) - })?; - builder = builder - .with_server_name(domain) - .with_alpn_protos(Bytes::from(alpn_wire_protocols(&config.alpn)?)); - if config.skip_cert_verify || config.certificate_fingerprint.is_some() { - builder = builder.with_server_verify_mode(RamaServerVerifyMode::Disable); - } - if let Some(identity) = &config.client_identity { - builder = builder.with_client_auth(load_boring_client_identity(identity)?); - } - let data = builder.build().with_context(|| { - format!( - "failed to configure ShadowTLS v2 browser TLS profile for {}", - config.host - ) - })?; - let hashed = DetachableStream::new(HmacReadStream::new(stream, &config.password)); - let mut tls = rama_tls_boring::core::tokio::connect(data.config, config.host.as_str(), hashed) - .await - .map_err(|err| { - anyhow!( - "ShadowTLS v2 browser-profile handshake failed for {} using client-fingerprint {}: {err}", - config.host, - fingerprint.mihomo_name() - ) - })?; - if let Some(certificate_fingerprint) = config.certificate_fingerprint.as_ref() { - verify_boring_tls_peer_certificate( - &tls, - certificate_fingerprint, - "ShadowTLS v2 browser-profile", - )?; - } - let hashed = tls - .get_mut() - .take() - .context("failed to recover ShadowTLS v2 browser-profile transport")?; - let (stream, prefix) = hashed.finish(); - Ok(Box::new(ShadowTlsV2Stream::new(stream, prefix))) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn shadow_tls_v2_boring_profile(fingerprint: MihomoClientFingerprint) -> Result { - let profile = mihomo_browser_tls_profile(fingerprint)?; - Ok(strip_shadow_tls_v2_unsupported_groups(profile)) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn mihomo_browser_tls_profile(fingerprint: MihomoClientFingerprint) -> Result { - Ok(mihomo_browser_user_agent_profile(fingerprint)?.tls.clone()) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn mihomo_browser_user_agent_profile( - fingerprint: MihomoClientFingerprint, -) -> Result<&'static rama_ua::profile::UserAgentProfile> { - let selected = match fingerprint { - MihomoClientFingerprint::Random => mihomo_initial_random_fingerprint(), - other => other, - }; - let (kind, platform, edge, required_ua_marker) = match selected { - MihomoClientFingerprint::Chrome => ( - UserAgentKind::Chromium, - Some(PlatformKind::MacOS), - false, - None, - ), - MihomoClientFingerprint::Firefox => ( - UserAgentKind::Firefox, - Some(PlatformKind::MacOS), - false, - None, - ), - MihomoClientFingerprint::Safari => ( - UserAgentKind::Safari, - Some(PlatformKind::MacOS), - false, - None, - ), - MihomoClientFingerprint::Safari16 => ( - UserAgentKind::Safari, - Some(PlatformKind::MacOS), - false, - Some("Version/16."), - ), - MihomoClientFingerprint::Ios => { - (UserAgentKind::Safari, Some(PlatformKind::IOS), false, None) - } - MihomoClientFingerprint::Android => ( - UserAgentKind::Chromium, - Some(PlatformKind::Android), - false, - None, - ), - MihomoClientFingerprint::Edge => ( - UserAgentKind::Chromium, - Some(PlatformKind::Windows), - true, - None, - ), - other => bail!( - "unsupported client-fingerprint {}: no matching browser TLS profile is available yet", - other.mihomo_name() - ), - }; - embedded_user_agent_profiles()? - .iter() - .filter(|profile| { - profile.ua_kind == kind - && profile.platform == platform - && required_ua_marker - .map(|marker| { - profile - .ua_str() - .map(|ua| ua.contains(marker)) - .unwrap_or(false) - }) - .unwrap_or(true) - && profile - .ua_str() - .map(|ua| ua.contains(" Edg/") == edge) - .unwrap_or(!edge) - }) - .max_by_key(|profile| profile.ua_version.unwrap_or(0)) - .ok_or_else(|| { - anyhow!( - "no embedded browser TLS profile matched client-fingerprint {}", - fingerprint.mihomo_name() - ) - }) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn strip_shadow_tls_v2_unsupported_groups(mut profile: TlsProfile) -> TlsProfile { - let mut config = profile.client_config.as_ref().clone(); - strip_x25519_mlkem768_from_client_config(&mut config); - profile.client_config = Arc::new(config); - profile -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn strip_x25519_mlkem768_from_client_config(config: &mut rama_net::tls::client::ClientConfig) { - if let Some(extensions) = config.extensions.as_mut() { - for extension in extensions { - if let RamaClientHelloExtension::SupportedGroups(groups) = extension { - groups.retain(|group| *group != RamaSupportedGroup::X25519MLKEM768); - } - } - } -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn mihomo_initial_random_fingerprint() -> MihomoClientFingerprint { - use std::sync::OnceLock; - - static FINGERPRINT: OnceLock = OnceLock::new(); - *FINGERPRINT.get_or_init(|| match rand::thread_rng().gen_range(0..12) { - 0..=5 => MihomoClientFingerprint::Chrome, - 6..=8 => MihomoClientFingerprint::Safari, - 9..=10 => MihomoClientFingerprint::Ios, - _ => MihomoClientFingerprint::Firefox, - }) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn embedded_user_agent_profiles() -> Result<&'static [rama_ua::profile::UserAgentProfile]> { - use std::sync::OnceLock; - - static PROFILES: OnceLock, String>> = - OnceLock::new(); - PROFILES - .get_or_init(|| { - try_load_embedded_profiles() - .map(|profiles| profiles.collect()) - .map_err(|err| err.to_string()) - }) - .as_deref() - .map_err(|err| anyhow!("failed to load embedded browser TLS profiles: {err}")) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn alpn_wire_protocols(protocols: &[String]) -> Result> { - let mut out = Vec::new(); - for protocol in protocols { - let protocol = protocol.as_bytes(); - let len = u8::try_from(protocol.len()).context("ALPN protocol identifier is too long")?; - out.push(len); - out.extend_from_slice(protocol); - } - Ok(out) -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn verify_boring_tls_peer_certificate( - tls: &rama_tls_boring::client::BoringTlsStream, - fingerprint: &CertificateFingerprint, - protocol: &str, -) -> Result<()> { - let leaf = tls - .ssl() - .peer_certificate() - .ok_or_else(|| anyhow!("{protocol} peer did not present a certificate"))?; - if boring_certificate_fingerprint_matches(&leaf, fingerprint)? { - return Ok(()); - } - if let Some(chain) = tls.ssl().peer_cert_chain() { - for certificate in chain { - if boring_certificate_fingerprint_matches(certificate, fingerprint)? { - return Ok(()); - } - } - } - bail!("{protocol} certificate fingerprint mismatch"); -} - -#[cfg(feature = "boring-browser-fingerprints")] -fn boring_certificate_fingerprint_matches( - certificate: &BoringX509Ref, - fingerprint: &CertificateFingerprint, -) -> Result { - let digest = certificate - .digest(BoringMessageDigest::sha256()) - .context("failed to compute TLS certificate fingerprint")?; - Ok(&*digest == fingerprint.0) -} - -#[cfg(feature = "boring-browser-fingerprints")] -async fn restls_boring_connect( - stream: Box, - config: &ShadowsocksRestlsTransport, -) -> Result> { - let fingerprint = config.client_fingerprint.browser_profile(); - let profile = mihomo_browser_tls_profile(fingerprint)?; - let domain = RamaDomain::try_from(config.host.clone()) - .with_context(|| format!("invalid Restls host {}", config.host))?; - let builder = BoringTlsConnectorDataBuilder::try_from(profile.client_config.as_ref()) - .with_context(|| { - format!( - "failed to build Restls browser TLS profile for client-fingerprint {}", - fingerprint.mihomo_name() - ) - })? - .with_server_name(domain); - let data = builder.build().with_context(|| { - format!( - "failed to configure Restls browser TLS profile for {}", - config.host - ) - })?; - let handshake_stream = RestlsHandshakeStream::new(stream, config.secret, false); - let signed_stream = DetachableStream::new(RestlsSignedClientHelloStream::new( - handshake_stream, - config.secret, - )); - let mut tls = - rama_tls_boring::core::tokio::connect(data.config, config.host.as_str(), signed_stream) - .await - .map_err(|err| { - anyhow!( - "Restls browser-profile handshake failed for {} using client-fingerprint {}: {err}", - config.host, - fingerprint.mihomo_name() - ) - })?; - let signed_stream = tls - .get_mut() - .take() - .context("failed to recover Restls browser-profile transport")?; - let (handshake_stream, client_hello_signed) = signed_stream.into_inner(); - if !client_hello_signed { - bail!("Restls browser-profile handshake did not sign the ClientHello session ID"); - } - let (stream, handshake) = handshake_stream.into_inner(); - if !handshake.server_auth_unmasked { - bail!("Restls server authentication record was not observed"); - } - let server_random = handshake - .server_random - .ok_or_else(|| anyhow!("Restls handshake did not expose server random"))?; - let client_finished = handshake - .client_finished_auth - .ok_or_else(|| anyhow!("Restls handshake did not capture ClientFinished record"))?; - let mut codec = RestlsApplicationCodec::new(config.secret, server_random, false); - codec.set_client_finished_auth(client_finished); - codec.set_tls12_gcm_to_client_disable_counter(handshake.tls12_gcm_server_disable_counter); - let state = RestlsStreamState::new(codec, config.script.clone()); - Ok(Box::new(RestlsStream::new(stream, state))) -} - -fn shadow_tls_client_config(config: &ShadowsocksShadowTlsTransport) -> Result { - install_rustls_crypto_provider(); - let mut root_store = RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let versions = if config.version == 1 { - &[&version::TLS12][..] - } else { - rustls::DEFAULT_VERSIONS - }; - let builder = - ClientConfig::builder_with_protocol_versions(versions).with_root_certificates(root_store); - let mut tls_config = if let Some(identity) = config.client_identity.as_ref() { - let (cert_chain, private_key) = load_rustls_client_identity(identity)?; - builder - .with_client_auth_cert(cert_chain, private_key) - .context("failed to configure ShadowTLS client certificate")? - } else { - builder.with_no_client_auth() - }; - tls_config.alpn_protocols = config - .alpn - .iter() - .map(|value| value.as_bytes().to_vec()) - .collect(); - if let Some(fingerprint) = config.certificate_fingerprint.as_ref() { - tls_config - .dangerous() - .set_certificate_verifier(Arc::new(PinnedCertificateVerifier { - fingerprint: fingerprint.clone(), - })); - } else if config.skip_cert_verify { - tls_config - .dangerous() - .set_certificate_verifier(Arc::new(NoCertificateVerifier)); - } - Ok(tls_config) -} - -async fn read_http_upgrade_response(stream: &mut Box) -> Result { - let mut response = Vec::with_capacity(1024); - let mut byte = [0u8; 1]; - while response.len() < 16 * 1024 { - stream.read_exact(&mut byte).await?; - response.push(byte[0]); - if response.ends_with(b"\r\n\r\n") { - return String::from_utf8(response) - .context("websocket plugin response headers were not valid UTF-8"); - } - } - bail!("websocket plugin response headers exceeded Burrow limit") -} - -fn validate_websocket_upgrade_response( - response: &str, - v2ray_http_upgrade: bool, - _websocket_key: Option<&str>, -) -> Result<()> { - let mut lines = response.split("\r\n"); - let status = lines.next().unwrap_or_default(); - let switching = status.contains(" 101 "); - let mut connection_upgrade = false; - let mut upgrade_websocket = false; - for line in lines { - let Some((key, value)) = line.split_once(':') else { - continue; - }; - let value = value.trim(); - if key.eq_ignore_ascii_case("connection") - && value - .split(',') - .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) - { - connection_upgrade = true; - } - if key.eq_ignore_ascii_case("upgrade") && value.eq_ignore_ascii_case("websocket") { - upgrade_websocket = true; - } - } - if !switching || !connection_upgrade || !upgrade_websocket { - bail!("unexpected websocket plugin response status {status}"); - } - if !v2ray_http_upgrade { - // Mihomo validates Sec-WebSocket-Accept only in debug builds. Burrow keeps - // the same operational behavior and requires the protocol switch headers. - } - Ok(()) -} - -fn websocket_binary_frame(payload: &[u8]) -> io::Result> { - let mut frame = Vec::with_capacity(payload.len() + 14); - frame.push(0x82); - let len = payload.len(); - if len < 126 { - frame.push(0x80 | len as u8); - } else if u16::try_from(len).is_ok() { - frame.push(0x80 | 126); - frame.extend_from_slice(&(len as u16).to_be_bytes()); - } else { - frame.push(0x80 | 127); - frame.extend_from_slice(&(len as u64).to_be_bytes()); - } - let mut mask = [0u8; 4]; - OsRng.fill_bytes(&mut mask); - frame.extend_from_slice(&mask); - let mut masked = payload.to_vec(); - apply_websocket_mask(&mut masked, mask); - frame.extend_from_slice(&masked); - Ok(frame) -} - -fn apply_websocket_mask(payload: &mut [u8], mask: [u8; 4]) { - for (index, byte) in payload.iter_mut().enumerate() { - *byte ^= mask[index % 4]; - } -} - -fn websocket_path(path: &str) -> String { - let path = path.trim(); - if path.is_empty() { - return "/".to_owned(); - } - if path.starts_with('/') { - path.to_owned() - } else { - format!("/{path}") - } -} - -fn websocket_path_and_early_data(path: &str) -> (String, Option) { - let path = websocket_path(path); - let Some((base, query)) = path.split_once('?') else { - return (path, None); - }; - let mut max_early_data = None; - let mut kept = Vec::new(); - for pair in query.split('&') { - if pair.is_empty() { - continue; - } - let (key, value) = pair.split_once('=').unwrap_or((pair, "")); - if key == "ed" { - if max_early_data.is_none() { - max_early_data = value.parse::().ok().filter(|value| *value > 0); - } - continue; - } - kept.push(pair); - } - let path = if kept.is_empty() { - base.to_owned() - } else { - format!("{base}?{}", kept.join("&")) - }; - (path, max_early_data) -} - -fn websocket_plugin_request( - config: &ShadowsocksWebSocketTransport, - _port: u16, - path: &str, - early_data: Option<&[u8]>, -) -> Result<(Vec, Option)> { - let host = config.websocket_host_header(); - let mut request = format!( - "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" - ); - for (key, value) in config.headers.iter() { - if key.eq_ignore_ascii_case("host") - || key.eq_ignore_ascii_case("connection") - || key.eq_ignore_ascii_case("upgrade") - || (early_data.is_some() && key.eq_ignore_ascii_case("sec-websocket-protocol")) - || (!config.v2ray_http_upgrade - && (key.eq_ignore_ascii_case("sec-websocket-key") - || key.eq_ignore_ascii_case("sec-websocket-version"))) - { - continue; - } - request.push_str(key); - request.push_str(": "); - request.push_str(value); - request.push_str("\r\n"); - } - - let websocket_key = if config.v2ray_http_upgrade { - None - } else { - let mut key = [0u8; 16]; - OsRng.fill_bytes(&mut key); - let key = BASE64_STANDARD.encode(key); - request.push_str("Sec-WebSocket-Version: 13\r\n"); - request.push_str("Sec-WebSocket-Key: "); - request.push_str(&key); - request.push_str("\r\n"); - Some(key) - }; - if let Some(early_data) = early_data { - request.push_str("Sec-WebSocket-Protocol: "); - request.push_str(&BASE64_URL_SAFE_NO_PAD.encode(early_data)); - request.push_str("\r\n"); - } - request.push_str("\r\n"); - Ok((request.into_bytes(), websocket_key)) -} - -impl ShadowsocksWebSocketTransport { - fn websocket_host_header(&self) -> &str { - self.headers - .iter() - .find(|(key, _)| key.eq_ignore_ascii_case("host")) - .map(|(_, value)| value.as_str()) - .unwrap_or(&self.host) - } -} - -fn v2ray_mux_open_frame() -> Vec { - let mut frame = Vec::new(); - frame.extend_from_slice(&[0u8, 0u8]); - frame.extend_from_slice(&[0u8, 0u8]); - frame.push(0x01); - frame.push(0x00); - frame.push(0x01); - frame.extend_from_slice(&0u16.to_be_bytes()); - frame.push(0x02); - frame.extend_from_slice(b"127.0.0.1"); - let len = u16::try_from(frame.len() - 2).expect("v2ray mux open frame fits u16"); - frame[..2].copy_from_slice(&len.to_be_bytes()); - frame -} - -fn v2ray_mux_data_frame(payload: &[u8]) -> io::Result> { - if payload.len() > u16::MAX as usize { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "v2ray-plugin mux payload is too large", - )); - } - let mut frame = Vec::with_capacity(payload.len() + 8); - frame.extend_from_slice(&4u16.to_be_bytes()); - frame.extend_from_slice(&[0u8, 0u8]); - frame.push(0x02); - frame.push(0x01); - frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - frame.extend_from_slice(payload); - Ok(frame) -} - -fn shadowsocks_plugin_transport( - plugin: Option<&ShadowsocksPlugin>, - client_fingerprint: Option<&str>, -) -> Result> { - let Some(plugin) = plugin else { - return Ok(None); - }; - let name = plugin.name.trim(); - if name == "obfs" { - let mode = plugin - .opts - .get("mode") - .or_else(|| plugin.opts.get("obfs")) - .map(|value| value.trim().to_ascii_lowercase()) - .ok_or_else(|| anyhow!("Shadowsocks obfs plugin requires mode"))?; - let mode = match mode.as_str() { - "http" => SimpleObfsMode::Http, - "tls" => SimpleObfsMode::Tls, - other => bail!("unsupported Shadowsocks obfs mode {other}"), - }; - let host = plugin - .opts - .get("host") - .or_else(|| plugin.opts.get("obfs-host")) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or("bing.com") - .to_owned(); - return Ok(Some(ShadowsocksPluginTransport::SimpleObfs { mode, host })); - } - if name == "v2ray-plugin" || name == "gost-plugin" { - let mode = plugin - .opts - .get("mode") - .or_else(|| plugin.opts.get("obfs")) - .map(|value| value.trim().to_ascii_lowercase()) - .ok_or_else(|| anyhow!("Shadowsocks {name} plugin requires websocket mode"))?; - if mode != "websocket" { - bail!("unsupported Shadowsocks {name} mode {mode}"); - } - let mux = plugin_bool_opt(&plugin.opts, "mux").unwrap_or(true); - let mux = match (name, mux) { - ("v2ray-plugin", true) => ShadowsocksWebSocketMux::V2ray, - ("gost-plugin", true) => ShadowsocksWebSocketMux::Gost, - (_, false) => ShadowsocksWebSocketMux::None, - _ => ShadowsocksWebSocketMux::None, - }; - let host = plugin - .opts - .get("host") - .or_else(|| plugin.opts.get("obfs-host")) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or("bing.com") - .to_owned(); - let path = plugin - .opts - .get("path") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or("/") - .to_owned(); - let transport = ShadowsocksWebSocketTransport { - kind: if name == "v2ray-plugin" { - ShadowsocksWebSocketKind::V2ray - } else { - ShadowsocksWebSocketKind::Gost - }, - host, - path, - headers: plugin_headers(&plugin.opts), - tls: plugin_bool_opt(&plugin.opts, "tls").unwrap_or(false), - ech: plugin_ech_config(&plugin.opts)?, - skip_cert_verify: plugin_bool_opt(&plugin.opts, "skip-cert-verify").unwrap_or(false), - certificate_fingerprint: plugin - .opts - .get("fingerprint") - .map(|value| parse_certificate_fingerprint(value)) - .transpose()?, - client_identity: plugin_tls_client_identity(&plugin.opts)?, - mux, - v2ray_http_upgrade: plugin_bool_opt(&plugin.opts, "v2ray-http-upgrade") - .unwrap_or(false), - v2ray_http_upgrade_fast_open: name == "v2ray-plugin" - && plugin_bool_opt(&plugin.opts, "v2ray-http-upgrade-fast-open").unwrap_or(false), - }; - return Ok(Some(ShadowsocksPluginTransport::WebSocket(transport))); - } - if name == "shadow-tls" { - let host = plugin - .opts - .get("host") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| anyhow!("Shadowsocks shadow-tls plugin requires host"))? - .to_owned(); - let version = plugin_u8_opt(&plugin.opts, "version").unwrap_or(2); - match version { - 1 | 2 => {} - other => bail!("unsupported Shadowsocks shadow-tls version {other}"), - } - let parsed_client_fingerprint = - client_fingerprint.and_then(MihomoClientFingerprint::from_mihomo_name); - if let Some(client_fingerprint) = parsed_client_fingerprint { - if version == 2 && !client_fingerprint.shadow_tls_v2_boring_supported() { - bail!( - "unsupported Shadowsocks {name} client-fingerprint {}: no matching browser TLS profile is available yet", - client_fingerprint.mihomo_name() - ); - } - } - let password = plugin - .opts - .get("password") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - if version != 1 && password.is_empty() { - bail!("Shadowsocks shadow-tls plugin requires password for version {version}"); - } - let password = password.to_owned(); - let alpn = plugin_list_opt(&plugin.opts, "alpn") - .unwrap_or_else(|| vec!["h2".to_owned(), "http/1.1".to_owned()]); - return Ok(Some(ShadowsocksPluginTransport::ShadowTls( - ShadowsocksShadowTlsTransport { - host, - password, - version, - alpn, - skip_cert_verify: plugin_bool_opt(&plugin.opts, "skip-cert-verify") - .unwrap_or(false), - certificate_fingerprint: plugin - .opts - .get("fingerprint") - .map(|value| parse_certificate_fingerprint(value)) - .transpose()?, - client_identity: plugin_tls_client_identity(&plugin.opts)?, - client_fingerprint: if version == 2 || version == 3 { - parsed_client_fingerprint - } else { - None - }, - }, - ))); - } - if name == "kcptun" { - let mut transport = ShadowsocksKcptunTransport::empty(); - if let Some(value) = plugin.opts.get("key") { - transport.key = value.trim().to_owned(); - } - if let Some(value) = plugin.opts.get("crypt") { - transport.crypt = value.trim().to_owned(); - } - if let Some(value) = plugin.opts.get("mode") { - transport.mode = value.trim().to_owned(); - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "conn") { - transport.conn = value; - } - if let Some(value) = plugin_u64_opt(&plugin.opts, "autoexpire") { - transport.auto_expire = value; - } - if let Some(value) = plugin_u64_opt(&plugin.opts, "scavengettl") { - transport.scavenge_ttl = value; - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "mtu") { - transport.mtu = value; - } - if let Some(value) = plugin_u32_opt(&plugin.opts, "ratelimit") { - transport.rate_limit = value; - } - if let Some(value) = plugin_u16_opt(&plugin.opts, "sndwnd") { - transport.sndwnd = value; - } - if let Some(value) = plugin_u16_opt(&plugin.opts, "rcvwnd") { - transport.rcvwnd = value; - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "datashard") { - transport.data_shard = value; - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "parityshard") { - transport.parity_shard = value; - } - if let Some(value) = plugin_u32_opt(&plugin.opts, "dscp") { - transport.dscp = value; - } - if let Some(value) = plugin_bool_opt(&plugin.opts, "nocomp") { - transport.no_comp = value; - } - if let Some(value) = plugin_bool_opt(&plugin.opts, "acknodelay") { - transport.ack_nodelay = value; - } - if let Some(value) = plugin_i32_opt(&plugin.opts, "nodelay") { - transport.no_delay = value; - } - if let Some(value) = plugin_i32_opt(&plugin.opts, "interval") { - transport.interval = value; - } - if let Some(value) = plugin_i32_opt(&plugin.opts, "resend") { - transport.resend = value; - } - if let Some(value) = plugin_bool_opt(&plugin.opts, "nc") { - transport.no_congestion = value; - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "sockbuf") { - transport.sockbuf = value; - } - if let Some(value) = plugin_u8_opt(&plugin.opts, "smuxver") { - transport.smux_ver = value; - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "smuxbuf") { - transport.smux_buf = value; - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "framesize") { - transport.frame_size = value; - } - if let Some(value) = plugin_usize_opt(&plugin.opts, "streambuf") { - transport.stream_buf = value; - } - if let Some(value) = plugin_u64_opt(&plugin.opts, "keepalive") { - transport.keep_alive = value; - } - transport.fill_defaults(); - return Ok(Some(ShadowsocksPluginTransport::Kcptun(transport))); - } - Ok(None) -} - -fn restls_transport( - plugin: &ShadowsocksPlugin, - client_fingerprint: Option<&str>, -) -> Result { - let host = plugin - .opts - .get("host") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| anyhow!("Shadowsocks restls plugin requires host"))? - .to_owned(); - let password = plugin - .opts - .get("password") - .map(|value| value.trim()) - .unwrap_or("") - .to_owned(); - let version_hint = plugin - .opts - .get("version-hint") - .map(|value| restls_version_hint(value)) - .transpose()? - .ok_or_else(|| anyhow!("Shadowsocks restls plugin requires version-hint tls12 or tls13"))?; - let script = plugin - .opts - .get("restls-script") - .map(|value| value.as_str()) - .unwrap_or(RESTLS_DEFAULT_SCRIPT); - let script = parse_restls_script(script)?; - let client_fingerprint = restls_client_fingerprint(client_fingerprint); - let secret = blake3::derive_key("restls-traffic-key", password.as_bytes()); - let session_tickets_disabled = version_hint == RestlsVersionHint::Tls13; - Ok(ShadowsocksRestlsTransport { - host, - password, - version_hint, - script, - client_fingerprint, - session_tickets_disabled, - secret, - }) -} - -fn restls_version_hint(value: &str) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "tls12" => Ok(RestlsVersionHint::Tls12), - "tls13" => Ok(RestlsVersionHint::Tls13), - _ => bail!("invalid Shadowsocks restls version-hint: expected tls12 or tls13"), - } -} - -fn restls_client_fingerprint(value: Option<&str>) -> RestlsClientFingerprint { - match value.map(str::trim).unwrap_or("") { - "firefox" => RestlsClientFingerprint::Firefox, - "safari" => RestlsClientFingerprint::Safari, - "ios" => RestlsClientFingerprint::Ios, - _ => RestlsClientFingerprint::Chrome, - } -} - -fn parse_restls_script(script: &str) -> Result> { - let mut lines = Vec::new(); - for raw in script.replace(' ', "").split(',') { - if raw.is_empty() { - continue; - } - let mut input = raw; - let base = restls_take_integer(&mut input) - .with_context(|| format!("invalid Shadowsocks restls script line {raw}"))?; - let mut target_len = RestlsTargetLength { base, random_range: 0 }; - if input.starts_with('~') || input.starts_with('?') { - let randomize_once = input.starts_with('?'); - input = &input[1..]; - let random_range = restls_take_integer(&mut input) - .with_context(|| format!("invalid Shadowsocks restls script line {raw}"))?; - if u32::from(base) + u32::from(random_range) > 32768 { - bail!("Shadowsocks restls script target length exceeds 32768"); - } - if randomize_once { - let offset = if random_range == 0 { - 0 - } else { - rand::thread_rng().gen_range(0..usize::from(random_range)) as u16 - }; - target_len.base = base + offset; - } else { - target_len.random_range = random_range; - } - } - let command = if input.is_empty() { - RestlsCommand::Noop - } else if let Some(rest) = input.strip_prefix('<') { - input = rest; - RestlsCommand::Response( - restls_take_integer(&mut input) - .with_context(|| format!("invalid Shadowsocks restls script line {raw}"))?, - ) - } else { - bail!("invalid Shadowsocks restls script line {raw}"); - }; - if !input.is_empty() { - bail!("invalid Shadowsocks restls script line {raw}"); - } - lines.push(RestlsScriptLine { target_len, command }); - } - Ok(lines) -} - -fn restls_take_integer(input: &mut &str) -> Result { - let digit_len = input - .bytes() - .take_while(|byte| byte.is_ascii_digit()) - .count(); - if digit_len == 0 { - bail!("missing integer"); - } - let (digits, rest) = input.split_at(digit_len); - let value = digits.parse::().context("invalid integer")?; - if value > 32768 { - bail!("integer exceeds 32768"); - } - *input = rest; - Ok(value) -} - -#[allow(dead_code)] -impl RestlsCommand { - fn to_bytes(self) -> [u8; RESTLS_CMD_LEN] { - match self { - RestlsCommand::Noop => [0, 0], - RestlsCommand::Response(value) => [1, value as u8], - } - } - - fn from_bytes(bytes: [u8; RESTLS_CMD_LEN]) -> Result { - match bytes[0] { - 0 => Ok(RestlsCommand::Noop), - 1 => Ok(RestlsCommand::Response(u16::from(bytes[1]))), - _ => bail!("unsupported Shadowsocks restls command"), - } - } -} - -#[allow(dead_code)] -impl RestlsTargetLength { - fn len(&self) -> usize { - let offset = if self.random_range == 0 { - 0 - } else { - rand::thread_rng().gen_range(0..usize::from(self.random_range)) - }; - usize::from(self.base) + offset - } -} - -#[allow(dead_code)] -fn restls_tls13_session_id( - secret: &[u8; 32], - key_shares: &[(u16, Vec)], - psk_labels: &[Vec], - mut session_id: [u8; 32], -) -> [u8; 32] { - let mut hmac = blake3::Hasher::new_keyed(secret); - for (group, data) in key_shares { - hmac.update(&group.to_be_bytes()); - hmac.update(data); - } - for label in psk_labels { - hmac.update(label); - } - let digest = hmac.finalize(); - session_id[..RESTLS_HANDSHAKE_MAC_LEN] - .copy_from_slice(&digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]); - session_id -} - -#[allow(dead_code)] -fn restls_tls13_session_id_from_client_hello( - secret: &[u8; 32], - client_hello: &[u8], - seed_session_id: [u8; 32], -) -> Result<[u8; 32]> { - let material = restls_tls13_client_hello_auth_material(client_hello)?; - Ok(restls_tls13_session_id( - secret, - &material.key_shares, - &material.psk_labels, - seed_session_id, - )) -} - -fn restls_tls13_session_id_for_client_hello( - secret: &[u8; 32], - client_hello: &[u8], - error: &StdMutex>, -) -> [u8; 32] { - let mut session_id_seed = [0u8; 32]; - OsRng.fill_bytes(&mut session_id_seed[RESTLS_HANDSHAKE_MAC_LEN..]); - match restls_tls13_session_id_from_client_hello(secret, client_hello, session_id_seed) { - Ok(session_id) => session_id, - Err(err) => { - let message = - format!("failed to derive Restls TLS 1.3 session id from ClientHello: {err:#}"); - tracing::warn!(error = %err, "failed to derive Restls TLS 1.3 session id from ClientHello"); - if let Ok(mut slot) = error.lock() { - *slot = Some(message); - } - session_id_seed - } - } -} - -fn restls_tls12_session_id_for_client_hello( - secret: &[u8; 32], - _client_hello: &[u8], - materials: &[Vec], - error: &StdMutex>, -) -> [u8; 32] { - match restls_tls12_session_id(secret, materials) { - Ok(session_id) => session_id, - Err(err) => { - let message = - format!("failed to derive Restls TLS 1.2 session id from key materials: {err:#}"); - tracing::warn!(error = %err, "failed to derive Restls TLS 1.2 session id"); - if let Ok(mut slot) = error.lock() { - *slot = Some(message); - } - let mut fallback = [0u8; 32]; - OsRng.fill_bytes(&mut fallback); - fallback - } - } -} - -fn take_restls_session_id_error(error: &StdMutex>) -> Option { - error.lock().ok().and_then(|mut slot| slot.take()) -} - -#[allow(dead_code)] -fn restls_tls13_sign_client_hello_record( - secret: &[u8; 32], - bytes: &[u8], -) -> io::Result>> { - if bytes.len() < TLS_RECORD_HEADER_LEN { - return Ok(None); - } - if bytes[0] != 0x16 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Restls first TLS record is not a handshake record", - )); - } - let payload_len = usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); - let record_len = TLS_RECORD_HEADER_LEN + payload_len; - if bytes.len() < record_len { - return Ok(None); - } - let payload = &bytes[TLS_RECORD_HEADER_LEN..record_len]; - if payload.first().copied() != Some(0x01) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Restls first handshake message is not ClientHello", - )); - } - let session_id_length_index = SHADOW_TLS_V3_SESSION_ID_START - 1; - if payload.get(session_id_length_index).copied() != Some(32) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Restls browser ClientHello did not reserve a 32-byte session ID", - )); - } - if payload.len() < SHADOW_TLS_V3_SESSION_ID_START + 32 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Restls browser ClientHello session ID is truncated", - )); - } - let mut seed_session_id = [0u8; 32]; - seed_session_id.copy_from_slice( - &payload[SHADOW_TLS_V3_SESSION_ID_START..SHADOW_TLS_V3_SESSION_ID_START + 32], - ); - let session_id = restls_tls13_session_id_from_client_hello(secret, payload, seed_session_id) - .map_err(|err| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("failed to derive Restls TLS 1.3 session ID from ClientHello: {err:#}"), - ) - })?; - let mut signed = bytes.to_vec(); - let session_id_start = TLS_RECORD_HEADER_LEN + SHADOW_TLS_V3_SESSION_ID_START; - signed[session_id_start..session_id_start + 32].copy_from_slice(&session_id); - Ok(Some(signed)) -} - -#[allow(dead_code)] -fn restls_tls13_client_hello_auth_material( - client_hello: &[u8], -) -> Result { - let mut input = client_hello; - let handshake_type = restls_read_u8(&mut input) - .context("Shadowsocks restls ClientHello is missing handshake type")?; - if handshake_type != 0x01 { - bail!("Shadowsocks restls auth material requires a ClientHello handshake"); - } - let handshake_len = usize::try_from(restls_read_u24(&mut input)?).expect("u24 fits into usize"); - let handshake = restls_read_exact(&mut input, handshake_len) - .context("Shadowsocks restls ClientHello body is truncated")?; - if !input.is_empty() { - bail!("Shadowsocks restls ClientHello has trailing bytes"); - } - - let mut body = handshake; - restls_read_exact(&mut body, 2).context("Shadowsocks restls ClientHello misses version")?; - restls_read_exact(&mut body, 32).context("Shadowsocks restls ClientHello misses random")?; - let session_id_len = usize::from( - restls_read_u8(&mut body).context("Shadowsocks restls ClientHello misses session ID")?, - ); - restls_read_exact(&mut body, session_id_len) - .context("Shadowsocks restls ClientHello session ID is truncated")?; - let cipher_suites_len = usize::from( - restls_read_u16(&mut body) - .context("Shadowsocks restls ClientHello misses cipher suites")?, - ); - if cipher_suites_len % 2 != 0 { - bail!("Shadowsocks restls ClientHello cipher suites length is invalid"); - } - restls_read_exact(&mut body, cipher_suites_len) - .context("Shadowsocks restls ClientHello cipher suites are truncated")?; - let compression_len = usize::from( - restls_read_u8(&mut body).context("Shadowsocks restls ClientHello misses compression")?, - ); - restls_read_exact(&mut body, compression_len) - .context("Shadowsocks restls ClientHello compression list is truncated")?; - let extensions_len = usize::from( - restls_read_u16(&mut body).context("Shadowsocks restls ClientHello misses extensions")?, - ); - let mut extensions = restls_read_exact(&mut body, extensions_len) - .context("Shadowsocks restls ClientHello extensions are truncated")?; - if !body.is_empty() { - bail!("Shadowsocks restls ClientHello body has trailing bytes"); - } - - let mut key_shares = Vec::new(); - let mut psk_labels = Vec::new(); - while !extensions.is_empty() { - let extension_type = restls_read_u16(&mut extensions) - .context("Shadowsocks restls ClientHello extension type is truncated")?; - let extension_len = usize::from(restls_read_u16(&mut extensions).with_context(|| { - format!("Shadowsocks restls ClientHello extension {extension_type} misses length") - })?); - let mut extension = - restls_read_exact(&mut extensions, extension_len).with_context(|| { - format!("Shadowsocks restls ClientHello extension {extension_type} is truncated") - })?; - match extension_type { - 0x0033 => key_shares = restls_tls13_key_share_material(&mut extension)?, - 0x0029 => psk_labels = restls_tls13_psk_labels(&mut extension)?, - _ => {} - } - if !extension.is_empty() { - bail!("Shadowsocks restls ClientHello extension {extension_type} has trailing bytes"); - } - } - - if key_shares.is_empty() { - bail!("Shadowsocks restls TLS 1.3 ClientHello has no key share material"); - } - Ok(RestlsTls13ClientHelloAuthMaterial { key_shares, psk_labels }) -} - -#[allow(dead_code)] -fn restls_tls13_key_share_material(extension: &mut &[u8]) -> Result)>> { - let list_len = usize::from( - restls_read_u16(extension) - .context("Shadowsocks restls key_share extension misses length")?, - ); - let mut shares = restls_read_exact(extension, list_len) - .context("Shadowsocks restls key_share extension is truncated")?; - let mut material = Vec::new(); - while !shares.is_empty() { - let group = restls_read_u16(&mut shares) - .context("Shadowsocks restls key_share entry misses group")?; - let key_len = usize::from( - restls_read_u16(&mut shares) - .context("Shadowsocks restls key_share entry misses key length")?, - ); - let key = restls_read_exact(&mut shares, key_len) - .context("Shadowsocks restls key_share entry is truncated")?; - material.push((group, key.to_vec())); - } - Ok(material) -} - -#[allow(dead_code)] -fn restls_tls13_psk_labels(extension: &mut &[u8]) -> Result>> { - let identities_len = usize::from( - restls_read_u16(extension) - .context("Shadowsocks restls pre_shared_key extension misses identities length")?, - ); - let mut identities = restls_read_exact(extension, identities_len) - .context("Shadowsocks restls pre_shared_key identities are truncated")?; - let mut labels = Vec::new(); - while !identities.is_empty() { - let label_len = usize::from( - restls_read_u16(&mut identities) - .context("Shadowsocks restls pre_shared_key identity misses label length")?, - ); - let label = restls_read_exact(&mut identities, label_len) - .context("Shadowsocks restls pre_shared_key identity label is truncated")?; - labels.push(label.to_vec()); - restls_read_exact(&mut identities, 4) - .context("Shadowsocks restls pre_shared_key identity misses ticket age")?; - } - let binders_len = usize::from( - restls_read_u16(extension) - .context("Shadowsocks restls pre_shared_key extension misses binders length")?, - ); - restls_read_exact(extension, binders_len) - .context("Shadowsocks restls pre_shared_key binders are truncated")?; - Ok(labels) -} - -#[allow(dead_code)] -fn restls_read_u8(input: &mut &[u8]) -> Result { - let (byte, rest) = input - .split_first() - .ok_or_else(|| anyhow!("unexpected end of Restls TLS data"))?; - *input = rest; - Ok(*byte) -} - -#[allow(dead_code)] -fn restls_read_u16(input: &mut &[u8]) -> Result { - let bytes = restls_read_exact(input, 2)?; - Ok(u16::from_be_bytes( - bytes.try_into().expect("fixed u16 byte length"), - )) -} - -#[allow(dead_code)] -fn restls_read_u24(input: &mut &[u8]) -> Result { - let bytes = restls_read_exact(input, 3)?; - Ok((u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2])) -} - -#[allow(dead_code)] -fn restls_read_exact<'a>(input: &mut &'a [u8], len: usize) -> Result<&'a [u8]> { - if input.len() < len { - bail!("unexpected end of Restls TLS data"); - } - let (head, tail) = input.split_at(len); - *input = tail; - Ok(head) -} - -#[allow(dead_code)] -fn restls_tls12_session_id(secret: &[u8; 32], materials: &[Vec]) -> Result<[u8; 32]> { - let layout: &[usize] = match materials.len() { - 3 => &RESTLS_TLS12_CLIENT_AUTH_LAYOUT_3, - 4 => &RESTLS_TLS12_CLIENT_AUTH_LAYOUT_4, - len => bail!("Shadowsocks restls TLS 1.2 auth requires 3 or 4 key materials, got {len}"), - }; - let mut session_id = [0u8; 32]; - for (index, material) in materials.iter().enumerate() { - let mut hmac = blake3::Hasher::new_keyed(secret); - hmac.update(material); - let digest = hmac.finalize(); - let start = layout[index]; - let end = layout[index + 1]; - session_id[start..end].copy_from_slice(&digest.as_bytes()[..end - start]); - } - Ok(session_id) -} - -#[allow(dead_code)] -fn restls_unmask_server_auth_record( - secret: &[u8; 32], - server_random: &[u8; 32], - record: &[u8], - tls12_gcm: bool, -) -> Result { - if record.len() < TLS_RECORD_HEADER_LEN { - bail!("Shadowsocks restls server auth record is too short"); - } - let mut hmac = blake3::Hasher::new_keyed(secret); - hmac.update(server_random); - let digest = hmac.finalize(); - let mask = &digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]; - let mut unmasked = record.to_vec(); - let mut tls12_gcm_server_disable_counter = false; - let offset = if tls12_gcm - && record.len() >= TLS_RECORD_HEADER_LEN + 8 - && record[TLS_RECORD_HEADER_LEN..TLS_RECORD_HEADER_LEN + 8] == [0u8; 8] - { - TLS_RECORD_HEADER_LEN + 8 - } else { - tls12_gcm_server_disable_counter = tls12_gcm; - TLS_RECORD_HEADER_LEN - }; - xor_with_restls_mask(&mut unmasked[offset..], mask); - Ok(RestlsServerAuthRecord { - record: unmasked, - tls12_gcm_server_disable_counter, - }) -} - -#[allow(dead_code)] -fn restls_server_hello_random(record: &[u8]) -> Option<[u8; 32]> { - if record.len() < TLS_RECORD_HEADER_LEN + 1 + 3 + 2 + 32 { - return None; - } - if record[0] != 0x16 || record[TLS_RECORD_HEADER_LEN] != 0x02 { - return None; - } - let payload_len = usize::from(u16::from_be_bytes([record[3], record[4]])); - if payload_len + TLS_RECORD_HEADER_LEN != record.len() { - return None; - } - let handshake_len = (usize::from(record[TLS_RECORD_HEADER_LEN + 1]) << 16) - | (usize::from(record[TLS_RECORD_HEADER_LEN + 2]) << 8) - | usize::from(record[TLS_RECORD_HEADER_LEN + 3]); - if handshake_len + 4 > payload_len || handshake_len < 34 { - return None; - } - let random_start = TLS_RECORD_HEADER_LEN + 1 + 3 + 2; - let mut server_random = [0u8; 32]; - server_random.copy_from_slice(&record[random_start..random_start + 32]); - Some(server_random) -} - -#[allow(dead_code)] -fn restls_tls_records(mut bytes: &[u8]) -> Vec<&[u8]> { - let mut records = Vec::new(); - while bytes.len() >= TLS_RECORD_HEADER_LEN { - let record_len = - TLS_RECORD_HEADER_LEN + usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); - if bytes.len() < record_len { - break; - } - let (record, rest) = bytes.split_at(record_len); - records.push(record); - bytes = rest; - } - records -} - -#[allow(dead_code)] -impl RestlsApplicationCodec { - fn new(secret: [u8; 32], server_random: [u8; 32], tls12_gcm: bool) -> Self { - Self { - secret, - server_random, - to_server_counter: 0, - to_client_counter: 0, - tls12_gcm, - tls12_gcm_to_client_disable_counter: false, - client_finished_auth: None, - } - } - - fn set_client_finished_auth(&mut self, record: Vec) { - self.client_finished_auth = Some(record); - } - - fn set_tls12_gcm_to_client_disable_counter(&mut self, disabled: bool) { - self.tls12_gcm_to_client_disable_counter = disabled; - } - - fn auth_header_hash(&self, direction: RestlsRecordDirection) -> blake3::Hasher { - let mut hasher = blake3::Hasher::new_keyed(&self.secret); - hasher.update(&self.server_random); - let mut counter = [0u8; 8]; - match direction { - RestlsRecordDirection::ToServer => { - hasher.update(b"client-to-server"); - counter.copy_from_slice(&self.to_server_counter.to_be_bytes()); - } - RestlsRecordDirection::ToClient => { - hasher.update(b"server-to-client"); - counter.copy_from_slice(&self.to_client_counter.to_be_bytes()); - } - } - hasher.update(&counter); - hasher - } - - fn build_to_server_record( - &mut self, - data: &[u8], - script: &[RestlsScriptLine], - ) -> Result { - self.build_to_server_record_inner(data, script, false) - } - - fn build_to_server_fake_response_record( - &mut self, - script: &[RestlsScriptLine], - ) -> Result { - self.build_to_server_record_inner(&[], script, true) - } - - fn build_to_server_record_inner( - &mut self, - data: &[u8], - script: &[RestlsScriptLine], - allow_empty: bool, - ) -> Result { - if data.is_empty() && !allow_empty { - bail!("Shadowsocks restls application record requires non-empty data"); - } - let mut data_len = data.len(); - let mut padding_len = 0usize; - let mut command = RestlsCommand::Noop; - if let Some(line) = script.get(self.to_server_counter as usize) { - data_len = line.target_len.len(); - command = line.command; - } - if data_len == 0 { - padding_len = 19 + rand::thread_rng().gen_range(0..100); - } - if data.len() < data_len { - padding_len = data_len - data.len(); - data_len = data.len(); - } - - let auth_header_len = RESTLS_APP_DATA_AUTH_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 }; - let mut payload_len = data_len + padding_len + auth_header_len; - if payload_len > RESTLS_MAX_PLAINTEXT { - payload_len = RESTLS_MAX_PLAINTEXT; - } - data_len = payload_len - .checked_sub(auth_header_len + padding_len) - .ok_or_else(|| anyhow!("Shadowsocks restls target length is too large"))?; - let payload_len_u16 = u16::try_from(payload_len) - .context("Shadowsocks restls application record is too large")?; - - let header_len = TLS_RECORD_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 }; - let mut record = Vec::with_capacity(TLS_RECORD_HEADER_LEN + payload_len); - record.push(TLS_APPLICATION_DATA_RECORD_TYPE); - record.extend_from_slice(&TLS12_RECORD_VERSION); - record.extend_from_slice(&payload_len_u16.to_be_bytes()); - if self.tls12_gcm { - record.extend_from_slice(&(self.to_server_counter + 1).to_be_bytes()); - } - record.resize(header_len + RESTLS_APP_DATA_AUTH_HEADER_LEN, 0); - record.extend_from_slice(&data[..data_len]); - let padding_start = record.len(); - record.resize(padding_start + padding_len, 0); - OsRng.fill_bytes(&mut record[padding_start..]); - self.write_auth_header( - &mut record, - data_len, - command, - header_len, - RestlsRecordDirection::ToServer, - )?; - self.to_server_counter += 1; - Ok(RestlsWriteResult { - record, - consumed: data_len, - command, - }) - } - - fn decode_to_client_record(&mut self, record: &[u8]) -> Result { - let frame = self.decode_record(record, RestlsRecordDirection::ToClient)?; - self.to_client_counter += 1; - Ok(frame) - } - - fn decode_to_server_record(&mut self, record: &[u8]) -> Result { - let frame = self.decode_record(record, RestlsRecordDirection::ToServer)?; - self.to_server_counter += 1; - Ok(frame) - } - - #[cfg(test)] - fn build_to_client_test_record( - &mut self, - data: &[u8], - command: RestlsCommand, - ) -> Result> { - let payload_len = - RESTLS_APP_DATA_AUTH_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 } + data.len(); - let payload_len_u16 = u16::try_from(payload_len) - .context("Shadowsocks restls application record is too large")?; - let header_len = TLS_RECORD_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 }; - let mut record = Vec::with_capacity(TLS_RECORD_HEADER_LEN + payload_len); - record.push(TLS_APPLICATION_DATA_RECORD_TYPE); - record.extend_from_slice(&TLS12_RECORD_VERSION); - record.extend_from_slice(&payload_len_u16.to_be_bytes()); - if self.tls12_gcm { - record.extend_from_slice(&(self.to_client_counter + 1).to_be_bytes()); - } - record.resize(header_len + RESTLS_APP_DATA_AUTH_HEADER_LEN, 0); - record.extend_from_slice(data); - self.write_auth_header( - &mut record, - data.len(), - command, - header_len, - RestlsRecordDirection::ToClient, - )?; - self.to_client_counter += 1; - Ok(record) - } - - fn write_auth_header( - &mut self, - record: &mut [u8], - data_len: usize, - command: RestlsCommand, - header_len: usize, - direction: RestlsRecordDirection, - ) -> Result<()> { - let (header, body) = record.split_at_mut(header_len); - let sample_len = 32.min(body[RESTLS_APP_DATA_OFFSET..].len()); - let mut hmac_mask = self.auth_header_hash(direction); - hmac_mask.update(&body[RESTLS_APP_DATA_OFFSET..RESTLS_APP_DATA_OFFSET + sample_len]); - let mask = hmac_mask.finalize(); - let mask = &mask.as_bytes()[..RESTLS_MASK_LEN]; - - body[RESTLS_APP_DATA_LEN_OFFSET..RESTLS_APP_DATA_LEN_OFFSET + 2] - .copy_from_slice(&(data_len as u16).to_be_bytes()); - body[RESTLS_APP_DATA_LEN_OFFSET + 2..RESTLS_APP_DATA_OFFSET] - .copy_from_slice(&command.to_bytes()); - xor_with_restls_mask( - &mut body[RESTLS_APP_DATA_LEN_OFFSET..RESTLS_APP_DATA_OFFSET], - mask, - ); - - let mut hmac_auth = self.auth_header_hash(direction); - if direction == RestlsRecordDirection::ToServer { - if let Some(client_finished) = self.client_finished_auth.take() { - hmac_auth.update(&client_finished); - } - } - hmac_auth.update(header); - hmac_auth.update(&body[RESTLS_APP_DATA_LEN_OFFSET..]); - let auth_mac = hmac_auth.finalize(); - body[..RESTLS_APP_DATA_MAC_LEN] - .copy_from_slice(&auth_mac.as_bytes()[..RESTLS_APP_DATA_MAC_LEN]); - Ok(()) - } - - fn decode_record( - &mut self, - record: &[u8], - direction: RestlsRecordDirection, - ) -> Result { - if record.len() < TLS_RECORD_HEADER_LEN + RESTLS_APP_DATA_AUTH_HEADER_LEN { - bail!("Shadowsocks restls application record is too short"); - } - if record[0] != TLS_APPLICATION_DATA_RECORD_TYPE { - bail!("Shadowsocks restls record is not application data"); - } - if record[1..3] != TLS12_RECORD_VERSION { - bail!("Shadowsocks restls record has invalid TLS record version"); - } - let payload_len = usize::from(u16::from_be_bytes([record[3], record[4]])); - if payload_len + TLS_RECORD_HEADER_LEN != record.len() { - bail!("Shadowsocks restls application record length mismatch"); - } - - let mut header_len = TLS_RECORD_HEADER_LEN; - let tls12_gcm_counter_present = self.tls12_gcm - && !(direction == RestlsRecordDirection::ToClient - && self.tls12_gcm_to_client_disable_counter); - if tls12_gcm_counter_present { - if record.len() < TLS_RECORD_HEADER_LEN + 8 + RESTLS_APP_DATA_AUTH_HEADER_LEN { - bail!("Shadowsocks restls GCM application record is too short"); - } - let expected_counter = match direction { - RestlsRecordDirection::ToServer => self.to_server_counter + 1, - RestlsRecordDirection::ToClient => self.to_client_counter + 1, - }; - let nonce = u64::from_be_bytes( - record[TLS_RECORD_HEADER_LEN..TLS_RECORD_HEADER_LEN + 8] - .try_into() - .expect("nonce slice has fixed length"), - ); - if nonce != expected_counter { - bail!("Shadowsocks restls GCM application record counter mismatch"); - } - header_len += 8; - } - - let header = &record[..header_len]; - let mut body = record[header_len..].to_vec(); - let mut hmac_auth = self.auth_header_hash(direction); - if direction == RestlsRecordDirection::ToServer { - if let Some(client_finished) = self.client_finished_auth.as_ref() { - hmac_auth.update(client_finished); - } - } - hmac_auth.update(header); - hmac_auth.update(&body[RESTLS_APP_DATA_LEN_OFFSET..]); - let auth_mac = hmac_auth.finalize(); - if auth_mac.as_bytes()[..RESTLS_APP_DATA_MAC_LEN] - .ct_eq(&body[..RESTLS_APP_DATA_MAC_LEN]) - .unwrap_u8() - != 1 - { - bail!("Shadowsocks restls application record authentication failed"); - } - if direction == RestlsRecordDirection::ToServer { - self.client_finished_auth = None; - } - - let sample_len = 32.min(body[RESTLS_APP_DATA_OFFSET..].len()); - let mut hmac_mask = self.auth_header_hash(direction); - hmac_mask.update(&body[RESTLS_APP_DATA_OFFSET..RESTLS_APP_DATA_OFFSET + sample_len]); - let mask = hmac_mask.finalize(); - xor_with_restls_mask( - &mut body[RESTLS_APP_DATA_LEN_OFFSET..RESTLS_APP_DATA_OFFSET], - &mask.as_bytes()[..RESTLS_MASK_LEN], - ); - let data_len = usize::from(u16::from_be_bytes([ - body[RESTLS_APP_DATA_LEN_OFFSET], - body[RESTLS_APP_DATA_LEN_OFFSET + 1], - ])); - if body.len() < RESTLS_APP_DATA_OFFSET + data_len { - bail!("Shadowsocks restls application record data length exceeds payload"); - } - let command = RestlsCommand::from_bytes([ - body[RESTLS_APP_DATA_LEN_OFFSET + 2], - body[RESTLS_APP_DATA_LEN_OFFSET + 3], - ])?; - Ok(RestlsApplicationFrame { - data: body[RESTLS_APP_DATA_OFFSET..RESTLS_APP_DATA_OFFSET + data_len].to_vec(), - command, - }) - } -} - -#[allow(dead_code)] -impl RestlsCommand { - fn need_interrupt(self) -> bool { - matches!(self, RestlsCommand::Response(_)) - } -} - -#[allow(dead_code)] -impl RestlsStreamState { - fn new(codec: RestlsApplicationCodec, script: Vec) -> Self { - Self { - codec, - script, - send_buffer: Vec::new(), - write_pending: false, - } - } - - fn write(&mut self, data_new: &[u8]) -> Result { - let fake_response = data_new == RESTLS_RANDOM_RESPONSE_MAGIC; - let accepted = if fake_response { 0 } else { data_new.len() }; - let data_new = if fake_response { &[][..] } else { data_new }; - if fake_response { - self.write_pending = false; - } - - if self.write_pending { - self.send_buffer.extend_from_slice(data_new); - return Ok(RestlsStreamWriteResult { accepted, records: Vec::new() }); - } - - let mut data = if self.send_buffer.is_empty() { - data_new.to_vec() - } else { - self.send_buffer.extend_from_slice(data_new); - std::mem::take(&mut self.send_buffer) - }; - let mut records = Vec::new(); - let mut fake_response_pending = fake_response; - - while !data.is_empty() || fake_response_pending { - let record = if fake_response_pending { - self.codec - .build_to_server_fake_response_record(&self.script)? - } else { - self.codec.build_to_server_record(&data, &self.script)? - }; - let consumed = record.consumed; - let command = record.command; - records.push(record.record); - if command.need_interrupt() && !fake_response_pending { - self.write_pending = true; - } - if consumed > data.len() { - bail!("Shadowsocks restls consumed more data than available"); - } - data.drain(..consumed); - if command.need_interrupt() && !fake_response_pending { - break; - } - fake_response_pending = false; - } - - self.send_buffer = data; - Ok(RestlsStreamWriteResult { accepted, records }) - } - - fn receive_command( - &mut self, - command: RestlsCommand, - resumed_pending_write: bool, - ) -> Result>> { - let mut records = Vec::new(); - let RestlsCommand::Response(count) = command else { - return Ok(records); - }; - let count = restls_response_repeat_count(count, resumed_pending_write); - for _ in 0..count { - records.extend(self.write(RESTLS_RANDOM_RESPONSE_MAGIC)?.records); - } - Ok(records) - } - - fn resume_pending_write(&mut self) -> Result { - self.write_pending = false; - self.write(&[]) - } - - fn read_to_client_record(&mut self, record: &[u8]) -> Result { - let frame = self.codec.decode_to_client_record(record)?; - let mut records = Vec::new(); - let mut resumed_pending_write = false; - if self.write_pending { - let resumed = self.resume_pending_write()?; - resumed_pending_write = !resumed.records.is_empty(); - records.extend(resumed.records); - } - records.extend(self.receive_command(frame.command, resumed_pending_write)?); - Ok(RestlsStreamReadResult { - data: frame.data, - records, - resumed_pending_write, - }) - } -} - -fn restls_response_repeat_count(count: u16, resumed_pending_write: bool) -> usize { - let mut count = (count as u8) as i8; - if resumed_pending_write { - count = count.wrapping_sub(1); - } - if count <= 0 { - 0 - } else { - count as usize - } -} - -#[allow(dead_code)] -fn xor_with_restls_mask(data: &mut [u8], mask: &[u8]) { - for (byte, mask) in data.iter_mut().zip(mask.iter()) { - *byte ^= *mask; - } -} - -fn plugin_bool_opt(opts: &HashMap, key: &str) -> Option { - opts.get(key).map(|value| { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "y" | "on" - ) - }) -} - -fn plugin_u8_opt(opts: &HashMap, key: &str) -> Option { - opts.get(key) - .and_then(|value| value.trim().parse::().ok()) -} - -fn plugin_u16_opt(opts: &HashMap, key: &str) -> Option { - opts.get(key) - .and_then(|value| value.trim().parse::().ok()) -} - -fn plugin_u32_opt(opts: &HashMap, key: &str) -> Option { - opts.get(key) - .and_then(|value| value.trim().parse::().ok()) -} - -fn plugin_u64_opt(opts: &HashMap, key: &str) -> Option { - opts.get(key) - .and_then(|value| value.trim().parse::().ok()) -} - -fn plugin_usize_opt(opts: &HashMap, key: &str) -> Option { - opts.get(key) - .and_then(|value| value.trim().parse::().ok()) -} - -fn plugin_i32_opt(opts: &HashMap, key: &str) -> Option { - opts.get(key) - .and_then(|value| value.trim().parse::().ok()) -} - -fn plugin_list_opt(opts: &HashMap, key: &str) -> Option> { - opts.get(key).map(|value| { - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .collect() - }) -} - -fn plugin_tls_client_identity(opts: &HashMap) -> Result> { - let certificate = opts - .get("certificate") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - let private_key = opts - .get("private-key") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - match (certificate, private_key) { - (None, None) => Ok(None), - (Some(certificate), Some(private_key)) => Ok(Some(TlsClientIdentity { - certificate: certificate.to_owned(), - private_key: private_key.to_owned(), - })), - _ => bail!("Shadowsocks TLS plugin certificate and private-key must be provided together"), - } -} - -fn plugin_ech_config(opts: &HashMap) -> Result> { - let enabled = plugin_bool_opt(opts, "ech-opts.enable") - .or_else(|| plugin_bool_opt(opts, "ech.enable")) - .unwrap_or(false); - if !enabled { - return Ok(None); - } - - let config_list = opts - .get("ech-opts.config") - .or_else(|| opts.get("ech.config")) - .map(|value| { - BASE64_STANDARD - .decode(value.trim()) - .context("failed to decode Shadowsocks websocket ECH config") - }) - .transpose()?; - let query_server_name = opts - .get("ech-opts.query-server-name") - .or_else(|| opts.get("ech.query-server-name")) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); - - Ok(Some(ShadowsocksEchConfig { - config_list, - query_server_name, - })) -} - -fn plugin_headers(opts: &HashMap) -> Vec<(String, String)> { - opts.iter() - .filter_map(|(key, value)| { - key.strip_prefix("headers.") - .or_else(|| key.strip_prefix("header.")) - .map(|header| (header.to_owned(), value.to_owned())) - }) - .collect() -} - -fn simple_obfs_http_request(host: &str, port: u16, payload: &[u8]) -> Vec { - let mut websocket_key = [0u8; 16]; - OsRng.fill_bytes(&mut websocket_key); - let user_agent_minor = OsRng.next_u32() % 54; - let user_agent_patch = OsRng.next_u32() % 2; - let request_host = if port == 80 { - host.to_owned() - } else { - format!("{host}:{port}") - }; - let mut request = Vec::new(); - request.extend_from_slice(format!("GET http://{host}/ HTTP/1.1\r\n").as_bytes()); - request.extend_from_slice(format!("Host: {request_host}\r\n").as_bytes()); - request.extend_from_slice( - format!("User-Agent: curl/7.{user_agent_minor}.{user_agent_patch}\r\n").as_bytes(), - ); - request.extend_from_slice(b"Upgrade: websocket\r\n"); - request.extend_from_slice(b"Connection: Upgrade\r\n"); - request.extend_from_slice( - format!( - "Sec-WebSocket-Key: {}\r\n", - BASE64_URL_SAFE.encode(websocket_key) - ) - .as_bytes(), - ); - request.extend_from_slice(format!("Content-Length: {}\r\n\r\n", payload.len()).as_bytes()); - request.extend_from_slice(payload); - request -} - -fn simple_obfs_tls_record(payload: &[u8]) -> io::Result> { - if payload.len() > u16::MAX as usize { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS payload is too large", - )); - } - let mut record = Vec::with_capacity(5 + payload.len()); - record.extend_from_slice(&[0x17, 0x03, 0x03]); - record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - record.extend_from_slice(payload); - Ok(record) -} - -fn shadow_tls_v2_record( - payload: &[u8], - len: usize, - first_write_hmac: Option<[u8; 8]>, -) -> io::Result> { - if len > SHADOW_TLS_MAX_RECORD_PAYLOAD || len > payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "ShadowTLS v2 payload is too large", - )); - } - let prefix_len = first_write_hmac.as_ref().map(|_| 8).unwrap_or(0); - let record_len = prefix_len + len; - let mut record = Vec::with_capacity(5 + record_len); - record.extend_from_slice(&[0x17, 0x03, 0x03]); - record.extend_from_slice(&(record_len as u16).to_be_bytes()); - if let Some(prefix) = first_write_hmac { - record.extend_from_slice(&prefix); - } - record.extend_from_slice(&payload[..len]); - Ok(record) -} - -fn shadow_tls_v3_record( - payload: &[u8], - len: usize, - hmac_add: &mut hmac::Context, -) -> io::Result> { - if len > SHADOW_TLS_MAX_RECORD_PAYLOAD || len > payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "ShadowTLS v3 payload is too large", - )); - } - let payload = &payload[..len]; - let mut record = Vec::with_capacity(SHADOW_TLS_V3_HMAC_HEADER_SIZE + payload.len()); - record.extend_from_slice(&[0x17, 0x03, 0x03]); - record.extend_from_slice(&((SHADOW_TLS_V3_HMAC_SIZE + payload.len()) as u16).to_be_bytes()); - hmac_add.update(payload); - let hmac_prefix = shadow_tls_v3_hmac_prefix(hmac_add, SHADOW_TLS_V3_HMAC_SIZE); - hmac_add.update(&hmac_prefix); - record.extend_from_slice(&hmac_prefix); - record.extend_from_slice(payload); - Ok(record) -} - -fn shadow_tls_v3_generate_session_id(password: &str, client_hello: &[u8]) -> [u8; 32] { - let mut session_id = [0u8; SHADOW_TLS_V3_SESSION_ID_SIZE]; - OsRng.fill_bytes(&mut session_id[..SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE]); - if client_hello.len() >= SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE { - let mut context = shadow_tls_v3_hmac(password, b"", b""); - context.update(&client_hello[..SHADOW_TLS_V3_SESSION_ID_START]); - context.update(&session_id); - context.update( - &client_hello[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..], - ); - let prefix = shadow_tls_v3_hmac_prefix(&context, SHADOW_TLS_V3_HMAC_SIZE); - session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..] - .copy_from_slice(&prefix); - } - session_id -} - -#[allow(dead_code)] -fn shadow_tls_v3_sign_client_hello_record( - password: &str, - bytes: &[u8], -) -> io::Result>> { - if bytes.len() < SHADOW_TLS_V3_TLS_HEADER_SIZE { - return Ok(None); - } - if bytes[0] != 0x16 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ShadowTLS v3 first TLS record is not a handshake record", - )); - } - let payload_len = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; - let record_len = SHADOW_TLS_V3_TLS_HEADER_SIZE + payload_len; - if bytes.len() < record_len { - return Ok(None); - } - let payload = &bytes[SHADOW_TLS_V3_TLS_HEADER_SIZE..record_len]; - if payload.first().copied() != Some(0x01) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ShadowTLS v3 first handshake message is not ClientHello", - )); - } - let session_id_length_index = SHADOW_TLS_V3_SESSION_ID_START - 1; - if payload.get(session_id_length_index).copied() != Some(SHADOW_TLS_V3_SESSION_ID_SIZE as u8) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ShadowTLS v3 browser ClientHello did not reserve a 32-byte session ID", - )); - } - if payload.len() < SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ShadowTLS v3 browser ClientHello session ID is truncated", - )); - } - let session_id = shadow_tls_v3_generate_session_id(password, payload); - let mut signed = bytes.to_vec(); - let session_id_start = SHADOW_TLS_V3_TLS_HEADER_SIZE + SHADOW_TLS_V3_SESSION_ID_START; - signed[session_id_start..session_id_start + SHADOW_TLS_V3_SESSION_ID_SIZE] - .copy_from_slice(&session_id); - Ok(Some(signed)) -} - -fn shadow_tls_v3_hmac(password: &str, first: &[u8], second: &[u8]) -> hmac::Context { - let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, password.as_bytes()); - let mut context = hmac::Context::with_key(&key); - context.update(first); - context.update(second); - context -} - -fn shadow_tls_v3_hmac_prefix(context: &hmac::Context, len: usize) -> Vec { - let digest = context.clone().sign(); - digest.as_ref()[..len].to_vec() -} - -fn shadow_tls_v3_application_data_hmac(frame: &[u8], context: &hmac::Context) -> Option> { - if frame.len() < SHADOW_TLS_V3_HMAC_HEADER_SIZE - || frame[0] != 0x17 - || frame[1] != 0x03 - || frame[2] != 0x03 - { - return None; - } - let mut candidate = context.clone(); - candidate.update(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); - Some(shadow_tls_v3_hmac_prefix( - &candidate, - SHADOW_TLS_V3_HMAC_SIZE, - )) -} - -fn shadow_tls_v3_kdf( - password: &str, - server_random: &[u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE], -) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(password.as_bytes()); - hasher.update(server_random); - hasher.finalize().into() -} - -fn shadow_tls_v3_xor(data: &mut [u8], key: &[u8; 32]) { - for (index, value) in data.iter_mut().enumerate() { - *value ^= key[index % key.len()]; - } -} - -fn shadow_tls_v3_server_hello_supports_tls13(frame: &[u8]) -> bool { - if frame.len() < SHADOW_TLS_V3_SESSION_ID_LENGTH_INDEX { - return false; - } - let mut index = SHADOW_TLS_V3_SESSION_ID_LENGTH_INDEX; - let Some(session_id_len) = frame.get(index).copied().map(usize::from) else { - return false; - }; - index += 1 + session_id_len; - if frame.len() < index + 3 { - return false; - } - index += 3; - if frame.len() < index + 2 { - return false; - } - let extensions_len = u16::from_be_bytes([frame[index], frame[index + 1]]) as usize; - index += 2; - let end = index.saturating_add(extensions_len).min(frame.len()); - while index + 4 <= end { - let extension_type = u16::from_be_bytes([frame[index], frame[index + 1]]); - let extension_len = u16::from_be_bytes([frame[index + 2], frame[index + 3]]) as usize; - index += 4; - if index + extension_len > end { - return false; - } - if extension_type == 43 { - return extension_len == 2 - && u16::from_be_bytes([frame[index], frame[index + 1]]) == 0x0304; - } - index += extension_len; - } - false -} - -fn simple_obfs_tls_client_hello(server: &str, payload: &[u8]) -> io::Result> { - let payload_len = u16::try_from(payload.len()).map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello payload is too large", - ) - })?; - let server_len = u16::try_from(server.len()).map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello server name is too large", - ) - })?; - let record_len = 212u16 - .checked_add(payload_len) - .and_then(|len| len.checked_add(server_len)) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello is too large", - ) - })?; - let handshake_len = 208u16 - .checked_add(payload_len) - .and_then(|len| len.checked_add(server_len)) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello is too large", - ) - })?; - let extension_len = 79u16 - .checked_add(payload_len) - .and_then(|len| len.checked_add(server_len)) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello is too large", - ) - })?; - let sni_ext_len = server_len.checked_add(5).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello server name is too large", - ) - })?; - let sni_list_len = server_len.checked_add(3).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello server name is too large", - ) - })?; - if server_len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "simple-obfs TLS client hello server name is empty", - )); - } - let mut random = [0u8; 28]; - let mut session_id = [0u8; 32]; - OsRng.fill_bytes(&mut random); - OsRng.fill_bytes(&mut session_id); - - let mut hello = Vec::with_capacity(217 + server.len() + payload.len()); - hello.push(22); - hello.extend_from_slice(&[0x03, 0x01]); - hello.extend_from_slice(&record_len.to_be_bytes()); - hello.push(1); - hello.push(0); - hello.extend_from_slice(&handshake_len.to_be_bytes()); - hello.extend_from_slice(&[0x03, 0x03]); - hello.extend_from_slice(&(aead2022_now_timestamp().unwrap_or(0) as u32).to_be_bytes()); - hello.extend_from_slice(&random); - hello.push(32); - hello.extend_from_slice(&session_id); - hello.extend_from_slice(&[0x00, 0x38]); - hello.extend_from_slice(&[ - 0xc0, 0x2c, 0xc0, 0x30, 0x00, 0x9f, 0xcc, 0xa9, 0xcc, 0xa8, 0xcc, 0xaa, 0xc0, 0x2b, 0xc0, - 0x2f, 0x00, 0x9e, 0xc0, 0x24, 0xc0, 0x28, 0x00, 0x6b, 0xc0, 0x23, 0xc0, 0x27, 0x00, 0x67, - 0xc0, 0x0a, 0xc0, 0x14, 0x00, 0x39, 0xc0, 0x09, 0xc0, 0x13, 0x00, 0x33, 0x00, 0x9d, 0x00, - 0x9c, 0x00, 0x3d, 0x00, 0x3c, 0x00, 0x35, 0x00, 0x2f, 0x00, 0xff, - ]); - hello.extend_from_slice(&[0x01, 0x00]); - hello.extend_from_slice(&extension_len.to_be_bytes()); - hello.extend_from_slice(&[0x00, 0x23]); - hello.extend_from_slice(&payload_len.to_be_bytes()); - hello.extend_from_slice(payload); - hello.extend_from_slice(&[0x00, 0x00]); - hello.extend_from_slice(&sni_ext_len.to_be_bytes()); - hello.extend_from_slice(&sni_list_len.to_be_bytes()); - hello.push(0); - hello.extend_from_slice(&server_len.to_be_bytes()); - hello.extend_from_slice(server.as_bytes()); - hello.extend_from_slice(&[0x00, 0x0b, 0x00, 0x04, 0x03, 0x01, 0x00, 0x02]); - hello.extend_from_slice(&[ - 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x19, 0x00, 0x18, - ]); - hello.extend_from_slice(&[ - 0x00, 0x0d, 0x00, 0x20, 0x00, 0x1e, 0x06, 0x01, 0x06, 0x02, 0x06, 0x03, 0x05, 0x01, 0x05, - 0x02, 0x05, 0x03, 0x04, 0x01, 0x04, 0x02, 0x04, 0x03, 0x03, 0x01, 0x03, 0x02, 0x03, 0x03, - 0x02, 0x01, 0x02, 0x02, 0x02, 0x03, - ]); - hello.extend_from_slice(&[0x00, 0x16, 0x00, 0x00]); - hello.extend_from_slice(&[0x00, 0x17, 0x00, 0x00]); - Ok(hello) -} diff --git a/burrow/src/proxy_runtime/tests.rs b/burrow/src/proxy_runtime/tests.rs deleted file mode 100644 index 9eb046b..0000000 --- a/burrow/src/proxy_runtime/tests.rs +++ /dev/null @@ -1,3677 +0,0 @@ -#[cfg(test)] -mod tests { - use super::*; - - async fn read_test_tls_record(stream: &mut S) -> Vec - where - S: AsyncRead + Unpin, - { - let mut header = [0u8; TLS_RECORD_HEADER_LEN]; - stream.read_exact(&mut header).await.unwrap(); - let payload_len = usize::from(u16::from_be_bytes([header[3], header[4]])); - let mut record = header.to_vec(); - record.resize(TLS_RECORD_HEADER_LEN + payload_len, 0); - stream - .read_exact(&mut record[TLS_RECORD_HEADER_LEN..]) - .await - .unwrap(); - record - } - - #[test] - fn trojan_hash_is_sha224_hex() { - assert_eq!( - trojan_password_hash("password"), - "d63dc919e201d7bc4c825630d2cf25fdc93d4b2f0d46706d29038d01" - ); - } - - #[test] - fn socks_addr_round_trips_ipv4_and_ipv6() { - for addr in [ - "1.2.3.4:53".parse::().unwrap(), - "[2001:db8::1]:443".parse::().unwrap(), - ] { - let encoded = socks_addr(addr).unwrap(); - let (decoded, used) = parse_socks_addr(&encoded).unwrap(); - assert_eq!(decoded, addr); - assert_eq!(used, encoded.len()); - } - } - - #[test] - fn shadowsocks_runtime_accepts_expanded_cipher_families() { - for cipher in [ - "none", - "plain", - "table", - "rc4-md5", - "rc4", - "aes-128-ctr", - "aes-192-ctr", - "aes-256-ctr", - "aes-128-cfb", - "aes-128-cfb1", - "aes-128-cfb8", - "aes-128-cfb128", - "aes-192-cfb", - "aes-192-cfb1", - "aes-192-cfb8", - "aes-192-cfb128", - "aes-256-cfb", - "aes-256-cfb1", - "aes-256-cfb8", - "aes-256-cfb128", - "aes-128-ofb", - "aes-192-ofb", - "aes-256-ofb", - "camellia-128-ctr", - "camellia-192-ctr", - "camellia-256-ctr", - "camellia-128-cfb", - "camellia-128-cfb1", - "camellia-128-cfb8", - "camellia-128-cfb128", - "camellia-192-cfb", - "camellia-192-cfb1", - "camellia-192-cfb8", - "camellia-192-cfb128", - "camellia-256-cfb", - "camellia-256-cfb1", - "camellia-256-cfb8", - "camellia-256-cfb128", - "camellia-128-ofb", - "camellia-192-ofb", - "camellia-256-ofb", - "chacha20", - "chacha20-ietf", - "xchacha20", - "aes-128-gcm", - "aes-192-gcm", - "aes-256-gcm", - "aes-128-ccm", - "aes-192-ccm", - "aes-256-ccm", - "aes-128-gcm-siv", - "aes-256-gcm-siv", - "chacha8-ietf-poly1305", - "chacha20-ietf-poly1305", - "rabbit128-poly1305", - "xchacha8-ietf-poly1305", - "xchacha20-ietf-poly1305", - "sm4-gcm", - "sm4-ccm", - "aegis-128l", - "aegis-256", - "aez-384", - "deoxys-ii-256-128", - "ascon128", - "ascon128a", - "lea-128-gcm", - "lea-192-gcm", - "lea-256-gcm", - "2022-blake3-aes-128-gcm", - "2022-blake3-aes-256-gcm", - "2022-blake3-aes-128-ccm", - "2022-blake3-aes-256-ccm", - "2022-blake3-chacha20-poly1305", - "2022-blake3-chacha8-poly1305", - ] { - validate_shadowsocks_cipher(cipher) - .unwrap_or_else(|err| panic!("expected {cipher} to be supported: {err:#}")); - } - assert!(validate_shadowsocks_cipher("not-a-real-cipher").is_err()); - } - - #[test] - fn shadowsocks_runtime_has_no_known_mihomo_cipher_gaps() { - let mihomo_only_ciphers: [&str; 0] = []; - assert!(mihomo_only_ciphers - .iter() - .all(|cipher| validate_shadowsocks_cipher(cipher).is_ok())); - } - - #[test] - fn shadowsocks_none_cipher_builds_runtime_outbound() { - let node = ProxyNode { - ordinal: 0, - name: "ss-none".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "none".to_owned(), - password: "unused".to_owned(), - udp: true, - udp_over_tcp: true, - udp_over_tcp_version: UOT_LEGACY_VERSION, - client_fingerprint: None, - plugin: None, - smux: None, - }), - }; - - assert!(runtime_support_error(&node).is_none()); - let ProxyNodeConfig::Shadowsocks(config) = &node.config else { - panic!("expected Shadowsocks config"); - }; - let outbound = ShadowsocksOutbound::from_node(&node, config).unwrap(); - assert!(matches!(&outbound.protocol, ShadowsocksProtocol::None)); - assert!(outbound.udp_enabled); - assert!(outbound.udp_over_tcp); - } - - #[test] - fn shadowsocks_runtime_keeps_legacy_cipher_aliases() { - for cipher in [ - "aead_aes_128_gcm", - "aead_aes_256_gcm", - "chacha20-poly1305", - "aead_chacha20_poly1305", - ] { - shadowsocks_cipher_kind(cipher) - .unwrap_or_else(|err| panic!("expected {cipher} alias to be supported: {err:#}")); - } - } - - #[test] - fn shadowsocks_runtime_accepts_udp_over_tcp_nodes() { - let payload = ProxySubscriptionPayload { - name: "sub".to_owned(), - source: crate::proxy_subscription::ProxySubscriptionSource { - url: "https://sub.example/path".to_owned(), - }, - detected_format: crate::proxy_subscription::ProxySubscriptionFormat::ClashYaml, - selected_ordinal: Some(0), - selected_name: None, - nodes: vec![ProxyNode { - ordinal: 0, - name: "ss-uot".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "pass".to_owned(), - udp: true, - udp_over_tcp: true, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: None, - }), - }], - warnings: Vec::new(), - }; - - assert_eq!(selected_node(&payload).unwrap().name, "ss-uot"); - assert!(normalize_uot_version(0).is_ok()); - assert!(normalize_uot_version(UOT_LEGACY_VERSION).is_ok()); - assert!(normalize_uot_version(UOT_VERSION).is_ok()); - assert!(normalize_uot_version(3).is_err()); - } - - #[test] - fn runtime_support_accepts_tcp_only_shadowsocks_smux() { - let mut node = ProxyNode { - ordinal: 0, - name: "ss-smux".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "pass".to_owned(), - udp: true, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: Some(crate::proxy_subscription::ShadowsocksSmux { - enabled: true, - protocol: Some("smux".to_owned()), - max_connections: Some(4), - min_streams: Some(2), - max_streams: None, - padding: false, - statistic: false, - only_tcp: true, - brutal: None, - }), - }), - }; - - assert!(runtime_support_error(&node).is_none()); - let ProxyNodeConfig::Shadowsocks(config) = &node.config else { - panic!("expected Shadowsocks node"); - }; - let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); - let transport = - shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); - assert_eq!( - transport, - Some(ShadowsocksSingMuxTransport { - protocol: ShadowsocksSingMuxProtocol::Smux, - max_connections: 4, - min_streams: 2, - max_streams: 0, - padding: false, - udp: false, - brutal: None, - }) - ); - - if let ProxyNodeConfig::Shadowsocks(config) = &mut node.config { - config.udp = true; - config.smux.as_mut().unwrap().only_tcp = false; - } - assert!(runtime_support_error(&node).is_none()); - let ProxyNodeConfig::Shadowsocks(config) = &node.config else { - panic!("expected Shadowsocks node"); - }; - let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); - let transport = - shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); - assert_eq!( - transport, - Some(ShadowsocksSingMuxTransport { - protocol: ShadowsocksSingMuxProtocol::Smux, - max_connections: 4, - min_streams: 2, - max_streams: 0, - padding: false, - udp: true, - brutal: None, - }) - ); - } - - #[test] - fn runtime_support_accepts_default_shadowsocks_h2mux() { - let node = ProxyNode { - ordinal: 0, - name: "ss-h2mux".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "pass".to_owned(), - udp: true, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: Some(crate::proxy_subscription::ShadowsocksSmux { - enabled: true, - protocol: None, - max_connections: Some(4), - min_streams: Some(2), - max_streams: None, - padding: false, - statistic: false, - only_tcp: true, - brutal: None, - }), - }), - }; - - assert!(runtime_support_error(&node).is_none()); - let ProxyNodeConfig::Shadowsocks(config) = &node.config else { - panic!("expected Shadowsocks node"); - }; - let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); - let transport = - shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); - assert_eq!( - transport, - Some(ShadowsocksSingMuxTransport { - protocol: ShadowsocksSingMuxProtocol::H2Mux, - max_connections: 4, - min_streams: 2, - max_streams: 0, - padding: false, - udp: false, - brutal: None, - }) - ); - } - - #[test] - fn runtime_support_accepts_padded_shadowsocks_smux_udp_when_base_udp_is_false() { - let node = ProxyNode { - ordinal: 0, - name: "ss-smux-padding".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "pass".to_owned(), - udp: false, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: Some(crate::proxy_subscription::ShadowsocksSmux { - enabled: true, - protocol: Some("smux".to_owned()), - max_connections: Some(4), - min_streams: Some(2), - max_streams: None, - padding: true, - statistic: false, - only_tcp: false, - brutal: None, - }), - }), - }; - - assert!(runtime_support_error(&node).is_none()); - let ProxyNodeConfig::Shadowsocks(config) = &node.config else { - panic!("expected Shadowsocks node"); - }; - let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); - let transport = - shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); - assert_eq!( - transport, - Some(ShadowsocksSingMuxTransport { - protocol: ShadowsocksSingMuxProtocol::Smux, - max_connections: 4, - min_streams: 2, - max_streams: 0, - padding: true, - udp: true, - brutal: None, - }) - ); - - let outbound = ShadowsocksOutbound::from_node(&node, config).unwrap(); - assert!( - outbound.udp_enabled, - "Mihomo sing-mux supports UDP unless only-tcp is set, even when the underlying Shadowsocks udp flag is false" - ); - } - - #[test] - fn runtime_support_accepts_brutal_shadowsocks_smux() { - let node = ProxyNode { - ordinal: 0, - name: "ss-smux-brutal".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "pass".to_owned(), - udp: true, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: Some(crate::proxy_subscription::ShadowsocksSmux { - enabled: true, - protocol: Some("smux".to_owned()), - max_connections: Some(4), - min_streams: Some(2), - max_streams: None, - padding: false, - statistic: false, - only_tcp: false, - brutal: Some(crate::proxy_subscription::ShadowsocksSmuxBrutal { - enabled: true, - up: Some("10 Mbps".to_owned()), - down: Some("20 Mbps".to_owned()), - }), - }), - }), - }; - - assert!(runtime_support_error(&node).is_none()); - let ProxyNodeConfig::Shadowsocks(config) = &node.config else { - panic!("expected Shadowsocks node"); - }; - let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); - let transport = - shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); - assert_eq!( - transport, - Some(ShadowsocksSingMuxTransport { - protocol: ShadowsocksSingMuxProtocol::Smux, - max_connections: 4, - min_streams: 2, - max_streams: 0, - padding: false, - udp: true, - brutal: Some(ShadowsocksSingMuxBrutalTransport { - send_bps: 1_250_000, - receive_bps: 2_500_000, - }), - }) - ); - } - - #[test] - fn runtime_support_accepts_tcp_only_shadowsocks_yamux() { - let node = ProxyNode { - ordinal: 0, - name: "ss-yamux".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "pass".to_owned(), - udp: true, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: Some(crate::proxy_subscription::ShadowsocksSmux { - enabled: true, - protocol: Some("yamux".to_owned()), - max_connections: Some(4), - min_streams: Some(2), - max_streams: None, - padding: false, - statistic: false, - only_tcp: true, - brutal: None, - }), - }), - }; - - assert!(runtime_support_error(&node).is_none()); - let ProxyNodeConfig::Shadowsocks(config) = &node.config else { - panic!("expected Shadowsocks node"); - }; - let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); - let transport = - shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); - assert_eq!( - transport, - Some(ShadowsocksSingMuxTransport { - protocol: ShadowsocksSingMuxProtocol::Yamux, - max_connections: 4, - min_streams: 2, - max_streams: 0, - padding: false, - udp: false, - brutal: None, - }) - ); - } - - #[test] - fn runtime_support_accepts_custom_cipher_shadowsocks_smux() { - let node = ProxyNode { - ordinal: 0, - name: "ss-custom-smux".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-192-gcm".to_owned(), - password: "pass".to_owned(), - udp: false, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: Some(crate::proxy_subscription::ShadowsocksSmux { - enabled: true, - protocol: Some("smux".to_owned()), - max_connections: Some(2), - min_streams: None, - max_streams: Some(16), - padding: false, - statistic: false, - only_tcp: false, - brutal: None, - }), - }), - }; - - assert!(runtime_support_error(&node).is_none()); - } - - #[test] - fn runtime_support_accepts_aead2022_ccm_shadowsocks_smux() { - let kind = Aead2022AesCcmKind::Aes128; - let password = BASE64_STANDARD.encode(vec![3u8; kind.key_len()]); - let node = ProxyNode { - ordinal: 0, - name: "ss-2022-ccm-smux".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: kind.name().to_owned(), - password, - udp: false, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: Some(crate::proxy_subscription::ShadowsocksSmux { - enabled: true, - protocol: Some("smux".to_owned()), - max_connections: Some(2), - min_streams: None, - max_streams: Some(16), - padding: false, - statistic: false, - only_tcp: false, - brutal: None, - }), - }), - }; - - assert!(runtime_support_error(&node).is_none()); - } - - #[tokio::test] - async fn sing_mux_tcp_request_uses_mihomo_stream_header_shape() { - let (mut client, mut server) = tokio::io::duplex(64); - let target = ProxyTcpTarget { - address: "198.18.64.1:443".parse().unwrap(), - hostname: Some("example.com.".to_owned()), - }; - - let writer = - tokio::spawn(async move { write_sing_mux_tcp_request(&mut client, &target).await }); - let mut request = vec![0u8; 2 + 1 + 1 + "example.com".len() + 2]; - server.read_exact(&mut request).await.unwrap(); - writer.await.unwrap().unwrap(); - - let mut expected = vec![0, 0, 3, "example.com".len() as u8]; - expected.extend_from_slice(b"example.com"); - expected.extend_from_slice(&443u16.to_be_bytes()); - assert_eq!(request, expected); - } - - #[tokio::test] - async fn sing_mux_udp_request_uses_mihomo_packet_header_shape() { - let (mut client, mut server) = tokio::io::duplex(64); - let target: SocketAddr = "198.18.64.1:53".parse().unwrap(); - let writer = - tokio::spawn( - async move { write_sing_mux_udp_request(&mut client, target, b"query").await }, - ); - let mut request = vec![0u8; 2 + 1 + 4 + 2 + 2 + "query".len()]; - server.read_exact(&mut request).await.unwrap(); - writer.await.unwrap().unwrap(); - - let mut expected = vec![0, SING_MUX_STREAM_FLAG_UDP as u8, 1, 198, 18, 64, 1]; - expected.extend_from_slice(&53u16.to_be_bytes()); - expected.extend_from_slice(&5u16.to_be_bytes()); - expected.extend_from_slice(b"query"); - assert_eq!(request, expected); - } - - #[tokio::test] - async fn sing_mux_udp_payload_reads_status_then_length_prefixed_packet() { - let (mut client, mut server) = tokio::io::duplex(64); - let writer = tokio::spawn(async move { - server - .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS]) - .await - .unwrap(); - server.write_all(&4u16.to_be_bytes()).await.unwrap(); - server.write_all(b"pong").await.unwrap(); - }); - let mut response_read = false; - let payload = read_sing_mux_udp_payload(&mut client, &mut response_read) - .await - .unwrap(); - writer.await.unwrap(); - assert!(response_read); - assert_eq!(&payload, b"pong"); - } - - #[test] - fn mihomo_bps_parser_matches_decimal_rate_strings() { - assert_eq!(mihomo_string_to_bps(""), 0); - assert_eq!(mihomo_string_to_bps("10"), 1_250_000); - assert_eq!(mihomo_string_to_bps("10 Mbps"), 1_250_000); - assert_eq!(mihomo_string_to_bps("10Mbps"), 1_250_000); - assert_eq!(mihomo_string_to_bps("10 MBps"), 10_000_000); - assert_eq!(mihomo_string_to_bps("1 Kbps"), 125); - assert_eq!(mihomo_string_to_bps("not-a-rate"), 0); - } - - #[tokio::test] - async fn sing_mux_brutal_request_uses_mihomo_exchange_shape() { - let (mut client, mut server) = tokio::io::duplex(128); - let writer = - tokio::spawn( - async move { write_sing_mux_brutal_request(&mut client, 2_500_000).await }, - ); - let mut request = vec![0u8; 2 + 1 + 1 + SING_MUX_BRUTAL_EXCHANGE_DOMAIN.len() + 2 + 8]; - server.read_exact(&mut request).await.unwrap(); - writer.await.unwrap().unwrap(); - - let mut expected = vec![0, 0, 3, SING_MUX_BRUTAL_EXCHANGE_DOMAIN.len() as u8]; - expected.extend_from_slice(SING_MUX_BRUTAL_EXCHANGE_DOMAIN.as_bytes()); - expected.extend_from_slice(&0u16.to_be_bytes()); - expected.extend_from_slice(&2_500_000u64.to_be_bytes()); - assert_eq!(request, expected); - } - - #[tokio::test] - async fn sing_mux_brutal_response_reads_stream_status_then_server_rate() { - let (mut client, mut server) = tokio::io::duplex(64); - let writer = tokio::spawn(async move { - server - .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS, 1]) - .await - .unwrap(); - server.write_all(&3_000_000u64.to_be_bytes()).await.unwrap(); - }); - let receive_bps = read_sing_mux_brutal_response(&mut client).await.unwrap(); - writer.await.unwrap(); - assert_eq!(receive_bps, 3_000_000); - - let (mut client, mut server) = tokio::io::duplex(64); - let writer = tokio::spawn(async move { - server - .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS, 0, 4]) - .await - .unwrap(); - server.write_all(b"oops").await.unwrap(); - }); - let err = read_sing_mux_brutal_response(&mut client) - .await - .unwrap_err(); - writer.await.unwrap(); - assert!(err.to_string().contains("oops"), "{err}"); - } - - #[tokio::test] - async fn sing_mux_session_preface_uses_mihomo_padding_shape() { - let (mut client, mut server) = tokio::io::duplex(1024); - let writer = tokio::spawn(async move { - write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::Smux, false) - .await - }); - let mut plain = [0u8; 2]; - server.read_exact(&mut plain).await.unwrap(); - writer.await.unwrap().unwrap(); - assert_eq!(plain, [SING_MUX_VERSION_0, SING_MUX_PROTOCOL_SMUX]); - - let (mut client, mut server) = tokio::io::duplex(1024); - let writer = tokio::spawn(async move { - write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::Yamux, false) - .await - }); - let mut plain = [0u8; 2]; - server.read_exact(&mut plain).await.unwrap(); - writer.await.unwrap().unwrap(); - assert_eq!(plain, [SING_MUX_VERSION_0, SING_MUX_PROTOCOL_YAMUX]); - - let (mut client, mut server) = tokio::io::duplex(1024); - let writer = tokio::spawn(async move { - write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::H2Mux, false) - .await - }); - let mut plain = [0u8; 2]; - server.read_exact(&mut plain).await.unwrap(); - writer.await.unwrap().unwrap(); - assert_eq!(plain, [SING_MUX_VERSION_0, SING_MUX_PROTOCOL_H2MUX]); - - let (mut client, mut server) = tokio::io::duplex(2048); - let writer = tokio::spawn(async move { - write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::Smux, true) - .await - }); - let mut header = [0u8; 5]; - server.read_exact(&mut header).await.unwrap(); - assert_eq!( - &header[..3], - &[SING_MUX_VERSION_1, SING_MUX_PROTOCOL_SMUX, 1] - ); - let padding_len = u16::from_be_bytes([header[3], header[4]]) as usize; - assert!(padding_len >= SING_MUX_MIN_PADDING_LEN); - assert!(padding_len < SING_MUX_MIN_PADDING_LEN + SING_MUX_PADDING_LEN_RANGE); - let mut padding = vec![0u8; padding_len]; - server.read_exact(&mut padding).await.unwrap(); - writer.await.unwrap().unwrap(); - assert!(padding.iter().all(|byte| *byte == 0)); - } - - #[tokio::test] - async fn sing_mux_yamux_session_opens_mihomo_style_streams() { - let (client, server) = tokio::io::duplex(4096); - let session = start_sing_mux_yamux_session(Box::new(client)) - .await - .unwrap(); - let mut server_connection = yamux::Connection::new( - server.compat(), - yamux::Config::default(), - yamux::Mode::Server, - ); - let (stream_tx, mut stream_rx) = mpsc::channel(1); - let server_driver = tokio::spawn(async move { - let mut stream_tx = Some(stream_tx); - std::future::poll_fn(move |cx| loop { - match server_connection.poll_next_inbound(cx) { - Poll::Ready(Some(Ok(stream))) => { - if let Some(sender) = stream_tx.take() { - let _ = sender.try_send(stream.compat()); - } - } - Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(anyhow!(err))), - Poll::Ready(None) => return Poll::Ready(Ok(())), - Poll::Pending => return Poll::Pending, - } - }) - .await - }); - - let mut client_stream = session.open_stream().await.unwrap(); - client_stream.write_all(b"ping").await.unwrap(); - let mut server_stream = stream_rx.recv().await.unwrap(); - let mut request = [0u8; 4]; - server_stream.read_exact(&mut request).await.unwrap(); - assert_eq!(&request, b"ping"); - - drop(client_stream); - assert_eq!(session.num_streams().await, 0); - server_driver.abort(); - } - - #[tokio::test] - async fn sing_mux_h2mux_session_opens_mihomo_style_connect_streams() { - let (client, server) = tokio::io::duplex(4096); - let session = start_sing_mux_h2mux_session(Box::new(client)) - .await - .unwrap(); - let (done_tx, mut done_rx) = oneshot::channel(); - let server_driver = tokio::spawn(async move { - let mut server = h2::server::handshake(server).await?; - let Some(request) = server.accept().await else { - bail!("h2mux server closed before receiving CONNECT stream"); - }; - let (request, mut respond) = request?; - assert_eq!(request.method(), Method::CONNECT); - assert_eq!(request.uri().to_string(), "localhost"); - - let mut body = request.into_body(); - let response = http::Response::builder() - .status(StatusCode::OK) - .body(()) - .unwrap(); - let mut send = respond.send_response(response, false)?; - let data = body.data().await.unwrap()?; - body.flow_control().release_capacity(data.len())?; - assert_eq!(&data[..], b"ping"); - send.reserve_capacity(4); - std::future::poll_fn(|cx| send.poll_capacity(cx)) - .await - .context("h2mux test response stream closed before capacity")??; - send.send_data(Bytes::from_static(b"pong"), true)?; - loop { - tokio::select! { - _ = &mut done_rx => break, - accepted = server.accept() => { - if accepted.is_none() { - break; - } - } - } - } - Ok::<_, anyhow::Error>(()) - }); - - let mut client_stream = session.open_stream().await.unwrap(); - client_stream.write_all(b"ping").await.unwrap(); - let mut response = [0u8; 4]; - client_stream.read_exact(&mut response).await.unwrap(); - assert_eq!(&response, b"pong"); - let _ = done_tx.send(()); - - drop(client_stream); - assert_eq!(session.num_streams().await, 0); - server_driver.await.unwrap().unwrap(); - } - - #[tokio::test] - async fn sing_mux_padding_stream_frames_first_reads_and_writes() { - let (client, mut server) = tokio::io::duplex(4096); - let mut stream = sing_mux_padding_stream(Box::new(client)); - - stream.write_all(b"ping").await.unwrap(); - let mut header = [0u8; 4]; - server.read_exact(&mut header).await.unwrap(); - let payload_len = u16::from_be_bytes([header[0], header[1]]) as usize; - let padding_len = u16::from_be_bytes([header[2], header[3]]) as usize; - assert_eq!(payload_len, 4); - assert!(padding_len >= SING_MUX_MIN_PADDING_LEN); - assert!(padding_len < SING_MUX_MIN_PADDING_LEN + SING_MUX_PADDING_LEN_RANGE); - let mut payload = vec![0u8; payload_len]; - server.read_exact(&mut payload).await.unwrap(); - assert_eq!(&payload, b"ping"); - let mut padding = vec![0u8; padding_len]; - server.read_exact(&mut padding).await.unwrap(); - - let response_padding = SING_MUX_MIN_PADDING_LEN; - server.write_all(&4u16.to_be_bytes()).await.unwrap(); - server - .write_all(&(response_padding as u16).to_be_bytes()) - .await - .unwrap(); - server.write_all(b"pong").await.unwrap(); - server - .write_all(&vec![0u8; response_padding]) - .await - .unwrap(); - - let mut response = [0u8; 4]; - stream.read_exact(&mut response).await.unwrap(); - assert_eq!(&response, b"pong"); - } - - #[tokio::test] - async fn sing_mux_stream_response_accepts_success_and_reports_remote_errors() { - let (mut client, mut server) = tokio::io::duplex(64); - let success = tokio::spawn(async move { - server - .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS]) - .await - .unwrap(); - }); - read_sing_mux_stream_response(&mut client).await.unwrap(); - success.await.unwrap(); - - let (mut client, mut server) = tokio::io::duplex(64); - let failure = tokio::spawn(async move { - server - .write_all(&[SING_MUX_STREAM_STATUS_ERROR, 4, b'o', b'o', b'p', b's']) - .await - .unwrap(); - }); - let err = read_sing_mux_stream_response(&mut client) - .await - .unwrap_err(); - failure.await.unwrap(); - assert!(err.to_string().contains("oops"), "{err}"); - } - - #[tokio::test] - async fn sing_mux_tcp_stream_reads_lazy_success_status_before_payload() { - let (client, mut server) = tokio::io::duplex(64); - let mut stream = SingMuxTcpStream::new(client); - let writer = tokio::spawn(async move { - server - .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS, b'o', b'k']) - .await - .unwrap(); - }); - - let mut payload = [0u8; 2]; - stream.read_exact(&mut payload).await.unwrap(); - writer.await.unwrap(); - assert_eq!(&payload, b"ok"); - - let (client, mut server) = tokio::io::duplex(64); - let mut stream = SingMuxTcpStream::new(client); - let writer = tokio::spawn(async move { - server - .write_all(&[SING_MUX_STREAM_STATUS_ERROR]) - .await - .unwrap(); - }); - let err = stream.read_exact(&mut payload).await.unwrap_err(); - writer.await.unwrap(); - assert!(err.to_string().contains("remote error"), "{err}"); - } - - #[tokio::test] - async fn custom_aead_plain_tcp_stream_exposes_plaintext_for_sing_mux() { - let kind = CustomSsAeadKind::Aes192Gcm; - let key: Arc<[u8]> = Arc::from(vec![7u8; kind.key_len()]); - let (client_tcp, server_tcp) = tokio::io::duplex(4096); - let target = ProxyTcpTarget { - address: "198.18.64.1:443".parse().unwrap(), - hostname: Some("example.com.".to_owned()), - }; - let mut plain = custom_aead_plain_tcp_stream(client_tcp, &target, kind, key.clone()) - .await - .unwrap(); - let (server_reader, server_writer) = split(server_tcp); - let mut ss_reader = CustomSsAeadReader::new(server_reader, kind, key.clone()); - let mut ss_writer = CustomSsAeadWriter::new(server_writer, kind, key) - .await - .unwrap(); - - let mut received = Vec::new(); - ss_reader.read_chunk(&mut received).await.unwrap(); - assert_eq!(received, socks_domain_addr("example.com", 443).unwrap()); - - plain.write_all(b"ping").await.unwrap(); - received.clear(); - ss_reader.read_chunk(&mut received).await.unwrap(); - assert_eq!(received, b"ping"); - - ss_writer.write_chunk(b"pong").await.unwrap(); - let mut response = [0u8; 4]; - plain.read_exact(&mut response).await.unwrap(); - assert_eq!(&response, b"pong"); - } - - #[tokio::test] - async fn custom_stream_plain_tcp_stream_exposes_plaintext_for_sing_mux() { - let kind = CustomSsStreamKind::XChacha20; - let key: Arc<[u8]> = Arc::from(vec![9u8; kind.key_len()]); - let (client_tcp, server_tcp) = tokio::io::duplex(4096); - let target = ProxyTcpTarget { - address: "198.18.64.1:443".parse().unwrap(), - hostname: Some("example.com.".to_owned()), - }; - let mut plain = custom_stream_plain_tcp_stream(client_tcp, &target, kind, key.clone()) - .await - .unwrap(); - let (server_reader, server_writer) = split(server_tcp); - let mut ss_reader = CustomSsStreamReader::new(server_reader, kind, key.clone()); - let mut ss_writer = CustomSsStreamWriter::new(server_writer, kind, key) - .await - .unwrap(); - - let mut received = vec![0u8; 64]; - let n = ss_reader.read_decrypted(&mut received).await.unwrap(); - assert_eq!( - &received[..n], - socks_domain_addr("example.com", 443).unwrap().as_slice() - ); - - plain.write_all(b"ping").await.unwrap(); - let n = ss_reader.read_decrypted(&mut received).await.unwrap(); - assert_eq!(&received[..n], b"ping"); - - ss_writer.write_all_encrypted(b"pong").await.unwrap(); - let mut response = [0u8; 4]; - plain.read_exact(&mut response).await.unwrap(); - assert_eq!(&response, b"pong"); - } - - #[tokio::test] - async fn aead2022_aes_ccm_plain_tcp_stream_exposes_plaintext_for_sing_mux() { - let kind = Aead2022AesCcmKind::Aes128; - let user_key: Arc<[u8]> = Arc::from(vec![11u8; kind.key_len()]); - let identity_keys: Arc<[Arc<[u8]>]> = Arc::from(Vec::>::new()); - let (client_tcp, mut server_tcp) = tokio::io::duplex(4096); - let target = ProxyTcpTarget { - address: "198.18.64.1:443".parse().unwrap(), - hostname: Some("example.com.".to_owned()), - }; - let mut plain = aead2022_aes_ccm_plain_tcp_stream( - client_tcp, - &target, - kind, - user_key.clone(), - identity_keys, - ) - .await - .unwrap(); - - let (request_salt, mut request_cipher, variable) = - read_aead2022_tcp_request_for_test(&mut server_tcp, kind, &user_key) - .await - .unwrap(); - let expected_target = socks_domain_addr("example.com", 443).unwrap(); - assert!(variable.starts_with(&expected_target)); - let padding_len_offset = expected_target.len(); - let padding_len = u16::from_be_bytes([ - variable[padding_len_offset], - variable[padding_len_offset + 1], - ]) as usize; - assert_eq!(padding_len, 1); - - plain.write_all(b"ping").await.unwrap(); - let payload = read_aead2022_tcp_chunk_for_test(&mut server_tcp, kind, &mut request_cipher) - .await - .unwrap(); - assert_eq!(payload, b"ping"); - - let mut response_cipher = write_aead2022_tcp_response_for_test( - &mut server_tcp, - kind, - &user_key, - request_salt.as_ref(), - ) - .await - .unwrap(); - write_aead2022_tcp_chunk_for_test(&mut server_tcp, &mut response_cipher, b"pong") - .await - .unwrap(); - - let mut response = [0u8; 4]; - plain.read_exact(&mut response).await.unwrap(); - assert_eq!(&response, b"pong"); - } - - async fn read_aead2022_tcp_request_for_test( - reader: &mut R, - kind: Aead2022AesCcmKind, - user_key: &[u8], - ) -> Result<(Arc<[u8]>, Aead2022TcpCipher, Vec)> - where - R: AsyncRead + Unpin, - { - let mut salt = vec![0u8; kind.key_len()]; - reader.read_exact(&mut salt).await?; - let mut cipher = Aead2022TcpCipher::new(kind, user_key, &salt)?; - let mut encrypted_fixed = vec![0u8; 1 + 8 + 2 + kind.tag_len()]; - reader.read_exact(&mut encrypted_fixed).await?; - let fixed = cipher.open_packet(&mut encrypted_fixed)?; - assert_eq!(fixed[0], AEAD2022_HEADER_TYPE_CLIENT); - aead2022_validate_timestamp(u64::from_be_bytes( - fixed[1..9].try_into().expect("fixed timestamp"), - ))?; - let variable_len = u16::from_be_bytes([fixed[9], fixed[10]]) as usize; - let mut encrypted_variable = vec![0u8; variable_len + kind.tag_len()]; - reader.read_exact(&mut encrypted_variable).await?; - let variable = cipher.open_packet(&mut encrypted_variable)?.to_vec(); - Ok((Arc::from(salt), cipher, variable)) - } - - async fn read_aead2022_tcp_chunk_for_test( - reader: &mut R, - kind: Aead2022AesCcmKind, - cipher: &mut Aead2022TcpCipher, - ) -> Result> - where - R: AsyncRead + Unpin, - { - let mut encrypted_len = vec![0u8; 2 + kind.tag_len()]; - reader.read_exact(&mut encrypted_len).await?; - let len = cipher.open_packet(&mut encrypted_len)?; - let payload_len = u16::from_be_bytes([len[0], len[1]]) as usize; - let mut encrypted_payload = vec![0u8; payload_len + kind.tag_len()]; - reader.read_exact(&mut encrypted_payload).await?; - Ok(cipher.open_packet(&mut encrypted_payload)?.to_vec()) - } - - async fn write_aead2022_tcp_response_for_test( - writer: &mut W, - kind: Aead2022AesCcmKind, - user_key: &[u8], - request_salt: &[u8], - ) -> Result - where - W: AsyncWrite + Unpin, - { - let response_salt = vec![13u8; kind.key_len()]; - let mut cipher = Aead2022TcpCipher::new(kind, user_key, &response_salt)?; - let mut fixed = Vec::with_capacity(1 + 8 + kind.key_len() + 2); - fixed.push(AEAD2022_HEADER_TYPE_SERVER); - fixed.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); - fixed.extend_from_slice(request_salt); - fixed.extend_from_slice(&0u16.to_be_bytes()); - writer.write_all(&response_salt).await?; - writer.write_all(&cipher.seal_packet(&fixed)?).await?; - writer.flush().await?; - Ok(cipher) - } - - async fn write_aead2022_tcp_chunk_for_test( - writer: &mut W, - cipher: &mut Aead2022TcpCipher, - payload: &[u8], - ) -> Result<()> - where - W: AsyncWrite + Unpin, - { - writer - .write_all(&cipher.seal_packet(&(payload.len() as u16).to_be_bytes())?) - .await?; - writer.write_all(&cipher.seal_packet(payload)?).await?; - writer.flush().await?; - Ok(()) - } - - #[test] - fn shadowsocks_udp_over_tcp_enables_udp_session_path() { - fn shadowsocks_node( - name: &str, - udp: bool, - udp_over_tcp: bool, - plugin: Option, - ) -> ProxyNode { - ProxyNode { - ordinal: 0, - name: name.to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "pass".to_owned(), - udp, - udp_over_tcp, - udp_over_tcp_version: UOT_LEGACY_VERSION, - client_fingerprint: None, - plugin, - smux: None, - }), - } - } - - let uot_node = shadowsocks_node("ss-uot-without-native-udp", false, true, None); - let ProxyNodeConfig::Shadowsocks(config) = &uot_node.config else { - panic!("expected Shadowsocks config"); - }; - let outbound = ShadowsocksOutbound::from_node(&uot_node, config).unwrap(); - assert!(outbound.udp_enabled); - assert!(outbound.udp_over_tcp); - - let native_udp_node = shadowsocks_node("ss-native-udp-disabled", false, false, None); - let ProxyNodeConfig::Shadowsocks(config) = &native_udp_node.config else { - panic!("expected Shadowsocks config"); - }; - let outbound = ShadowsocksOutbound::from_node(&native_udp_node, config).unwrap(); - assert!(!outbound.udp_enabled); - assert!(!outbound.udp_over_tcp); - - let kcptun_node = shadowsocks_node( - "ss-kcptun-forced-uot", - false, - false, - Some(ShadowsocksPlugin { - name: "kcptun".to_owned(), - opts: HashMap::new(), - }), - ); - let ProxyNodeConfig::Shadowsocks(config) = &kcptun_node.config else { - panic!("expected Shadowsocks config"); - }; - let outbound = ShadowsocksOutbound::from_node(&kcptun_node, config).unwrap(); - assert!(outbound.udp_enabled); - assert!(outbound.udp_over_tcp); - } - - #[test] - fn runtime_support_rejects_shadowsocks_tls_client_fingerprint() { - fn tls_plugin_node(plugin: ShadowsocksPlugin) -> ProxyNode { - ProxyNode { - ordinal: 0, - name: "ss-tls-fingerprint".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "proxy.example".to_owned(), - port: 443, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher: "aes-128-gcm".to_owned(), - password: "ss-secret".to_owned(), - udp: true, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: Some("firefox".to_owned()), - plugin: Some(plugin), - smux: None, - }), - } - } - - let shadow_tls = ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("password".to_owned(), "secret".to_owned()), - ("version".to_owned(), "2".to_owned()), - ]), - }; - - #[cfg(not(feature = "boring-browser-fingerprints"))] - let rejected_nodes = [tls_plugin_node(shadow_tls.clone())]; - #[cfg(feature = "boring-browser-fingerprints")] - let rejected_nodes: [ProxyNode; 0] = []; - - for node in rejected_nodes { - let err = runtime_support_error(&node) - .expect("uTLS client-fingerprint is not runtime-supported yet"); - assert!(err.contains("client-fingerprint firefox"), "{err}"); - assert!(err.contains("browser TLS profile"), "{err}"); - } - - #[cfg(feature = "boring-browser-fingerprints")] - assert!(runtime_support_error(&tls_plugin_node(shadow_tls)).is_none()); - - let shadow_tls_v1 = tls_plugin_node(ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("version".to_owned(), "1".to_owned()), - ]), - }); - assert!(runtime_support_error(&shadow_tls_v1).is_none()); - - let mut none_node = tls_plugin_node(ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("password".to_owned(), "secret".to_owned()), - ("version".to_owned(), "2".to_owned()), - ]), - }); - if let ProxyNodeConfig::Shadowsocks(shadowsocks) = &mut none_node.config { - shadowsocks.client_fingerprint = Some("none".to_owned()); - } - assert!(runtime_support_error(&none_node).is_none()); - - let mut unknown_node = none_node; - if let ProxyNodeConfig::Shadowsocks(shadowsocks) = &mut unknown_node.config { - shadowsocks.client_fingerprint = Some("unknown-browser".to_owned()); - } - assert!(runtime_support_error(&unknown_node).is_none()); - } - - #[cfg(feature = "boring-browser-fingerprints")] - const TEST_CLIENT_CERTIFICATE_PEM: &str = r#"-----BEGIN CERTIFICATE----- -MIIDDTCCAfWgAwIBAgIUcQjiH+9SQYcKZJBHGs3/ymabzqYwDQYJKoZIhvcNAQEL -BQAwFjEUMBIGA1UEAwwLYnVycm93LXRlc3QwHhcNMjYwNjAxMTUwMjA5WhcNMjYw -NjAyMTUwMjA5WjAWMRQwEgYDVQQDDAtidXJyb3ctdGVzdDCCASIwDQYJKoZIhvcN -AQEBBQADggEPADCCAQoCggEBAOahzwpBhDKatLPvsnSjIQGdb9jq34dGt5Yu2znj -PAL4aTI9mFsid5L7RlHJTV1MSRsYddrLTCchDZl46hEArRQCQRf/kGiHIqJWcYDt -7gJaQHZXNTasQ9CWEoC0X+Jqv89lf9q9caMYye9ZXICeRbDxhCrQSEfY1rxXtN3i -iqJFcxmaUdzMXjgfIyMlFCHdcYtGiITZQEVizI1EelFr0RyxDuE+9IEvokpgbI2y -PnMirWVoeOKyaWFbJyPR3qSKuDF1jYrtsluW7IkTHF0B8jvbg6IJNSV7r2JaiQhD -RrgPxvXXHvfPjoZbCqAMN6JarRnWkw58uuNCr+3OxZygUkECAwEAAaNTMFEwHQYD -VR0OBBYEFI2Pv9COWSRHYwScAy17EP8MUr6uMB8GA1UdIwQYMBaAFI2Pv9COWSRH -YwScAy17EP8MUr6uMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB -AHgOvphtyX9CO/ibtA8KKcH9puWeV7CJLoqHW9XU5ZvdawI01IEdn8oIn5IAzurj -X7DAqaBUvzc58EB9cmnGnGhZZJMHBE2PVlxea1EcB5geWV+y6lIM7peqP+XGsAJo -32pUr2W+Sl3IMf6FkXy4VneZCkGlOgnxrEB9ULm1foLGQtTkXTT3dqT098Gqs3ip -HC37IQ6Bqg3/TseRUpzAJ8RVV8qvyJlYvrPvYqYRMl8xHRS2Gj5q2FvxuKlCWP/t -3oXNpvfjecQFMpghL3BcMoQUq2kMLwOz5sAUFxjP6pQf6K1kB98u1NgkzF8VXgUU -0dEYKIH7JFYVNeMzscI+9cM= ------END CERTIFICATE-----"#; - - #[cfg(feature = "boring-browser-fingerprints")] - const TEST_CLIENT_PRIVATE_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmoc8KQYQymrSz -77J0oyEBnW/Y6t+HRreWLts54zwC+GkyPZhbIneS+0ZRyU1dTEkbGHXay0wnIQ2Z -eOoRAK0UAkEX/5BohyKiVnGA7e4CWkB2VzU2rEPQlhKAtF/iar/PZX/avXGjGMnv -WVyAnkWw8YQq0EhH2Na8V7Td4oqiRXMZmlHczF44HyMjJRQh3XGLRoiE2UBFYsyN -RHpRa9EcsQ7hPvSBL6JKYGyNsj5zIq1laHjismlhWycj0d6kirgxdY2K7bJbluyJ -ExxdAfI724OiCTUle69iWokIQ0a4D8b11x73z46GWwqgDDeiWq0Z1pMOfLrjQq/t -zsWcoFJBAgMBAAECggEADce+8wTCzTKWN9F2JGb3hWeunlORAz2z9tAaPuViGWar -WRpPZ1PyDhZfzL+OP6lbuDfoGX3Kho9V3ITaLiWQBzaH732l12u0+OXZBqmbbjON -+iQwJJylY/9biqonDS8bV9003FEzytl5KLPPsEsErKkO9cSX1Qcn9Cf6FsSUS6ll -SIq5iKPYtFIKOV7U8Bu1MqiMx9eKIpCcHVKNlP4uConpsq/TPc0pouSIkT8okdt0 -JxAAvNo+YvqkqbK7+t5rCvaG8csT+Uw6osa0GcupKmXWbVbIeMWjdHJNqBfinmRF -U62sGDzkVDdj1HroBNf9Ns9sovET29UgX5z6oj9WuwKBgQD1OTsDgrfkoWaXQuJR -8Vm5SILE+vKWlAXAyJrN6Dp+jbUNaV98kYWziTg4ngftjBPjUf0PYyZUmYo55+Os -LALCEjc7axgFSAjIvlH+WlfLChAGOAcOyLA1QzSn1G6f31OlMUFu9GRmGsZf5Ufc -wMn/MKQbAxTrOtmYyUmxztYnRwKBgQDwxGx2odDVD2NnE03427wQfnKETkS31DQb -7mp7uZDVg21Dr+5V0tmuxRywQFtSVDNo8sxB4REozbhCibDJcvWD0hAd/8FsxAuU -AkeXjfwaneSrw8b0JoEK/9aEkm0rin+XN/CmmCCPWNjiIguG/klL2YfXkSv71fpw -if4PgcgONwKBgQCJQVFAs8fOFnDftTYL+3Tm+ikHrBZgJdXag+3x1kv3TcXLDfG+ -PY2CYgmv1vRFB6SSFe/4ztxDefUeWCbc1X1ttthnT5gQTLNt+OjX3yVIpgc2E+IP -alEGXul4DrUkktG0oo8nVW9knxPt1N2WN+pYBZe07tKknznwBKpU9Zp0PQKBgC1w -1Qu61KAxrFAa659pUWBHjTN9VijfywnugHhjeHtjt66LuM7H4b/Dgfud2d5698z5 -7iUM5mEuGnWsaQpMQRwk/Fe9GnN9uLWxjHOFH6yiWjM02wrfbYF28bTJsgMCu7v9 -mdTHZ3XGjgB37ncG7Sx8nM/JnWSFaSPuV13z358XAoGAIuigV+9koumwqNd2ueIQ -uCWixSkOVwRHPiWG4iyWku9RpspR+Sw23gUbQ45J7u326Aw0P3oHDme2bxgsvME6 -ld+z0BdOLxyLEw55UtQrJkFNCbGsNFHRs774PuZdfExfzcd9d+56vYcVb49PSBb6 -OW4mfN8jmXq6wTtOv85mBGE= ------END PRIVATE KEY-----"#; - - #[cfg(feature = "boring-browser-fingerprints")] - #[test] - fn boring_client_identity_loads_inline_pem() { - let auth = load_boring_client_identity(&TlsClientIdentity { - certificate: TEST_CLIENT_CERTIFICATE_PEM.to_owned(), - private_key: TEST_CLIENT_PRIVATE_KEY_PEM.to_owned(), - }) - .expect("inline PEM client identity should load for BoringSSL"); - - assert_eq!(auth.cert_chain.len(), 1); - } - - #[cfg(feature = "boring-browser-fingerprints")] - #[test] - fn shadow_tls_random_fingerprint_is_mihomo_style_initial_choice() { - let first = mihomo_initial_random_fingerprint(); - assert!(matches!( - first, - MihomoClientFingerprint::Chrome - | MihomoClientFingerprint::Safari - | MihomoClientFingerprint::Ios - | MihomoClientFingerprint::Firefox - )); - for _ in 0..16 { - assert_eq!(mihomo_initial_random_fingerprint(), first); - } - } - - #[cfg(feature = "boring-browser-fingerprints")] - #[test] - fn shadow_tls_v2_browser_profile_removes_x25519_mlkem768() { - let mut config = rama_net::tls::client::ClientConfig { - extensions: Some(vec![RamaClientHelloExtension::SupportedGroups(vec![ - RamaSupportedGroup::X25519MLKEM768, - RamaSupportedGroup::X25519, - ])]), - ..Default::default() - }; - - strip_x25519_mlkem768_from_client_config(&mut config); - - let extensions = config.extensions.expect("extensions should remain present"); - let RamaClientHelloExtension::SupportedGroups(groups) = &extensions[0] else { - panic!("expected supported groups extension"); - }; - assert_eq!(groups, &[RamaSupportedGroup::X25519]); - } - - #[cfg(feature = "boring-browser-fingerprints")] - #[test] - fn shadow_tls_browser_profiles_cover_mihomo_safari16_alias() { - let profile = mihomo_browser_user_agent_profile(MihomoClientFingerprint::Safari16) - .expect("safari16 should map to an embedded browser TLS profile"); - - assert_eq!(profile.ua_kind, UserAgentKind::Safari); - assert_eq!(profile.platform, Some(PlatformKind::MacOS)); - assert!(profile - .ua_str() - .is_some_and(|ua| ua.contains("Version/16."))); - assert!(MihomoClientFingerprint::Safari16.shadow_tls_v2_boring_supported()); - } - - #[test] - fn shadowsocks_runtime_accepts_simple_obfs_plugins() { - let plugin = ShadowsocksPlugin { - name: "obfs".to_owned(), - opts: HashMap::from([ - ("mode".to_owned(), "http".to_owned()), - ("host".to_owned(), "front.example".to_owned()), - ]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&plugin), None).unwrap(), - Some(ShadowsocksPluginTransport::SimpleObfs { - mode: SimpleObfsMode::Http, - host: "front.example".to_owned() - }) - ); - - let uri_normalized_plugin = ShadowsocksPlugin { - name: "obfs".to_owned(), - opts: HashMap::from([ - ("obfs".to_owned(), "tls".to_owned()), - ("obfs-host".to_owned(), "cdn.example".to_owned()), - ]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&uri_normalized_plugin), None).unwrap(), - Some(ShadowsocksPluginTransport::SimpleObfs { - mode: SimpleObfsMode::Tls, - host: "cdn.example".to_owned() - }) - ); - - let yaml_simple_obfs_alias = ShadowsocksPlugin { - name: "obfs-local".to_owned(), - opts: HashMap::from([ - ("obfs".to_owned(), "tls".to_owned()), - ("obfs-host".to_owned(), "cdn.example".to_owned()), - ]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&yaml_simple_obfs_alias), None).unwrap(), - None - ); - - let v2ray = ShadowsocksPlugin { - name: "v2ray-plugin".to_owned(), - opts: HashMap::from([ - ("mode".to_owned(), "websocket".to_owned()), - ("path".to_owned(), "/ws".to_owned()), - ]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&v2ray), None).unwrap(), - Some(ShadowsocksPluginTransport::WebSocket( - ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::V2ray, - host: "bing.com".to_owned(), - path: "/ws".to_owned(), - headers: Vec::new(), - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::V2ray, - v2ray_http_upgrade: false, - v2ray_http_upgrade_fast_open: false, - } - )) - ); - - let v2ray_uri_aliases = ShadowsocksPlugin { - name: "v2ray-plugin".to_owned(), - opts: HashMap::from([ - ("obfs".to_owned(), "websocket".to_owned()), - ("obfs-host".to_owned(), "cdn.example".to_owned()), - ("path".to_owned(), "/ws".to_owned()), - ("tls".to_owned(), "true".to_owned()), - ]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&v2ray_uri_aliases), None).unwrap(), - Some(ShadowsocksPluginTransport::WebSocket( - ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::V2ray, - host: "cdn.example".to_owned(), - path: "/ws".to_owned(), - headers: Vec::new(), - tls: true, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::V2ray, - v2ray_http_upgrade: false, - v2ray_http_upgrade_fast_open: false, - } - )) - ); - - let v2ray_fast_open = ShadowsocksPlugin { - name: "v2ray-plugin".to_owned(), - opts: HashMap::from([ - ("mode".to_owned(), "websocket".to_owned()), - ("v2ray-http-upgrade".to_owned(), "true".to_owned()), - ("v2ray-http-upgrade-fast-open".to_owned(), "true".to_owned()), - ]), - }; - let Some(ShadowsocksPluginTransport::WebSocket(v2ray_fast_open)) = - shadowsocks_plugin_transport(Some(&v2ray_fast_open), None).unwrap() - else { - panic!("expected websocket plugin transport"); - }; - assert!(v2ray_fast_open.v2ray_http_upgrade); - assert!(v2ray_fast_open.v2ray_http_upgrade_fast_open); - - let v2ray_mtls = ShadowsocksPlugin { - name: "v2ray-plugin".to_owned(), - opts: HashMap::from([ - ("mode".to_owned(), "websocket".to_owned()), - ("tls".to_owned(), "true".to_owned()), - ("certificate".to_owned(), "/tmp/client.crt".to_owned()), - ("private-key".to_owned(), "/tmp/client.key".to_owned()), - ]), - }; - let Some(ShadowsocksPluginTransport::WebSocket(v2ray_mtls)) = - shadowsocks_plugin_transport(Some(&v2ray_mtls), None).unwrap() - else { - panic!("expected websocket plugin transport"); - }; - assert_eq!( - v2ray_mtls.client_identity, - Some(TlsClientIdentity { - certificate: "/tmp/client.crt".to_owned(), - private_key: "/tmp/client.key".to_owned(), - }) - ); - - let v2ray_ech = ShadowsocksPlugin { - name: "v2ray-plugin".to_owned(), - opts: HashMap::from([ - ("mode".to_owned(), "websocket".to_owned()), - ("tls".to_owned(), "true".to_owned()), - ("ech-opts.enable".to_owned(), "true".to_owned()), - ("ech-opts.config".to_owned(), "AQIDBA==".to_owned()), - ( - "ech-opts.query-server-name".to_owned(), - "public.example".to_owned(), - ), - ]), - }; - let Some(ShadowsocksPluginTransport::WebSocket(v2ray_ech)) = - shadowsocks_plugin_transport(Some(&v2ray_ech), None).unwrap() - else { - panic!("expected websocket plugin transport"); - }; - assert_eq!( - v2ray_ech.ech, - Some(ShadowsocksEchConfig { - config_list: Some(vec![1, 2, 3, 4]), - query_server_name: Some("public.example".to_owned()), - }) - ); - - let gost_without_mux = ShadowsocksPlugin { - name: "gost-plugin".to_owned(), - opts: HashMap::from([ - ("mode".to_owned(), "websocket".to_owned()), - ("mux".to_owned(), "false".to_owned()), - ]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&gost_without_mux), None).unwrap(), - Some(ShadowsocksPluginTransport::WebSocket( - ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::Gost, - host: "bing.com".to_owned(), - path: "/".to_owned(), - headers: Vec::new(), - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::None, - v2ray_http_upgrade: false, - v2ray_http_upgrade_fast_open: false, - } - )) - ); - - let gost_default_mux = ShadowsocksPlugin { - name: "gost-plugin".to_owned(), - opts: HashMap::from([("mode".to_owned(), "websocket".to_owned())]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&gost_default_mux), None).unwrap(), - Some(ShadowsocksPluginTransport::WebSocket( - ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::Gost, - host: "bing.com".to_owned(), - path: "/".to_owned(), - headers: Vec::new(), - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::Gost, - v2ray_http_upgrade: false, - v2ray_http_upgrade_fast_open: false, - } - )) - ); - - let missing_obfs_mode = ShadowsocksPlugin { - name: "obfs".to_owned(), - opts: HashMap::new(), - }; - assert!(shadowsocks_plugin_transport(Some(&missing_obfs_mode), None).is_err()); - - let uppercase_obfs_plugin = ShadowsocksPlugin { - name: "Obfs".to_owned(), - opts: HashMap::from([("mode".to_owned(), "http".to_owned())]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&uppercase_obfs_plugin), None).unwrap(), - None - ); - - let missing_websocket_mode = ShadowsocksPlugin { - name: "v2ray-plugin".to_owned(), - opts: HashMap::new(), - }; - assert!(shadowsocks_plugin_transport(Some(&missing_websocket_mode), None).is_err()); - - let shadow_tls_alias = ShadowsocksPlugin { - name: "shadowtls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("password".to_owned(), "secret".to_owned()), - ]), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&shadow_tls_alias), None).unwrap(), - None - ); - - let unknown_plugin = ShadowsocksPlugin { - name: "unknown-plugin".to_owned(), - opts: HashMap::new(), - }; - assert_eq!( - shadowsocks_plugin_transport(Some(&unknown_plugin), None).unwrap(), - None - ); - - let shadow_tls = ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("password".to_owned(), "secret".to_owned()), - ("version".to_owned(), "1".to_owned()), - ("alpn".to_owned(), "http/1.1".to_owned()), - ("skip-cert-verify".to_owned(), "true".to_owned()), - ( - "fingerprint".to_owned(), - "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" - .to_owned(), - ), - ]), - }; - let shadow_tls_fingerprint = parse_certificate_fingerprint( - "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", - ) - .unwrap(); - assert_eq!( - shadowsocks_plugin_transport(Some(&shadow_tls), None).unwrap(), - Some(ShadowsocksPluginTransport::ShadowTls( - ShadowsocksShadowTlsTransport { - host: "cover.example".to_owned(), - password: "secret".to_owned(), - version: 1, - alpn: vec!["http/1.1".to_owned()], - skip_cert_verify: true, - certificate_fingerprint: Some(shadow_tls_fingerprint), - client_identity: None, - client_fingerprint: None, - } - )) - ); - - let shadow_tls_v1_without_password_plugin = ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("version".to_owned(), "1".to_owned()), - ]), - }; - let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_v1_without_password)) = - shadowsocks_plugin_transport(Some(&shadow_tls_v1_without_password_plugin), None) - .unwrap() - else { - panic!("expected shadow-tls plugin transport"); - }; - assert_eq!(shadow_tls_v1_without_password.version, 1); - assert_eq!(shadow_tls_v1_without_password.password, ""); - assert_eq!( - shadow_tls_v1_without_password.alpn, - vec!["h2".to_owned(), "http/1.1".to_owned()] - ); - - let shadow_tls_explicit_empty_alpn = ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("version".to_owned(), "1".to_owned()), - ("alpn".to_owned(), "".to_owned()), - ]), - }; - let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_empty_alpn)) = - shadowsocks_plugin_transport(Some(&shadow_tls_explicit_empty_alpn), None).unwrap() - else { - panic!("expected shadow-tls plugin transport"); - }; - assert!(shadow_tls_empty_alpn.alpn.is_empty()); - - let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_v1_with_fingerprint)) = - shadowsocks_plugin_transport( - Some(&shadow_tls_v1_without_password_plugin), - Some("firefox"), - ) - .unwrap() - else { - panic!("expected shadow-tls plugin transport"); - }; - assert_eq!(shadow_tls_v1_with_fingerprint.version, 1); - assert_eq!(shadow_tls_v1_with_fingerprint.client_fingerprint, None); - - let shadow_tls_v2_with_fingerprint_plugin = ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("password".to_owned(), "secret".to_owned()), - ("version".to_owned(), "2".to_owned()), - ]), - }; - #[cfg(feature = "boring-browser-fingerprints")] - { - let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_v2_with_fingerprint)) = - shadowsocks_plugin_transport( - Some(&shadow_tls_v2_with_fingerprint_plugin), - Some("firefox"), - ) - .unwrap() - else { - panic!("expected shadow-tls plugin transport"); - }; - assert_eq!(shadow_tls_v2_with_fingerprint.version, 2); - assert_eq!( - shadow_tls_v2_with_fingerprint.client_fingerprint, - Some(MihomoClientFingerprint::Firefox) - ); - } - #[cfg(not(feature = "boring-browser-fingerprints"))] - assert!(shadowsocks_plugin_transport( - Some(&shadow_tls_v2_with_fingerprint_plugin), - Some("firefox"), - ) - .is_err()); - - let shadow_tls_v2_without_password = ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("version".to_owned(), "2".to_owned()), - ]), - }; - assert!(shadowsocks_plugin_transport(Some(&shadow_tls_v2_without_password), None).is_err()); - - let incomplete_mtls = ShadowsocksPlugin { - name: "gost-plugin".to_owned(), - opts: HashMap::from([ - ("mode".to_owned(), "websocket".to_owned()), - ("certificate".to_owned(), "/tmp/client.crt".to_owned()), - ]), - }; - assert!(shadowsocks_plugin_transport(Some(&incomplete_mtls), None).is_err()); - - let shadow_tls_v3_plugin = ShadowsocksPlugin { - name: "shadow-tls".to_owned(), - opts: HashMap::from([ - ("host".to_owned(), "cover.example".to_owned()), - ("password".to_owned(), "secret".to_owned()), - ("version".to_owned(), "3".to_owned()), - ]), - }; - assert!(shadowsocks_plugin_transport(Some(&shadow_tls_v3_plugin), None).is_err()); - - let kcptun = ShadowsocksPlugin { - name: "kcptun".to_owned(), - opts: HashMap::from([ - ("key".to_owned(), "secret".to_owned()), - ("crypt".to_owned(), "tea".to_owned()), - ("mode".to_owned(), "fast3".to_owned()), - ("conn".to_owned(), "2".to_owned()), - ("nocomp".to_owned(), "true".to_owned()), - ("ratelimit".to_owned(), "2048".to_owned()), - ("dscp".to_owned(), "46".to_owned()), - ("sockbuf".to_owned(), "8388608".to_owned()), - ("smuxver".to_owned(), "3".to_owned()), - ("framesize".to_owned(), "4096".to_owned()), - ]), - }; - let Some(ShadowsocksPluginTransport::Kcptun(kcptun)) = - shadowsocks_plugin_transport(Some(&kcptun), None).unwrap() - else { - panic!("expected kcptun plugin transport"); - }; - assert_eq!(kcptun.key, "secret"); - assert_eq!(kcptun.crypt, "tea"); - assert_eq!(kcptun.mode, "fast3"); - assert_eq!(kcptun.conn, 2); - assert_eq!(kcptun.no_delay, 1); - assert_eq!(kcptun.interval, 10); - assert_eq!(kcptun.resend, 2); - assert!(kcptun.no_congestion); - assert!(kcptun.no_comp); - assert_eq!(kcptun.rate_limit, 2048); - assert_eq!(kcptun.dscp, 46); - assert_eq!(kcptun.sockbuf, 8_388_608); - assert_eq!(kcptun.smux_ver, 2); - assert_eq!(kcptun.frame_size, 4096); - assert_eq!(kcptun_ipv4_dscp_tos(kcptun.dscp), 184); - - } - - #[test] - fn restls_application_records_authenticate_and_mask_headers() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x42; 32]; - let script = vec![RestlsScriptLine { - target_len: RestlsTargetLength { base: 8, random_range: 0 }, - command: RestlsCommand::Response(1), - }]; - let mut writer = RestlsApplicationCodec::new(secret, server_random, false); - let result = writer.build_to_server_record(b"hello", &script).unwrap(); - - assert_eq!(result.consumed, 5); - assert_eq!(result.command, RestlsCommand::Response(1)); - assert_eq!(result.record[0], TLS_APPLICATION_DATA_RECORD_TYPE); - assert_eq!(&result.record[1..3], &TLS12_RECORD_VERSION); - assert_eq!( - usize::from(u16::from_be_bytes([result.record[3], result.record[4]])), - RESTLS_APP_DATA_AUTH_HEADER_LEN + 8 - ); - assert_ne!( - &result.record[TLS_RECORD_HEADER_LEN + RESTLS_APP_DATA_LEN_OFFSET - ..TLS_RECORD_HEADER_LEN + RESTLS_APP_DATA_OFFSET], - &[0, 5, 1, 1], - "Restls masks the length/command bytes with the BLAKE3 auth header hash" - ); - - let mut reader = RestlsApplicationCodec::new(secret, server_random, false); - let frame = reader.decode_to_server_record(&result.record).unwrap(); - assert_eq!(frame.data, b"hello"); - assert_eq!(frame.command, RestlsCommand::Response(1)); - - let mut tampered = result.record.clone(); - let last = tampered.last_mut().unwrap(); - *last ^= 0x01; - let mut reader = RestlsApplicationCodec::new(secret, server_random, false); - assert!(reader.decode_to_server_record(&tampered).is_err()); - } - - #[test] - fn restls_first_application_record_binds_client_finished() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x11; 32]; - let client_finished = b"encrypted-client-finished".to_vec(); - let mut writer = RestlsApplicationCodec::new(secret, server_random, false); - writer.set_client_finished_auth(client_finished.clone()); - let first = writer.build_to_server_record(b"first", &[]).unwrap(); - let second = writer.build_to_server_record(b"second", &[]).unwrap(); - - let mut missing_finished = RestlsApplicationCodec::new(secret, server_random, false); - assert!(missing_finished - .decode_to_server_record(&first.record) - .is_err()); - - let mut reader = RestlsApplicationCodec::new(secret, server_random, false); - reader.set_client_finished_auth(client_finished); - let first_frame = reader.decode_to_server_record(&first.record).unwrap(); - assert_eq!(first_frame.data, b"first"); - assert_eq!(first_frame.command, RestlsCommand::Noop); - - let second_frame = reader.decode_to_server_record(&second.record).unwrap(); - assert_eq!(second_frame.data, b"second"); - assert_eq!(second_frame.command, RestlsCommand::Noop); - } - - #[test] - fn restls_tls12_gcm_application_records_carry_explicit_counters() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x24; 32]; - let mut writer = RestlsApplicationCodec::new(secret, server_random, true); - let result = writer.build_to_server_record(b"abc", &[]).unwrap(); - assert_eq!( - u64::from_be_bytes( - result.record[TLS_RECORD_HEADER_LEN..TLS_RECORD_HEADER_LEN + 8] - .try_into() - .unwrap() - ), - 1 - ); - - let mut reader = RestlsApplicationCodec::new(secret, server_random, true); - let frame = reader.decode_to_server_record(&result.record).unwrap(); - assert_eq!(frame.data, b"abc"); - assert_eq!(frame.command, RestlsCommand::Noop); - - let mut replay_reader = RestlsApplicationCodec::new(secret, server_random, true); - replay_reader.to_server_counter = 1; - assert!(replay_reader - .decode_to_server_record(&result.record) - .is_err()); - } - - #[test] - fn restls_tls12_gcm_to_client_can_disable_explicit_counter() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x25; 32]; - let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); - let record = server_writer - .build_to_client_test_record(b"abc", RestlsCommand::Noop) - .unwrap(); - - let mut strict_reader = RestlsApplicationCodec::new(secret, server_random, true); - assert!(strict_reader.decode_to_client_record(&record).is_err()); - - let mut reader = RestlsApplicationCodec::new(secret, server_random, true); - reader.set_tls12_gcm_to_client_disable_counter(true); - let frame = reader.decode_to_client_record(&record).unwrap(); - assert_eq!(frame.data, b"abc"); - assert_eq!(frame.command, RestlsCommand::Noop); - } - - #[test] - fn restls_response_count_matches_mihomo_signed_byte_shape() { - assert_eq!(restls_response_repeat_count(2, false), 2); - assert_eq!(restls_response_repeat_count(2, true), 1); - assert_eq!(restls_response_repeat_count(0, true), 0); - assert_eq!(restls_response_repeat_count(200, false), 0); - assert_eq!(restls_response_repeat_count(128, true), 127); - assert_eq!(restls_response_repeat_count(300, false), 44); - } - - #[test] - fn restls_stream_state_buffers_and_resumes_scripted_interrupts() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x51; 32]; - let script = vec![ - RestlsScriptLine { - target_len: RestlsTargetLength { base: 3, random_range: 0 }, - command: RestlsCommand::Response(2), - }, - RestlsScriptLine { - target_len: RestlsTargetLength { base: 6, random_range: 0 }, - command: RestlsCommand::Noop, - }, - ]; - let mut writer = RestlsStreamState::new( - RestlsApplicationCodec::new(secret, server_random, false), - script, - ); - let mut reader = RestlsApplicationCodec::new(secret, server_random, false); - - let first = writer.write(b"abcdef").unwrap(); - assert_eq!(first.accepted, 6); - assert_eq!(first.records.len(), 1); - assert!(writer.write_pending); - assert_eq!(writer.send_buffer, b"def"); - let frame = reader.decode_to_server_record(&first.records[0]).unwrap(); - assert_eq!(frame.data, b"abc"); - assert_eq!(frame.command, RestlsCommand::Response(2)); - - let buffered = writer.write(b"ghi").unwrap(); - assert_eq!(buffered.accepted, 3); - assert!(buffered.records.is_empty()); - assert_eq!(writer.send_buffer, b"defghi"); - - let resumed = writer.resume_pending_write().unwrap(); - assert_eq!(resumed.records.len(), 1); - assert!(!writer.write_pending); - assert!(writer.send_buffer.is_empty()); - let frame = reader.decode_to_server_record(&resumed.records[0]).unwrap(); - assert_eq!(frame.data, b"defghi"); - assert_eq!(frame.command, RestlsCommand::Noop); - - let response_records = writer - .receive_command(RestlsCommand::Response(2), true) - .unwrap(); - assert_eq!(response_records.len(), 1); - let frame = reader - .decode_to_server_record(&response_records[0]) - .unwrap(); - assert!(frame.data.is_empty()); - assert_eq!(frame.command, RestlsCommand::Noop); - } - - #[test] - fn restls_stream_state_decodes_server_records_and_unblocks_upload() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x52; 32]; - let script = vec![ - RestlsScriptLine { - target_len: RestlsTargetLength { base: 2, random_range: 0 }, - command: RestlsCommand::Response(2), - }, - RestlsScriptLine { - target_len: RestlsTargetLength { base: 2, random_range: 0 }, - command: RestlsCommand::Noop, - }, - RestlsScriptLine { - target_len: RestlsTargetLength { base: 0, random_range: 0 }, - command: RestlsCommand::Noop, - }, - ]; - let mut state = RestlsStreamState::new( - RestlsApplicationCodec::new(secret, server_random, false), - script, - ); - let mut peer_reader = RestlsApplicationCodec::new(secret, server_random, false); - let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); - - let upload = state.write(b"abcd").unwrap(); - assert_eq!(upload.records.len(), 1); - assert!(state.write_pending); - assert_eq!(state.send_buffer, b"cd"); - assert_eq!( - peer_reader - .decode_to_server_record(&upload.records[0]) - .unwrap() - .data, - b"ab" - ); - - let server_record = server_writer - .build_to_client_test_record(b"server-data", RestlsCommand::Response(2)) - .unwrap(); - let read = state.read_to_client_record(&server_record).unwrap(); - assert_eq!(read.data, b"server-data"); - assert!(read.resumed_pending_write); - assert_eq!(read.records.len(), 2); - assert!(!state.write_pending); - assert!(state.send_buffer.is_empty()); - - let resumed = peer_reader - .decode_to_server_record(&read.records[0]) - .unwrap(); - assert_eq!(resumed.data, b"cd"); - assert_eq!(resumed.command, RestlsCommand::Noop); - let response = peer_reader - .decode_to_server_record(&read.records[1]) - .unwrap(); - assert!(response.data.is_empty()); - assert_eq!(response.command, RestlsCommand::Noop); - } - - #[tokio::test] - async fn restls_stream_wraps_application_read_and_write() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x61; 32]; - let state = RestlsStreamState::new( - RestlsApplicationCodec::new(secret, server_random, false), - Vec::new(), - ); - let (client, mut server) = tokio::io::duplex(4096); - let mut stream = RestlsStream::new(client, state); - - stream.write_all(b"upload").await.unwrap(); - stream.flush().await.unwrap(); - let record = read_test_tls_record(&mut server).await; - let mut peer_reader = RestlsApplicationCodec::new(secret, server_random, false); - let frame = peer_reader.decode_to_server_record(&record).unwrap(); - assert_eq!(frame.data, b"upload"); - assert_eq!(frame.command, RestlsCommand::Noop); - - let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); - let record = server_writer - .build_to_client_test_record(b"download", RestlsCommand::Noop) - .unwrap(); - server.write_all(&record).await.unwrap(); - - let mut body = [0u8; 8]; - stream.read_exact(&mut body).await.unwrap(); - assert_eq!(&body, b"download"); - } - - #[tokio::test] - async fn restls_stream_resumes_pending_write_from_server_response() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x62; 32]; - let script = vec![ - RestlsScriptLine { - target_len: RestlsTargetLength { base: 2, random_range: 0 }, - command: RestlsCommand::Response(2), - }, - RestlsScriptLine { - target_len: RestlsTargetLength { base: 2, random_range: 0 }, - command: RestlsCommand::Noop, - }, - RestlsScriptLine { - target_len: RestlsTargetLength { base: 0, random_range: 0 }, - command: RestlsCommand::Noop, - }, - ]; - let state = RestlsStreamState::new( - RestlsApplicationCodec::new(secret, server_random, false), - script, - ); - let (client, mut server) = tokio::io::duplex(4096); - let mut stream = RestlsStream::new(client, state); - let mut peer_reader = RestlsApplicationCodec::new(secret, server_random, false); - - stream.write_all(b"abcd").await.unwrap(); - stream.flush().await.unwrap(); - let first = read_test_tls_record(&mut server).await; - let frame = peer_reader.decode_to_server_record(&first).unwrap(); - assert_eq!(frame.data, b"ab"); - assert_eq!(frame.command, RestlsCommand::Response(2)); - - let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); - let response = server_writer - .build_to_client_test_record(b"ok", RestlsCommand::Response(2)) - .unwrap(); - server.write_all(&response).await.unwrap(); - - let mut body = [0u8; 2]; - stream.read_exact(&mut body).await.unwrap(); - assert_eq!(&body, b"ok"); - - let resumed = read_test_tls_record(&mut server).await; - let frame = peer_reader.decode_to_server_record(&resumed).unwrap(); - assert_eq!(frame.data, b"cd"); - assert_eq!(frame.command, RestlsCommand::Noop); - - let fake_response = read_test_tls_record(&mut server).await; - let frame = peer_reader.decode_to_server_record(&fake_response).unwrap(); - assert!(frame.data.is_empty()); - assert_eq!(frame.command, RestlsCommand::Noop); - } - - #[test] - fn restls_client_hello_auth_material_matches_mihomo_shape() { - fn extension(extension_type: u16, payload: Vec) -> Vec { - let mut encoded = Vec::with_capacity(4 + payload.len()); - encoded.extend_from_slice(&extension_type.to_be_bytes()); - encoded.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - encoded.extend_from_slice(&payload); - encoded - } - - fn test_client_hello(key_shares: &[(u16, Vec)], psk_labels: &[Vec]) -> Vec { - let mut key_share_entries = Vec::new(); - for (group, key) in key_shares { - key_share_entries.extend_from_slice(&group.to_be_bytes()); - key_share_entries.extend_from_slice(&(key.len() as u16).to_be_bytes()); - key_share_entries.extend_from_slice(key); - } - let mut key_share_payload = Vec::new(); - key_share_payload.extend_from_slice(&(key_share_entries.len() as u16).to_be_bytes()); - key_share_payload.extend_from_slice(&key_share_entries); - - let mut identities = Vec::new(); - for label in psk_labels { - identities.extend_from_slice(&(label.len() as u16).to_be_bytes()); - identities.extend_from_slice(label); - identities.extend_from_slice(&0u32.to_be_bytes()); - } - let mut psk_payload = Vec::new(); - psk_payload.extend_from_slice(&(identities.len() as u16).to_be_bytes()); - psk_payload.extend_from_slice(&identities); - psk_payload.extend_from_slice(&2u16.to_be_bytes()); - psk_payload.extend_from_slice(&[0xaa, 0xbb]); - - let mut extensions = Vec::new(); - extensions.extend_from_slice(&extension(0x0033, key_share_payload)); - extensions.extend_from_slice(&extension(0x0029, psk_payload)); - - let mut body = Vec::new(); - body.extend_from_slice(&[0x03, 0x03]); - body.extend_from_slice(&[0x44; 32]); - body.push(32); - body.extend_from_slice(&[0; 32]); - body.extend_from_slice(&2u16.to_be_bytes()); - body.extend_from_slice(&0x1301u16.to_be_bytes()); - body.push(1); - body.push(0); - body.extend_from_slice(&(extensions.len() as u16).to_be_bytes()); - body.extend_from_slice(&extensions); - - let mut hello = Vec::new(); - hello.push(0x01); - hello.extend_from_slice(&[ - ((body.len() >> 16) & 0xff) as u8, - ((body.len() >> 8) & 0xff) as u8, - (body.len() & 0xff) as u8, - ]); - hello.extend_from_slice(&body); - hello - } - - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let key_shares = vec![(0x001d, vec![1, 2, 3, 4]), (0x0017, vec![5, 6, 7, 8])]; - let psk_labels = vec![b"ticket-a".to_vec(), b"ticket-b".to_vec()]; - let seed_session_id = [0x7a; 32]; - let session_id = - restls_tls13_session_id(&secret, &key_shares, &psk_labels, seed_session_id); - - let mut expected_hmac = blake3::Hasher::new_keyed(&secret); - expected_hmac.update(&0x001du16.to_be_bytes()); - expected_hmac.update(&[1, 2, 3, 4]); - expected_hmac.update(&0x0017u16.to_be_bytes()); - expected_hmac.update(&[5, 6, 7, 8]); - expected_hmac.update(b"ticket-a"); - expected_hmac.update(b"ticket-b"); - let expected = expected_hmac.finalize(); - assert_eq!( - &session_id[..RESTLS_HANDSHAKE_MAC_LEN], - &expected.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN] - ); - assert_eq!(&session_id[RESTLS_HANDSHAKE_MAC_LEN..], &[0x7a; 16]); - - let client_hello = test_client_hello(&key_shares, &psk_labels); - let material = restls_tls13_client_hello_auth_material(&client_hello).unwrap(); - assert_eq!(material.key_shares, key_shares); - assert_eq!(material.psk_labels, psk_labels); - assert_eq!( - restls_tls13_session_id_from_client_hello(&secret, &client_hello, seed_session_id) - .unwrap(), - session_id - ); - let mut truncated = client_hello; - truncated.pop(); - assert!(restls_tls13_client_hello_auth_material(&truncated).is_err()); - - let error = StdMutex::new(None); - let fallback_session_id = restls_tls13_session_id_for_client_hello(&secret, &[], &error); - assert_eq!( - &fallback_session_id[..RESTLS_HANDSHAKE_MAC_LEN], - &[0; RESTLS_HANDSHAKE_MAC_LEN] - ); - let err = take_restls_session_id_error(&error).expect("session id error was recorded"); - assert!(err.contains("failed to derive Restls TLS 1.3 session id from ClientHello")); - assert!(err.contains("missing handshake type")); - - let materials = vec![ - vec![0x11; 32], - vec![0x22; 65], - vec![0x33; 97], - b"session-ticket".to_vec(), - ]; - let session_id = restls_tls12_session_id(&secret, &materials).unwrap(); - for (index, window) in RESTLS_TLS12_CLIENT_AUTH_LAYOUT_4.windows(2).enumerate() { - let mut hmac = blake3::Hasher::new_keyed(&secret); - hmac.update(&materials[index]); - let digest = hmac.finalize(); - assert_eq!( - &session_id[window[0]..window[1]], - &digest.as_bytes()[..window[1] - window[0]] - ); - } - assert!(restls_tls12_session_id(&secret, &materials[..2]).is_err()); - } - - #[test] - fn restls_tls13_client_hello_record_signer_preserves_random_tail() { - fn extension(extension_type: u16, payload: Vec) -> Vec { - let mut encoded = Vec::with_capacity(4 + payload.len()); - encoded.extend_from_slice(&extension_type.to_be_bytes()); - encoded.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - encoded.extend_from_slice(&payload); - encoded - } - - let mut key_share_entries = Vec::new(); - key_share_entries.extend_from_slice(&0x001du16.to_be_bytes()); - key_share_entries.extend_from_slice(&4u16.to_be_bytes()); - key_share_entries.extend_from_slice(&[1, 2, 3, 4]); - let mut key_share_payload = Vec::new(); - key_share_payload.extend_from_slice(&(key_share_entries.len() as u16).to_be_bytes()); - key_share_payload.extend_from_slice(&key_share_entries); - - let mut extensions = Vec::new(); - extensions.extend_from_slice(&extension(0x0033, key_share_payload)); - - let mut body = Vec::new(); - body.extend_from_slice(&[0x03, 0x03]); - body.extend_from_slice(&[0x44; 32]); - body.push(32); - body.extend_from_slice(&[0x7a; 32]); - body.extend_from_slice(&2u16.to_be_bytes()); - body.extend_from_slice(&0x1301u16.to_be_bytes()); - body.push(1); - body.push(0); - body.extend_from_slice(&(extensions.len() as u16).to_be_bytes()); - body.extend_from_slice(&extensions); - - let mut payload = vec![0x01]; - payload.extend_from_slice(&[ - ((body.len() >> 16) & 0xff) as u8, - ((body.len() >> 8) & 0xff) as u8, - (body.len() & 0xff) as u8, - ]); - payload.extend_from_slice(&body); - - let mut record = vec![0x16, 0x03, 0x03]; - record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - record.extend_from_slice(&payload); - record.extend_from_slice(b"next-record"); - - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - assert!(restls_tls13_sign_client_hello_record(&secret, &record[..4]) - .unwrap() - .is_none()); - let signed = restls_tls13_sign_client_hello_record(&secret, &record) - .unwrap() - .expect("complete ClientHello record is signed"); - assert_eq!( - &signed[signed.len() - b"next-record".len()..], - b"next-record" - ); - - let session_id_start = TLS_RECORD_HEADER_LEN + SHADOW_TLS_V3_SESSION_ID_START; - let session_id = &signed[session_id_start..session_id_start + 32]; - let mut expected_hmac = blake3::Hasher::new_keyed(&secret); - expected_hmac.update(&0x001du16.to_be_bytes()); - expected_hmac.update(&[1, 2, 3, 4]); - let expected = expected_hmac.finalize(); - assert_eq!( - &session_id[..RESTLS_HANDSHAKE_MAC_LEN], - &expected.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN] - ); - assert_eq!(&session_id[RESTLS_HANDSHAKE_MAC_LEN..], &[0x7a; 16]); - } - - #[test] - fn restls_server_auth_record_unmasking_matches_mihomo_offsets() { - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x99; 32]; - let mut hmac = blake3::Hasher::new_keyed(&secret); - hmac.update(&server_random); - let digest = hmac.finalize(); - let mask = &digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]; - - let mut plain = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 3, 3, 0, 18]; - plain.extend_from_slice(b"server-auth-record"); - let mut masked = plain.clone(); - xor_with_restls_mask(&mut masked[TLS_RECORD_HEADER_LEN..], mask); - let unmasked = - restls_unmask_server_auth_record(&secret, &server_random, &masked, false).unwrap(); - assert_eq!(unmasked.record, plain); - assert!(!unmasked.tls12_gcm_server_disable_counter); - - let mut plain_gcm = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 3, 3, 0, 23]; - plain_gcm.extend_from_slice(&[0; 8]); - plain_gcm.extend_from_slice(b"gcm-server-auth"); - let mut masked_gcm = plain_gcm.clone(); - xor_with_restls_mask(&mut masked_gcm[TLS_RECORD_HEADER_LEN + 8..], mask); - let unmasked = - restls_unmask_server_auth_record(&secret, &server_random, &masked_gcm, true).unwrap(); - assert_eq!(unmasked.record, plain_gcm); - assert!(!unmasked.tls12_gcm_server_disable_counter); - - let mut plain_gcm_no_counter = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 3, 3, 0, 23]; - plain_gcm_no_counter.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 1]); - plain_gcm_no_counter.extend_from_slice(b"gcm-server-auth"); - let mut masked_gcm_no_counter = plain_gcm_no_counter.clone(); - xor_with_restls_mask(&mut masked_gcm_no_counter[TLS_RECORD_HEADER_LEN..], mask); - let unmasked = - restls_unmask_server_auth_record(&secret, &server_random, &masked_gcm_no_counter, true) - .unwrap(); - assert_eq!(unmasked.record, plain_gcm_no_counter); - assert!(unmasked.tls12_gcm_server_disable_counter); - } - - #[test] - fn restls_handshake_stream_captures_auth_state() { - fn server_hello_record(server_random: [u8; 32]) -> Vec { - let mut body = Vec::new(); - body.extend_from_slice(&[0x03, 0x03]); - body.extend_from_slice(&server_random); - body.push(0); - - let mut payload = Vec::new(); - payload.push(0x02); - payload.extend_from_slice(&[ - ((body.len() >> 16) & 0xff) as u8, - ((body.len() >> 8) & 0xff) as u8, - (body.len() & 0xff) as u8, - ]); - payload.extend_from_slice(&body); - - let mut record = vec![0x16, 0x03, 0x03]; - record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - record.extend_from_slice(&payload); - record - } - - let secret = blake3::derive_key("restls-traffic-key", b"secret"); - let server_random = [0x71; 32]; - let mut stream = RestlsHandshakeStream::new((), secret, false); - let server_hello = server_hello_record(server_random); - stream.process_read_frame(server_hello.clone()).unwrap(); - assert_eq!(stream.server_random, Some(server_random)); - assert_eq!(stream.read_buf, server_hello); - - let mut hmac = blake3::Hasher::new_keyed(&secret); - hmac.update(&server_random); - let digest = hmac.finalize(); - let mask = &digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]; - let mut plain_auth = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 0x03, 0x03, 0, 11]; - plain_auth.extend_from_slice(b"server-auth"); - let mut masked_auth = plain_auth.clone(); - xor_with_restls_mask(&mut masked_auth[TLS_RECORD_HEADER_LEN..], mask); - stream.process_read_frame(masked_auth).unwrap(); - assert_eq!(stream.read_buf, plain_auth); - assert!(stream.server_auth_unmasked); - - let ccs = [0x14, 0x03, 0x03, 0, 1, 1]; - let client_finished = [TLS_APPLICATION_DATA_RECORD_TYPE, 0x03, 0x03, 0, 3, 1, 2, 3]; - let mut write = Vec::new(); - write.extend_from_slice(&ccs); - stream.process_write_bytes(&write); - stream.process_write_bytes(&client_finished[..2]); - assert_eq!(stream.client_finished_auth, None); - stream.process_write_bytes(&client_finished[2..]); - - let (_, state) = stream.into_inner(); - assert_eq!(state.server_random, Some(server_random)); - assert_eq!(state.client_finished_auth, Some(client_finished.to_vec())); - assert!(state.server_auth_unmasked); - assert!(!state.tls12_gcm_server_disable_counter); - } - - #[test] - fn kcptun_rate_limiter_uses_mihomo_burst_shape() { - let (client, _server) = tokio::io::duplex(1); - let stream = RateLimitedStream::new(client, 2048); - assert_eq!(stream.bytes_per_second, 2048); - assert_eq!(stream.burst_capacity, KCPTUN_RATE_LIMIT_BURST_BYTES); - assert_eq!(stream.available, KCPTUN_RATE_LIMIT_BURST_BYTES); - } - - #[test] - fn kcptun_smux_keep_alive_timeout_matches_mihomo_default_shape() { - assert_eq!( - kcptun_smux_keep_alive_timeout(Duration::from_secs(5)), - Duration::from_secs(30) - ); - assert_eq!( - kcptun_smux_keep_alive_timeout(Duration::from_secs(10)), - Duration::from_secs(30) - ); - assert_eq!( - kcptun_smux_keep_alive_timeout(Duration::from_secs(30)), - Duration::from_secs(90) - ); - } - - #[test] - fn kcptun_block_crypt_accepts_mihomo_names() { - assert!(kcptun_block_crypt("secret", "null").unwrap().is_none()); - for crypt in [ - "aes", - "aes-128", - "aes-192", - "aes-256", - "tea", - "xor", - "none", - "blowfish", - "cast5", - "3des", - "twofish", - "xtea", - "salsa20", - "sm4", - "aes-128-gcm", - "unknown-falls-back-to-aes", - ] { - assert!( - kcptun_block_crypt("secret", crypt).unwrap().is_some(), - "crypt {crypt}" - ); - } - } - - #[tokio::test] - async fn uot_frame_round_trips_ipv4_and_ipv6() { - for addr in [ - "1.2.3.4:53".parse::().unwrap(), - "[2001:db8::1]:443".parse::().unwrap(), - ] { - let (mut writer, mut reader) = tokio::io::duplex(128); - let write = - tokio::spawn(async move { write_uot_frame(&mut writer, addr, b"hello").await }); - - let (decoded_addr, payload) = read_uot_frame(&mut reader).await.unwrap(); - write.await.unwrap().unwrap(); - assert_eq!(decoded_addr, addr); - assert_eq!(payload, b"hello"); - } - } - - #[tokio::test] - async fn simple_obfs_http_wraps_first_write_and_strips_response() { - let (client, mut server) = tokio::io::duplex(4096); - let mut stream = SimpleObfsStream::new( - client, - SimpleObfsMode::Http, - "front.example".to_owned(), - 8080, - ); - - let write = tokio::spawn(async move { - stream.write_all(b"hello").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - Result::<[u8; 5]>::Ok(body) - }); - - let mut request = vec![0u8; 2048]; - let len = server.read(&mut request).await.unwrap(); - let request = &request[..len]; - assert!(request.starts_with(b"GET http://front.example/ HTTP/1.1\r\n")); - assert!(request - .windows(b"Host: front.example:8080\r\n".len()) - .any(|window| window == b"Host: front.example:8080\r\n")); - assert!(request.ends_with(b"\r\n\r\nhello")); - - server - .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\n\r\nworld") - .await - .unwrap(); - assert_eq!(write.await.unwrap().unwrap(), *b"world"); - } - - #[tokio::test] - async fn simple_obfs_tls_wraps_writes_and_strips_records() { - let (client, mut server) = tokio::io::duplex(4096); - let mut stream = - SimpleObfsStream::new(client, SimpleObfsMode::Tls, "front.example".to_owned(), 443); - - let write = tokio::spawn(async move { - stream.write_all(b"hello").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - stream.write_all(b"again").await?; - stream.flush().await?; - Result::<[u8; 5]>::Ok(body) - }); - - let mut client_hello = vec![0u8; 4096]; - let len = server.read(&mut client_hello).await.unwrap(); - let client_hello = &client_hello[..len]; - assert_eq!(client_hello[0], 22); - assert!(client_hello - .windows("front.example".len()) - .any(|window| window == b"front.example")); - assert!(client_hello.windows(5).any(|window| window == b"hello")); - - let mut response = vec![0u8; 105]; - response.extend_from_slice(&5u16.to_be_bytes()); - response.extend_from_slice(b"world"); - server.write_all(&response).await.unwrap(); - - let mut record = [0u8; 10]; - server.read_exact(&mut record).await.unwrap(); - assert_eq!(&record[..3], &[0x17, 0x03, 0x03]); - assert_eq!(u16::from_be_bytes([record[3], record[4]]), 5); - assert_eq!(&record[5..], b"again"); - assert_eq!(write.await.unwrap().unwrap(), *b"world"); - } - - #[tokio::test] - async fn shadow_tls_v2_wraps_records_and_strips_headers() { - let (client, mut server) = tokio::io::duplex(4096); - let mut stream = ShadowTlsV2Stream::new(client, [1, 2, 3, 4, 5, 6, 7, 8]); - - let write = tokio::spawn(async move { - stream.write_all(b"hello").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - stream.write_all(b"again").await?; - stream.flush().await?; - Result::<[u8; 5]>::Ok(body) - }); - - let mut first_record = [0u8; 18]; - server.read_exact(&mut first_record).await.unwrap(); - assert_eq!(&first_record[..3], &[0x17, 0x03, 0x03]); - assert_eq!(u16::from_be_bytes([first_record[3], first_record[4]]), 13); - assert_eq!(&first_record[5..13], &[1, 2, 3, 4, 5, 6, 7, 8]); - assert_eq!(&first_record[13..], b"hello"); - - server - .write_all(&[0x17, 0x03, 0x03, 0x00, 0x05]) - .await - .unwrap(); - server.write_all(b"world").await.unwrap(); - - let mut second_record = [0u8; 10]; - server.read_exact(&mut second_record).await.unwrap(); - assert_eq!(&second_record[..3], &[0x17, 0x03, 0x03]); - assert_eq!(u16::from_be_bytes([second_record[3], second_record[4]]), 5); - assert_eq!(&second_record[5..], b"again"); - assert_eq!(write.await.unwrap().unwrap(), *b"world"); - } - - #[test] - fn shadow_tls_v3_session_id_carries_password_hmac() { - let mut client_hello = - vec![0u8; SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE + 8]; - client_hello[0] = 0x01; - client_hello[SHADOW_TLS_V3_SESSION_ID_START - 1] = SHADOW_TLS_V3_SESSION_ID_SIZE as u8; - let session_id = shadow_tls_v3_generate_session_id("secret", &client_hello); - - let mut context = shadow_tls_v3_hmac("secret", b"", b""); - let mut signed_session_id = session_id; - signed_session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..].fill(0); - context.update(&client_hello[..SHADOW_TLS_V3_SESSION_ID_START]); - context.update(&signed_session_id); - context.update( - &client_hello[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..], - ); - let expected = shadow_tls_v3_hmac_prefix(&context, SHADOW_TLS_V3_HMAC_SIZE); - assert_eq!( - &session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..], - expected.as_slice() - ); - } - - #[test] - fn shadow_tls_v3_client_hello_record_signer_rewrites_session_id() { - let mut payload = - vec![0u8; SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE + 8]; - payload[0] = 0x01; - payload[SHADOW_TLS_V3_SESSION_ID_START - 1] = SHADOW_TLS_V3_SESSION_ID_SIZE as u8; - payload[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..].fill(0x7a); - - let mut record = vec![0x16, 0x03, 0x03]; - record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - record.extend_from_slice(&payload); - - assert!( - shadow_tls_v3_sign_client_hello_record("secret", &record[..4]) - .unwrap() - .is_none() - ); - let signed = shadow_tls_v3_sign_client_hello_record("secret", &record) - .unwrap() - .expect("complete ClientHello record is signed"); - assert_eq!(signed.len(), record.len()); - let session_id_start = SHADOW_TLS_V3_TLS_HEADER_SIZE + SHADOW_TLS_V3_SESSION_ID_START; - let session_id = - &signed[session_id_start..session_id_start + SHADOW_TLS_V3_SESSION_ID_SIZE]; - assert_ne!(session_id, &[0u8; SHADOW_TLS_V3_SESSION_ID_SIZE]); - - let mut expected_context = shadow_tls_v3_hmac("secret", b"", b""); - let mut signed_session_id = [0u8; SHADOW_TLS_V3_SESSION_ID_SIZE]; - signed_session_id.copy_from_slice(session_id); - signed_session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..].fill(0); - expected_context.update(&payload[..SHADOW_TLS_V3_SESSION_ID_START]); - expected_context.update(&signed_session_id); - expected_context - .update(&payload[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..]); - let expected = shadow_tls_v3_hmac_prefix(&expected_context, SHADOW_TLS_V3_HMAC_SIZE); - assert_eq!( - &session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..], - expected.as_slice() - ); - } - - #[tokio::test] - async fn shadow_tls_v3_wraps_and_verifies_application_data() { - let (client, mut server) = tokio::io::duplex(4096); - let server_random = [9u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]; - let mut stream = ShadowTlsV3Stream::new(client, "secret", server_random, None); - - let task = tokio::spawn(async move { - stream.write_all(b"hello").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - Result::<[u8; 5]>::Ok(body) - }); - - let mut first_record = [0u8; SHADOW_TLS_V3_HMAC_HEADER_SIZE + 5]; - server.read_exact(&mut first_record).await.unwrap(); - assert_eq!(&first_record[..3], &[0x17, 0x03, 0x03]); - assert_eq!( - u16::from_be_bytes([first_record[3], first_record[4]]), - (SHADOW_TLS_V3_HMAC_SIZE + 5) as u16 - ); - let mut client_hmac = shadow_tls_v3_hmac("secret", &server_random, b"C"); - client_hmac.update(b"hello"); - assert_eq!( - &first_record[SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE], - shadow_tls_v3_hmac_prefix(&client_hmac, SHADOW_TLS_V3_HMAC_SIZE).as_slice() - ); - assert_eq!(&first_record[SHADOW_TLS_V3_HMAC_HEADER_SIZE..], b"hello"); - - let mut server_hmac = shadow_tls_v3_hmac("secret", &server_random, b"S"); - let response = shadow_tls_v3_record(b"world", 5, &mut server_hmac).unwrap(); - server.write_all(&response).await.unwrap(); - - assert_eq!(task.await.unwrap().unwrap(), *b"world"); - } - - #[tokio::test] - async fn websocket_plugin_wraps_frames_after_upgrade() { - let (client, mut server) = tokio::io::duplex(4096); - let config = ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::V2ray, - host: "front.example".to_owned(), - path: "ws?ed=0".to_owned(), - headers: vec![("User-Agent".to_owned(), "BurrowTest".to_owned())], - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::None, - v2ray_http_upgrade: false, - v2ray_http_upgrade_fast_open: false, - }; - - let client_task = tokio::spawn(async move { - let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; - stream.write_all(b"hello").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - Result::<[u8; 5]>::Ok(body) - }); - - let request = read_http_headers_for_test(&mut server).await; - assert!(request.starts_with("GET /ws HTTP/1.1\r\n")); - assert!(request.contains("Host: front.example\r\n")); - assert!(request.contains("Upgrade: websocket\r\n")); - assert!(request.contains("Sec-WebSocket-Key: ")); - assert!(request.contains("User-Agent: BurrowTest\r\n")); - server - .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") - .await - .unwrap(); - - assert_eq!( - read_client_websocket_frame_for_test(&mut server).await, - b"hello" - ); - server - .write_all(&server_websocket_frame_for_test(b"world")) - .await - .unwrap(); - assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); - } - - #[tokio::test] - async fn websocket_early_data_moves_first_payload_to_protocol_header() { - let (client, mut server) = tokio::io::duplex(4096); - let config = ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::V2ray, - host: "front.example".to_owned(), - path: "/ws?ed=8&x=1".to_owned(), - headers: vec![("Sec-WebSocket-Protocol".to_owned(), "stale".to_owned())], - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::None, - v2ray_http_upgrade: false, - v2ray_http_upgrade_fast_open: false, - }; - - let client_task = tokio::spawn(async move { - let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; - stream.write_all(b"hello-world").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - Result::<[u8; 5]>::Ok(body) - }); - - let request = read_http_headers_for_test(&mut server).await; - assert!(request.starts_with("GET /ws?x=1 HTTP/1.1\r\n")); - assert!(request.contains("Sec-WebSocket-Protocol: aGVsbG8td28\r\n")); - assert!(!request.contains("Sec-WebSocket-Protocol: stale\r\n")); - server - .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") - .await - .unwrap(); - assert_eq!( - read_client_websocket_frame_for_test(&mut server).await, - b"rld" - ); - server - .write_all(&server_websocket_frame_for_test(b"world")) - .await - .unwrap(); - assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); - } - - #[tokio::test] - async fn websocket_early_data_without_payload_omits_protocol_header() { - let (client, mut server) = tokio::io::duplex(4096); - let config = ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::V2ray, - host: "front.example".to_owned(), - path: "/ws?ed=8".to_owned(), - headers: Vec::new(), - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::None, - v2ray_http_upgrade: false, - v2ray_http_upgrade_fast_open: false, - }; - - let client_task = tokio::spawn(async move { - let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - Result::<[u8; 5]>::Ok(body) - }); - - let request = read_http_headers_for_test(&mut server).await; - assert!(request.starts_with("GET /ws HTTP/1.1\r\n")); - assert!(!request.contains("Sec-WebSocket-Protocol:")); - server - .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") - .await - .unwrap(); - server - .write_all(&server_websocket_frame_for_test(b"world")) - .await - .unwrap(); - assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); - } - - #[tokio::test] - async fn websocket_http_upgrade_leaves_stream_raw() { - let (client, mut server) = tokio::io::duplex(4096); - let config = ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::V2ray, - host: "front.example".to_owned(), - path: "/upgrade".to_owned(), - headers: Vec::new(), - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::None, - v2ray_http_upgrade: true, - v2ray_http_upgrade_fast_open: false, - }; - - let client_task = tokio::spawn(async move { - let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; - stream.write_all(b"raw").await?; - stream.flush().await?; - let mut body = [0u8; 4]; - stream.read_exact(&mut body).await?; - Result::<[u8; 4]>::Ok(body) - }); - - let request = read_http_headers_for_test(&mut server).await; - assert!(request.starts_with("GET /upgrade HTTP/1.1\r\n")); - assert!(!request.contains("Sec-WebSocket-Key")); - server - .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") - .await - .unwrap(); - let mut raw = [0u8; 3]; - server.read_exact(&mut raw).await.unwrap(); - assert_eq!(&raw, b"raw"); - server.write_all(b"pong").await.unwrap(); - assert_eq!(client_task.await.unwrap().unwrap(), *b"pong"); - } - - #[tokio::test] - async fn websocket_http_upgrade_fast_open_writes_before_response() { - let (client, mut server) = tokio::io::duplex(4096); - let config = ShadowsocksWebSocketTransport { - kind: ShadowsocksWebSocketKind::V2ray, - host: "front.example".to_owned(), - path: "/upgrade".to_owned(), - headers: Vec::new(), - tls: false, - ech: None, - skip_cert_verify: false, - certificate_fingerprint: None, - client_identity: None, - mux: ShadowsocksWebSocketMux::None, - v2ray_http_upgrade: true, - v2ray_http_upgrade_fast_open: true, - }; - - let client_task = tokio::spawn(async move { - let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; - stream.write_all(b"raw").await?; - stream.flush().await?; - let mut body = [0u8; 4]; - stream.read_exact(&mut body).await?; - Result::<[u8; 4]>::Ok(body) - }); - - let request = read_http_headers_for_test(&mut server).await; - assert!(request.starts_with("GET /upgrade HTTP/1.1\r\n")); - let mut raw = [0u8; 3]; - server.read_exact(&mut raw).await.unwrap(); - assert_eq!(&raw, b"raw"); - server - .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\npong") - .await - .unwrap(); - assert_eq!(client_task.await.unwrap().unwrap(), *b"pong"); - } - - #[tokio::test] - async fn v2ray_mux_wraps_open_and_data_frames() { - let (client, mut server) = tokio::io::duplex(4096); - let mut stream = V2rayMuxStream::new(client); - - let client_task = tokio::spawn(async move { - stream.write_all(b"hello").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - Result::<[u8; 5]>::Ok(body) - }); - - let mut open_len = [0u8; 2]; - server.read_exact(&mut open_len).await.unwrap(); - let open_len = u16::from_be_bytes(open_len) as usize; - let mut open = vec![0u8; open_len]; - server.read_exact(&mut open).await.unwrap(); - assert_eq!(&open[..4], &[0, 0, 0x01, 0x00]); - assert!(open - .windows("127.0.0.1".len()) - .any(|window| window == b"127.0.0.1")); - - let mut data_prefix = [0u8; 8]; - server.read_exact(&mut data_prefix).await.unwrap(); - assert_eq!(&data_prefix[..6], &[0, 4, 0, 0, 0x02, 0x01]); - assert_eq!(u16::from_be_bytes([data_prefix[6], data_prefix[7]]), 5); - let mut payload = [0u8; 5]; - server.read_exact(&mut payload).await.unwrap(); - assert_eq!(&payload, b"hello"); - - server - .write_all(&[0, 4, 0, 0, 0x02, 0x01, 0, 5]) - .await - .unwrap(); - server.write_all(b"world").await.unwrap(); - assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); - } - - #[tokio::test] - async fn gost_smux_stream_opens_client_stream() { - let (client, server) = tokio::io::duplex(4096); - let client_task = tokio::spawn(async move { - let mut stream = gost_smux_stream(Box::new(client)).await?; - stream.write_all(b"hello").await?; - stream.flush().await?; - let mut body = [0u8; 5]; - stream.read_exact(&mut body).await?; - Result::<[u8; 5]>::Ok(body) - }); - - let mut config = tokio_smux::SmuxConfig::default(); - config.keep_alive_disable = true; - let mut session = tokio_smux::Session::server(server, config).unwrap(); - let mut stream = session.accept_stream().await.unwrap(); - assert_eq!(stream.recv_message().await.unwrap().unwrap(), b"hello"); - stream.send_message(b"world".to_vec()).await.unwrap(); - assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); - } - - async fn read_http_headers_for_test(stream: &mut S) -> String - where - S: AsyncRead + Unpin, - { - let mut bytes = Vec::new(); - let mut byte = [0u8; 1]; - loop { - stream.read_exact(&mut byte).await.unwrap(); - bytes.push(byte[0]); - if bytes.ends_with(b"\r\n\r\n") { - return String::from_utf8(bytes).unwrap(); - } - } - } - - async fn read_client_websocket_frame_for_test(stream: &mut S) -> Vec - where - S: AsyncRead + Unpin, - { - let mut header = [0u8; 2]; - stream.read_exact(&mut header).await.unwrap(); - assert_eq!(header[0] & 0x0f, 0x02); - assert_ne!(header[1] & 0x80, 0); - let len = usize::from(header[1] & 0x7f); - let mut mask = [0u8; 4]; - stream.read_exact(&mut mask).await.unwrap(); - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).await.unwrap(); - apply_websocket_mask(&mut payload, mask); - payload - } - - fn server_websocket_frame_for_test(payload: &[u8]) -> Vec { - let mut frame = Vec::new(); - frame.push(0x82); - frame.push(payload.len() as u8); - frame.extend_from_slice(payload); - frame - } - - #[tokio::test] - async fn custom_aead_byte_reader_reassembles_uot_frame_chunks() { - let kind = CustomSsAeadKind::Aes192Gcm; - let key = Arc::<[u8]>::from(evp_bytes_to_key(b"secret", kind.key_len())); - let target = "8.8.8.8:53".parse::().unwrap(); - let frame = uot_frame(target, b"hello").unwrap(); - let split_at = 3; - let (client, server) = tokio::io::duplex(256); - let mut writer = CustomSsAeadWriter::new(client, kind, key.clone()) - .await - .unwrap(); - let mut reader = - CustomSsAeadByteReader::new(CustomSsAeadReader::new(server, kind, key.clone())); - - let write = tokio::spawn(async move { - writer.write_chunk(&frame[..split_at]).await?; - writer.write_chunk(&frame[split_at..]).await?; - Result::<()>::Ok(()) - }); - - let (decoded_addr, payload) = reader.read_frame().await.unwrap().unwrap(); - write.await.unwrap().unwrap(); - assert_eq!(decoded_addr, target); - assert_eq!(payload, b"hello"); - } - - #[test] - fn aead2022_aes_ccm_keys_follow_mihomo_base64_rules() { - let kind = Aead2022AesCcmKind::Aes128; - let identity = vec![1u8; kind.key_len()]; - let user = vec![2u8; kind.key_len()]; - let password = format!( - "{}:{}", - BASE64_STANDARD.encode(&identity), - BASE64_STANDARD.encode(&user) - ); - let (parsed_user, parsed_identities) = parse_aead2022_keys(kind, &password).unwrap(); - assert_eq!(parsed_user.as_ref(), user.as_slice()); - assert_eq!(parsed_identities.len(), 1); - assert_eq!(parsed_identities[0].as_ref(), identity.as_slice()); - - assert!(parse_aead2022_keys(kind, &BASE64_STANDARD.encode([3u8; 64])).is_err()); - assert!(parse_aead2022_keys(kind, &BASE64_STANDARD.encode([1u8; 8])).is_err()); - } - - #[test] - fn standard_aead2022_keys_follow_mihomo_base64_rules() { - let identity = vec![1u8; 16]; - let user = vec![2u8; 16]; - let password = format!( - "{}:{}", - BASE64_STANDARD.encode(&identity), - BASE64_STANDARD.encode(&user) - ); - let config = ShadowsocksNode { - cipher: "2022-blake3-aes-128-gcm".to_owned(), - password, - udp: true, - udp_over_tcp: false, - udp_over_tcp_version: UOT_VERSION, - client_fingerprint: None, - plugin: None, - smux: None, - }; - let node = ProxyNode { - ordinal: 0, - name: "ss-2022-gcm".to_owned(), - protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, - server: "127.0.0.1".to_owned(), - port: 8388, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(config.clone()), - }; - - let protocol = shadowsocks_protocol_for_node(&node, &config).unwrap(); - let ShadowsocksProtocol::Standard { server_config, .. } = protocol else { - panic!("expected delegated standard Shadowsocks protocol"); - }; - assert_eq!(server_config.key(), user.as_slice()); - assert_eq!(server_config.identity_keys().len(), 1); - assert_eq!( - server_config.identity_keys()[0].as_ref(), - identity.as_slice() - ); - - let unpadded_user = BASE64_STANDARD - .encode(&user) - .trim_end_matches('=') - .to_owned(); - assert!( - validate_standard_aead2022_mihomo_password( - SsCipherKind::AEAD2022_BLAKE3_AES_128_GCM, - &unpadded_user - ) - .is_err(), - "Mihomo's base64.StdEncoding requires key padding for 16-byte keys" - ); - assert!( - validate_standard_aead2022_mihomo_password( - SsCipherKind::AEAD2022_BLAKE3_CHACHA20_POLY1305, - &format!( - "{}:{}", - BASE64_STANDARD.encode([3u8; 32]), - BASE64_STANDARD.encode([4u8; 32]) - ) - ) - .is_err(), - "Mihomo rejects EIH identity chains on Shadowsocks 2022 chacha methods" - ); - validate_standard_aead2022_mihomo_password( - SsCipherKind::AEAD2022_BLAKE3_CHACHA20_POLY1305, - &BASE64_STANDARD.encode([5u8; 32]), - ) - .unwrap(); - } - - #[test] - fn aead2022_aes_ccm_tcp_cipher_round_trips_chunks() { - for kind in [Aead2022AesCcmKind::Aes128, Aead2022AesCcmKind::Aes256] { - let key = vec![7u8; kind.key_len()]; - let salt = vec![9u8; kind.key_len()]; - let mut writer = Aead2022TcpCipher::new(kind, &key, &salt).unwrap(); - let mut reader = Aead2022TcpCipher::new(kind, &key, &salt).unwrap(); - - let mut encrypted_len = writer.seal_packet(&5u16.to_be_bytes()).unwrap(); - let len = reader.open_packet(&mut encrypted_len).unwrap(); - assert_eq!(u16::from_be_bytes([len[0], len[1]]), 5); - - let mut encrypted_payload = writer.seal_packet(b"hello").unwrap(); - let payload = reader.open_packet(&mut encrypted_payload).unwrap(); - assert_eq!(payload, b"hello"); - } - } - - #[test] - fn aead2022_aes_ccm_udp_packets_round_trip_client_and_server_shapes() { - for kind in [Aead2022AesCcmKind::Aes128, Aead2022AesCcmKind::Aes256] { - let user_key = Arc::<[u8]>::from(vec![5u8; kind.key_len()]); - let mut session = - Aead2022AesCcmUdpSession::new(kind, user_key.clone(), Arc::from(Vec::new())) - .unwrap(); - let target = "8.8.8.8:443".parse::().unwrap(); - let packet = session.encrypt_client_packet(target, b"hello").unwrap(); - let (decoded_target, decoded_payload) = - decrypt_aead2022_client_packet_for_test(kind, &user_key, &packet).unwrap(); - assert_eq!(decoded_target, target); - assert_eq!(decoded_payload, b"hello"); - - let source = "1.1.1.1:53".parse::().unwrap(); - let response = encrypt_aead2022_server_packet_for_test( - kind, - &user_key, - 99, - 1, - session.client_session_id, - source, - b"world", - ) - .unwrap(); - let (decoded_source, decoded_payload) = - session.decrypt_server_packet(&response).unwrap(); - assert_eq!(decoded_source, source); - assert_eq!(decoded_payload, b"world"); - } - } - - #[test] - fn custom_shadowsocks_aead_udp_packet_round_trips_mihomo_extra_methods() { - for cipher in [ - "aes-192-gcm", - "aes-192-ccm", - "chacha8-ietf-poly1305", - "xchacha8-ietf-poly1305", - "rabbit128-poly1305", - "aegis-128l", - "aegis-256", - "aez-384", - "deoxys-ii-256-128", - "ascon128", - "ascon128a", - "lea-128-gcm", - "lea-192-gcm", - "lea-256-gcm", - ] { - let kind = CustomSsAeadKind::from_name(cipher).unwrap(); - let key = evp_bytes_to_key(b"secret", kind.key_len()); - let target = "8.8.8.8:53".parse().unwrap(); - let packet = encrypt_custom_ss_udp_packet(kind, &key, target, b"hello").unwrap(); - let (decoded_target, payload) = - decrypt_custom_ss_udp_packet(kind, &key, &packet).unwrap(); - assert_eq!(decoded_target, target); - assert_eq!(payload, b"hello"); - } - } - - #[test] - fn custom_shadowsocks_stream_udp_packet_round_trips_mihomo_methods() { - for cipher in ["chacha20", "xchacha20"] { - let kind = CustomSsStreamKind::from_name(cipher).unwrap(); - let key = evp_bytes_to_key(b"secret", kind.key_len()); - let target = "8.8.8.8:53".parse().unwrap(); - let packet = encrypt_custom_ss_stream_udp_packet(kind, &key, target, b"hello").unwrap(); - let (decoded_target, payload) = - decrypt_custom_ss_stream_udp_packet(kind, &key, &packet).unwrap(); - assert_eq!(decoded_target, target); - assert_eq!(payload, b"hello"); - } - } - - #[tokio::test] - async fn custom_shadowsocks_stream_tcp_cipher_keeps_state_across_writes() { - for cipher in ["chacha20", "xchacha20"] { - let kind = CustomSsStreamKind::from_name(cipher).unwrap(); - let key = Arc::<[u8]>::from(evp_bytes_to_key(b"secret", kind.key_len())); - let (client, mut server) = tokio::io::duplex(1024); - let mut writer = CustomSsStreamWriter::new(client, kind, key.clone()) - .await - .unwrap(); - writer.write_all_encrypted(b"hello").await.unwrap(); - writer.write_all_encrypted(b"world").await.unwrap(); - writer.shutdown().await.unwrap(); - - let mut raw = Vec::new(); - server.read_to_end(&mut raw).await.unwrap(); - let (salt, encrypted) = raw.split_at(kind.salt_len()); - let mut decrypted = encrypted.to_vec(); - let mut cipher = CustomSsStreamCipher::new(kind, &key, salt).unwrap(); - cipher.apply_keystream(&mut decrypted); - assert_eq!(decrypted, b"helloworld"); - - let (mut server, client) = tokio::io::duplex(1024); - let salt = vec![7u8; kind.salt_len()]; - let mut encrypted = b"response".to_vec(); - let mut cipher = CustomSsStreamCipher::new(kind, &key, &salt).unwrap(); - cipher.apply_keystream(&mut encrypted[..3]); - cipher.apply_keystream(&mut encrypted[3..]); - server.write_all(&salt).await.unwrap(); - server.write_all(&encrypted).await.unwrap(); - drop(server); - - let mut reader = CustomSsStreamReader::new(client, kind, key); - let mut response = Vec::new(); - let mut output = [0u8; 4]; - loop { - let n = reader.read_decrypted(&mut output).await.unwrap(); - if n == 0 { - break; - } - response.extend_from_slice(&output[..n]); - } - assert_eq!(response, b"response"); - } - } - - #[test] - fn custom_ascon128_matches_v12_reference_vector() { - let key: [u8; 16] = core::array::from_fn(|index| index as u8); - let nonce: [u8; 16] = core::array::from_fn(|index| index as u8); - let mut plaintext = Vec::new(); - let tag = ascon_seal_in_place(&key, &nonce, AsconMode::Ascon128, &mut plaintext); - assert_eq!(hex_lower(&tag), "e355159f292911f794cb1432a0103a8a"); - - let mut plaintext = Vec::new(); - let tag = ascon_seal_in_place(&key, &nonce, AsconMode::Ascon128A, &mut plaintext); - assert_eq!(hex_lower(&tag), "7a834e6f09210957067b10fd831f0078"); - } - - #[test] - fn custom_lea_gcm_matches_mihomo_vectors() { - for (cipher, expected) in [ - ("lea-128-gcm", "b644a1f5ad436a94f4eac45f12010eed1396e49e9b"), - ("lea-192-gcm", "9ed6792982070248de7ddcb355da658774ab781ea0"), - ("lea-256-gcm", "223060f9a91775199a3f9331cd8d410e5935b0be50"), - ] { - let kind = CustomSsAeadKind::from_name(cipher).unwrap(); - let key: Vec = (0..kind.key_len()).map(|index| index as u8).collect(); - let cipher = CustomSsAeadCipher::new(kind, &key).unwrap(); - let mut nonce: Vec = (0..kind.nonce_len()).map(|index| index as u8).collect(); - let mut message = b"hello".to_vec(); - cipher.seal_in_place(&mut nonce, &mut message).unwrap(); - assert_eq!(hex_lower(&message), expected); - } - } - - fn decrypt_aead2022_client_packet_for_test( - kind: Aead2022AesCcmKind, - user_key: &[u8], - packet: &[u8], - ) -> Result<(SocketAddr, Vec)> { - let mut buf = packet.to_vec(); - aead2022_aes_decrypt_block(kind, user_key, &mut buf[..16])?; - let session_id_bytes: [u8; 8] = buf[..8].try_into().expect("fixed session id"); - let cipher = Aead2022AesCcmCipher::new( - kind, - &aead2022_session_key(kind, user_key, &session_id_bytes)?, - )?; - let nonce: [u8; 12] = buf[4..16].try_into().expect("fixed nonce"); - let plaintext = cipher.open_in_place(&nonce, &mut buf[16..])?; - if plaintext[0] != AEAD2022_HEADER_TYPE_CLIENT { - bail!("unexpected test client packet type"); - } - let padding_len = u16::from_be_bytes([plaintext[9], plaintext[10]]) as usize; - let address_offset = 11 + padding_len; - let (target, used) = parse_socks_addr(&plaintext[address_offset..])?; - Ok((target, plaintext[address_offset + used..].to_vec())) - } - - fn encrypt_aead2022_server_packet_for_test( - kind: Aead2022AesCcmKind, - user_key: &[u8], - session_id: u64, - packet_id: u64, - client_session_id: u64, - source: SocketAddr, - payload: &[u8], - ) -> Result> { - let session_id_bytes = session_id.to_be_bytes(); - let cipher = Aead2022AesCcmCipher::new( - kind, - &aead2022_session_key(kind, user_key, &session_id_bytes)?, - )?; - let mut packet = Vec::new(); - packet.extend_from_slice(&session_id.to_be_bytes()); - packet.extend_from_slice(&packet_id.to_be_bytes()); - let data_index = packet.len(); - packet.push(AEAD2022_HEADER_TYPE_SERVER); - packet.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); - packet.extend_from_slice(&client_session_id.to_be_bytes()); - packet.extend_from_slice(&0u16.to_be_bytes()); - packet.extend_from_slice(&socks_addr(source)?); - packet.extend_from_slice(payload); - let nonce: [u8; 12] = packet[4..16].try_into().expect("fixed nonce"); - let mut encrypted_body = packet[data_index..].to_vec(); - cipher.seal_in_place(&nonce, &mut encrypted_body)?; - packet.truncate(data_index); - packet.extend_from_slice(&encrypted_body); - aead2022_aes_encrypt_block(kind, user_key, &mut packet[..16])?; - Ok(packet) - } - - #[test] - fn shadowsocks_address_preserves_fake_dns_hostname() { - let target = ProxyTcpTarget { - address: "198.18.64.1:443".parse().unwrap(), - hostname: Some("example.com.".to_owned()), - }; - - assert_eq!( - shadowsocks_addr_for_target(&target).unwrap(), - SsAddress::DomainNameAddress("example.com".to_owned(), 443) - ); - } - - #[test] - fn trojan_runtime_uses_mihomo_default_alpn_when_absent() { - let mut node = TrojanNode { - password: "secret".to_owned(), - sni: None, - alpn: Vec::new(), - alpn_present: false, - skip_cert_verify: false, - network: TrojanNetwork::Tcp, - udp: true, - client_fingerprint: None, - fingerprint: None, - }; - assert_eq!(trojan_alpn(&node), vec!["h2", "http/1.1"]); - - node.alpn_present = true; - assert!(trojan_alpn(&node).is_empty()); - - node.alpn = vec!["h3".to_owned()]; - assert_eq!(trojan_alpn(&node), vec!["h3"]); - } - - #[test] - fn parses_certificate_fingerprint_hex() { - let fp = parse_certificate_fingerprint( - "00:112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", - ) - .unwrap(); - assert_eq!(fp.0[0], 0x00); - assert_eq!(fp.0[1], 0x11); - assert_eq!(fp.0[31], 0xff); - assert!(parse_certificate_fingerprint("chrome").is_err()); - assert!(parse_certificate_fingerprint("abcd").is_err()); - } - - #[tokio::test] - async fn trojan_udp_writer_fragments_oversized_payloads() { - let target = socks_addr("1.2.3.4:53".parse().unwrap()).unwrap(); - let payload = vec![7u8; TROJAN_MAX_UDP_PAYLOAD + 3]; - let (mut writer, mut reader) = tokio::io::duplex(20_000); - - let write = - tokio::spawn( - async move { write_trojan_udp_packet(&mut writer, &target, &payload).await }, - ); - - let mut encoded = Vec::new(); - reader.read_to_end(&mut encoded).await.unwrap(); - write.await.unwrap().unwrap(); - - let first_payload_offset = 7 + 2 + 2; - assert_eq!( - u16::from_be_bytes([encoded[7], encoded[8]]) as usize, - TROJAN_MAX_UDP_PAYLOAD - ); - assert_eq!(&encoded[9..11], b"\r\n"); - assert_eq!(encoded[first_payload_offset], 7); - let second = 7 + 2 + 2 + TROJAN_MAX_UDP_PAYLOAD; - assert_eq!( - u16::from_be_bytes([encoded[second + 7], encoded[second + 8]]) as usize, - 3 - ); - assert_eq!(&encoded[second + 9..second + 11], b"\r\n"); - assert_eq!(encoded.len(), second + 11 + 3); - } - - #[test] - fn fake_ip_detection_flags_benchmark_range() { - assert!(is_fake_or_benchmark_ip("198.18.0.1".parse().unwrap())); - assert!(is_fake_or_benchmark_ip("198.19.255.254".parse().unwrap())); - assert!(!is_fake_or_benchmark_ip("198.20.0.1".parse().unwrap())); - assert!(!is_fake_or_benchmark_ip("2001:db8::1".parse().unwrap())); - } - - #[test] - fn proxy_outbound_socket_binding_skips_loopback() { - assert!(!should_bind_proxy_outbound_socket( - "127.0.0.1".parse().unwrap() - )); - assert!(!should_bind_proxy_outbound_socket("::1".parse().unwrap())); - assert!(should_bind_proxy_outbound_socket( - "192.0.2.10".parse().unwrap() - )); - } - - #[test] - fn dns_query_encodes_host_labels() { - let query = build_dns_query("example.com", 1).unwrap(); - assert_eq!(&query[12..25], b"\x07example\x03com\0"); - assert_eq!(u16::from_be_bytes([query[25], query[26]]), 1); - assert_eq!(u16::from_be_bytes([query[27], query[28]]), 1); - } - - #[test] - fn dns_response_parser_reads_compressed_a_and_aaaa_answers() { - let mut response = Vec::new(); - response.extend_from_slice(&0x1234u16.to_be_bytes()); - response.extend_from_slice(&0x8180u16.to_be_bytes()); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&2u16.to_be_bytes()); - response.extend_from_slice(&0u16.to_be_bytes()); - response.extend_from_slice(&0u16.to_be_bytes()); - response.extend_from_slice(b"\x07example\x03com\0"); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&[0xc0, 0x0c]); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&60u32.to_be_bytes()); - response.extend_from_slice(&4u16.to_be_bytes()); - response.extend_from_slice(&[93, 184, 216, 34]); - response.extend_from_slice(&[0xc0, 0x0c]); - response.extend_from_slice(&28u16.to_be_bytes()); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&60u32.to_be_bytes()); - response.extend_from_slice(&16u16.to_be_bytes()); - response.extend_from_slice(&[ - 0x26, 0x06, 0x28, 0x00, 0x02, 0x20, 0x00, 0x01, 0x02, 0x48, 0x18, 0x93, 0x25, 0xc8, - 0x19, 0x46, - ]); - - let addresses = parse_dns_response(&response, 0x1234).unwrap(); - assert_eq!( - addresses, - vec![ - "93.184.216.34".parse::().unwrap(), - "2606:2800:220:1:248:1893:25c8:1946" - .parse::() - .unwrap(), - ] - ); - } - - #[test] - fn dns_https_parser_extracts_ech_service_parameter() { - let mut response = build_dns_query("example.com", DNS_TYPE_HTTPS).unwrap(); - response[0..2].copy_from_slice(&0x1234u16.to_be_bytes()); - response[2..4].copy_from_slice(&0x8180u16.to_be_bytes()); - response[6..8].copy_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&[0xc0, 0x0c]); - response.extend_from_slice(&DNS_TYPE_HTTPS.to_be_bytes()); - response.extend_from_slice(&1u16.to_be_bytes()); - response.extend_from_slice(&60u32.to_be_bytes()); - response.extend_from_slice(&10u16.to_be_bytes()); - response.extend_from_slice(&1u16.to_be_bytes()); - response.push(0); - response.extend_from_slice(&HTTPS_SVC_PARAM_ECH.to_be_bytes()); - response.extend_from_slice(&3u16.to_be_bytes()); - response.extend_from_slice(&[0xaa, 0xbb, 0xcc]); - - assert_eq!( - parse_dns_https_ech_config_list(&response, 0x1234).unwrap(), - Some(vec![0xaa, 0xbb, 0xcc]) - ); - } - - #[tokio::test] - async fn fake_dns_response_returns_stable_hostname_mapping() { - let query = build_dns_query("example.com", 1).unwrap(); - let cache = Arc::new(RwLock::new(DnsHostnameCacheState::new())); - let response = build_fake_dns_response(&cache, &query).await.unwrap(); - let addresses = parse_dns_response(&response, 0).unwrap(); - assert_eq!(addresses, vec!["198.18.64.1".parse::().unwrap()]); - assert_eq!( - cache - .read() - .await - .lookup_hostname("198.18.64.1".parse().unwrap()), - Some("example.com".to_owned()) - ); - } - - #[test] - fn runtime_selection_skips_unsupported_transports_when_unselected() { - let preview = crate::proxy_subscription::parse_subscription_body( - r#" -proxies: - - name: trojan-grpc - type: trojan - server: example.com - port: 443 - password: secret - network: grpc - - name: ss-aead - type: ss - server: example.net - port: 8388 - cipher: aes-128-gcm - password: secret -"#, - Some("https://example.com/sub"), - ); - let payload = crate::proxy_subscription::build_payload( - preview, - "https://example.com/sub", - Some("Proxy Subscription".to_owned()), - None, - ); - assert_eq!(selected_node(&payload).unwrap().name, "ss-aead"); - } - - #[test] - fn runtime_selection_prefers_selected_name_over_stale_ordinal() { - let preview = crate::proxy_subscription::parse_subscription_body( - r#" -proxies: - - name: first - type: ss - server: first.example.net - port: 8388 - cipher: aes-128-gcm - password: secret - - name: selected - type: ss - server: selected.example.net - port: 8388 - cipher: aes-128-gcm - password: secret -"#, - Some("https://example.com/sub"), - ); - let mut payload = crate::proxy_subscription::build_payload( - preview, - "https://example.com/sub", - Some("Proxy Subscription".to_owned()), - Some(0), - ); - payload.selected_name = Some("selected".to_owned()); - - assert_eq!(selected_node(&payload).unwrap().name, "selected"); - } - - #[test] - fn proxy_server_bypass_routes_use_host_prefixes() { - let routes = excluded_routes_for_server("127.0.0.1", 443).unwrap(); - assert_eq!(routes, vec!["127.0.0.1/32"]); - } - - #[test] - fn parses_physical_dns_from_scutil_output() { - let servers = parse_apple_physical_dns_servers( - r#" -DNS configuration - -resolver #1 - nameserver[0] : 2606:4700:4700::1111 - nameserver[1] : 1.1.1.1 - if_index : 35 (utun8) - -DNS configuration (for scoped queries) - -resolver #1 - search domain[0] : lan - nameserver[0] : 192.168.2.1 - if_index : 17 (en0) - -resolver #2 - nameserver[0] : 198.18.0.1 - if_index : 35 (utun8) -"#, - ); - assert_eq!(servers, vec!["192.168.2.1"]); - } - - #[test] - fn parses_resolv_conf_dns_servers() { - let servers = parse_resolv_conf_dns_servers( - r#" -# macOS generated resolver file -nameserver 192.168.2.1 -nameserver 198.18.0.1 -nameserver 192.168.2.1 -nameserver ::1 -"#, - ); - assert_eq!(servers, vec!["192.168.2.1"]); - } - - #[test] - fn proxy_dns_excluded_routes_use_host_prefixes() { - let routes = proxy_dns_excluded_routes(&[ - "100.64.0.2".to_owned(), - "192.168.2.1".to_owned(), - "2001:db8::53".to_owned(), - "not-an-ip".to_owned(), - ]); - assert_eq!(routes, vec!["192.168.2.1/32", "2001:db8::53/128"]); - } - - #[test] - fn proxy_dns_servers_use_daemon_resolver() { - assert_eq!(proxy_dns_servers(), vec!["100.64.0.2"]); - } -} diff --git a/burrow/src/proxy_subscription.rs b/burrow/src/proxy_subscription.rs deleted file mode 100644 index 428f74d..0000000 --- a/burrow/src/proxy_subscription.rs +++ /dev/null @@ -1,1455 +0,0 @@ -use std::{collections::HashMap, time::Duration}; - -use anyhow::{anyhow, bail, Context, Result}; -use base64::{ - engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}, - Engine as _, -}; -use reqwest::Url; -use serde::{Deserialize, Serialize}; -use serde_yaml::Value; - -const MAX_SUBSCRIPTION_BYTES: usize = 1024 * 1024; -const SUBSCRIPTION_USER_AGENT: &str = "mihomo/1.18.3"; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ProxySubscriptionPayload { - pub name: String, - pub source: ProxySubscriptionSource, - pub detected_format: ProxySubscriptionFormat, - pub selected_ordinal: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub selected_name: Option, - pub nodes: Vec, - pub warnings: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ProxySubscriptionSource { - pub url: String, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum ProxySubscriptionFormat { - ClashYaml, - UriList, -} - -impl ProxySubscriptionFormat { - pub fn as_str(self) -> &'static str { - match self { - Self::ClashYaml => "clash-yaml", - Self::UriList => "uri-list", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ProxyNode { - pub ordinal: usize, - pub name: String, - pub protocol: ProxyProtocol, - pub server: String, - pub port: u16, - pub warnings: Vec, - pub config: ProxyNodeConfig, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum ProxyProtocol { - Trojan, - Shadowsocks, -} - -impl ProxyProtocol { - pub fn as_str(self) -> &'static str { - match self { - Self::Trojan => "trojan", - Self::Shadowsocks => "ss", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "kebab-case")] -pub enum ProxyNodeConfig { - Trojan(TrojanNode), - Shadowsocks(ShadowsocksNode), -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TrojanNode { - pub password: String, - pub sni: Option, - pub alpn: Vec, - #[serde(default)] - pub alpn_present: bool, - pub skip_cert_verify: bool, - pub network: TrojanNetwork, - #[serde(default = "default_true")] - pub udp: bool, - pub client_fingerprint: Option, - pub fingerprint: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum TrojanNetwork { - Tcp, - Ws { - path: Option, - host: Option, - }, - Grpc { - service_name: Option, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ShadowsocksNode { - pub cipher: String, - pub password: String, - pub udp: bool, - pub udp_over_tcp: bool, - #[serde(default = "default_uot_legacy_version")] - pub udp_over_tcp_version: u8, - pub client_fingerprint: Option, - pub plugin: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub smux: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ShadowsocksPlugin { - pub name: String, - pub opts: HashMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ShadowsocksSmux { - #[serde(default)] - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub protocol: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_connections: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub min_streams: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_streams: Option, - #[serde(default)] - pub padding: bool, - #[serde(default)] - pub statistic: bool, - #[serde(default)] - pub only_tcp: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub brutal: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ShadowsocksSmuxBrutal { - #[serde(default)] - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub up: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub down: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProxySubscriptionPreview { - pub suggested_name: String, - pub detected_format: ProxySubscriptionFormat, - pub nodes: Vec, - pub rejected: Vec, - pub warnings: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProxyRejectedEntry { - pub ordinal: usize, - pub scheme: Option, - pub reason: String, -} - -#[derive(Debug, Deserialize)] -struct ProxySchema { - proxies: Option>>, -} - -pub async fn fetch_subscription(url: &str) -> Result { - let redacted_url = redacted_subscription_url(url); - let client = reqwest::Client::builder() - .user_agent(SUBSCRIPTION_USER_AGENT) - .redirect(reqwest::redirect::Policy::limited(5)) - .timeout(Duration::from_secs(20)) - .build()?; - let response = client - .get(url) - .send() - .await - .with_context(|| format!("failed to fetch {redacted_url}"))?; - let status = response.status(); - let body = response - .bytes() - .await - .with_context(|| format!("failed to read subscription body from {redacted_url}"))?; - if !status.is_success() { - bail!( - "subscription request failed for {}: HTTP {}{}", - redacted_url, - status.as_u16(), - subscription_error_body(&body) - ); - } - if body.len() > MAX_SUBSCRIPTION_BYTES { - bail!("subscription body exceeds {} bytes", MAX_SUBSCRIPTION_BYTES); - } - Ok(String::from_utf8_lossy(&body).into_owned()) -} - -fn redacted_subscription_url(raw: &str) -> String { - let Ok(mut url) = Url::parse(raw) else { - return "".to_owned(); - }; - - if !url.username().is_empty() { - let _ = url.set_username("REDACTED"); - } - if url.password().is_some() { - let _ = url.set_password(Some("REDACTED")); - } - if url.query().is_some() { - url.set_query(Some("REDACTED")); - } - if url.fragment().is_some() { - url.set_fragment(Some("REDACTED")); - } - url.to_string() -} - -fn subscription_error_body(body: &[u8]) -> String { - let excerpt = String::from_utf8_lossy(&body[..body.len().min(160)]) - .chars() - .map(|ch| if ch.is_control() { ' ' } else { ch }) - .collect::() - .trim() - .to_owned(); - if excerpt.is_empty() { - String::new() - } else { - format!(": {excerpt}") - } -} - -pub fn build_payload( - preview: ProxySubscriptionPreview, - source_url: &str, - name: Option, - selected_ordinal: Option, -) -> ProxySubscriptionPayload { - let selected_name = selected_ordinal.and_then(|ordinal| { - preview - .nodes - .iter() - .find(|node| node.ordinal == ordinal) - .map(|node| node.name.clone()) - }); - ProxySubscriptionPayload { - name: name - .and_then(|value| non_empty(value.trim()).map(ToOwned::to_owned)) - .unwrap_or_else(|| preview.suggested_name.clone()), - source: ProxySubscriptionSource { url: source_url.to_owned() }, - detected_format: preview.detected_format, - selected_ordinal, - selected_name, - nodes: preview.nodes, - warnings: preview.warnings, - } -} - -pub fn validate_payload(payload: &ProxySubscriptionPayload) -> Result<()> { - if payload.name.trim().is_empty() { - bail!("proxy subscription name must not be empty"); - } - if payload.nodes.is_empty() { - bail!("proxy subscription must include at least one compatible node"); - } - if let Some(selected) = payload.selected_ordinal { - if !payload.nodes.iter().any(|node| node.ordinal == selected) { - bail!("selected proxy node ordinal {selected} is not present in subscription"); - } - } - if let Some(selected) = &payload.selected_name { - if !payload.nodes.iter().any(|node| node.name == *selected) { - bail!("selected proxy node {selected} is not present in subscription"); - } - } - for node in &payload.nodes { - validate_node(node)?; - } - Ok(()) -} - -pub fn parse_subscription_body(body: &str, source_url: Option<&str>) -> ProxySubscriptionPreview { - match parse_yaml_subscription(body, source_url) { - Ok(preview) => preview, - Err(yaml_error) => { - let mut preview = parse_uri_subscription(body, source_url); - if preview.nodes.is_empty() && preview.rejected.is_empty() { - preview.warnings.push(format!( - "subscription is not Mihomo YAML and did not contain proxy URIs: {yaml_error}" - )); - } - preview - } - } -} - -pub fn summarize_payload(payload: &ProxySubscriptionPayload) -> String { - let selected = payload - .selected_name - .as_deref() - .and_then(|name| payload.nodes.iter().find(|node| node.name == name)) - .or_else(|| { - payload - .selected_ordinal - .and_then(|ordinal| payload.nodes.iter().find(|node| node.ordinal == ordinal)) - }) - .or_else(|| payload.nodes.first()); - match selected { - Some(node) => format!( - "{} - {} {}:{} ({} nodes)", - payload.name, - node.protocol.as_str(), - node.server, - node.port, - payload.nodes.len() - ), - None => format!("{} - no compatible nodes", payload.name), - } -} - -fn parse_yaml_subscription( - body: &str, - source_url: Option<&str>, -) -> Result { - let value: Value = serde_yaml::from_str(body).context("subscription is not proxy YAML")?; - let mapping = value - .as_mapping() - .ok_or_else(|| anyhow!("YAML subscription must be a mapping"))?; - if !mapping.contains_key(Value::String("proxies".to_owned())) { - bail!("YAML subscription must include a `proxies` field"); - } - - let schema: ProxySchema = - serde_yaml::from_str(body).context("subscription is not proxy YAML")?; - let proxies = schema - .proxies - .ok_or_else(|| anyhow!("YAML subscription must include a `proxies` field"))?; - let mut nodes = Vec::new(); - let mut rejected = Vec::new(); - let mut warnings = Vec::new(); - - for (ordinal, mapping) in proxies.into_iter().enumerate() { - let scheme = yaml_string(&mapping, "type").map(str::to_owned); - match parse_yaml_proxy(ordinal, mapping) { - Ok(node) => nodes.push(node), - Err(error) => rejected.push(ProxyRejectedEntry { - ordinal, - scheme, - reason: error.to_string(), - }), - } - } - - if nodes.is_empty() { - warnings.push("no compatible Trojan or Shadowsocks nodes found in YAML".to_owned()); - } - - Ok(ProxySubscriptionPreview { - suggested_name: suggested_name(source_url), - detected_format: ProxySubscriptionFormat::ClashYaml, - nodes, - rejected, - warnings, - }) -} - -fn parse_uri_subscription(body: &str, source_url: Option<&str>) -> ProxySubscriptionPreview { - let decoded = decode_subscription_body(body).unwrap_or_else(|| body.to_owned()); - let mut nodes = Vec::new(); - let mut rejected = Vec::new(); - - for (ordinal, raw) in decoded - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .enumerate() - { - let scheme = raw - .split_once("://") - .map(|(scheme, _)| scheme.to_ascii_lowercase()); - let parsed = match scheme.as_deref() { - Some("trojan") => parse_trojan_uri(raw, ordinal), - Some("ss") => parse_shadowsocks_uri(raw, ordinal), - Some(other) => Err(anyhow!("unsupported proxy URI scheme {other}")), - None => Err(anyhow!("missing proxy URI scheme")), - }; - match parsed { - Ok(node) => nodes.push(node), - Err(error) => rejected.push(ProxyRejectedEntry { - ordinal, - scheme, - reason: error.to_string(), - }), - } - } - - ProxySubscriptionPreview { - suggested_name: suggested_name(source_url), - detected_format: ProxySubscriptionFormat::UriList, - nodes, - rejected, - warnings: Vec::new(), - } -} - -fn parse_yaml_proxy(ordinal: usize, mapping: HashMap) -> Result { - let proxy_type = required_yaml_string(&mapping, "type")?.to_ascii_lowercase(); - match proxy_type.as_str() { - "trojan" => parse_yaml_trojan(ordinal, &mapping), - "ss" | "shadowsocks" => parse_yaml_shadowsocks(ordinal, &mapping), - _ => bail!("unsupported proxy type {proxy_type}"), - } -} - -fn parse_yaml_trojan(ordinal: usize, mapping: &HashMap) -> Result { - let name = required_yaml_string(mapping, "name")?.to_owned(); - let server = required_yaml_string(mapping, "server")?.to_owned(); - let port = required_yaml_u16(mapping, "port")?; - let password = required_yaml_string(mapping, "password")?.to_owned(); - let alpn = yaml_string(mapping, "alpn") - .map(split_csv) - .or_else(|| yaml_string_vec(mapping, "alpn")) - .unwrap_or_default(); - let alpn_present = mapping.contains_key("alpn"); - let skip_cert_verify = yaml_bool(mapping, "skip-cert-verify").unwrap_or(false); - let network = match yaml_string(mapping, "network") - .unwrap_or("tcp") - .to_ascii_lowercase() - .as_str() - { - "tcp" | "" => TrojanNetwork::Tcp, - "ws" => { - let ws_opts = yaml_mapping(mapping, "ws-opts"); - let host = ws_opts - .as_ref() - .and_then(|opts| yaml_mapping(opts, "headers")) - .and_then(|headers| { - yaml_string(&headers, "Host") - .or_else(|| yaml_string(&headers, "host")) - .map(ToOwned::to_owned) - }); - TrojanNetwork::Ws { - path: ws_opts - .as_ref() - .and_then(|opts| yaml_string(opts, "path").map(ToOwned::to_owned)), - host, - } - } - "grpc" => { - let grpc_opts = yaml_mapping(mapping, "grpc-opts"); - TrojanNetwork::Grpc { - service_name: grpc_opts - .as_ref() - .and_then(|opts| yaml_string(opts, "grpc-service-name")) - .map(ToOwned::to_owned), - } - } - other => bail!("unsupported Trojan network {other}"), - }; - Ok(ProxyNode { - ordinal, - name, - protocol: ProxyProtocol::Trojan, - server, - port, - warnings: Vec::new(), - config: ProxyNodeConfig::Trojan(TrojanNode { - password, - sni: yaml_string(mapping, "sni").map(ToOwned::to_owned), - alpn, - alpn_present, - skip_cert_verify, - network, - udp: yaml_bool(mapping, "udp").unwrap_or(false), - client_fingerprint: yaml_string(mapping, "client-fingerprint").map(ToOwned::to_owned), - fingerprint: yaml_string(mapping, "fingerprint").map(ToOwned::to_owned), - }), - }) -} - -fn parse_yaml_shadowsocks(ordinal: usize, mapping: &HashMap) -> Result { - let name = required_yaml_scalar_string(mapping, "name")?; - let server = required_yaml_scalar_string(mapping, "server")?; - let port = required_yaml_u16(mapping, "port")?; - let cipher = required_yaml_scalar_string(mapping, "cipher")?; - let password = required_yaml_scalar_string(mapping, "password")?; - Ok(ProxyNode { - ordinal, - name, - protocol: ProxyProtocol::Shadowsocks, - server, - port, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher, - password, - udp: yaml_bool(mapping, "udp").unwrap_or(false), - udp_over_tcp: yaml_bool(mapping, "udp-over-tcp").unwrap_or(false), - udp_over_tcp_version: yaml_u8(mapping, "udp-over-tcp-version") - .unwrap_or(UOT_LEGACY_VERSION), - client_fingerprint: yaml_scalar_string(mapping, "client-fingerprint"), - plugin: yaml_plugin(mapping), - smux: yaml_smux(mapping), - }), - }) -} - -fn parse_trojan_uri(uri: &str, ordinal: usize) -> Result { - let url = Url::parse(uri).context("invalid Trojan URI")?; - let server = url - .host_str() - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("missing Trojan server host"))?; - let port = url - .port() - .ok_or_else(|| anyhow!("missing Trojan server port"))?; - let password = percent_decode(url.username())?; - if password.is_empty() { - bail!("missing Trojan password"); - } - - let query = url.query_pairs().collect::>(); - let skip_cert_verify = query - .get("allowInsecure") - .or_else(|| query.get("skip-cert-verify")) - .or_else(|| query.get("skipCertVerify")) - .map(|value| is_truthy(value)) - .unwrap_or(false); - let network = query - .get("type") - .or_else(|| query.get("network")) - .map(|value| value.to_ascii_lowercase()) - .unwrap_or_else(|| "tcp".to_owned()); - let trojan_network = match network.as_str() { - "" | "tcp" => TrojanNetwork::Tcp, - "ws" => TrojanNetwork::Ws { - path: query - .get("path") - .and_then(|value| non_empty(value).map(ToOwned::to_owned)), - host: query - .get("host") - .or_else(|| query.get("Host")) - .and_then(|value| non_empty(value).map(ToOwned::to_owned)), - }, - "grpc" => TrojanNetwork::Grpc { - service_name: query - .get("serviceName") - .or_else(|| query.get("grpc-service-name")) - .and_then(|value| non_empty(value).map(ToOwned::to_owned)), - }, - other => bail!("unsupported Trojan network {other}"), - }; - let fingerprint = query - .get("fp") - .and_then(|value| non_empty(value).map(ToOwned::to_owned)) - .or_else(|| Some("chrome".to_owned())); - let alpn = query - .get("alpn") - .and_then(|value| non_empty(value).map(split_csv)) - .unwrap_or_default(); - let alpn_present = !alpn.is_empty(); - - Ok(ProxyNode { - ordinal, - name: uri_fragment_name(&url, "Proxy Node", ordinal)?, - protocol: ProxyProtocol::Trojan, - server, - port, - warnings: Vec::new(), - config: ProxyNodeConfig::Trojan(TrojanNode { - password, - sni: query - .get("sni") - .or_else(|| query.get("peer")) - .or_else(|| query.get("host")) - .and_then(|value| non_empty(value).map(ToOwned::to_owned)), - alpn, - alpn_present, - skip_cert_verify, - network: trojan_network, - udp: true, - client_fingerprint: fingerprint, - fingerprint: query - .get("pcs") - .and_then(|value| non_empty(value).map(ToOwned::to_owned)), - }), - }) -} - -fn parse_shadowsocks_uri(uri: &str, ordinal: usize) -> Result { - let mut url = Url::parse(uri).context("invalid Shadowsocks URI")?; - if url.port().is_none() { - let decoded_host = decode_base64_segment(url.host_str().unwrap_or_default()) - .context("invalid legacy Shadowsocks host encoding")?; - url = Url::parse(&format!("ss://{decoded_host}")) - .context("invalid decoded legacy Shadowsocks URI")?; - } - let server = url - .host_str() - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("missing Shadowsocks server host"))?; - let port = url - .port() - .ok_or_else(|| anyhow!("missing Shadowsocks server port"))?; - let user = url.username(); - let (cipher, password) = match url.password() { - Some(password) => (user.to_owned(), password.to_owned()), - None => decode_base64_segment(user) - .ok() - .and_then(|decoded| { - decoded - .split_once(':') - .map(|(a, b)| (a.to_owned(), b.to_owned())) - }) - .ok_or_else(|| anyhow!("missing Shadowsocks cipher/password"))?, - }; - let query = url.query_pairs().collect::>(); - let udp_over_tcp = query - .get("udp-over-tcp") - .map(|value| value == "true") - .unwrap_or(false) - || query.get("uot").map(|value| value == "1").unwrap_or(false); - let udp_over_tcp_version = query - .get("udp-over-tcp-version") - .and_then(|value| value.parse::().ok()) - .or_else(|| { - query - .get("uot-version") - .and_then(|value| value.parse::().ok()) - }) - .unwrap_or(UOT_LEGACY_VERSION); - - Ok(ProxyNode { - ordinal, - name: uri_fragment_name(&url, "Proxy Node", ordinal)?, - protocol: ProxyProtocol::Shadowsocks, - server, - port, - warnings: Vec::new(), - config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { - cipher, - password, - udp: true, - udp_over_tcp, - udp_over_tcp_version, - client_fingerprint: query - .get("client-fingerprint") - .or_else(|| query.get("fp")) - .and_then(|value| non_empty(value).map(ToOwned::to_owned)), - plugin: query.get("plugin").and_then(|value| parse_ss_plugin(value)), - smux: None, - }), - }) -} - -fn validate_node(node: &ProxyNode) -> Result<()> { - if node.name.trim().is_empty() { - bail!("proxy node name must not be empty"); - } - if node.server.trim().is_empty() { - bail!("proxy node server must not be empty"); - } - match &node.config { - ProxyNodeConfig::Trojan(config) if config.password.is_empty() => { - bail!("Trojan node password must not be empty") - } - ProxyNodeConfig::Shadowsocks(config) - if config.cipher.is_empty() || config.password.is_empty() => - { - bail!("Shadowsocks node cipher and password must not be empty") - } - _ => Ok(()), - } -} - -fn yaml_string<'a>(mapping: &'a HashMap, key: &str) -> Option<&'a str> { - mapping.get(key).and_then(Value::as_str) -} - -fn yaml_scalar_string(mapping: &HashMap, key: &str) -> Option { - yaml_value_to_scalar_string(mapping.get(key)?) -} - -fn yaml_value_to_scalar_string(value: &Value) -> Option { - value - .as_str() - .map(ToOwned::to_owned) - .or_else(|| value.as_bool().map(|value| value.to_string())) - .or_else(|| value.as_i64().map(|value| value.to_string())) - .or_else(|| value.as_u64().map(|value| value.to_string())) - .or_else(|| value.as_f64().map(|value| value.to_string())) -} - -fn yaml_string_vec(mapping: &HashMap, key: &str) -> Option> { - let values = mapping.get(key)?.as_sequence()?; - Some( - values - .iter() - .filter_map(Value::as_str) - .map(ToOwned::to_owned) - .collect(), - ) -} - -fn yaml_mapping<'a>( - mapping: &'a HashMap, - key: &str, -) -> Option> { - mapping.get(key)?.as_mapping().and_then(|map| { - map.iter() - .map(|(key, value)| Some((key.as_str()?.to_owned(), value.clone()))) - .collect::>>() - }) -} - -fn required_yaml_string<'a>(mapping: &'a HashMap, key: &str) -> Result<&'a str> { - yaml_string(mapping, key).ok_or_else(|| anyhow!("missing `{key}`")) -} - -fn required_yaml_scalar_string(mapping: &HashMap, key: &str) -> Result { - yaml_scalar_string(mapping, key).ok_or_else(|| anyhow!("missing `{key}`")) -} - -fn required_yaml_u16(mapping: &HashMap, key: &str) -> Result { - if let Some(value) = mapping.get(key).and_then(Value::as_u64) { - return u16::try_from(value).with_context(|| format!("invalid `{key}`")); - } - let value = required_yaml_string(mapping, key)?; - value - .parse::() - .with_context(|| format!("invalid `{key}`")) -} - -fn yaml_bool(mapping: &HashMap, key: &str) -> Option { - mapping - .get(key) - .and_then(Value::as_bool) - .or_else(|| { - mapping - .get(key) - .and_then(Value::as_i64) - .map(|value| value != 0) - }) - .or_else(|| { - mapping - .get(key) - .and_then(Value::as_u64) - .map(|value| value != 0) - }) - .or_else(|| yaml_string(mapping, key).map(is_truthy)) -} - -fn yaml_u8(mapping: &HashMap, key: &str) -> Option { - mapping - .get(key) - .and_then(Value::as_u64) - .and_then(|value| u8::try_from(value).ok()) - .or_else(|| yaml_string(mapping, key).and_then(|value| value.parse::().ok())) -} - -fn yaml_u32(mapping: &HashMap, key: &str) -> Option { - mapping - .get(key) - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .or_else(|| yaml_string(mapping, key).and_then(|value| value.parse::().ok())) -} - -fn yaml_smux(mapping: &HashMap) -> Option { - let smux = yaml_mapping(mapping, "smux")?; - let brutal = yaml_mapping(&smux, "brutal-opts").map(|brutal| ShadowsocksSmuxBrutal { - enabled: yaml_bool(&brutal, "enabled").unwrap_or(false), - up: yaml_string(&brutal, "up").map(ToOwned::to_owned), - down: yaml_string(&brutal, "down").map(ToOwned::to_owned), - }); - - Some(ShadowsocksSmux { - enabled: yaml_bool(&smux, "enabled").unwrap_or(false), - protocol: yaml_string(&smux, "protocol").map(ToOwned::to_owned), - max_connections: yaml_u32(&smux, "max-connections"), - min_streams: yaml_u32(&smux, "min-streams"), - max_streams: yaml_u32(&smux, "max-streams"), - padding: yaml_bool(&smux, "padding").unwrap_or(false), - statistic: yaml_bool(&smux, "statistic").unwrap_or(false), - only_tcp: yaml_bool(&smux, "only-tcp").unwrap_or(false), - brutal, - }) -} - -fn yaml_plugin(mapping: &HashMap) -> Option { - let name = yaml_scalar_string(mapping, "plugin")?; - let mut opts: HashMap = yaml_mapping(mapping, "plugin-opts") - .map(|opts| { - opts.iter() - .filter_map(|(key, value)| { - yaml_plugin_value(value).map(|value| (key.clone(), value)) - }) - .collect() - }) - .unwrap_or_default(); - if let Some(headers) = - yaml_mapping(mapping, "plugin-opts").and_then(|opts| yaml_mapping(&opts, "headers")) - { - for (key, value) in headers { - if let Some(value) = yaml_plugin_value(&value) { - opts.insert(format!("headers.{key}"), value.to_owned()); - } - } - } - if let Some(ech_opts) = - yaml_mapping(mapping, "plugin-opts").and_then(|opts| yaml_mapping(&opts, "ech-opts")) - { - for (key, value) in ech_opts { - if let Some(value) = yaml_plugin_value(&value) { - opts.insert(format!("ech-opts.{key}"), value.to_owned()); - } - } - } - Some(ShadowsocksPlugin { name, opts }) -} - -fn yaml_plugin_value(value: &Value) -> Option { - yaml_value_to_scalar_string(value).or_else(|| { - value.as_sequence().map(|values| { - values - .iter() - .filter_map(yaml_value_to_scalar_string) - .collect::>() - .join(",") - }) - }) -} - -fn parse_ss_plugin(plugin: &str) -> Option { - if !plugin.contains(';') { - return None; - } - let (name, rest) = plugin.split_once(';').unwrap_or((plugin, "")); - let mut opts = HashMap::new(); - for part in rest.split(';').filter(|part| !part.is_empty()) { - if let Some((key, value)) = part.split_once('=') { - let key = decode_ss_plugin_component(key); - if let Some(key) = non_empty(&key) { - opts.insert(key.to_owned(), decode_ss_plugin_component(value)); - } - } else if let Some(key) = non_empty(part) { - let key = decode_ss_plugin_component(key); - if let Some(key) = non_empty(&key) { - opts.insert(key.to_owned(), "true".to_owned()); - } - } - } - let name = non_empty(name)?; - let normalized_name = normalize_ss_uri_plugin_name(name)?; - normalize_ss_uri_plugin_opts(plugin, normalized_name, &mut opts); - Some(ShadowsocksPlugin { - name: normalized_name.to_owned(), - opts, - }) -} - -fn normalize_ss_uri_plugin_name(name: &str) -> Option<&str> { - let normalized = name.trim().to_ascii_lowercase(); - if normalized.contains("obfs") { - Some("obfs") - } else if normalized.contains("v2ray-plugin") { - Some("v2ray-plugin") - } else { - None - } -} - -fn normalize_ss_uri_plugin_opts( - raw_plugin: &str, - normalized_name: &str, - opts: &mut HashMap, -) { - if normalized_name == "v2ray-plugin" { - if !opts.contains_key("mode") { - if let Some(mode) = opts.get("obfs").cloned() { - opts.insert("mode".to_owned(), mode); - } - } - if !opts.contains_key("host") { - if let Some(host) = opts.get("obfs-host").cloned() { - opts.insert("host".to_owned(), host); - } - } - if raw_plugin.contains("tls") { - opts.insert("tls".to_owned(), "true".to_owned()); - } - } -} - -fn decode_ss_plugin_component(value: &str) -> String { - let query_value = value.replace('+', " "); - percent_decode(&query_value).unwrap_or_else(|_| value.to_owned()) -} - -fn decode_subscription_body(body: &str) -> Option { - let compact: String = body.chars().filter(|ch| !ch.is_whitespace()).collect(); - decode_base64_segment(&compact).ok() -} - -fn decode_base64_segment(input: &str) -> Result { - if let Ok(decoded) = STANDARD_NO_PAD.decode(input.as_bytes()) { - return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); - } - if let Ok(decoded) = STANDARD.decode(input.as_bytes()) { - return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); - } - if let Ok(decoded) = URL_SAFE_NO_PAD.decode(input.as_bytes()) { - return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); - } - if let Ok(decoded) = URL_SAFE.decode(input.as_bytes()) { - return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); - } - bail!("invalid base64 text") -} - -fn uri_fragment_name(url: &Url, fallback: &str, ordinal: usize) -> Result { - Ok(url - .fragment() - .map(percent_decode) - .transpose()? - .and_then(|fragment| non_empty(&fragment).map(ToOwned::to_owned)) - .or_else(|| non_empty(url.host_str().unwrap_or_default()).map(ToOwned::to_owned)) - .unwrap_or_else(|| format!("{fallback} {}", ordinal + 1))) -} - -fn suggested_name(source_url: Option<&str>) -> String { - source_url - .and_then(|url| Url::parse(url).ok()) - .and_then(|url| url.host_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| "Proxy Subscription".to_owned()) -} - -fn split_csv(value: &str) -> Vec { - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .collect() -} - -fn is_truthy(value: impl AsRef) -> bool { - matches!( - value.as_ref().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "y" - ) -} - -fn default_true() -> bool { - true -} - -const UOT_LEGACY_VERSION: u8 = 1; - -fn default_uot_legacy_version() -> u8 { - UOT_LEGACY_VERSION -} - -fn non_empty(value: &str) -> Option<&str> { - let value = value.trim(); - (!value.is_empty()).then_some(value) -} - -fn percent_decode(input: &str) -> Result { - let mut out = Vec::with_capacity(input.len()); - let bytes = input.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' { - if i + 2 >= bytes.len() { - bail!("invalid percent encoding"); - } - let hi = hex_value(bytes[i + 1]).ok_or_else(|| anyhow!("invalid percent encoding"))?; - let lo = hex_value(bytes[i + 2]).ok_or_else(|| anyhow!("invalid percent encoding"))?; - out.push((hi << 4) | lo); - i += 3; - } else { - out.push(bytes[i]); - i += 1; - } - } - String::from_utf8(out).context("percent-decoded text is not valid UTF-8") -} - -fn hex_value(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_base64_uri_list_with_trojan_and_shadowsocks() { - let body = STANDARD.encode( - "trojan://secret@example.com:443?security=tls&sni=front.example&type=grpc&serviceName=edge&allowInsecure=1&fp=random#US%20Trojan\nss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?uot=1&uot-version=2#SS%20Node\n", - ); - let preview = parse_subscription_body(&body, Some("https://sub.example/path?token=secret")); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); - assert_eq!(preview.nodes.len(), 2); - assert_eq!(preview.rejected.len(), 0); - assert_eq!(preview.nodes[0].name, "US Trojan"); - assert!(preview.nodes[0].warnings.is_empty()); - let ProxyNodeConfig::Trojan(trojan) = &preview.nodes[0].config else { - panic!("expected Trojan node"); - }; - assert!(!trojan.alpn_present); - assert_eq!(trojan_alpn_for_test(trojan), Vec::::new()); - assert!(trojan.udp); - assert_eq!(preview.nodes[1].protocol, ProxyProtocol::Shadowsocks); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[1].config else { - panic!("expected Shadowsocks node"); - }; - assert!(shadowsocks.udp); - assert!(shadowsocks.udp_over_tcp); - assert_eq!(shadowsocks.udp_over_tcp_version, 2); - } - - #[test] - fn parses_shadowsocks_v2ray_plugin_uri_aliases() { - let body = "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=v2ray-plugin%3Bobfs%3Dwebsocket%3Bobfs-host%3Dcdn.example%3Bpath%3D%252Fws%3Btls#SS%20V2Ray"; - let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); - assert_eq!(preview.nodes.len(), 1); - assert_eq!(preview.rejected.len(), 0); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { - panic!("expected Shadowsocks node"); - }; - let plugin = shadowsocks.plugin.as_ref().unwrap(); - assert_eq!(plugin.name, "v2ray-plugin"); - assert_eq!(plugin.opts.get("mode"), Some(&"websocket".to_owned())); - assert_eq!(plugin.opts.get("host"), Some(&"cdn.example".to_owned())); - assert_eq!(plugin.opts.get("obfs"), Some(&"websocket".to_owned())); - assert_eq!( - plugin.opts.get("obfs-host"), - Some(&"cdn.example".to_owned()) - ); - assert_eq!(plugin.opts.get("path"), Some(&"/ws".to_owned())); - assert_eq!(plugin.opts.get("tls"), Some(&"true".to_owned())); - } - - #[test] - fn parses_base64_uri_list_even_when_yaml_accepts_scalar_text() { - let body = STANDARD - .encode("ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpwYXNz@example.net:1080#SS%20Node\n"); - let preview = parse_subscription_body(&body, Some("https://sub.example/path?token=secret")); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); - assert_eq!(preview.rejected.len(), 0); - assert_eq!(preview.nodes.len(), 1); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { - panic!("expected Shadowsocks node"); - }; - assert_eq!(shadowsocks.cipher, "chacha20-ietf-poly1305"); - assert_eq!(shadowsocks.password, "pass"); - } - - #[test] - fn parses_shadowsocks_simple_obfs_uri_like_mihomo_converter() { - let body = "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dcdn.example#SS%20Obfs"; - let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); - assert_eq!(preview.nodes.len(), 1); - assert_eq!(preview.rejected.len(), 0); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { - panic!("expected Shadowsocks node"); - }; - let plugin = shadowsocks.plugin.as_ref().unwrap(); - assert_eq!(plugin.name, "obfs"); - assert_eq!(plugin.opts.get("obfs"), Some(&"http".to_owned())); - assert_eq!( - plugin.opts.get("obfs-host"), - Some(&"cdn.example".to_owned()) - ); - } - - #[test] - fn ignores_bare_shadowsocks_uri_plugin_like_mihomo_converter() { - let body = - "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=v2ray-plugin#SS%20Bare%20Plugin"; - let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); - assert_eq!(preview.nodes.len(), 1); - assert_eq!(preview.rejected.len(), 0); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { - panic!("expected Shadowsocks node"); - }; - assert!(shadowsocks.plugin.is_none()); - } - - #[test] - fn ignores_unknown_shadowsocks_uri_plugin_like_mihomo_converter() { - let body = "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=gost-plugin%3Bmode%3Dwebsocket#SS%20Gost%20URI"; - let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); - assert_eq!(preview.nodes.len(), 1); - assert_eq!(preview.rejected.len(), 0); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { - panic!("expected Shadowsocks node"); - }; - assert!(shadowsocks.plugin.is_none()); - } - - #[test] - fn parses_mihomo_yaml_proxies() { - let preview = parse_subscription_body( - r#" -proxies: - - name: trojan-ws - type: trojan - server: example.com - port: 443 - password: secret - sni: edge.example.com - network: ws - ws-opts: - path: /ws - headers: - Host: edge.example.com - - name: ss - type: ss - server: example.net - port: 8388 - cipher: aes-128-gcm - password: pass - plugin: v2ray-plugin - plugin-opts: - mode: websocket - path: /ws - v2ray-http-upgrade: true - v2ray-http-upgrade-fast-open: true - headers: - Host: edge.example.net - ech-opts: - enable: true - config: AQIDBA== - query-server-name: public.example.net - udp-over-tcp: true - udp-over-tcp-version: 2 - smux: - enabled: true - protocol: smux - max-connections: 4 - min-streams: 2 - padding: true - statistic: true - only-tcp: false - brutal-opts: - enabled: true - up: 10 Mbps - down: 20 Mbps - - name: ss-shadowtls - type: ss - server: shadow.example.net - port: 443 - cipher: aes-128-gcm - password: pass - client-fingerprint: chrome - plugin: shadow-tls - plugin-opts: - host: cover.example.com - password: shadow-secret - version: 1 - fingerprint: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - alpn: - - h2 - - http/1.1 - - name: ss-kcptun - type: ss - server: kcp.example.net - port: 29900 - cipher: aes-128-gcm - password: pass - plugin: kcptun - plugin-opts: - key: secret - crypt: aes - mode: fast2 - conn: 2 - nocomp: true - smuxver: 2 -"#, - None, - ); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::ClashYaml); - assert_eq!(preview.nodes.len(), 4); - assert_eq!(preview.rejected.len(), 0); - let ProxyNodeConfig::Trojan(trojan) = &preview.nodes[0].config else { - panic!("expected Trojan node"); - }; - assert!(!trojan.alpn_present); - assert!(!trojan.udp); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[1].config else { - panic!("expected Shadowsocks node"); - }; - assert!(!shadowsocks.udp); - assert!(shadowsocks.udp_over_tcp); - assert_eq!(shadowsocks.udp_over_tcp_version, 2); - assert_eq!( - shadowsocks - .plugin - .as_ref() - .unwrap() - .opts - .get("headers.Host"), - Some(&"edge.example.net".to_owned()) - ); - assert_eq!( - shadowsocks - .plugin - .as_ref() - .unwrap() - .opts - .get("v2ray-http-upgrade-fast-open"), - Some(&"true".to_owned()) - ); - assert_eq!( - shadowsocks - .plugin - .as_ref() - .unwrap() - .opts - .get("ech-opts.enable"), - Some(&"true".to_owned()) - ); - assert_eq!( - shadowsocks - .plugin - .as_ref() - .unwrap() - .opts - .get("ech-opts.config"), - Some(&"AQIDBA==".to_owned()) - ); - assert_eq!( - shadowsocks - .plugin - .as_ref() - .unwrap() - .opts - .get("ech-opts.query-server-name"), - Some(&"public.example.net".to_owned()) - ); - let smux = shadowsocks - .smux - .as_ref() - .expect("expected Shadowsocks smux"); - assert!(smux.enabled); - assert_eq!(smux.protocol.as_deref(), Some("smux")); - assert_eq!(smux.max_connections, Some(4)); - assert_eq!(smux.min_streams, Some(2)); - assert!(smux.padding); - assert!(smux.statistic); - assert!(!smux.only_tcp); - let brutal = smux.brutal.as_ref().expect("expected brutal opts"); - assert!(brutal.enabled); - assert_eq!(brutal.up.as_deref(), Some("10 Mbps")); - assert_eq!(brutal.down.as_deref(), Some("20 Mbps")); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[2].config else { - panic!("expected Shadowsocks node"); - }; - let plugin = shadowsocks.plugin.as_ref().unwrap(); - assert_eq!(plugin.name, "shadow-tls"); - assert_eq!(shadowsocks.client_fingerprint.as_deref(), Some("chrome")); - assert_eq!(plugin.opts.get("version"), Some(&"1".to_owned())); - assert_eq!( - plugin.opts.get("fingerprint"), - Some(&"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff".to_owned()) - ); - assert_eq!(plugin.opts.get("alpn"), Some(&"h2,http/1.1".to_owned())); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[3].config else { - panic!("expected Shadowsocks node"); - }; - let plugin = shadowsocks.plugin.as_ref().unwrap(); - assert_eq!(plugin.name, "kcptun"); - assert_eq!(plugin.opts.get("conn"), Some(&"2".to_owned())); - assert_eq!(plugin.opts.get("nocomp"), Some(&"true".to_owned())); - assert_eq!(plugin.opts.get("smuxver"), Some(&"2".to_owned())); - } - - #[test] - fn shadowsocks_yaml_udp_defaults_match_mihomo() { - let preview = parse_subscription_body( - r#" -proxies: - - name: ss-default - type: ss - server: example.net - port: 8388 - cipher: aes-128-gcm - password: pass - - name: ss-udp - type: ss - server: example.org - port: 8388 - cipher: aes-128-gcm - password: pass - udp: true -"#, - None, - ); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::ClashYaml); - assert_eq!(preview.rejected.len(), 0); - let ProxyNodeConfig::Shadowsocks(default_node) = &preview.nodes[0].config else { - panic!("expected Shadowsocks node"); - }; - assert!(!default_node.udp); - let ProxyNodeConfig::Shadowsocks(udp_node) = &preview.nodes[1].config else { - panic!("expected Shadowsocks node"); - }; - assert!(udp_node.udp); - } - - #[test] - fn shadowsocks_yaml_accepts_weakly_typed_scalars_like_mihomo() { - let preview = parse_subscription_body( - r#" -proxies: - - name: 12345 - type: ss - server: 127.0.0.1 - port: "8388" - cipher: aes-128-gcm - password: 123456 - udp: 1 - udp-over-tcp: 0 - client-fingerprint: false - plugin: v2ray-plugin - plugin-opts: - mode: websocket - path: /ws - mux: true - alpn: - - h2 - - 123 - smux: - enabled: 1 - padding: 0 -"#, - None, - ); - assert_eq!(preview.detected_format, ProxySubscriptionFormat::ClashYaml); - assert_eq!(preview.rejected.len(), 0); - assert_eq!(preview.nodes.len(), 1); - assert_eq!(preview.nodes[0].name, "12345"); - let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { - panic!("expected Shadowsocks node"); - }; - assert_eq!(shadowsocks.password, "123456"); - assert!(shadowsocks.udp); - assert!(!shadowsocks.udp_over_tcp); - assert_eq!(shadowsocks.client_fingerprint.as_deref(), Some("false")); - let smux = shadowsocks.smux.as_ref().expect("expected smux"); - assert!(smux.enabled); - assert!(!smux.padding); - let plugin = shadowsocks.plugin.as_ref().expect("expected plugin"); - assert_eq!(plugin.name, "v2ray-plugin"); - assert_eq!(plugin.opts.get("mux"), Some(&"true".to_owned())); - assert_eq!(plugin.opts.get("alpn"), Some(&"h2,123".to_owned())); - } - - #[test] - fn tracks_explicit_trojan_alpn_presence() { - let preview = parse_subscription_body( - r#" -proxies: - - name: trojan-empty-alpn - type: trojan - server: example.com - port: 443 - password: secret - alpn: [] - - name: trojan-custom-alpn - type: trojan - server: example.net - port: 443 - password: secret - alpn: - - h3 -"#, - None, - ); - let ProxyNodeConfig::Trojan(empty_alpn) = &preview.nodes[0].config else { - panic!("expected Trojan node"); - }; - assert!(empty_alpn.alpn_present); - assert!(empty_alpn.alpn.is_empty()); - - let ProxyNodeConfig::Trojan(custom_alpn) = &preview.nodes[1].config else { - panic!("expected Trojan node"); - }; - assert!(custom_alpn.alpn_present); - assert_eq!(custom_alpn.alpn, vec!["h3"]); - } - - #[test] - fn build_payload_records_selected_node_name() { - let preview = parse_subscription_body( - "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388#SS%20Node\n", - Some("https://sub.example/path"), - ); - let payload = build_payload( - preview, - "https://sub.example/path", - Some("sub".to_owned()), - Some(0), - ); - - assert_eq!(payload.selected_ordinal, Some(0)); - assert_eq!(payload.selected_name.as_deref(), Some("SS Node")); - } - - #[test] - fn redacts_subscription_url_secrets_for_errors() { - let redacted = redacted_subscription_url( - "https://user:password@sub.example/path?token=secret&user=alice#fragment", - ); - - assert_eq!( - redacted, - "https://REDACTED:REDACTED@sub.example/path?REDACTED#REDACTED" - ); - assert!(!redacted.contains("password")); - assert!(!redacted.contains("token=secret")); - assert!(!redacted.contains("fragment")); - } - - fn trojan_alpn_for_test(node: &TrojanNode) -> Vec { - node.alpn.clone() - } -} diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index fb88100..8a245ef 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -1,13 +1,7 @@ -use std::{ - fs::{File, OpenOptions}, - io::{self, Write}, - path::PathBuf, - sync::{Arc, Mutex, Once}, -}; +use std::sync::Once; use tracing::{error, info}; use tracing_subscriber::{ - fmt::writer::MakeWriter, layer::{Layer, SubscriberExt}, EnvFilter, Registry, }; @@ -26,30 +20,14 @@ pub fn initialize() { .with_writer(std::io::stderr) .with_line_number(true) .compact() - .with_filter(env_filter()) - }; - - let make_file = || { - tracing_log_file().map(|writer| { - tracing_subscriber::fmt::layer() - .with_ansi(false) - .with_level(true) - .with_line_number(true) - .compact() - .with_writer(writer) - .with_filter(env_filter()) - }) + .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) - .with(make_file()) + let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default().with(stderr).with(system_log) }; #[cfg(target_os = "linux")] @@ -63,12 +41,8 @@ pub fn initialize() { None } }; - let stderr = - (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); - Registry::default() - .with(stderr) - .with(system_log) - .with(make_file()) + let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default().with(stderr).with(system_log) }; #[cfg(target_os = "macos")] @@ -80,20 +54,15 @@ pub fn initialize() { 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) - .with(make_file()) + 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())) - .with(make_file()); + let subscriber = Registry::default().with(Some(make_stderr())); #[cfg(feature = "tokio-console")] let subscriber = subscriber.with( @@ -111,50 +80,3 @@ pub fn initialize() { info!("Initialized logging") }); } - -fn env_filter() -> EnvFilter { - EnvFilter::try_from_env("BURROW_LOG") - .or_else(|_| EnvFilter::try_from_default_env()) - .unwrap_or_else(|_| EnvFilter::new("info")) -} - -fn tracing_log_file() -> Option { - let path = std::env::var_os("BURROW_LOG_FILE").map(PathBuf::from)?; - let file = match OpenOptions::new().create(true).append(true).open(&path) { - Ok(file) => file, - Err(err) => { - error!(path = %path.display(), error = %err, "failed to open Burrow log file"); - return None; - } - }; - Some(SharedLogWriter { - file: Arc::new(Mutex::new(file)), - }) -} - -#[derive(Clone)] -struct SharedLogWriter { - file: Arc>, -} - -struct SharedLogGuard { - file: Arc>, -} - -impl<'a> MakeWriter<'a> for SharedLogWriter { - type Writer = SharedLogGuard; - - fn make_writer(&'a self) -> Self::Writer { - SharedLogGuard { file: self.file.clone() } - } -} - -impl Write for SharedLogGuard { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.file.lock().unwrap().write(buf) - } - - fn flush(&mut self) -> io::Result<()> { - self.file.lock().unwrap().flush() - } -} diff --git a/contributors.nix b/contributors.nix index 60501d1..e13da91 100644 --- a/contributors.nix +++ b/contributors.nix @@ -2,6 +2,7 @@ groups = { users = "burrow-users"; admins = "burrow-admins"; + automation = "burrow-automation"; linear = { owners = "linear-owners"; admins = "linear-admins"; @@ -75,6 +76,30 @@ ]; }; + 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"; diff --git a/crates/burrow-core/Cargo.toml b/crates/burrow-core/Cargo.toml new file mode 100644 index 0000000..b6bc9fd --- /dev/null +++ b/crates/burrow-core/Cargo.toml @@ -0,0 +1,9 @@ +[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 new file mode 100644 index 0000000..6cf84dd --- /dev/null +++ b/crates/burrow-core/src/lib.rs @@ -0,0 +1,61 @@ +#![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 new file mode 100644 index 0000000..701c2b4 --- /dev/null +++ b/crates/burrow-mobile-ffi/Cargo.toml @@ -0,0 +1,13 @@ +[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 new file mode 100644 index 0000000..ff75ad8 --- /dev/null +++ b/crates/burrow-mobile-ffi/src/lib.rs @@ -0,0 +1,27 @@ +#![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 new file mode 100644 index 0000000..4ef859a --- /dev/null +++ b/crates/burrow-windows-stub/Cargo.toml @@ -0,0 +1,10 @@ +[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 new file mode 100644 index 0000000..fddc637 --- /dev/null +++ b/crates/burrow-windows-stub/src/main.rs @@ -0,0 +1,8 @@ +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/deploy/railway-sing-box/Dockerfile b/deploy/railway-sing-box/Dockerfile deleted file mode 100644 index 2b2c4cf..0000000 --- a/deploy/railway-sing-box/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM ghcr.io/sagernet/sing-box:v1.13.12 - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/deploy/railway-sing-box/README.md b/deploy/railway-sing-box/README.md deleted file mode 100644 index 64d4c87..0000000 --- a/deploy/railway-sing-box/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Railway sing-box Shadowsocks Test Node - -This is a minimal sing-box Shadowsocks server for testing Burrow against a real -internet-hosted node on Railway. - -Railway public networking exposes raw TCP through TCP Proxy. That is enough for -plain Shadowsocks TCP testing. It is not a full UDP validation environment, so -use Burrow's local self-tests or a VPS for native UDP testing. - -## Deploy - -1. Create a new Railway service from this repo. -2. Set the service root directory to: - - ```text - deploy/railway-sing-box - ``` - -3. Add Railway variables: - - ```text - SS_PASSWORD= - SS_METHOD=chacha20-ietf-poly1305 - SS_LISTEN_HOST=0.0.0.0 - SS_LISTEN_PORT=8388 - ``` - - `SS_METHOD`, `SS_LISTEN_HOST`, and `SS_LISTEN_PORT` are optional; those are - the defaults. - -4. Deploy the service. -5. In the service settings, create a TCP Proxy with internal port `8388`. -6. Railway will show an external proxy host and port, for example: - - ```text - shuttle.proxy.rlwy.net:15140 - ``` - -## Burrow Test URI - -Build the Shadowsocks URI with: - -```sh -uv run python - <<'PY' -import base64 -import urllib.parse - -method = "chacha20-ietf-poly1305" -password = "" -host = "" -port = "" -name = "railway-sing-box-ss" - -userinfo = base64.urlsafe_b64encode(f"{method}:{password}".encode()).decode().rstrip("=") -print(f"ss://{userinfo}@{host}:{port}#{urllib.parse.quote(name)}") -PY -``` - -Use the generated `ss://` URI in a Burrow proxy subscription or local test -payload. - -## Notes - -- Railway's TCP Proxy external port is assigned by Railway. Use that external - port in the client URI, not `8388`. -- This is intentionally a plain Shadowsocks node. Once plain TCP works, add - extra protocol features in separate test deployments so failures stay easy to - isolate. diff --git a/deploy/railway-sing-box/entrypoint.sh b/deploy/railway-sing-box/entrypoint.sh deleted file mode 100644 index 1ba2800..0000000 --- a/deploy/railway-sing-box/entrypoint.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/sh -set -eu - -: "${SS_PASSWORD:?Set SS_PASSWORD to a strong test password in Railway variables}" - -SS_METHOD="${SS_METHOD:-chacha20-ietf-poly1305}" -SS_LISTEN_HOST="${SS_LISTEN_HOST:-0.0.0.0}" -SS_LISTEN_PORT="${SS_LISTEN_PORT:-8388}" -LOG_LEVEL="${LOG_LEVEL:-info}" - -json_escape() { - printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' -} - -SS_METHOD_JSON="$(json_escape "$SS_METHOD")" -SS_PASSWORD_JSON="$(json_escape "$SS_PASSWORD")" -LOG_LEVEL_JSON="$(json_escape "$LOG_LEVEL")" -SS_LISTEN_HOST_JSON="$(json_escape "$SS_LISTEN_HOST")" - -mkdir -p /etc/sing-box -cat >/etc/sing-box/config.json </` 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/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 346f7e7..e36bbad 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 git@github.com:hackclub/burrow.git +git clone https://git.burrow.net/hackclub/burrow.git ``` 2. Open the `burrow` folder in Visual Studio Code: diff --git a/docs/GTK_APP.md b/docs/GTK_APP.md index 582b0a2..a22f8fc 100644 --- a/docs/GTK_APP.md +++ b/docs/GTK_APP.md @@ -1,7 +1,10 @@ # Linux GTK App Getting Started -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! +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. ## Dependencies diff --git a/docs/NIX_CACHE.md b/docs/NIX_CACHE.md new file mode 100644 index 0000000..96862c3 --- /dev/null +++ b/docs/NIX_CACHE.md @@ -0,0 +1,88 @@ +# 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 new file mode 100644 index 0000000..04a17b3 --- /dev/null +++ b/docs/OBSERVABILITY.md @@ -0,0 +1,60 @@ +# 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 new file mode 100644 index 0000000..b4d1304 --- /dev/null +++ b/docs/PLATFORM_SERVICES.md @@ -0,0 +1,99 @@ +# 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/trojan-mihomo-discrepancies.md b/docs/trojan-mihomo-discrepancies.md deleted file mode 100644 index 606fcbb..0000000 --- a/docs/trojan-mihomo-discrepancies.md +++ /dev/null @@ -1,601 +0,0 @@ -# Burrow vs Mihomo Trojan Implementation Discrepancies - -Date: 2026-05-31 - -Scope: code-path comparison between the local Burrow checkout at -`/Users/jettchen/dev/burrow` and the local Mihomo checkout at -`/Users/jettchen/dev/vpn-ref/mihomo`. This is not based on current live system -network state. - -## Executive Summary - -The most important differences for the stored Hong Kong Trojan node are: - -1. Mihomo uses `client-fingerprint: chrome` for Trojan URIs when no `fp` query is - present and switches to uTLS when that fingerprint is configured. Burrow - stores `client_fingerprint` but the Trojan runtime does not use it. -2. Mihomo distinguishes absent ALPN from explicitly empty ALPN. For Trojan TCP, - absent ALPN defaults to `["h2", "http/1.1"]`; explicitly empty ALPN remains - empty. Burrow now carries an `alpn_present` marker for new imports and treats - legacy missing metadata as absent. -3. Mihomo has a dedicated proxy-server DNS path (`proxy-server-nameserver` / - `ProxyServerHostResolver`). Burrow resolves proxy server names with the - process/system resolver through `ToSocketAddrs`. -4. Mihomo preserves and reconstructs destination hostname metadata in fake-IP - and mapping modes. Burrow's packet runtime receives `SocketAddr` values from - its userspace stack and sends IP-form SOCKS addresses to Trojan, not domain - names. -5. Mihomo supports Trojan `tcp`, `ws`, and `grpc` runtimes, plus additional TLS - features such as ECH, REALITY, pinned certificate fingerprint, and optional - Trojan-over-Shadowsocks wrapping. Burrow parses some of that metadata but the - packet runtime only supports Trojan TCP. -6. Mihomo's dial path has timeout, retry, dual-stack fallback, optional parallel - dialing, interface/routing-mark controls, DNS cache/fallback, and UDP - fragmentation behavior. Burrow now matches Trojan UDP fragmentation, but its - dial path remains a much narrower serial dialer plus a fixed packet bridge. - -Implementation note: - -- `BEP-0011` and the current runtime patch address ALPN absence/defaulting, - Trojan UDP import/runtime gating, Trojan UDP fragmentation, and certificate - fingerprint pinning. -- Burrow still does not implement Mihomo's uTLS/browser ClientHello - impersonation, ws/grpc Trojan transports, REALITY/ECH, proxy-server DNS plane, - fake-IP metadata, or policy/rule pipeline. - -## Stored Node Context - -Current Burrow database state inspected from the app group DB: - -```text -network type: ProxySubscription -subscription: bitz -selected ordinal: 2 -selected name: Hong Kong Guangdong BGP 2 -protocol: trojan -server: ce1081d2-df04-48d7-80bf-46b8c50b4f33.bnsepserv.com -port: 32445 -network: tcp -sni: pull-flv-q1-admin.douyincdn.com -skip_cert_verify: true -alpn: [] -client_fingerprint: chrome -fingerprint: null -``` - -Evidence: - -- Burrow stores Trojan node fields in `TrojanNode`: `password`, `sni`, `alpn`, - `alpn_present`, `skip_cert_verify`, `network`, `udp`, - `client_fingerprint`, and `fingerprint` - (`burrow/src/proxy_subscription.rs:82`). -- The runtime `TrojanOutbound` carries endpoint, password, SNI, ALPN, skip-cert, - UDP enablement, and parsed certificate fingerprint. It still does not apply - `client_fingerprint`. - -## 1. Subscription and Config Parsing - -### Supported subscription shapes - -Mihomo: - -- Parses individual proxies through a generic adapter parser - (`adapter/parser.go:11`). -- For `type: trojan`, decodes the full `TrojanOption` with the common structure - decoder and constructs `NewTrojan` (`adapter/parser.go:79`). -- Supports proxy providers with `file`, `http`, and `inline` vehicles - (`adapter/provider/parser.go:77`). -- Provider schemas include filter, exclude-filter, exclude-type, dialer-proxy, - size-limit, headers, override, payload, health-check, and refresh interval - (`adapter/provider/parser.go:28`). - -Burrow: - -- Parses either a top-level YAML `proxies` field or a plaintext/base64 URI list - (`burrow/src/proxy_subscription.rs:235`, `burrow/src/proxy_subscription.rs:274`, - `burrow/src/proxy_subscription.rs:312`). -- Does not parse Mihomo `proxy-providers`, `proxy-groups`, provider refresh - options, health-checks, overrides, or group selectors. -- For URI-list content, only `trojan://` and `ss://` are accepted; other schemes - become rejected entries (`burrow/src/proxy_subscription.rs:323`). - -Impact: - -- Burrow can import the selected node, but it does not preserve Mihomo's full - provider/group behavior. Selection is Burrow-owned, not Mihomo group semantics. - -### URI conversion - -Mihomo: - -- Trojan URI conversion maps fragment to name, host/port/user to server/port/ - password, `allowInsecure` to `skip-cert-verify`, optional `sni`, optional - `alpn`, optional `type`, ws/grpc options, `pcs` to certificate fingerprint, - and `fp` to `client-fingerprint` (`common/convert/converter.go:149`). -- The URI converter also sets `udp: true` for Trojan URI imports - (`common/convert/converter.go:162`). -- If `fp` is absent, Mihomo sets `client-fingerprint` to `chrome` - (`common/convert/converter.go:198`). - -Burrow: - -- Maps similar basics and also defaults missing `fp` to `chrome` - (`burrow/src/proxy_subscription.rs:491`). -- Stores `pcs` as `fingerprint` (`burrow/src/proxy_subscription.rs:517`). -- Stores ALPN with an `alpn_present` marker, so new imports can distinguish - missing ALPN from explicit empty ALPN (`burrow/src/proxy_subscription.rs:83`). - -Discrepancy: - -- Mihomo can distinguish "ALPN field absent" from "ALPN field present but empty" - at the decoded option/runtime layer because the option slice can be nil or - non-nil. Burrow now records that distinction for new imports. -- Mihomo URI conversion only sets `alpn` when the URI query value is non-empty; - this is still different from Burrow because Burrow imports absent URI ALPN as - a concrete empty vector that the runtime later treats as intentional. -- Mihomo carries Trojan's `udp` option through the base outbound model. Burrow - now stores the Trojan `udp` field and gates Trojan UDP sessions on it. - -## 2. Runtime Transport Support - -Mihomo: - -- `TrojanOption.Network` supports default TCP, `ws`, and `grpc` - (`adapter/outbound/trojan.go:51`). -- Runtime branches: - - `ws`: wraps the TCP connection in websocket over TLS - (`adapter/outbound/trojan.go:67`). - - `grpc`: uses gun transport and a gRPC client pool - (`adapter/outbound/trojan.go:175`, `adapter/outbound/trojan.go:298`). - - default TCP: TLS, then Trojan header (`adapter/outbound/trojan.go:119`). -- Trojan options also include `ECHOpts`, `RealityOpts`, `SSOpts`, and - `ClientFingerprint` (`adapter/outbound/trojan.go:52`). -- If `ss-opts.enabled` is set, Mihomo wraps Trojan streams in Shadowsocks using - the configured method/password, defaulting the method to `AES-128-GCM` - (`adapter/outbound/trojan.go:284`). - -Burrow: - -- Parser represents `Tcp`, `Ws`, and `Grpc` (`burrow/src/proxy_subscription.rs:93`). -- Runtime rejects any Trojan network except `Tcp` - (`burrow/src/proxy_runtime.rs:553`). -- Runtime preview can mark unsupported transports via `runtime_support_error` - (`burrow/src/proxy_runtime.rs:279`). - -Discrepancy: - -- Burrow parses ws/grpc metadata but cannot run ws/grpc Trojan nodes. -- Mihomo can run ws and grpc Trojan nodes. -- Burrow has no Trojan-over-Shadowsocks wrapper equivalent for Mihomo - `ss-opts`. -- Burrow has no Trojan REALITY or ECH runtime support. - -## 3. ALPN Behavior - -Mihomo: - -- Trojan TCP default ALPN is `["h2", "http/1.1"]` - (`transport/trojan/trojan.go:23`). -- For default TCP, Mihomo starts with that default, then overrides only if - `option.ALPN != nil` (`adapter/outbound/trojan.go:122`). -- For websocket, default ALPN is `["http/1.1"]` and is overridden only if - `option.ALPN != nil` (`adapter/outbound/trojan.go:95`). -- For grpc, TLS `NextProtos` is fixed to `["h2"]` - (`adapter/outbound/trojan.go:307`). - -Burrow: - -- Runtime ALPN uses Mihomo's TCP default when `alpn_present` is false, and uses - `TrojanNode.alpn` exactly when `alpn_present` is true - (`burrow/src/proxy_runtime.rs:695`). -- TLS config writes those strings directly into rustls `alpn_protocols` - (`burrow/src/proxy_runtime.rs:820`). -- Parser stores missing ALPN as `alpn_present: false`; legacy stored nodes that - lack `alpn_present` deserialize the same way. - -Discrepancy: - -- For a Trojan URI that omits `alpn`, Mihomo sends `h2,http/1.1`; Burrow now - applies the same default through the presence marker. -- Explicit empty ALPN is possible through Mihomo's decoded YAML/options path, - but not through its URI converter because empty URI `alpn` is not emitted. - -Likely relevance: - -- The selected node has `alpn: []` in Burrow. If the subscription omitted ALPN, - Mihomo would use the default ALPN list, while Burrow now cannot know that and - sends none. - -## 4. Client Fingerprint and uTLS - -Mihomo: - -- Trojan options include `ClientFingerprint` (`adapter/outbound/trojan.go:57`). -- URI conversion defaults missing `fp` to `chrome` - (`common/convert/converter.go:198`). -- TLS connection code checks `GetFingerprint`; when recognized, it converts the - TLS config to a uTLS config and creates a uTLS client - (`transport/vmess/tls.go:46`). -- uTLS preserves `NextProtos`, `ServerName`, cert settings, and version bounds - from the config (`component/tls/utls.go:175`). -- `GetFingerprint` falls back to a global fingerprint if the node omits one, - returns no uTLS for empty/`none`, supports `random`, and recognizes browser - profiles such as `chrome`, `firefox`, `safari`, `ios`, `android`, `edge`, - `360`, and `qq` (`component/tls/utls.go:42`, `component/tls/utls.go:65`, - `component/tls/utls.go:81`). -- For websocket, Mihomo forces the uTLS ALPN extension to `http/1.1` - (`component/tls/utls.go:258`). - -Burrow: - -- Parser stores `client_fingerprint` (`burrow/src/proxy_subscription.rs:89`). -- Runtime logs it when present, but does not use it to build a uTLS ClientHello. -- Runtime uses OpenSSL on macOS and rustls/tokio-rustls elsewhere, not Mihomo's - uTLS implementation. - -Discrepancy: - -- Mihomo sends a Chrome-like TLS ClientHello for this selected node - (`client_fingerprint: chrome`). -- Burrow sends the platform TLS stack's ClientHello. - -Likely relevance: - -- Many Trojan subscriptions use `fp=chrome` or Mihomo's default `chrome` because - the server or fronting path expects that TLS fingerprint. Burrow ignoring it is - a high-probability compatibility gap. - -## 5. Certificate Verification and Pinning - -Mihomo: - -- Trojan options include `SkipCertVerify`, `Fingerprint`, `Certificate`, and - `PrivateKey` (`adapter/outbound/trojan.go:46`). -- TLS setup passes those into `ca.GetTLSConfig` - (`adapter/outbound/trojan.go:101`, `transport/vmess/tls.go:27`). -- URI conversion maps `pcs` to `fingerprint` - (`common/convert/converter.go:204`). -- Mihomo treats `fingerprint` as SHA-256 certificate pinning. Browser names such - as `chrome` are rejected there with an instruction to use - `client-fingerprint` instead (`component/ca/fingerprint.go:13`). -- A pinned fingerprint may match any certificate in the chain; if it matches a - non-leaf certificate, Mihomo verifies the leaf against that pinned certificate - as a trusted root (`component/ca/fingerprint.go:29`). -- `GetTLSConfig` installs `VerifyConnection` and sets `InsecureSkipVerify` when - pinning is enabled, so Go's default verifier is bypassed in favor of the pin - verifier (`component/ca/config.go:100`). -- Mihomo supports TLS client certificates through `certificate`/`private-key`, - including inline material, safe file paths, file watching, or generated random - key material when both are empty (`component/ca/config.go:114`, - `component/ca/keypair.go:25`). - -Burrow: - -- Parser stores `fingerprint` (`burrow/src/proxy_subscription.rs:90`). -- Runtime parses `fingerprint` as a SHA-256 certificate pin and checks the - presented certificate chain against it (`burrow/src/proxy_runtime.rs:865`). -- Runtime root store is webpki roots; `skip_cert_verify` installs a verifier - that accepts the certificate/signature checks (`burrow/src/proxy_runtime.rs:820`, - `burrow/src/proxy_runtime.rs:835`). - -Discrepancy: - -- Mihomo supports pinned certificate fingerprint and custom certificate material. -- Burrow now supports SHA-256 certificate fingerprint pinning, normal root - validation, or all-cert skip verification. -- Burrow has no client certificate fields for Trojan. - -## 6. SNI Behavior - -Mihomo: - -- `NewTrojan` defaults empty SNI to the server host - (`adapter/outbound/trojan.go:251`). -- Trojan URI conversion sets SNI only when the `sni` query is present - (`common/convert/converter.go:168`). -- TLS config uses `option.SNI` as host/server name - (`adapter/outbound/trojan.go:126`). - -Burrow: - -- Parser accepts URI `sni`, `peer`, or `host` as SNI - (`burrow/src/proxy_subscription.rs:505`). -- Runtime defaults SNI to node server if absent - (`burrow/src/proxy_runtime.rs:561`). - -Discrepancy: - -- Burrow accepts more SNI aliases in URI parsing than Mihomo's converter path. -- Runtime defaulting is effectively aligned for TCP. - -## 7. Trojan Header and Password Hash - -Mihomo: - -- Password is converted with `trojan.Key` (`adapter/outbound/trojan.go:269`). -- Trojan header writes hex password, CRLF, command byte, SOCKS address, CRLF - (`transport/trojan/trojan.go:39`). -- TCP command is `1`, UDP command is `3` - (`transport/trojan/trojan.go:31`). - -Burrow: - -- Password hash is SHA-224 hex (`burrow/src/proxy_runtime.rs:903`). -- Header writes hash, CRLF, command byte, SOCKS address, CRLF as a single - coalesced buffer, matching Mihomo's one-write `trojan.WriteHeader` behavior. -- TCP uses command `0x01`; UDP uses `0x03` - (`burrow/src/proxy_runtime.rs:583`, `burrow/src/proxy_runtime.rs:604`). - -Discrepancy: - -- No remaining framing discrepancy for basic TCP/UDP header shape. A live - provider check showed that splitting the Trojan TCP header across multiple TLS - writes could produce EOF before any target response bytes; Burrow now - coalesces the TCP header before writing it. - -## 8. Destination Address and Hostname Metadata - -Mihomo: - -- `serializesSocksAddr` writes a domain-form SOCKS address when - `metadata.AddrType()` is domain (`adapter/outbound/util.go:15`). -- Fake-IP and mapping preprocessing can translate a destination fake IP back to - hostname, clear `DstIP`, and mark `DNSFakeIP` - (`tunnel/tunnel.go:287`). -- If fake-IP metadata is missing, Mihomo may sniff TCP to recover a domain - (`tunnel/tunnel.go:511`). - -Burrow: - -- Netstack TCP accept yields `remote_addr: SocketAddr`; Burrow now checks its - daemon-owned fake-IP DNS cache and writes a domain-form SOCKS address when the - accepted destination IP maps back to a hostname. It falls back to IP-form when - no hostname is known. -- UDP sessions key on local/remote `SocketAddr`. DNS queries to Burrow's - daemon-owned resolver are answered locally before they become proxy UDP flows. - Other UDP flows still use socket-address metadata. -- Burrow can parse a domain-form SOCKS address in Trojan UDP responses, but it - resolves that domain immediately through the process/system resolver and - returns a `SocketAddr` (`burrow/src/proxy_runtime.rs:1187`). -- Burrow has a basic fake-IP map for A queries answered by the daemon resolver, - but no TCP sniffer or rules engine. - -Discrepancy: - -- Mihomo can preserve domain targets through fake-IP/mapping paths. -- Burrow now preserves DNS-backed domain targets for TCP, but only for domains - resolved through its daemon-owned A-query fake DNS path. - -Compatibility impact: - -- For ordinary HTTPS this may still work because the application supplies TLS - SNI to the target site. It breaks or weakens Mihomo-style rule selection, - fake-IP DNS, and any proxy behavior that needs the destination hostname at the - outbound layer. - -## 9. Proxy Server DNS Resolution - -Mihomo: - -- The generic dialer resolves proxy server hostnames through - `resolver.ProxyServerHostResolver` by default (`component/dialer/dialer.go:358`). -- `ProxyServerHostResolver` is wired from `proxy-server-nameserver` when present, - otherwise from the normal resolver (`hub/executor/executor.go:290`). -- Resolver lookup checks hosts first, IP literals second, then configured - resolver or system resolver fallback (`component/resolver/resolver.go:154`). -- DNS exchange uses cache, singleflight, retry monitor, main/fallback - nameservers, and policy routing (`dns/resolver.go:150`, - `dns/resolver.go:220`, `dns/resolver.go:298`). -- Resolver caches are TTL based, can serve stale records with background refresh, - and use a default max size of 4096 (`dns/resolver.go:150`, - `dns/resolver.go:452`, `dns/util.go:48`). -- The system resolver refreshes `/etc/resolv.conf` periodically and can fall - back to public DNS servers if no system DNS clients are available - (`dns/system_common.go:15`, `dns/system.go:68`). - -Burrow: - -- Proxy endpoint resolution starts with `(host, port).to_socket_addrs()` via the - process/system resolver. -- The current workspace caches resolved proxy server addresses in - `ProxyServerEndpoint` when the outbound is configured - (`burrow/src/proxy_runtime.rs:1263`). -- If system DNS returns only `198.18.0.0/15` fake-IP addresses, Burrow falls - back to direct DNS using physical/public DNS servers and excludes the resolved - real proxy-server host routes from the tunnel. -- There is no user-configurable proxy-server resolver equivalent. -- There is no DNS TTL refresh or resolver cache for proxy server hosts beyond - the current cached address list. - -Discrepancy: - -- Mihomo has a first-class proxy-server DNS plane; Burrow does not. -- Mihomo has richer policy and cache controls for proxy-server DNS. Burrow now - has a narrower fake-IP fallback that avoids recursive proxy-server dials for - observed `198.18.0.0/15` answers. - -## 10. Dialing Strategy, Interface Binding, and Socket Options - -Mihomo: - -- Dialer parses all resolved IPs, normalizes IPv4-in-IPv6, and supports IPv4/ - IPv6 preference (`component/dialer/dialer.go:358`). -- It can dial serially, in dual-stack fallback mode, or in parallel depending on - `tcpConcurrent` (`component/dialer/dialer.go:58`, - `component/dialer/dialer.go:207`). -- Dialer applies keepalive and MPTCP settings (`component/dialer/dialer.go:139`). -- Dialer supports explicit `interface-name`, default interface, interface - finder, routing mark, TFO, MPTCP, and custom net dialers - (`component/dialer/dialer.go:143`, `adapter/outbound/base.go:145`). -- The dialer option model includes `interfaceName`, `fallbackBind`, - `routingMark`, preferred IP family, TFO, MPTCP, resolver, and custom net - dialer (`component/dialer/options.go:33`). -- Darwin interface binding uses `IP_BOUND_IF` / `IPV6_BOUND_IF` - (`component/dialer/bind_darwin.go:14`). -- Linux interface binding uses bind-to-device, and Linux routing marks use - `SO_MARK` (`component/dialer/bind_linux.go:12`, - `component/dialer/mark_linux.go:20`). -- Mihomo wraps outbound connection creation in a 5 second context and a retry - loop with up to 10 attempts, stopping early for non-retryable DNS/reject - errors (`tunnel/tunnel.go:560`, `tunnel/tunnel.go:702`). -- The dialer has a 300 ms dual-stack fallback window and optional parallel dial - mode (`component/dialer/dialer.go:25`, `component/dialer/dialer.go:58`, - `component/dialer/dialer.go:207`). - -Burrow: - -- Dials cached addresses sequentially (`burrow/src/proxy_runtime.rs:1294`). -- No explicit TCP connect timeout wrapper is applied in this path. -- No TFO, MPTCP, routing mark, custom dialer, or configurable interface name. -- On Apple, Burrow chooses a default physical interface by scanning interfaces, - preferring `en*` and skipping `utun`, loopback, bridge, awdl, etc. - (`burrow/src/proxy_runtime.rs:1501`). -- Burrow skips physical-interface binding for loopback proxy-server addresses so - controlled local proxy runtime tests can use `127.0.0.1`. -- On non-Apple, `bind_proxy_outbound_socket` is a no-op - (`burrow/src/proxy_runtime.rs:1495`). - -Discrepancy: - -- Mihomo has a mature, configurable dialer. Burrow has a narrow Apple-focused - physical-interface bind path and a sequential dial loop. -- Burrow has no high-level per-flow retry loop comparable to Mihomo's tunnel - retry. - -## 11. UDP Behavior - -Mihomo: - -- Trojan supports UDP through `ListenPacketContext` - (`adapter/outbound/trojan.go:202`). -- UDP exposure is gated by the node's `udp` option through `Base.SupportUDP` - (`adapter/outbound/trojan.go:50`, `adapter/outbound/base.go:105`). -- Before UDP, it resolves destination metadata if needed - (`adapter/outbound/trojan.go:203`, `adapter/outbound/base.go:178`). -- `SupportUOT` returns true (`adapter/outbound/trojan.go:225`). -- UDP packet handling maintains mappings from origin metadata to target address - (`tunnel/connection.go:70`). -- Trojan UDP packet writes fragment payloads larger than 8192 bytes into - multiple Trojan UDP frames (`transport/trojan/trojan.go:54`, - `transport/trojan/trojan.go:66`). - -Burrow: - -- UDP is enabled in the userspace netstack (`burrow/src/proxy_runtime.rs:379`). -- Burrow's import/runtime model has no Trojan `udp` enable flag, so the runtime - does not mirror Mihomo's per-node UDP gating. -- UDP sessions are keyed by local/remote socket address and use a channel per - flow (`burrow/src/proxy_runtime.rs:516`). -- Trojan UDP opens a TLS connection per UDP flow, writes a Trojan UDP header, - and relays Trojan UDP packets over that stream - (`burrow/src/proxy_runtime.rs:598`). -- UDP flow idle timeout is 30 seconds (`burrow/src/proxy_runtime.rs:45`). -- No UOT support is exposed. -- Burrow now fragments single Trojan UDP payloads above 8192 bytes into multiple - Trojan UDP frames (`burrow/src/proxy_runtime.rs:1050`). -- Burrow now carries a Trojan `udp` enable/disable flag. - -Discrepancy: - -- Mihomo has metadata-aware UDP resolution/mapping and UOT support. Burrow has - direct per-flow Trojan UDP over TLS without hostname metadata. -- Mihomo and Burrow both fragment oversized Trojan UDP payloads after - `BEP-0011`. - -## 12. Packet Tunnel and DNS Model - -Mihomo: - -- TUN mode has metadata preprocessing, fake-IP reverse mapping, sniffing, rule - resolution, and then proxy dial (`tunnel/tunnel.go:287`, - `tunnel/tunnel.go:540`). -- DNS service and resolver enhancer support fake-IP/mapping modes - (`dns/service.go:27`, `dns/enhancer.go:23`). - -Burrow: - -- Apple Network Extension forwards packets to a daemon packet stream and writes - daemon responses back to `packetFlow` (`Apple/NetworkExtension/PacketTunnelProvider.swift:91`). -- Burrow's proxy tunnel settings use virtual addresses and point DNS at the - daemon-owned resolver address `100.64.0.2`. -- The daemon answers A queries with stable fake IPs and records fake-IP to - hostname mappings for later TCP proxy requests. There is still no rules engine - or TCP sniffer in the packet runtime. - -Discrepancy: - -- Mihomo is a policy-aware packet tunnel with DNS hijack/fake-IP metadata. -- Burrow is a simpler packet-to-selected-outbound bridge with daemon-owned fake - DNS for A queries. It does not yet implement Mihomo's policy routing, - sniffing, resolver modes, or per-rule metadata pipeline. - -## 13. Logging and Observability - -Mihomo: - -- DNS cache hits, DNS resolution, metadata preprocessing failures, process lookup - failures, and UDP resolution failures are logged in the relevant paths - (`dns/resolver.go:170`, `tunnel/tunnel.go:339`, `tunnel/tunnel.go:506`, - `tunnel/connection.go:88`). - -Burrow: - -- Runtime now logs selected node, configured Trojan outbound metadata, resolved - proxy addresses, fake-IP/benchmark-range warning, and socket bind/connect - failures (`burrow/src/proxy_runtime.rs:215`, - `burrow/src/proxy_runtime.rs:552`, `burrow/src/proxy_runtime.rs:1263`, - `burrow/src/proxy_runtime.rs:1294`). -- `Scripts/burrow-proxy-selftest` provides a controlled daemon packet-runtime - test against a local Trojan server, and `proxy-tcp-probe --dns-name` exercises - daemon DNS plus raw TCP packet streaming without changing system proxy state. -- On macOS, Rust tracing goes to stderr by default; OSLog is opt-in via - `BURROW_ENABLE_OSLOG=1` (`burrow/src/tracing.rs:50`). - -Discrepancy: - -- Mihomo logs more of the full metadata/rule/DNS pipeline. -- Burrow now logs the outbound path and has standalone packet-runtime probes, - but still lacks Mihomo's full metadata/rule diagnostics. - -## 14. Likely Compatibility Gaps for the Selected Node - -Highest probability: - -1. `client_fingerprint: chrome` is stored but not implemented by Burrow runtime. - Mihomo would use uTLS Chrome, so this remains a compatibility gap for - providers that strictly require browser ClientHello impersonation. -2. The selected provider node succeeds through Mihomo when Mihomo is configured - from Burrow's stored DB payload, pinned to real proxy-server IPs, and bound to - `en0`. Burrow's daemon packet probe now also succeeds for `google.com:80` - after macOS OpenSSL TLS and coalesced Trojan header writes. -3. Older stored nodes may have `alpn: []` from pre-`BEP-0011` imports. Burrow now - treats missing `alpn_present` as absent ALPN and applies Mihomo's TCP default. -4. Burrow's proxy-server resolver is narrower than Mihomo's configurable - proxy-server DNS plane. - -Medium probability: - -5. Burrow only preserves domain metadata for TCP flows that went through its - daemon-owned A-query fake DNS path. -6. Burrow has no Mihomo-style sniffing or rule metadata. This is - less likely to break simple all-traffic proxying, but it is a major semantic - difference. - -Lower probability for this exact selected node: - -7. ws/grpc transport is unsupported by Burrow runtime. The selected node is TCP, - so this is not the immediate failure for that node. -8. Certificate pinning is now implemented by Burrow. The selected node has no - stored pinned fingerprint, so this does not explain that exact node. - -## Suggested Burrow Follow-Ups - -1. Implement `client_fingerprint` for Trojan, at least `chrome`, or reject nodes - requiring runtime fingerprints until uTLS-style support exists. -2. Expand the proxy-server resolver path toward Mihomo's configurable - `proxy-server-nameserver` behavior and TTL/cache controls. -3. Add sniffing or broader DNS metadata handling for flows that do not use the - daemon-owned A-query fake DNS path. -4. Either implement Trojan ws/grpc or make unsupported transport status more - prominent when importing/selecting nodes. -5. Add Trojan client certificate/private-key support if subscriptions need it. diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md index 93c2a26..a34a609 100644 --- a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md +++ b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md @@ -31,11 +31,6 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - login/session lifecycle brokering - runtime start/stop/reconcile - translating helper or bridge processes into stable daemon RPCs -- Apple uses two daemon sockets with one shared app-group database: - - the app-facing control socket, `burrow.sock`, is for UI management RPCs such as network list, import, preview, refresh, node selection, account/login control, and other non-packet workflows - - the packet-tunnel socket, `burrow-packet-tunnel.sock`, is opened by `PacketTunnelProvider` and owns active tunnel runtime RPCs such as `TunnelStart`, `TunnelConfiguration`, `TunnelPackets`, and `TunnelStop` -- The Network Extension must host or connect to the packet-tunnel daemon socket while a VPN session is active. The app must not hot-swap packet runtime state underneath an already-running Apple `NEPacketTunnelProvider`. -- UI-side network mutations may persist desired state through the control daemon. If those mutations affect active packet-tunnel settings or runtime, the Apple client must restart or otherwise reassert the Network Extension so settings and packet runtime are installed from the same selected network state. - `burrow/src/control/` owns transport-neutral control-plane semantics such as discovery, authority normalization, and request/response shaping. - Apple UI owns presentation only: - forms @@ -48,7 +43,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - 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: the app owns the control daemon used for presentation workflows, and the Network Extension owns the packet daemon used for active tunnel runtime. Both use the app-group database as the durable desired-state store. +- 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 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 new file mode 100644 index 0000000..4d8f5c9 --- /dev/null +++ b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md @@ -0,0 +1,374 @@ +# `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-0009-trojan-proxy-and-subscription-import.md b/evolution/proposals/BEP-0009-trojan-proxy-and-subscription-import.md deleted file mode 100644 index c7906ad..0000000 --- a/evolution/proposals/BEP-0009-trojan-proxy-and-subscription-import.md +++ /dev/null @@ -1,131 +0,0 @@ -# `BEP-0009` - Trojan Proxy and Subscription Import - -```text -Status: Superseded -Proposal: BEP-0009 -Authors: gpt-5.5 -Coordinator: gpt-5.5 -Reviewers: Pending -Constitution Sections: II, IV, V -Implementation PRs: Pending -Decision Date: 2026-05-27 -``` - -## Summary - -Superseded by `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md`. - -Burrow should add daemon-owned Trojan proxy support as a proxy egress transport, plus a subscription importer that can parse base64 URI subscriptions containing `trojan://` nodes. This proposal covers Trojan profile ingestion, validation, storage, runtime boundaries, and user-facing import behavior without claiming broader Clash or Mihomo protocol parity. - -Trojan support should be implemented as an explicit Burrow protocol family with tested URI parsing and daemon-managed runtime behavior. Apple clients may present imported profiles and statuses, but they must continue to talk only to the daemon over gRPC. - -## Motivation - -- Real-world proxy subscriptions commonly expose Trojan nodes as base64-encoded lists of `trojan://` URIs rather than static Clash YAML. -- Users should be able to import a subscription URL and let Burrow identify compatible nodes without leaking tokens into logs, source files, or UI copy. -- Trojan is different from WireGuard, Tailnet, and future MASQUE `CONNECT-IP`; it needs an explicit profile model, TLS validation policy, and proxy egress runtime. -- Supporting one proxy protocol first gives Burrow a concrete path for future proxy families without adding an unbounded "Clash compatible" claim. - -## Detailed Design - -- Add a new daemon-owned network/profile type for Trojan proxy egress. - - The stable code/protocol identifier should use `trojan`. - - User-facing copy should spell the project name as `Burrow` and should name the protocol as `Trojan`. - - The profile type should be distinct from `WireGuard` and `Tailnet`. -- Define a `TrojanProfile` payload with at least: - - display name - - server host - - server port - - password or credential reference - - TLS server name indication - - optional peer/host metadata when present in imported subscriptions - - transport, initially restricted to `tcp` - - certificate verification policy - - source metadata such as imported subscription name and refresh timestamp -- Add a subscription import flow owned by the daemon. - - Fetch subscription URLs in daemon code, not Swift UI. - - Redact query tokens and credentials from logs and errors. - - Accept base64 URI subscriptions whose decoded body is a newline-separated URI list. - - Parse `trojan://` URIs and reject unknown schemes unless future proposals add them. - - Preserve node labels from URI fragments as display names after normalization. - - Treat `allowInsecure=1`, `skip-cert-verify=true`, or equivalent flags as an explicit insecure TLS policy. - - Deduplicate imported nodes by stable profile fields rather than display label alone. -- Add daemon gRPC methods before any Apple UI work. - - A minimal shape is `SubscriptionImportPreview`, `SubscriptionImportApply`, and `SubscriptionRefresh`. - - Preview should return compatible profile counts, rejected entry counts, redacted warnings, and normalized display labels. - - Apply should write only selected compatible profiles. - - Refresh should preserve local enable/order choices where possible. -- Add runtime support in stages. - - Stage 1: parser, preview, storage, and tests only. - - Stage 2: daemon-managed Trojan outbound connector for TCP proxy egress. - - Evaluate `trojan-rust/trojan-rust` as the first implementation dependency for this stage. - - Prefer the narrowest usable crate, likely `trojan-proto` for protocol parsing/serialization or `trojan-client` for a SOCKS5-backed client runtime. - - Avoid depending on the top-level `trojan` crate with default features unless Burrow needs the bundled CLI, certificate, SQL, or agent features. - - Wrap any external runtime API behind a Burrow-owned adapter so profile storage, logging, kill-switch behavior, and daemon lifecycle remain local. - - Stage 3: route/DNS integration with Burrow's tunnel and proxy selection model. - - Stage 4: UI controls for import, refresh, certificate policy warnings, and per-profile status. -- Keep the runtime boundary explicit. - - Apple UI may display forms, previews, warning states, and daemon statuses. - - Apple UI must not fetch subscription URLs directly. - - Apple UI must not open Trojan sockets directly. - - Any helper process must be brokered and supervised by the daemon. - -## Security and Operational Considerations - -- Subscription URLs often contain bearer tokens. Burrow must treat full subscription URLs as secrets. -- Imported Trojan URIs contain passwords. Burrow must avoid writing decoded raw subscriptions to logs, crash reports, test snapshots, or telemetry. -- Certificate verification defaults should be secure. Insecure imported nodes may be stored only with an explicit warning field and UI-visible risk state. -- Passwords should move into the same secret-storage path Burrow uses for other long-lived credentials instead of remaining in plain payload blobs. -- The importer must set fetch limits for response size, redirect depth, and timeout. -- The parser must be independent and fuzzable enough to handle malformed URI lists without panics. -- Runtime support should include a kill-switch path so disabling a profile tears down active sockets and route state. -- Burrow must not present Trojan as a VPN with the same security or routing semantics as WireGuard. It is a proxy egress transport unless later proposals define full-device routing behavior. - -## Contributor Playbook - -1. Add parser-only fixtures for representative `trojan://` URIs, including TLS SNI, host, peer, TCP transport, fragments, duplicate labels, malformed ports, unsupported schemes, and insecure certificate flags. -2. Add a redacted subscription fixture that mirrors a real base64 URI-list response without preserving live tokens, passwords, or hostnames that identify a paid account. -3. Extend `proto/burrow.proto` with daemon-owned subscription preview/apply/refresh RPCs before adding Apple UI. -4. Add daemon storage for `trojan` profiles and migrate old payload storage only if the profile model requires it. -5. Verify generated Swift and Rust bindings compile after proto changes. -6. Implement runtime behind a feature flag or internal capability gate until end-to-end proxy egress tests exist. -7. Run: - -```bash -cargo test --workspace --all-features -python3 Scripts/check-bep-metadata.py -``` - -8. For Apple work, verify no Swift UI or support code fetches subscription URLs or talks to Trojan endpoints directly. - -## Alternatives Considered - -- Import full Clash or Mihomo YAML first. Rejected for this proposal because it would imply a broad compatibility contract across many protocols before Burrow has a proxy abstraction. -- Shell out to Mihomo as a helper. Rejected as the default path because it would create a large external runtime dependency and make Burrow's protocol guarantees harder to test. A future proposal may revisit helper-based compatibility as an explicit adapter. -- Vendor or reimplement the Trojan wire protocol immediately. Rejected as the default path while `trojan-rust/trojan-rust` appears to provide GPL-compatible Rust crates for protocol parsing and client behavior. Burrow should still keep an adapter boundary so it can replace the dependency if maintenance, API, or security review fails. -- Treat Trojan nodes as WireGuard-like networks. Rejected because Trojan is proxy egress over TLS, not a packet VPN with WireGuard peer semantics. -- Let Apple UI fetch and parse subscriptions for convenience. Rejected because it violates the daemon IPC boundary and spreads subscription tokens into UI code. - -## Impact on Other Work - -- Depends on the daemon boundary in BEP-0005. -- Should align with the transport-neutral route and policy work described in BEP-0003, but should not block parser-only subscription import. -- Creates a path for future Shadowsocks or VLESS proposals by forcing shared subscription parsing and redaction rules. -- May require a follow-up secret-storage proposal if current network payload storage cannot safely hold proxy credentials. - -## Decision - -Superseded by `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md` on 2026-05-27. - -## References - -- Trojan protocol: https://trojan-gfw.github.io/trojan/protocol.html -- Trojan config: https://trojan-gfw.github.io/trojan/config.html -- Trojan reference implementation: https://github.com/trojan-gfw/trojan -- Rust Trojan implementation candidate: https://github.com/trojan-rust/trojan-rust -- Rust `trojan-client` crate: https://crates.io/crates/trojan-client -- Rust `trojan-proto` crate: https://crates.io/crates/trojan-proto -- Mihomo Trojan proxy configuration: https://wiki.metacubex.one/en/config/proxies/trojan/ -- BEP-0003: `evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md` -- BEP-0005: `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md` -- Burrow protocol schema: `proto/burrow.proto` diff --git a/evolution/proposals/BEP-0010-platform-services-core-and-distribution.md b/evolution/proposals/BEP-0010-platform-services-core-and-distribution.md new file mode 100644 index 0000000..211554a --- /dev/null +++ b/evolution/proposals/BEP-0010-platform-services-core-and-distribution.md @@ -0,0 +1,332 @@ +# `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-0010-proxy-subscriptions-as-packet-tunnel-networks.md b/evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md deleted file mode 100644 index 48e8389..0000000 --- a/evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md +++ /dev/null @@ -1,271 +0,0 @@ -# `BEP-0010` - Proxy Subscriptions as Packet Tunnel Networks - -```text -Status: Draft -Proposal: BEP-0010 -Authors: Codex -Coordinator: Codex -Reviewers: Pending -Constitution Sections: II, IV, V -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow should support Trojan and Shadowsocks subscription imports as packet-tunnel networks, not as local SOCKS or system proxy mode. The user-facing add action should be named `Import Proxy Subscription` on every UI surface. Imported proxy subscriptions should participate in Burrow's existing tunnel model: platform tunnel capture feeds daemon-owned routing and proxy execution, and a selected proxy outbound carries TCP and UDP flows. - -This proposal supersedes `BEP-0009`. The earlier SOCKS-backed Trojan runtime was useful as a smoke-test path, but Burrow's product semantics are VPN-shaped. Keeping a local SOCKS mode would add another behavior surface, confuse how proxy networks relate to WireGuard and Tailnet, and delay the packet-tunnel work Burrow actually needs. - -## Motivation - -- Users expect the main Burrow tunnel switch to affect full-device traffic, as it does for packet-shaped WireGuard and Tailnet integrations. -- Trojan and Shadowsocks subscriptions are commonly distributed as Clash/Mihomo YAML, proxy-provider content, plain URI lists, or base64 URI lists. Burrow's current Trojan parser only covers a narrow `trojan://` URI-list subset. -- Mihomo's TUN mode is the closest reference behavior: it captures packets, runs a userspace IP stack, hijacks DNS, resolves rules and proxy selections, then dials protocol-specific outbounds. -- Burrow should avoid a second user-facing "proxy mode" based on `HTTP_PROXY`, SOCKS settings, or a loopback listener because that is not equivalent to VPN behavior and creates platform-specific state outside Burrow's tunnel contract. -- Subscription URLs and proxy node credentials are secret-bearing. Fetching, parsing, storing, refreshing, and redacting them belong in the daemon boundary described by `BEP-0005`. - -## Detailed Design - -### User Model - -- Add a UI action named `Import Proxy Subscription`. -- Imported subscriptions are stored as `Network` records because they represent routeable connectivity sources. -- Proxy nodes inside a subscription are selectable endpoints within that network. -- Node selection should mirror Mihomo selector behavior in `adapter/outboundgroup/selector.go`: persist an explicit selected node when the user chooses one, validate that it exists, and fall back to the first runtime-supported node only when no explicit supported selection is available. -- Proxy subscriptions do not create `Account` records by default. Accounts remain for identity and sign-in state such as Tailnet login or future provider identity flows. -- Apple SwiftUI, Linux GTK, and future UI surfaces should expose the same import, preview, warning, selection, refresh, and node status behavior over the daemon API. -- UI clients may show subscription name, refresh state, selected node, node health, warnings, and import preview results. They must not fetch subscription URLs or open Trojan/Shadowsocks sockets directly. -- Import preview UIs must expose the runtime-supported Trojan and Shadowsocks nodes as a selectable list and pass the selected ordinal to the daemon `ApplyImport` request. Parsed-but-unsupported nodes should remain visible as warnings or counts, not selectable runtime choices. - -### Removed SOCKS Mode - -- Remove the daemon runtime path that starts a stored Trojan profile as a local SOCKS5 proxy. -- Remove CLI and smoke entrypoints whose primary purpose is operating Burrow as a SOCKS proxy. -- Keep parser tests and sanitized live-comparison tooling only when they validate subscription import or packet-tunnel behavior. -- Do not expose system `HTTP_PROXY`, system SOCKS, PAC, or "set system proxy" behavior as part of this proposal. -- If a temporary developer-only loopback proxy is needed for debugging, it must be clearly separate from product behavior and must not be the stored network runtime. - -### Subscription Import Compatibility - -Implement the importer in the daemon and mirror Mihomo's behavior as closely as Burrow's narrower protocol scope allows. Path references in this section are relative to the Mihomo repository root. When behavior is ambiguous, contributors should inspect the referenced Mihomo code path and either match it or document a deliberate deviation in this BEP before implementation. - -- Full config parsing should recognize subscription-relevant top-level fields analogous to `proxies`, `proxy-providers`, and `proxy-groups` in `config/config.go`. -- Provider parsing should support `file`, `http`, and `inline` style sources analogous to `adapter/provider/parser.go`. -- Provider content parsing should first try YAML with a `proxies` field. If YAML cannot be parsed, fall back to V2Ray-style URI conversion, matching the shape in `adapter/provider/provider.go`. -- Plain and base64 URI-list inputs should follow Mihomo's behavior in `common/convert/converter.go` and `common/convert/base64.go`: attempt raw/std base64 decoding, otherwise treat the body as plaintext. -- Trojan URI conversion should closely follow Mihomo's `trojan` case in `common/convert/converter.go` and the accepted outbound fields in `adapter/outbound/trojan.go`. Burrow should preserve Mihomo-compatible names and semantics internally where practical, then adapt them into Burrow's storage model. It should map at least: - - URI fragment to display name - - host, port, and username to server, port, and password - - `allowInsecure` to certificate verification policy - - `sni`, `alpn`, `type=ws`, `type=grpc`, client fingerprint, and pinned certificate fingerprint where supported - - Mihomo's default client fingerprint behavior where applicable - - Mihomo's ws and grpc option shapes before translating them into Burrow-owned structs -- Shadowsocks URI conversion should map at least: - - legacy base64 host form - - base64 `cipher:password` userinfo - - server, port, cipher, password, and node name - - `udp-over-tcp` or `uot` - - common `obfs` and `v2ray-plugin` plugin options when support is implemented -- Unsupported protocols, unsupported plugin modes, malformed nodes, duplicate node names, and insecure TLS flags must be returned in import preview warnings without logging credentials. - -Burrow should not claim full Clash or full Mihomo compatibility in this proposal. The compatibility target is subscription import for Trojan and Shadowsocks nodes plus the minimum provider and group metadata needed to select and refresh outbounds. Within that target, subscription parsing and Trojan normalization should be intentionally Mihomo-like so a subscription that imports cleanly in Mihomo has the same node set, labels, security warnings, and supported Trojan transport metadata in Burrow unless the BEP records a specific exception. - -### Data Model - -Add a proxy-subscription network payload with: - -- subscription display name -- redacted subscription URL or a credential reference to the full URL -- provider format detected during import -- refresh policy and last refresh result -- ordered proxy nodes -- selected node or selection strategy -- certificate and transport warnings -- per-node protocol type, server metadata, transport metadata, and secret references - -Proxy node secrets must move out of plain network payload blobs when Burrow's secret-storage path exists. Until then, payload storage must be treated as sensitive and logs/tests must use redacted fixtures. - -### Daemon APIs - -Add daemon gRPC APIs before platform UI work: - -- preview import from subscription URL -- apply selected preview result as a network -- refresh an existing subscription network -- list nodes for a proxy subscription network -- set selected node or selection strategy - -Preview responses should include compatible count, rejected count, detected format, sanitized warnings, suggested network name, and redacted node labels. Apply and refresh must preserve local node order and selected-node state where possible. - -### Packet Tunnel Runtime - -Burrow should route proxy subscriptions through the existing tunnel architecture: - -1. A platform tunnel provider starts the tunnel and applies daemon-provided network settings. -2. Platform packet capture forwards packets through the daemon packet interface. Apple uses `PacketTunnelProvider` and the existing `TunnelPackets` gRPC stream against the packet-tunnel daemon socket owned by the Network Extension; Linux should use the daemon-owned TUN path already aligned with the GTK client. -3. The daemon owns a proxy packet engine for active proxy-subscription networks. -4. The packet engine reconstructs TCP and UDP flows from IP packets. -5. DNS packets are handled by a daemon-owned DNS path so hostname metadata can be preserved for rule selection and proxy dialing. -6. Each flow is matched to the selected proxy node or selection strategy. -7. The daemon opens a Trojan or Shadowsocks outbound connection and relays flow bytes. -8. Return packets are serialized back to Apple through `TunnelPackets`. - -Mihomo's equivalent kernel path is: - -- TUN recreation and listener lifecycle in `listener/listener.go` -- TUN option construction and stack startup in `listener/sing_tun/server.go` -- DNS hijack in `listener/sing_tun/dns.go` -- metadata conversion in `listener/sing/sing.go` -- TCP and UDP handling in `tunnel/tunnel.go` -- outbound protocol construction in `adapter/parser.go`, `adapter/outbound/trojan.go`, and `adapter/outbound/shadowsocks.go` - -Burrow should use these as architecture references, not as code to vendor blindly. The Burrow implementation must fit the daemon IPC boundary, Rust runtime, Apple NetworkExtension path, Linux daemon/TUN path, GTK client, and existing network selection model. - -### Implemented Runtime Shape - -The first packet-tunnel implementation follows the Mihomo adapter shape but keeps Burrow's narrower protocol surface: - -- `burrow/src/proxy_runtime.rs` owns the daemon packet engine. -- Apple uses the existing `TunnelPackets` stream, matching the Tailnet packet-stream boundary. -- Linux uses the daemon TUN bridge path. -- Tunnel configuration excludes resolved selected proxy server host routes from the captured default route so proxy-server dials cannot be trapped by the full tunnel. -- On Apple, the proxy packet runtime binds Trojan and Shadowsocks outbound sockets to a non-tunnel physical interface before connect and also installs resolved Network Extension excluded routes for the selected proxy server. Loopback proxy-server addresses are not physical-interface bound so controlled local tests and developer-only local endpoints can still connect. The excluded route is intentionally redundant: it prevents proxy-server dials and server DNS artifacts from recursively entering the full tunnel when socket binding is unavailable, delayed, or ignored by a platform path. -- On Apple, node selection and refresh are applied to both the app-facing control daemon and, while connected, the packet-tunnel daemon over `burrow-packet-tunnel.sock`. The packet daemon swaps the active proxy outbound inside the existing packet bridge, so new TCP and UDP flows use the selected node without restarting the Network Extension. Existing flows may continue on the outbound they opened with until they close. -- TCP and UDP packets are reconstructed with the existing smoltcp userspace stack. -- Trojan TCP nodes are dialed over TLS and use Trojan TCP and UDP request framing directly. -- Shadowsocks nodes are dialed directly for TCP and UDP through Burrow's Shadowsocks runtime adapter. `BEP-0012` refines the Mihomo-compatible Shadowsocks runtime path, including expanded cipher families, UDP gating, and staged plugin/UDP-over-TCP support. -- Unsupported runtime transports such as Trojan `ws`/`grpc`, Shadowsocks plugins, and Shadowsocks UDP-over-TCP are still parsed and preserved from subscriptions, but they are rejected by runtime selection until those adapter transports are implemented. If no node is explicitly selected, Burrow chooses the first runtime-supported node instead of starting an unsupported transport. -- Apple SwiftUI and Linux GTK import flows preview nodes, restrict the picker to runtime-supported nodes, and pass the chosen node ordinal to the daemon when applying an import. -- Proxy-subscription tunnel settings point DNS at the daemon-owned tunnel address. The packet runtime answers ordinary A queries with fake IPs and records the fake-IP to hostname mapping so later TCP flows can send hostname-form proxy requests. This avoids making website DNS depend on outbound UDP support while preserving hostname metadata for Trojan and Shadowsocks dialing. - -### Routing, DNS, and Apple Runtime Ownership - -For full-tunnel proxy networks, daemon tunnel configuration should include: - -- a virtual IPv4 address from a documentation or carrier-grade NAT range suitable for the packet engine -- a virtual IPv6 address once the packet engine is enabled for IPv6 flows -- MTU selected for userspace packet handling -- default IPv4 route when full-tunnel is enabled -- default IPv6 route when the platform tunnel surface supports it -- a daemon-owned DNS server address reachable inside the tunnel - -DNS handling should be explicit. Proxy-subscription tunnel configuration should use the daemon-owned DNS address inside the tunnel, not ambient physical resolvers and not a public upstream that requires outbound UDP before TCP can start. The daemon DNS path may answer with fake IPs and must keep those mappings scoped to the active proxy runtime so hostname-preserving proxy dialing does not leak between networks. - -On Apple, the app-facing daemon and packet-tunnel daemon are intentionally separate in-process daemon instances over different app-group Unix sockets. The app-facing daemon handles subscription preview, import, refresh, node selection, network list, and account/login control through `burrow.sock`. The Network Extension hosts the packet-tunnel daemon over `burrow-packet-tunnel.sock` while the VPN session is active; `TunnelStart`, `TunnelConfiguration`, `TunnelPackets`, and `TunnelStop` for the active session use that socket. - -This split keeps macOS packet runtime ownership aligned with `NEPacketTunnelProvider`: routes, DNS settings, packet stream lifetime, and proxy runtime are created from the same selected network state when the extension starts. UI mutations persist desired state in the shared app-group database. If a mutation changes active packet-tunnel behavior, the Apple client should update the packet daemon's desired state and only restart or reassert the Network Extension when the platform tunnel settings themselves change. - -Proxy-subscription routing must avoid recursively routing the proxy outbound through the full tunnel. Burrow uses daemon-side outbound socket binding, analogous to Mihomo's default-interface outbound behavior, and installs resolved proxy-server excluded routes as a second guardrail. Selector changes remain daemon-local when they do not require platform route changes; if the selected node's resolved proxy-server routes change, the Network Extension should be restarted or have its settings reapplied so the excluded routes match the active outbound. - -Selector changes should be daemon-local whenever socket binding is available. The Network Extension packet stream and route/DNS settings should stay up; the packet daemon updates its proxy outbound handle, and future flows read the new selection from that shared handle. This mirrors Mihomo's selector model more closely than restarting TUN for every node change. - -Daemon-owned DNS/fake-IP metadata is part of the proxy packet runtime. The proxy-server route exclusion protects the selected outbound itself; ordinary website DNS is answered by the daemon and converted into hostname metadata for future flows. Mihomo-style rule selection can build on the same mapping without depending on ambient system DNS after the tunnel is active. - -### Rollout Stages - -1. Proposal and model cleanup. - - Supersede `BEP-0009`. - - Remove SOCKS runtime from the target design. -2. Parser and preview. - - Implement daemon-owned import preview for Mihomo-compatible YAML and URI-list inputs. - - Add fixtures for Trojan and Shadowsocks. -3. Storage and refresh. - - Store proxy subscriptions as networks. - - Preserve selected node and local ordering across refreshes. -4. Packet engine foundation. - - Add daemon packet-stream support for proxy networks. - - Reconstruct TCP flows first with controlled DNS behavior. -5. Protocol outbounds. - - Add Trojan outbound. - - Add Shadowsocks outbound. - - Add UDP once NAT, timeout, and protocol support are explicit. -6. Platform UI. - - Add `Import Proxy Subscription` to Apple SwiftUI and Linux GTK add flows. - - Add preview, warning, selection, refresh, and node status views to Apple SwiftUI, Linux GTK, and any future first-party UI surface. -7. Compatibility and operations. - - Add Mihomo comparison fixtures and sanitized live-check scripts. - - Document remaining incompatibilities. - -## Security and Operational Considerations - -- Subscription URLs frequently contain bearer tokens. Full URLs must not appear in logs, screenshots, fixtures, crash reports, or generated docs. -- Proxy node credentials must be redacted in all errors and diagnostics. -- Insecure TLS settings imported from subscriptions must be visible in preview and stored as explicit risk metadata. -- Refresh must have timeout, redirect, response-size, and content-type limits. -- Parser failures must be non-fatal per node; malformed input should never panic the daemon. -- Packet-tunnel activation must have a kill-switch path: when the tunnel stops or a proxy network is disabled, packet handling, DNS state, NAT state, and outbound sockets must be torn down. -- If DNS mapping is fake-IP based, stale mappings must expire and must not leak between networks in ways that route traffic through the wrong node. -- Full-tunnel mode must avoid recursive routing of the proxy server connection through the tunnel. On Apple, preferred bypass behavior is outbound socket binding to a non-tunnel interface. Route exclusions are fallback behavior for platforms or environments where socket binding is not available. -- Removing SOCKS mode simplifies the threat model by leaving one supported runtime behavior for proxy networks. - -## Contributor Playbook - -1. Keep all UI subscription operations behind daemon gRPC. -2. Update or remove current Trojan SOCKS runtime code instead of layering the packet-tunnel runtime beside it. -3. Add parser fixtures for: - - Clash/Mihomo YAML with `proxies` - - proxy-provider content - - base64 URI list - - plaintext URI list - - Trojan TCP, ws, grpc metadata - - Shadowsocks legacy and SIP002-style links - - unsupported protocols and malformed nodes - - Mihomo parity cases copied from the behavior of `common/convert/converter.go`, especially Trojan `allowInsecure`, `sni`, `alpn`, `type=ws`, `type=grpc`, `fp`, and `pcs` -4. Add packet-engine tests with synthetic TCP and DNS packets before full platform UI integration. -5. Add a migration or cleanup path for any existing stored `NetworkType::Trojan` payloads created by the SOCKS-era implementation. -6. Validate metadata and affected builds: - -```bash -uv run python Scripts/check-bep-metadata.py -cargo test -p burrow proxy_subscription -cargo check -p burrow -``` - -7. For platform UI work, regenerate bindings after proto changes and verify Apple SwiftUI and Linux GTK still talk only to daemon gRPC for subscription operations. - -## Alternatives Considered - -- Keep local SOCKS mode. Rejected because it is not Burrow's VPN behavior, adds another support surface, and makes imported proxy subscriptions feel unlike WireGuard and Tailnet. -- Use system `HTTP_PROXY` or SOCKS settings. Rejected because only proxy-aware apps participate, DNS behavior is inconsistent, and cleanup/rollback becomes OS-setting specific. -- Shell out to Mihomo. Rejected as the default implementation because Burrow would inherit a large external runtime and a separate control plane. Mihomo remains the behavioral reference for import and TUN semantics. -- Claim full Mihomo compatibility immediately. Rejected because Burrow should first support Trojan and Shadowsocks imports plus packet-tunnel routing correctly. -- Treat every proxy node as a separate account. Rejected because nodes are endpoints under one imported network source, not identities. - -## Impact on Other Work - -- Supersedes `BEP-0009`. -- Depends on the daemon IPC boundary in `BEP-0005`. -- Aligns with the route and policy direction in `BEP-0003`. -- May require a follow-up secret-storage BEP if existing network payload storage remains insufficient for long-lived proxy credentials. -- Creates shared import and packet-engine foundations for future proxy protocols, but this proposal only covers Trojan and Shadowsocks. - -## Decision - -Pending. - -## References - -- Burrow daemon IPC boundary: `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md` -- Burrow route and negotiation roadmap: `evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md` -- Superseded Trojan SOCKS proposal: `evolution/proposals/BEP-0009-trojan-proxy-and-subscription-import.md` -- Burrow tunnel schema: `proto/burrow.proto` -- Burrow Apple tunnel provider: `Apple/NetworkExtension/PacketTunnelProvider.swift` -- Burrow Apple UI: `Apple/UI/` -- Burrow GTK UI: `burrow-gtk/src/` -- Burrow current Trojan parser/runtime: `burrow/src/trojan.rs` -- Burrow daemon runtime: `burrow/src/daemon/runtime.rs` -- Mihomo config model: `config/config.go` -- Mihomo proxy provider parsing: `adapter/provider/parser.go` -- Mihomo provider content parsing: `adapter/provider/provider.go` -- Mihomo URI conversion: `common/convert/converter.go` -- Mihomo base64 fallback: `common/convert/base64.go` -- Mihomo TUN listener lifecycle: `listener/listener.go` -- Mihomo TUN stack setup: `listener/sing_tun/server.go` -- Mihomo DNS hijack: `listener/sing_tun/dns.go` -- Mihomo listener metadata bridge: `listener/sing/sing.go` -- Mihomo TCP/UDP tunnel handling: `tunnel/tunnel.go` -- Mihomo outbound parser: `adapter/parser.go` -- Mihomo selector group: `adapter/outboundgroup/selector.go` -- Mihomo Trojan outbound: `adapter/outbound/trojan.go` -- Mihomo Shadowsocks outbound: `adapter/outbound/shadowsocks.go` diff --git a/evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md b/evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md deleted file mode 100644 index 360104e..0000000 --- a/evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md +++ /dev/null @@ -1,110 +0,0 @@ -# `BEP-0011` - Mihomo-Compatible Trojan Runtime - -```text -Status: Draft -Proposal: BEP-0011 -Authors: Codex -Coordinator: Jett Chen -Reviewers: Pending -Constitution Sections: 2, 4, 5 -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow's proxy subscription runtime should match Mihomo's Trojan wire behavior -for imported nodes wherever Burrow already claims runtime support. This proposal -covers TCP Trojan nodes in the packet tunnel runtime: ALPN defaults, DNS-backed -hostname preservation, UDP gating, UDP packet fragmentation, certificate -pinning, and diagnostics for Mihomo-only TLS fingerprint behavior. - -The goal is compatibility without overstating support. Burrow should apply -Mihomo semantics that can be implemented correctly today, and it should make -unsupported Mihomo features visible instead of silently pretending to support -them. - -## Motivation - -- Imported Trojan subscriptions commonly target Mihomo-compatible clients. -- A stored node can carry `client-fingerprint`, ALPN, UDP, and certificate - pinning metadata that materially changes whether the proxy server accepts the - connection. -- Burrow previously flattened absent ALPN and explicit empty ALPN into the same - value, ignored Trojan UDP enablement, rejected oversized Trojan UDP payloads - instead of fragmenting them, and stored certificate pins without enforcing - them. - -## Detailed Design - -- Store an `alpn_present` marker alongside the existing Trojan `alpn` vector. - Missing ALPN uses Mihomo's TCP Trojan default `h2,http/1.1`; explicit ALPN, - including an explicit empty YAML list, is honored as written. -- Store Trojan `udp`. URI imports default to enabled to match Mihomo's URI - converter. YAML imports default to disabled unless `udp` is present. -- Point proxy-network DNS at the daemon-owned tunnel address. The packet runtime - answers A queries with fake IPs, records fake-IP to hostname mappings, and - uses hostname-form SOCKS addresses in Trojan TCP requests when a later TCP - flow targets one of those fake IPs. -- Enforce Trojan UDP gating at runtime and fragment UDP payloads above 8192 - bytes into multiple Trojan UDP frames. -- Treat `fingerprint` as SHA-256 certificate pinning. Browser fingerprint names - are rejected in `fingerprint` with guidance to use `client-fingerprint`. -- Keep using rustls for non-Apple TLS and OpenSSL for the macOS Trojan TLS path. - `client-fingerprint` is logged when present because Mihomo's uTLS ClientHello - impersonation is not implemented in Burrow yet. -- Coalesce the Trojan request header into one TLS write, matching Mihomo's - `trojan.WriteHeader` behavior. -- Continue rejecting Trojan `ws` and `grpc` at runtime until those transports - are implemented with their complete TLS and framing behavior. - -## Security and Operational Considerations - -- Certificate pinning changes TLS verification semantics. When a pin is - configured, Burrow verifies the presented chain against the pin instead of the - WebPKI root store, matching Mihomo's pinning model. -- The runtime must not log proxy passwords or subscription tokens. -- uTLS/browser fingerprinting should not be faked with partial ClientHello - tweaks. Until Burrow has a complete implementation, the runtime should say - that it is using platform TLS rather than a Mihomo uTLS impersonation. -- Rollback is a code rollback plus re-importing any subscriptions whose stored - JSON schema has changed during testing. - -## Contributor Playbook - -1. Compare Burrow behavior against `/Users/jettchen/dev/vpn-ref/mihomo` before - changing protocol details. -2. Run `cargo fmt -p burrow`. -3. Run `cargo test -p burrow proxy_subscription::tests::`. -4. Run `cargo test -p burrow proxy_runtime::tests::`. -5. Run `cargo check -p burrow`. -6. For live validation, use a Trojan node with omitted ALPN and - `client-fingerprint: chrome`; confirm the logs show Mihomo default ALPN, the - physical-interface bind, the macOS OpenSSL handshake, and the platform TLS - fingerprint warning. - -## Alternatives Considered - -- Implement full uTLS immediately. Rejected for this change because rustls does - not expose enough ClientHello control, and adding a second TLS stack needs - separate review for Apple builds and static library consumers. -- Treat all empty ALPN vectors as explicit empty ALPN. Rejected because older - Burrow imports already stored omitted URI ALPN as `[]`, which would preserve - the compatibility bug. -- Ignore `fingerprint` until uTLS exists. Rejected because certificate pinning - is independent of browser ClientHello impersonation. - -## Impact on Other Work - -- BEP-0010 remains the packet tunnel direction for proxy subscriptions. -- A future BEP or revision should cover uTLS/browser fingerprint support, - websocket Trojan, gRPC Trojan, and daemon-owned proxy-server DNS resolution. - -## Decision - -Pending review. - -## References - -- `docs/trojan-mihomo-discrepancies.md` -- `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md` 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 new file mode 100644 index 0000000..a8ed094 --- /dev/null +++ b/evolution/proposals/BEP-0011-panel-icon-family-and-platform-assets.md @@ -0,0 +1,87 @@ +# `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/evolution/proposals/BEP-0012-mihomo-compatible-shadowsocks-runtime.md b/evolution/proposals/BEP-0012-mihomo-compatible-shadowsocks-runtime.md deleted file mode 100644 index b1a4cc7..0000000 --- a/evolution/proposals/BEP-0012-mihomo-compatible-shadowsocks-runtime.md +++ /dev/null @@ -1,356 +0,0 @@ -# `BEP-0012` - Mihomo-Compatible Shadowsocks Runtime - -```text -Status: Draft -Proposal: BEP-0012 -Authors: Codex -Coordinator: Jett Chen -Reviewers: Pending -Constitution Sections: 2, 4, 5 -Implementation PRs: Pending -Decision Date: Pending -``` - -## Summary - -Burrow's proxy subscription runtime should move from a narrow hand-written -Shadowsocks AEAD implementation toward Mihomo-compatible Shadowsocks behavior -for imported nodes. Mihomo remains the reference for accepted ciphers, URI -conversion, plugins, UDP-over-TCP, and packet tunnel outbound semantics, while -Burrow keeps the daemon-owned packet runtime and Apple gRPC boundary described -by `BEP-0005` and `BEP-0010`. - -This proposal covers the compatibility path for Shadowsocks outbounds. It starts -by delegating protocol crypto and stream/datagram framing to a maintained Rust -Shadowsocks implementation, adds Burrow-owned adapters for Mihomo cipher gaps -where Rust crates are available, and implements Mihomo-compatible UDP-over-TCP. -Plugin transports remain staged behind explicit lifecycle and security controls. - -## Motivation - -- Subscriptions that work in Mihomo commonly use more than `aes-128-gcm`, - `aes-256-gcm`, or `chacha20-ietf-poly1305`. -- Mihomo supports legacy stream ciphers, AEAD extra ciphers, AEAD 2022 methods, - SIP003-style plugins, ShadowTLS/restls/kcptun transports, and UDP-over-TCP. -- Burrow's previous hand-written Shadowsocks runtime made unsupported ciphers - appear like permanent product choices rather than implementation gaps. -- Protocol crypto and framing are security-sensitive. Burrow should use a - maintained Rust crate where it can do so without shelling out to Mihomo or - weakening the daemon packet-runtime boundary. - -## Detailed Design - -- Use the Rust `shadowsocks` crate as Burrow's default Shadowsocks protocol - adapter for supported cipher families, TCP stream framing, and UDP datagram - framing. -- Add Burrow-owned AEAD adapters backed by Rust crypto crates for Mihomo methods - missing from the default adapter when their wire behavior is straightforward. - The first such methods are `aes-192-gcm`, `aes-192-ccm`, - `chacha8-ietf-poly1305`, `xchacha8-ietf-poly1305`, `rabbit128-poly1305`, - `aegis-128l`, `aegis-256`, `aez-384`, `deoxys-ii-256-128`, `ascon128`, - `ascon128a`, `lea-*-gcm`, `2022-blake3-aes-128-ccm`, and - `2022-blake3-aes-256-ccm`. -- Add Burrow-owned legacy stream adapters for Mihomo's `chacha20` and - `xchacha20` methods. They use the same Shadowsocks v1 IV-plus-stream shape as - Mihomo's `sing-shadowsocks2` implementation, backed by Rust `chacha20` - primitives. -- Keep Burrow-owned socket creation, route exclusion, and Apple physical - interface binding behavior. The Shadowsocks adapter wraps already-connected - Burrow sockets where needed instead of taking over tunnel ownership. -- Runtime-supported ciphers include the compiled Rust crate feature set: - legacy stream methods, AEAD methods, AEAD extra methods, and AEAD 2022 - methods, plus an explicit Mihomo-compatible `none` transport, custom - ChaCha20/XChaCha20 stream, AES-192 GCM/CCM, ChaCha8-Poly1305, - Rabbit128-Poly1305, AEGIS, AEZ-384, Deoxys-II-256-128, Ascon128, Ascon128a, - LEA-GCM, and AEAD 2022 AES-CCM adapters. `none` stays Burrow-owned because - the upstream Rust crate accepts the method name during validation but cannot - build a client runtime for it. - AEAD 2022 password handling follows Mihomo's base64 key parsing rule: - every decoded password segment must be exactly the method key length, with - no hashing or truncation fallback for oversized material. Burrow pre-validates - the delegated GCM/ChaCha 2022 methods so the upstream Rust adapter cannot - accept unpadded key material that Mihomo's `base64.StdEncoding` would reject. - Burrow keeps legacy aliases such as `aead_aes_128_gcm` and - `chacha20-poly1305` for previously imported payloads. - Burrow also preserves Mihomo's top-level Shadowsocks `smux` option during - import. The packet runtime supports TCP and UDP `protocol: h2mux`, `protocol: - smux`, and `protocol: yamux` paths for delegated standard ciphers, `none`, Burrow's custom ciphers, - and AEAD 2022 CCM by dialing Mihomo's `sp.mux.sing-box.arpa:444` control - destination through Shadowsocks, writing the sing-mux protocol preface, and - opening mux streams with Mihomo's per-stream SOCKS destination request. Burrow - also supports Mihomo's top-level sing-mux padding shape: the version 1 session - preface carries a padding flag and 256-767 bytes of skipped padding, then the - first 16 mux reads and writes are length-prefixed padded frames before the - stream falls back to raw mux bytes. UDP-over-sing-mux uses Mihomo's packet - stream shape: UDP stream flags, one SOCKS destination, one status byte, and - length-prefixed datagrams. Brutal mode performs Mihomo's `_BrutalBwExchange` - stream handshake and negotiates the same send-rate cap; OS-level TCP Brutal - congestion control remains a runtime best-effort because it is - Linux-kernel-module dependent in Mihomo and unavailable through every Burrow - plugin wrapper. - The remaining Mihomo compatibility gaps for Shadowsocks are advanced plugin - transports and live interop coverage for additional external permutations, - not base Shadowsocks cipher, UDP-over-TCP framing, or top-level sing-mux - negotiation. -- Honor the Shadowsocks `udp` flag at runtime. Clash/Mihomo YAML imports default - omitted `udp` to false like Mihomo's `ShadowSocksOption` zero value, while - `ss://` URI conversion keeps Mihomo's converter behavior of setting `udp` to - true. UDP-disabled nodes must reject UDP sessions instead of silently - attempting native UDP relay. -- Honor `udp-over-tcp` for Shadowsocks nodes by opening a Shadowsocks TCP stream - to Mihomo's UOT magic destination. Burrow supports both Mihomo's legacy - version 1 destination, `sp.udp-over-tcp.arpa`, and version 2 destination, - `sp.v2.udp-over-tcp.arpa`; absent or zero versions normalize to legacy v1 to - match Mihomo config defaults. Runtime UDP sessions are allowed when - `udp-over-tcp` is active even if native UDP is disabled, because packets are - carried over the selected TCP plugin path instead of a native Shadowsocks UDP - relay. -- Support Mihomo's `obfs` plugin for HTTP and TLS simple-obfs by wrapping the - already-connected TCP socket before the Shadowsocks stream handshake. Native - UDP remains direct Shadowsocks UDP, while `udp-over-tcp` uses the same wrapped - TCP path. The plugin mode must be explicit, matching Mihomo's `obfs` option - decoder: simple-obfs accepts only `http` and `tls`. Burrow normalizes SIP002 - URI plugin names that contain `obfs` into Mihomo's YAML/runtime plugin name - `obfs` during import only when the plugin string has semicolon-delimited - SIP002 options; bare `ss://` plugin names and non-Mihomo-converted URI plugin - names are ignored like Mihomo's converter. Daemon runtime and Apple usability - checks intentionally require Mihomo's exact, case-sensitive plugin names. YAML - `plugin: obfs-local`, `plugin: Obfs`, `plugin: shadowtls`, and other - unrecognized plugin names therefore fall through to plain Shadowsocks like - Mihomo's outbound constructor instead of being treated as supported plugin - transports. -- Support Mihomo's `v2ray-plugin` websocket mode, including the lightweight - v2ray-plugin mux preface enabled by Mihomo's default `mux: true`, optional - TLS, custom path, host, and `v2ray-http-upgrade` raw-upgrade mode. Burrow also supports Mihomo's - `v2ray-http-upgrade-fast-open` behavior by returning the upgraded raw stream - after sending the HTTP request and validating the 101 response on first read. - Imported `ss://` v2ray-plugin strings preserve Mihomo's SIP002 aliases such - as `obfs=websocket`, `obfs-host`, and bare `tls` flags, while also - materializing Mihomo's converted `mode`, `host`, and boolean `tls` option - shape. - For websocket paths with Mihomo's `ed=` query option, Burrow removes `ed` - from the request path and moves the first payload bytes into the - `Sec-WebSocket-Protocol` header with unpadded URL-safe base64 encoding. - When TLS is enabled, Burrow accepts Mihomo's `certificate` and `private-key` - plugin options as a PEM client - certificate/key pair for mTLS, and enforces Mihomo's `fingerprint` - certificate pin option on both Rustls and Apple native TLS paths. Burrow also - supports Mihomo's `ech-opts` for websocket TLS by passing inline ECH config - lists to Rustls, or by resolving HTTPS records through the physical DNS - resolver when `ech-opts.enable` is set without an inline config. -- Support Mihomo's `gost-plugin` websocket mode, including the Go-smux client - stream enabled by Mihomo's default `mux: true` and `mux=false` raw websocket - mode. `v2ray-plugin` and `gost-plugin` require explicit `mode: websocket` or - the imported `obfs=websocket` alias, because Mihomo rejects those plugin nodes - when the mode field is omitted. TLS-enabled gost websocket nodes use the same - Mihomo-compatible client certificate/key and certificate pin handling, - including websocket ECH support. -- Support Mihomo's `shadow-tls` plugin for versions 1, 2, and 3 by performing the - cover TLS handshake in-process with Rustls, then exposing the post-handshake - stream to the Shadowsocks adapter. Version 2 wraps application data in - ShadowTLS application-data records and prepends the first client payload with - the handshake HMAC, matching Mihomo's `sing-shadowtls` client behavior. - Version 3 uses a ShadowTLS-capable Rustls fork for the ClientHello session ID - HMAC, then verifies and emits the four-byte HMAC application-data frames used - by Mihomo's `sing-shadowtls` v3 client. Version 1 follows Mihomo's option - shape and does not require a plugin password. - Burrow also parses and enforces Mihomo's ShadowTLS `fingerprint` certificate - pin option and supports Mihomo's `certificate` and `private-key` mTLS client - certificate options. `client-fingerprint` is preserved from subscriptions. - ShadowTLS v1 ignores `client-fingerprint` like Mihomo because it always uses a - normal TLS 1.2 handshake. For ShadowTLS v2/v3, unknown fingerprint names fall - back to the normal TLS ClientHello, while names that Mihomo maps to uTLS - profiles are rejected by the default runtime and Apple usability filter until - Burrow has uTLS-style ClientHello impersonation. Burrow carries an optional - `boring-browser-fingerprints` feature that wires ShadowTLS v2 active browser - fingerprints through Rama's BoringSSL-backed embedded browser TLS profiles. - ShadowTLS ALPN defaults to `h2,http/1.1` only when the option is absent; - explicitly empty `alpn` remains empty like Mihomo's decoded option struct. - The same feature also provides the ShadowTLS v3 browser-profile path by - signing the first ClientHello record's 32-byte session ID before it reaches - the wire, matching Mihomo's uTLS `SessionIDGenerator` shape. The BoringSSL - browser-profile paths also load Mihomo-compatible `certificate` and - `private-key` client-auth material, so ShadowTLS v2/v3 browser fingerprints - and mTLS can be combined the same way Mihomo passes those options into - `sing-shadowtls`. For ShadowTLS `client-fingerprint: random`, Burrow follows - Mihomo's initial random selection shape by choosing one process-wide browser - profile with the same chrome/safari/ios/firefox weights instead of re-rolling - each connection. The ShadowTLS v2 browser-profile path also removes the - `X25519MLKEM768` supported group before building the BoringSSL connector, - matching Mihomo's v2-only workaround for servers that fail with that hybrid - key-share. These paths are intentionally not default yet because they add - a CMake/BoringSSL toolchain requirement, still need CI coverage, and still need - live interop coverage. The optional browser-profile path covers Mihomo's - common `chrome`, `firefox`, `safari`, `ios`, `android`, `edge`, `random`, and - `safari16` names where Rama has matching embedded profiles; older fixed - aliases such as `chrome120` and `firefox120` remain gated until Burrow has - exact matching ClientHello profiles instead of approximate fallbacks. -- Support Mihomo's `kcptun` plugin in-process with Rust KCP, snappy, and smux - crates. Burrow parses Mihomo's kcptun option names and defaults, forces - UDP-over-TCP for kcptun nodes like Mihomo, and opens Shadowsocks TCP streams - over KCP plus optional snappy plus smux. Burrow maintains a Mihomo-style - round-robin smux session pool for `conn` and rotates expired sessions using - `autoexpire` plus delayed `scavengettl` cleanup. Burrow mirrors Mihomo's smux - keepalive timeout shape: the default timeout remains 30 seconds unless the - keepalive interval is at least that value, in which case timeout becomes three - times the interval. Burrow also applies Mihomo-style best-effort `dscp` and - `sockbuf` settings to the underlying UDP socket. For `ratelimit`, Burrow - honors Mihomo's bytes-per-second value with - the same `64 * 1500` byte burst shape at the KCP stream boundary; this is the - closest in-process equivalent available through the selected Rust KCP crate, - which does not expose kcp-go's exact queued UDP packet transmit hook. -- Plugin support landed in stages and must stay covered: - - `obfs`, matching Mihomo URI conversion and option shapes for - `mode`/`obfs`, `host`/`obfs-host`, and the default `bing.com` host. - - `v2ray-plugin` websocket and its lightweight mux. - - `gost-plugin` websocket and smux. - - TLS client certificate/key support for websocket plugins and `shadow-tls` - v1/v2. - - `kcptun` with Rust KCP, snappy, and smux transport code. - - ShadowTLS v3 with a Rustls fork that exposes the session ID generator - required by the protocol. - - Websocket `ech-opts` on Rustls-backed TLS paths. - - `v2ray-http-upgrade-fast-open` for v2ray-plugin raw upgrade mode. - - Websocket early data via the `ed=` path query option. - - `restls` after its TLS ClientHello, authentication, and traffic-shaping - behavior are mapped to audited Rust code. Burrow now parses Mihomo's - `restls` option shape, including `host`, `password`, `version-hint`, - `restls-script`, top-level `client-fingerprint` preservation, Mihomo's - case-sensitive Restls client ID lookup with Chrome fallback, Mihomo's TLS - 1.3 session ticket disablement rule, and the BLAKE3-derived traffic secret. - The default runtime still rejects non-`none` uTLS fingerprints, while the - optional `boring-browser-fingerprints` feature can run TLS 1.3 Restls over a - BoringSSL-backed browser profile and signs the first ClientHello record's - session ID using Mihomo's Restls TLS 1.3 auth material. It also - ports and tests Mihomo's BLAKE3-authenticated ClientHello session-ID - material for TLS 1.2 and TLS 1.3, extraction of TLS 1.3 key-share and PSK - identity labels from the serialized ClientHello shape exposed by Burrow's - Rustls session-ID hook, the server-auth record unmasking offsets, a - handshake stream shim that captures ServerHello random, unmasks the first - server-auth record, and captures the encrypted ClientFinished record for - later application-data authentication, the application-data record header, - masked length/command bytes, one-shot ClientFinished binding on the first - client application record, - script-driven padding target, TLS 1.2 GCM explicit counter shape, Mihomo's - signed one-byte response-interrupt count behavior, and the post-auth stream - state machine for buffering blocked writes, resuming them after server data, - and emitting response-interrupt records. The Restls - post-handshake state machine is also covered by an async stream wrapper that - turns user writes into authenticated TLS application-data records, decodes - server records back into plaintext reads, and flushes resumed uploads plus - response-interrupt records before surfacing server plaintext. Burrow now - wires TLS 1.3 Restls nodes into the Shadowsocks outbound path through the - Rustls session-ID hook, the server-auth handshake shim, and the async Restls - stream wrapper. Burrow vendors the ShadowTLS Rustls fork and its Tokio - adapter so TLS 1.2 Restls can pre-generate the ECDHE keys used in Mihomo's - session-ID HMAC material and then reuse the matching private key when - emitting ClientKeyExchange. TLS 1.2 Restls nodes are now accepted by runtime - support checks, surfaced as selectable Apple subscription nodes, and use the - TLS 1.2 GCM Restls application-record codec. - Restls still needs end-to-end interop coverage before it can be claimed as - full Mihomo parity. -- `Scripts/burrow-proxy-selftest` exercises daemon packet streaming against a - controlled local Trojan/Shadowsocks harness and asserts observed TCP, native - UDP, legacy UDP-over-TCP, version 2 UDP-over-TCP, simple-obfs HTTP/TLS, - v2ray HTTP-upgrade/websocket/mux/early-data, and gost raw websocket shapes. -- `Scripts/burrow-shadowsocks-singmux-interop` drives Burrow's daemon against a - temporary Go peer built from Mihomo's `github.com/metacubex/sing-mux` module - and asserts top-level Shadowsocks `smux`, `yamux`, and `h2mux` TCP and UDP - behavior through the daemon with both unpadded and padded sing-mux sessions, - including the `sp.mux.sing-box.arpa:444` control destination, per-stream TCP - destination metadata, UDP packet destination metadata, and UDP packet echo - delivery. The interop matrix intentionally leaves the underlying Shadowsocks - `udp` flag false so Burrow matches Mihomo's behavior: top-level sing-mux - carries UDP unless `only-tcp` is set. - Remaining compatibility evidence still needs live interop against full - Mihomo/sing-box deployments for plugin combinations that the local harness - does not cover, especially Restls post-handshake behavior and - kernel-dependent TCP Brutal socket behavior. -- Keep UDP-over-TCP packet framing compatible with Mihomo's - `udp-over-tcp-version` handling, including the version 2 request prefix and - per-packet address framing. -- UI runtime-support checks must not hard-code stale cipher lists that disagree - with the daemon. When local decoding is unavoidable, the local list must be - updated with the daemon-supported cipher families or replaced with daemon - preview/list-node data. - -## Security and Operational Considerations - -- Shadowsocks passwords and subscription URLs remain secrets. Error messages and - logs must not include decoded credentials or bearer tokens. -- Plugin support must stay within Burrow's daemon-owned socket lifecycle unless - a later BEP explicitly introduces subprocess management. Shelling out to - SIP003 plugins would add lifecycle and supply-chain risk and must include - teardown and selected-network scoping if introduced. -- `none` and legacy stream methods exist for compatibility, not as a security - recommendation. UI warnings may be added later for weak ciphers without - rejecting Mihomo-compatible imports. -- AEAD 2022 passwords may be encoded keys. Validation must report malformed keys - without logging the raw password. -- Rollback is a code rollback plus re-importing affected subscriptions if stored - payload shape changes. - -## Contributor Playbook - -1. Compare behavior against `/Users/jettchen/dev/vpn-ref/mihomo` on the `Alpha` - branch before changing Shadowsocks protocol semantics. -2. Keep Apple UI subscription operations behind daemon gRPC. -3. Prefer maintained Rust crates for Shadowsocks crypto/framing rather than - hand-rolled protocol code. -4. Preserve Burrow socket binding and proxy-server route exclusion behavior. -5. Add tests for each parity expansion: - - cipher acceptance and legacy aliases - - `udp` gating - - plugin parsing and runtime gating - - UDP-over-TCP version handling and packet framing - - AEAD 2022 TCP/UDP packet framing for custom adapters - - end-to-end local Shadowsocks TCP, native UDP, UDP-over-TCP, and plugin - selftests; the proxy selftest must switch the daemon from a Trojan node to - Shadowsocks nodes, prove both selected outbounds can carry a TCP probe, - prove the selected Shadowsocks outbounds can carry native UDP, legacy - UDP-over-TCP, and version 2 UDP-over-TCP echo probes, and prove the - simple-obfs HTTP/TLS plugins, v2ray-plugin websocket/default-mux/ - HTTP-upgrade/early-data modes, and gost-plugin raw websocket mode wrap - selected Shadowsocks TCP probes - - top-level sing-mux Shadowsocks TCP and UDP interop against Mihomo's Go - `sing-mux` module -6. Run: - -```bash -uv run python Scripts/check-bep-metadata.py -cargo fmt -p burrow -cargo test -p burrow proxy_subscription::tests:: -cargo test -p burrow proxy_runtime::tests:: -cargo check -p burrow -Scripts/burrow-proxy-selftest -Scripts/burrow-shadowsocks-singmux-interop -``` - -## Alternatives Considered - -- Keep the hand-written AEAD-only implementation. Rejected because it locks - Burrow into a narrow subset and makes Mihomo parity much slower. -- Shell out to Mihomo. Rejected for the same reasons recorded in `BEP-0010`: - Burrow would inherit a separate runtime, control plane, and lifecycle surface. -- Implement plugins before cipher parity. Rejected because cipher parity is a - lower-risk foundation and reduces custom crypto code first. - -## Impact on Other Work - -- Refines the Shadowsocks runtime portion of `BEP-0010`. -- Complements `BEP-0011`, which covers Trojan-specific Mihomo compatibility. -- Future plugin work may require a dedicated subprocess lifecycle BEP if the - runtime cannot stay within the existing daemon teardown model. - -## Decision - -Pending review. - -## References - -- `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md` -- `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md` -- `evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md` -- `/Users/jettchen/dev/vpn-ref/mihomo/adapter/outbound/shadowsocks.go` -- `/Users/jettchen/dev/vpn-ref/mihomo/common/convert/converter.go` -- Rust `shadowsocks` crate: https://crates.io/crates/shadowsocks diff --git a/flake.lock b/flake.lock index 0067dab..e2ce1a1 100644 --- a/flake.lock +++ b/flake.lock @@ -52,8 +52,8 @@ ] }, "locked": { - "lastModified": 1773889306, - "narHash": "sha256-PAqwnsBSI9SVC2QugvQ3xeYCB0otOwCacB1ueQj2tgw=", + "lastModified": 1780290312, + "narHash": "sha256-eTAlX0CwgB84Ts3GaBd944A3DRXVMzgA0EqroZBISUo=", "type": "tarball", "url": "https://codeload.github.com/nix-community/disko/tar.gz/master" }, @@ -113,14 +113,18 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773389992, - "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", - "type": "tarball", - "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" + "lastModified": 1780243769, + "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", + "type": "github" }, "original": { - "type": "tarball", - "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" } }, "nsc-autoscaler": { diff --git a/flake.nix b/flake.nix index e842fba..bdaa590 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "Burrow development shell and forge host configuration"; inputs = { - nixpkgs.url = "tarball+https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "tarball+https://codeload.github.com/numtide/flake-utils/tar.gz/main"; agenix = { url = "github:ryantm/agenix"; @@ -38,7 +38,148 @@ 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 new file mode 100644 index 0000000..6dc01f6 --- /dev/null +++ b/infra/grafana/providers.tf @@ -0,0 +1,4 @@ +provider "grafana" { + url = var.grafana_url + auth = var.grafana_auth +} diff --git a/infra/grafana/terraform.tfvars.example b/infra/grafana/terraform.tfvars.example new file mode 100644 index 0000000..27dbab5 --- /dev/null +++ b/infra/grafana/terraform.tfvars.example @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..585ad0e --- /dev/null +++ b/infra/grafana/variables.tf @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..1186143 --- /dev/null +++ b/infra/grafana/versions.tf @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..b12b9ed --- /dev/null +++ b/infra/identity/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +backend.hcl +terraform.tfstate +terraform.tfstate.backup +*.tfplan diff --git a/infra/identity/.terraform.lock.hcl b/infra/identity/.terraform.lock.hcl new file mode 100644 index 0000000..0a51a6e --- /dev/null +++ b/infra/identity/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# 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 new file mode 100644 index 0000000..b774578 --- /dev/null +++ b/infra/identity/README.md @@ -0,0 +1,147 @@ +# 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 new file mode 100644 index 0000000..60350e3 --- /dev/null +++ b/infra/identity/backend.hcl.example @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..a429401 --- /dev/null +++ b/infra/identity/google.tf @@ -0,0 +1,172 @@ +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 new file mode 100644 index 0000000..2a0b9a0 --- /dev/null +++ b/infra/identity/outputs.tf @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..5343c2e --- /dev/null +++ b/infra/identity/providers.tf @@ -0,0 +1,3 @@ +provider "google" { + project = var.google_project_id +} diff --git a/infra/identity/terraform.tfvars.example b/infra/identity/terraform.tfvars.example new file mode 100644 index 0000000..7411302 --- /dev/null +++ b/infra/identity/terraform.tfvars.example @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..68fad72 --- /dev/null +++ b/infra/identity/variables.tf @@ -0,0 +1,201 @@ +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 new file mode 100644 index 0000000..2b6a05d --- /dev/null +++ b/infra/identity/versions.tf @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..b12b9ed --- /dev/null +++ b/infra/openbao/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +backend.hcl +terraform.tfstate +terraform.tfstate.backup +*.tfplan diff --git a/infra/openbao/.terraform.lock.hcl b/infra/openbao/.terraform.lock.hcl new file mode 100644 index 0000000..ded7beb --- /dev/null +++ b/infra/openbao/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# 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 new file mode 100644 index 0000000..bca0089 --- /dev/null +++ b/infra/openbao/README.md @@ -0,0 +1,36 @@ +# 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 new file mode 100644 index 0000000..53c6b7d --- /dev/null +++ b/infra/openbao/backend.hcl.example @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..0b34f7c --- /dev/null +++ b/infra/openbao/google.tf @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..619f50e --- /dev/null +++ b/infra/openbao/openbao.tf @@ -0,0 +1,125 @@ +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 new file mode 100644 index 0000000..8474957 --- /dev/null +++ b/infra/openbao/outputs.tf @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..7e485d4 --- /dev/null +++ b/infra/openbao/providers.tf @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..ae1a4e1 --- /dev/null +++ b/infra/openbao/terraform.tfvars.example @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..011eaa7 --- /dev/null +++ b/infra/openbao/variables.tf @@ -0,0 +1,137 @@ +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 new file mode 100644 index 0000000..9de0722 --- /dev/null +++ b/infra/openbao/versions.tf @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..493f814 --- /dev/null +++ b/infra/releases/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfstate +*.tfstate.* +backend.hcl +terraform.tfvars diff --git a/infra/releases/.terraform.lock.hcl b/infra/releases/.terraform.lock.hcl new file mode 100644 index 0000000..0a51a6e --- /dev/null +++ b/infra/releases/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# 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 new file mode 100644 index 0000000..7d88b13 --- /dev/null +++ b/infra/releases/README.md @@ -0,0 +1,54 @@ +# 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 new file mode 100644 index 0000000..6f144bd --- /dev/null +++ b/infra/releases/backend.hcl.example @@ -0,0 +1,3 @@ +bucket = "burrow-opentofu-state" +key = "releases/releases.tfstate" +region = "us-west-2" diff --git a/infra/releases/outputs.tf b/infra/releases/outputs.tf new file mode 100644 index 0000000..ebb4611 --- /dev/null +++ b/infra/releases/outputs.tf @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..5343c2e --- /dev/null +++ b/infra/releases/providers.tf @@ -0,0 +1,3 @@ +provider "google" { + project = var.google_project_id +} diff --git a/infra/releases/storage.tf b/infra/releases/storage.tf new file mode 100644 index 0000000..f31f84c --- /dev/null +++ b/infra/releases/storage.tf @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..59bd1e3 --- /dev/null +++ b/infra/releases/terraform.tfvars.example @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..c1c8d4a --- /dev/null +++ b/infra/releases/variables.tf @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000..2b6a05d --- /dev/null +++ b/infra/releases/versions.tf @@ -0,0 +1,12 @@ +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 23907f3..c37a75b 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -2,7 +2,7 @@ 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). +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. ## Files @@ -20,6 +20,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `../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/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 @@ -34,18 +35,21 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 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/headscale-oidc-client-secret.age`, `secrets/infra/forgejo-nsc-token.age`, `secrets/infra/forgejo-nsc-dispatcher-config.age`, and `secrets/infra/forgejo-nsc-autoscaler-config.age`, and let agenix materialize them under `/run/agenix/`. -9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. +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. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. +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. ## 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 and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host. +- Authentik, Grafana, and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host. - Public Burrow forge cutover completed on March 15, 2026: - - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21` + - `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 - 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 diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index c4fc92e..a4f71f8 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -50,6 +50,29 @@ let 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 { @@ -60,8 +83,13 @@ in 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"; @@ -108,6 +136,24 @@ in 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"; @@ -138,6 +184,12 @@ in 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"; @@ -192,8 +244,8 @@ in }; networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net + 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 = { @@ -221,29 +273,39 @@ in services.forgejo-nsc = { enable = true; + nscPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.nsc; nscTokenFile = config.age.secrets.burrowForgejoNscToken.path; dispatcher = { + package = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-dispatcher; configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path; }; autoscaler = { enable = true; + package = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-autoscaler; configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.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"; @@ -258,12 +320,91 @@ in 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; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 977b641..a96ece8 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,6 +10,8 @@ let 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; @@ -136,6 +138,60 @@ in 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"; @@ -388,6 +444,12 @@ in 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 = { @@ -490,6 +552,13 @@ in 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} @@ -517,6 +586,7 @@ AUTHENTIK_BOOTSTRAP_PASSWORD=$AUTHENTIK_BOOTSTRAP_PASSWORD AUTHENTIK_BOOTSTRAP_TOKEN=$AUTHENTIK_BOOTSTRAP_TOKEN AUTHENTIK_BURROW_TS_CLIENT_SECRET=$(read_secret ${lib.escapeShellArg cfg.headscaleClientSecretFile}) ${lib.optionalString (cfg.forgejoClientSecretFile != null) "AUTHENTIK_BURROW_FORGEJO_CLIENT_SECRET=$(read_secret ${lib.escapeShellArg cfg.forgejoClientSecretFile})"} +${lib.optionalString (cfg.googleWifClientSecretFile != null) "AUTHENTIK_BURROW_GOOGLE_WIF_CLIENT_SECRET=$(read_secret ${lib.escapeShellArg cfg.googleWifClientSecretFile})"} EOF chown root:root ${envFile} chmod 0600 ${envFile} @@ -667,7 +737,7 @@ EOF ''; }; - systemd.services.burrow-authentik-directory = lib.mkIf (cfg.bootstrapUsers != [ ]) { + systemd.services.burrow-authentik-directory = lib.mkIf (cfg.bootstrapUsers != [ ] || cfg.extraGroupNames != [ ]) { description = "Reconcile Burrow Authentik users and groups"; after = [ @@ -706,6 +776,7 @@ EOF 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; @@ -810,12 +881,112 @@ EOF 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","https://${cfg.forgejoDomain}/user/oauth2/GitHub/callback"]' + 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 = [ diff --git a/nixos/modules/burrow-forge-runner.nix b/nixos/modules/burrow-forge-runner.nix index 034fb38..b5e9d72 100644 --- a/nixos/modules/burrow-forge-runner.nix +++ b/nixos/modules/burrow-forge-runner.nix @@ -7,6 +7,7 @@ let 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 ""; @@ -164,6 +165,9 @@ 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 <&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 = [ diff --git a/nixos/modules/burrow-garage.nix b/nixos/modules/burrow-garage.nix new file mode 100644 index 0000000..993d5ba --- /dev/null +++ b/nixos/modules/burrow-garage.nix @@ -0,0 +1,284 @@ +{ 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.nix b/nixos/modules/burrow-headscale.nix index ad5ec68..b79fe48 100644 --- a/nixos/modules/burrow-headscale.nix +++ b/nixos/modules/burrow-headscale.nix @@ -26,6 +26,24 @@ in 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}/"; @@ -154,6 +172,8 @@ in method = "S256"; }; }; + } // lib.optionalAttrs cfg.enableMetrics { + metrics_listen_addr = "${cfg.metricsListenAddress}:${toString cfg.metricsPort}"; }; }; diff --git a/nixos/modules/burrow-jitsi.nix b/nixos/modules/burrow-jitsi.nix new file mode 100644 index 0000000..6041632 --- /dev/null +++ b/nixos/modules/burrow-jitsi.nix @@ -0,0 +1,45 @@ +{ 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 new file mode 100644 index 0000000..420d3b9 --- /dev/null +++ b/nixos/modules/burrow-nix-cache.nix @@ -0,0 +1,163 @@ +{ 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 new file mode 100644 index 0000000..090c2ee --- /dev/null +++ b/nixos/modules/burrow-observability.nix @@ -0,0 +1,302 @@ +{ 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 new file mode 100644 index 0000000..82f7718 --- /dev/null +++ b/nixos/modules/burrow-openbao.nix @@ -0,0 +1,101 @@ +{ 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/package/arch/README.md b/package/arch/README.md new file mode 100644 index 0000000..71567cd --- /dev/null +++ b/package/arch/README.md @@ -0,0 +1,15 @@ +# 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 new file mode 100644 index 0000000..34cc320 --- /dev/null +++ b/package/arch/aur/burrow-git/.SRCINFO @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..92a7555 --- /dev/null +++ b/package/arch/aur/burrow-git/PKGBUILD @@ -0,0 +1,35 @@ +# 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 new file mode 100644 index 0000000..355547d --- /dev/null +++ b/package/arch/aur/burrow-git/burrow.install @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..bc227fe --- /dev/null +++ b/package/repositories/README.md @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 0000000..99da324 --- /dev/null +++ b/package/repositories/apt/burrow.sources @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..a813194 --- /dev/null +++ b/package/repositories/flatpak/burrow.flatpakrepo.in @@ -0,0 +1,8 @@ +[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 new file mode 100644 index 0000000..aad13c5 --- /dev/null +++ b/package/repositories/packagekit/README.md @@ -0,0 +1,16 @@ +# 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 new file mode 100644 index 0000000..4c206bc --- /dev/null +++ b/package/repositories/pacman/pacman.conf @@ -0,0 +1,3 @@ +[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 new file mode 100644 index 0000000..481aa37 --- /dev/null +++ b/package/repositories/rpm/burrow.repo @@ -0,0 +1,7 @@ +[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 new file mode 100644 index 0000000..c1fe36a --- /dev/null +++ b/patches/rcodesign-pkcs11-tool.patch @@ -0,0 +1,522 @@ +--- 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 3e3307d..ed1f89e 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -26,12 +26,6 @@ service TailnetControl { rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty); } -service ProxySubscriptions { - rpc PreviewImport (ProxySubscriptionImportRequest) returns (ProxySubscriptionPreviewResponse); - rpc ApplyImport (ProxySubscriptionApplyRequest) returns (ProxySubscriptionApplyResponse); - rpc SelectNode (ProxySubscriptionSelectRequest) returns (ProxySubscriptionSelectResponse); -} - message NetworkReorderRequest { int32 id = 1; int32 index = 2; @@ -61,62 +55,6 @@ message Network { enum NetworkType { WireGuard = 0; Tailnet = 1; - reserved 2; - reserved "Trojan"; - ProxySubscription = 3; -} - -message ProxySubscriptionImportRequest { - string url = 1; -} - -message ProxySubscriptionRejectedEntry { - int32 ordinal = 1; - string reason = 2; - string scheme = 3; -} - -message ProxySubscriptionNodePreview { - int32 ordinal = 1; - string name = 2; - string protocol = 3; - string server = 4; - int32 port = 5; - repeated string warnings = 6; - bool runtime_supported = 7; -} - -message ProxySubscriptionPreviewResponse { - string suggested_name = 1; - string detected_format = 2; - int32 compatible_count = 3; - int32 rejected_count = 4; - repeated ProxySubscriptionNodePreview node = 5; - repeated ProxySubscriptionRejectedEntry rejected = 6; - repeated string warnings = 7; -} - -message ProxySubscriptionApplyRequest { - int32 id = 1; - string url = 2; - string name = 3; - int32 selected_ordinal = 4; -} - -message ProxySubscriptionApplyResponse { - int32 id = 1; - int32 imported_count = 2; - int32 selected_ordinal = 3; -} - -message ProxySubscriptionSelectRequest { - int32 id = 1; - int32 selected_ordinal = 2; -} - -message ProxySubscriptionSelectResponse { - int32 id = 1; - int32 selected_ordinal = 2; } message NetworkListResponse { @@ -195,7 +133,6 @@ message TunnelConfigurationResponse { repeated string dns_servers = 4; repeated string search_domains = 5; bool include_default_route = 6; - repeated string excluded_routes = 7; } message TunnelPacket { diff --git a/secrets.nix b/secrets.nix index 3f9bba4..db95367 100644 --- a/secrets.nix +++ b/secrets.nix @@ -11,14 +11,22 @@ let 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; diff --git a/secrets/apple/appstore-connect.env.age b/secrets/apple/appstore-connect.env.age new file mode 100644 index 0000000..5762560 Binary files /dev/null and b/secrets/apple/appstore-connect.env.age differ diff --git a/secrets/apple/sparkle.env.age b/secrets/apple/sparkle.env.age new file mode 100644 index 0000000..7a73864 --- /dev/null +++ b/secrets/apple/sparkle.env.age @@ -0,0 +1,14 @@ +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/infra/authentik-google-wif-client-secret.age b/secrets/infra/authentik-google-wif-client-secret.age new file mode 100644 index 0000000..427dbba --- /dev/null +++ b/secrets/infra/authentik-google-wif-client-secret.age @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..42af338 Binary files /dev/null and b/secrets/infra/grafana-secret-key.age differ diff --git a/services/forgejo-nsc/README.md b/services/forgejo-nsc/README.md index 79058bb..e84a71b 100644 --- a/services/forgejo-nsc/README.md +++ b/services/forgejo-nsc/README.md @@ -121,14 +121,14 @@ dispatcher: url: "http://dispatcher:8080" instances: -- name: burrow - forgejo: - base_url: "https://git.burrow.net" - token: "PENDING-FORGEJO-PAT" - scope: - level: "repository" - owner: "burrow" - 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" @@ -152,13 +152,14 @@ 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`. -For ongoing operations, use `Scripts/sync-forgejo-nsc-config.sh`: +For ongoing operations, use `Scripts/seal-forgejo-nsc-secrets.sh`: -- `Scripts/sync-forgejo-nsc-config.sh` copies the intake-backed configs and - Namespace token onto `/var/lib/burrow/intake/` on the forge host, reapplies - file ownership for `forgejo-nsc`, and restarts the dispatcher/autoscaler. -- `Scripts/sync-forgejo-nsc-config.sh --rotate-pat` additionally mints a new - Forgejo PAT on the Burrow forge host and refreshes the local intake 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. Run it next to the dispatcher: diff --git a/services/forgejo-nsc/autoscaler.example.yaml b/services/forgejo-nsc/autoscaler.example.yaml index 866d3b5..35ba07a 100644 --- a/services/forgejo-nsc/autoscaler.example.yaml +++ b/services/forgejo-nsc/autoscaler.example.yaml @@ -1,6 +1,7 @@ listen: ":8090" dispatcher: url: "http://localhost:8080" + timeout: "2h" instances: - name: burrow @@ -9,7 +10,7 @@ instances: token: "PENDING-FORGEJO-PAT" scope: level: "repository" - owner: "burrow" + owner: "hackclub" name: "burrow" disable_polling: true poll_interval: "30s" diff --git a/services/forgejo-nsc/deploy/autoscaler.yaml b/services/forgejo-nsc/deploy/autoscaler.yaml index 084b076..42ae6c7 100644 --- a/services/forgejo-nsc/deploy/autoscaler.yaml +++ b/services/forgejo-nsc/deploy/autoscaler.yaml @@ -2,6 +2,7 @@ listen: "127.0.0.1:8090" dispatcher: url: "http://127.0.0.1:8080" + timeout: "3h" instances: - name: burrow @@ -10,7 +11,7 @@ instances: token: "PENDING-FORGEJO-PAT" scope: level: "repository" - owner: "burrow" + owner: "hackclub" name: "burrow" disable_polling: false poll_interval: "30s" @@ -25,6 +26,26 @@ instances: 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" + - 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" - 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 6d2aac5..28f55ac 100644 --- a/services/forgejo-nsc/deploy/dispatcher.yaml +++ b/services/forgejo-nsc/deploy/dispatcher.yaml @@ -6,7 +6,7 @@ forgejo: token: "PENDING-FORGEJO-PAT" default_scope: level: "repository" - owner: "burrow" + owner: "hackclub" name: "burrow" default_labels: - namespace-profile-linux-medium @@ -15,7 +15,7 @@ forgejo: namespace: nsc_binary: "/run/current-system/sw/bin/nsc" compute_base_url: "https://ord4.compute.namespaceapis.com" - image: "code.forgejo.org/forgejo/runner:3" + image: "code.forgejo.org/forgejo/runner:11" machine_type: "8x16" macos_base_image_id: "tahoe" macos_machine_arch: "arm64" diff --git a/services/forgejo-nsc/internal/autoscaler/config.go b/services/forgejo-nsc/internal/autoscaler/config.go index 7603e67..d252664 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: 15 * time.Second} + cfg.Dispatcher.Timeout = config.Duration{Duration: 2 * time.Hour} } if len(cfg.Instances) == 0 { return Config{}, fmt.Errorf("at least one instance must be configured") diff --git a/services/forgejo-nsc/internal/nsc/dispatcher.go b/services/forgejo-nsc/internal/nsc/dispatcher.go index 49cb4ec..33721b6 100644 --- a/services/forgejo-nsc/internal/nsc/dispatcher.go +++ b/services/forgejo-nsc/internal/nsc/dispatcher.go @@ -284,22 +284,28 @@ 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 { - continue + return false } for _, container := range target.Container { if container.Status != "stopped" && container.TerminatedAt == "" { return false } + seenTerminalState = true } } } - return true + return seenTerminalState } func (d *Dispatcher) waitForInstanceStop(ctx context.Context, runnerName, instanceID string, timeout time.Duration) bool { diff --git a/services/forgejo-nsc/internal/nsc/dispatcher_test.go b/services/forgejo-nsc/internal/nsc/dispatcher_test.go new file mode 100644 index 0000000..bcf450e --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/dispatcher_test.go @@ -0,0 +1,33 @@ +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 9bf3837..05324eb 100644 --- a/services/forgejo-nsc/internal/nsc/macos.go +++ b/services/forgejo-nsc/internal/nsc/macos.go @@ -151,6 +151,14 @@ 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 { @@ -183,10 +191,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 := d.opts.WorkDir - if strings.TrimSpace(workdir) == "" { - workdir = "/tmp/forgejo-runner" - } + workdir := macosRunnerWorkDir(d.opts.WorkDir) env := map[string]string{ "FORGEJO_INSTANCE_URL": req.InstanceURL, diff --git a/services/forgejo-nsc/internal/nsc/macos_nsc.go b/services/forgejo-nsc/internal/nsc/macos_nsc.go index c22fadb..b9b9a1b 100644 --- a/services/forgejo-nsc/internal/nsc/macos_nsc.go +++ b/services/forgejo-nsc/internal/nsc/macos_nsc.go @@ -52,6 +52,21 @@ func normalizeMacOSNSCMachineType(machineType string) (normalized string, change return normalized, changed, nil } +type macosNSCAttemptConfig struct { + waitTimeout time.Duration + createTimeout time.Duration +} + +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] +} + 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") @@ -92,17 +107,13 @@ func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName str } candidates = uniq - type attemptCfg struct { - waitTimeout time.Duration - createTimeout time.Duration - } - attempts := []attemptCfg{ + attempts := []macosNSCAttemptConfig{ {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 attemptCfg) (instanceID string, out string, err error) { + createInstance := func(mt string, a macosNSCAttemptConfig) (instanceID string, out string, err error) { tmpDir, err := os.MkdirTemp("", "forgejo-nsc-macos-*") if err != nil { return "", "", fmt.Errorf("mktemp: %w", err) @@ -173,10 +184,7 @@ func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName str lastErr error ) for i, mt := range candidates { - a := attempts[i] - if i >= len(attempts) { - a = attempts[len(attempts)-1] - } + a := macosNSCAttemptConfigFor(i, attempts) d.log.Info("launching Namespace macos runner via nsc", "runner", runnerName, @@ -296,9 +304,7 @@ func (d *Dispatcher) destroyNSCInstance(ctx context.Context, runnerName, instanc } func macosBootstrapWrapperScript(runnerName string, req LaunchRequest, executor, workdir string) string { - if strings.TrimSpace(workdir) == "" { - workdir = "/tmp/forgejo-runner" - } + workdir = macosRunnerWorkDir(workdir) // Pass all values via stdin script so secrets do not appear in the nsc ssh argv. env := map[string]string{ diff --git a/services/forgejo-nsc/internal/nsc/macos_nsc_test.go b/services/forgejo-nsc/internal/nsc/macos_nsc_test.go new file mode 100644 index 0000000..025b52c --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/macos_nsc_test.go @@ -0,0 +1,26 @@ +package nsc + +import ( + "testing" + "time" +) + +func TestMacOSNSCAttemptConfigForReusesLastFallback(t *testing.T) { + attempts := []macosNSCAttemptConfig{ + {waitTimeout: time.Minute, createTimeout: 2 * time.Minute}, + {waitTimeout: 30 * time.Second, createTimeout: time.Minute}, + } + + got := macosNSCAttemptConfigFor(3, attempts) + want := attempts[len(attempts)-1] + if got != want { + t.Fatalf("macosNSCAttemptConfigFor(3) = %+v, want %+v", got, want) + } +} + +func TestMacOSNSCAttemptConfigForEmpty(t *testing.T) { + got := macosNSCAttemptConfigFor(0, nil) + if got != (macosNSCAttemptConfig{}) { + t.Fatalf("macosNSCAttemptConfigFor(0, nil) = %+v, want zero value", got) + } +} diff --git a/services/forgejo-nsc/internal/nsc/macos_test.go b/services/forgejo-nsc/internal/nsc/macos_test.go new file mode 100644 index 0000000..28fe86b --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/macos_test.go @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..84b4c33 --- /dev/null +++ b/services/grafana/dashboards/burrow-overview.json @@ -0,0 +1,139 @@ +{ + "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 new file mode 100644 index 0000000..12eb33e --- /dev/null +++ b/services/grafana/dashboards/headscale.json @@ -0,0 +1,423 @@ +{ + "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 new file mode 100644 index 0000000..344f44d --- /dev/null +++ b/services/grafana/dashboards/observability.json @@ -0,0 +1,477 @@ +{ + "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 new file mode 100644 index 0000000..4b1c2a2 --- /dev/null +++ b/services/mcp-hub/README.md @@ -0,0 +1,25 @@ +# 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/pages/index.tsx b/site/pages/index.tsx index 20d7f1b..0356c17 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -18,19 +18,6 @@ function ExternalLinkIcon() { ); } -function GithubIcon() { - return ( - - ); -} - export default function Page() { const [chevron, setChevron] = useState(false); const menuButtonRef = useRef(null); @@ -134,10 +121,10 @@ export default function Page() { diff --git a/store-metadata/android/README.md b/store-metadata/android/README.md new file mode 100644 index 0000000..806d885 --- /dev/null +++ b/store-metadata/android/README.md @@ -0,0 +1,22 @@ +# 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 new file mode 100644 index 0000000..c5997ae Binary files /dev/null and b/store-metadata/android/icon.png differ diff --git a/store-metadata/app-store-connect/README.md b/store-metadata/app-store-connect/README.md new file mode 100644 index 0000000..e565b9a --- /dev/null +++ b/store-metadata/app-store-connect/README.md @@ -0,0 +1,21 @@ +# 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 new file mode 100644 index 0000000..82656b1 --- /dev/null +++ b/store-metadata/app-store-connect/testflight/en-US/beta_app_description.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..2de2c1a --- /dev/null +++ b/store-metadata/app-store-connect/testflight/en-US/feedback_email.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..2de2c1a --- /dev/null +++ b/store-metadata/app-store-connect/testflight/review/contact_email.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..d7bed7b --- /dev/null +++ b/store-metadata/app-store-connect/testflight/review/contact_first_name.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..9136ac3 --- /dev/null +++ b/store-metadata/app-store-connect/testflight/review/contact_last_name.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..27ba77d --- /dev/null +++ b/store-metadata/app-store-connect/testflight/review/demo_account_required.txt @@ -0,0 +1 @@ +true diff --git a/store-metadata/app-store-connect/testflight/review/notes.txt b/store-metadata/app-store-connect/testflight/review/notes.txt new file mode 100644 index 0000000..2e500d1 --- /dev/null +++ b/store-metadata/app-store-connect/testflight/review/notes.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..8733e31 --- /dev/null +++ b/store-metadata/flatpak/README.md @@ -0,0 +1,8 @@ +# 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 new file mode 100644 index 0000000..bc446ed --- /dev/null +++ b/store-metadata/sparkle/README.md @@ -0,0 +1,30 @@ +# 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.