diff --git a/.github/workflows/check-platform.yml b/.github/workflows/check-platform.yml index cb4bd437e..ecdb4634b 100644 --- a/.github/workflows/check-platform.yml +++ b/.github/workflows/check-platform.yml @@ -110,7 +110,7 @@ jobs: - name: Set up mold (Linux host link) if: inputs.os == 'linux' - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Build msb run: | @@ -164,7 +164,7 @@ jobs: - name: Set up mold (Linux host link) if: inputs.os == 'linux' - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Check workspace run: cargo +stable check --workspace --exclude microsandbox-agentd @@ -196,12 +196,12 @@ jobs: path: build/ - name: Set up mold (Linux host link) if: inputs.os == 'linux' - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Build Node SDK working-directory: sdk/node-ts run: | node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false + npm ci --ignore-scripts npm run build:ci python-build: @@ -232,7 +232,7 @@ jobs: path: build/ - name: Set up mold (Linux host link) if: inputs.os == 'linux' - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Stage runtime bundle run: | mkdir -p sdk/python/microsandbox/_bundled/bin sdk/python/microsandbox/_bundled/lib diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 536f86e9d..dd4f4e9d8 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -305,7 +305,7 @@ jobs: # mold keeps uncached workspace links fast. - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Build msb run: | @@ -348,7 +348,7 @@ jobs: path: build/ - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Build Go FFI run: cargo build --profile ci -p microsandbox-go @@ -380,12 +380,12 @@ jobs: cache-bin: false cache-targets: true - - uses: taiki-e/install-action@b6b84cf49ebfe0176417bdce007c624f0db37f20 # v2 + - uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: cargo-nextest@0.9.143 - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Install build dependencies run: scripts/ci/install-apt-packages.sh libcap-ng-dev @@ -753,7 +753,7 @@ jobs: cargo +stable clippy --manifest-path crates/agentd/Cargo.toml --target x86_64-unknown-linux-musl -- -D warnings - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Clippy run: cargo +stable clippy --workspace --exclude microsandbox-agentd -- -D warnings @@ -788,7 +788,7 @@ jobs: cache-targets: true - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -815,7 +815,7 @@ jobs: working-directory: sdk/node-ts run: | node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false + npm ci --ignore-scripts npm run build:ci - name: Build MCP server @@ -868,7 +868,7 @@ jobs: cache-targets: true - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: @@ -979,7 +979,7 @@ jobs: cache-targets: true - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev @@ -1038,7 +1038,7 @@ jobs: cache-targets: true - name: Set up mold - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev @@ -1059,7 +1059,7 @@ jobs: run: | gem build microsandbox.gemspec mold -run gem install --local microsandbox-*.gem --no-document - ruby --disable-gems -e 'require "rubygems"; require "microsandbox"; abort unless Microsandbox.version == "0.6.16"' + ruby --disable-gems -e 'require "rubygems"; require "microsandbox"; abort unless Microsandbox.version == "0.6.17"' - name: Restore standalone Ruby lockfile if: always() @@ -1364,6 +1364,35 @@ jobs: go test -count=1 . go test -tags "smoke microsandbox_ffi_path" -count=1 -timeout 2m . + # --------------------------------------------------------------------------- + # Previous-release upgrade smoke test + # + # Creates databases with the two latest released CLIs, then opens them with + # the candidate binary. This catches migration-order and compatibility gaps + # without requiring KVM. + # --------------------------------------------------------------------------- + previous-release-upgrade-smoke: + name: Previous Release Upgrade Smoke + needs: [build-linux-x86_64, changes] + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download candidate runtime + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: msb-linux-x86_64 + path: build/ + + - name: Test released database upgrades + env: + GH_TOKEN: ${{ github.token }} + run: | + chmod +x build/msb + python3 scripts/smoke/cli/previous_release_upgrade.py + # --------------------------------------------------------------------------- # CLI smoke tests (requires KVM) # @@ -1430,7 +1459,7 @@ jobs: - name: Clean runner disk run: scripts/ci/clean-runner-disk.sh - - uses: taiki-e/install-action@b6b84cf49ebfe0176417bdce007c624f0db37f20 # v2 + - uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: cargo-nextest@0.9.143 @@ -1558,7 +1587,7 @@ jobs: working-directory: sdk/node-ts run: | node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false --ignore-scripts + npm ci --ignore-scripts # The published platform-pkg msb may lag the SDK; replace it with the # freshly-built binaries so the smoke test runs against current code. @@ -1925,6 +1954,7 @@ jobs: - ruby-platform-smoke - ruby-platform-smoke-clean - go-quality + - previous-release-upgrade-smoke - cli-smoke-test - integration-test - node-sdk-test diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 990215189..7530d96b2 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -25,7 +25,7 @@ jobs: with: toolchain: nightly - - uses: taiki-e/install-action@b6b84cf49ebfe0176417bdce007c624f0db37f20 # v2 + - uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: cargo-fuzz diff --git a/.github/workflows/publish-runtime-commit.yml b/.github/workflows/publish-runtime-commit.yml new file mode 100644 index 000000000..00503e115 --- /dev/null +++ b/.github/workflows/publish-runtime-commit.yml @@ -0,0 +1,252 @@ +name: Publish Runtime Commit + +on: + workflow_dispatch: + inputs: + commit: + description: Full Microsandbox commit SHA to publish. + required: true + type: string + +concurrency: + group: publish-runtime-${{ inputs.commit }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/superradcompany/microsandbox + +jobs: + resolve: + name: Resolve CI artifacts + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: read + packages: read + outputs: + commit: ${{ steps.resolve.outputs.commit }} + run_id: ${{ steps.resolve.outputs.run_id }} + steps: + - name: Resolve successful Check run + id: resolve + env: + COMMIT: ${{ inputs.commit }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "::error::Run this workflow from the main branch." + exit 1 + fi + + if [[ ! "$COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::commit must be a full, lowercase 40-character SHA." + exit 1 + fi + + resolved_commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$COMMIT" --jq .sha) + if [[ "$resolved_commit" != "$COMMIT" ]]; then + echo "::error::commit did not resolve to the requested SHA." + exit 1 + fi + + runs=$(gh api -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/check.yml/runs" \ + -f head_sha="$COMMIT" \ + -f status=success \ + -f per_page=100) + run_id="" + while IFS= read -r candidate_run_id; do + artifacts=$(gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/runs/$candidate_run_id/artifacts?per_page=100" \ + --jq '.artifacts[] | select(.expired == false) | .name') + if grep -Fxq msb-linux-x86_64 <<<"$artifacts" && + grep -Fxq msb-linux-aarch64 <<<"$artifacts"; then + run_id=$candidate_run_id + break + fi + done < <(jq -r \ + --arg commit "$COMMIT" \ + --arg repository "$GITHUB_REPOSITORY" \ + '[.workflow_runs[] | select( + .head_sha == $commit and + .conclusion == "success" and + .head_repository.full_name == $repository + )] | sort_by(.run_number) | reverse | .[].id' <<<"$runs") + + if [[ -z "$run_id" ]]; then + echo "::error::No successful Check run with both runtime artifacts found for $COMMIT." + exit 1 + fi + + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "Using Check run $run_id for $COMMIT." + + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Refuse an existing commit tag + env: + COMMIT: ${{ steps.resolve.outputs.commit }} + run: | + set -euo pipefail + + error_file=$(mktemp) + if docker buildx imagetools inspect "$IMAGE:$COMMIT" >/dev/null 2>"$error_file"; then + echo "::error::$IMAGE:$COMMIT already exists and will not be overwritten." + exit 1 + fi + + if ! grep -Eqi 'not found|manifest unknown' "$error_file"; then + cat "$error_file" >&2 + echo "::error::Unable to determine whether $IMAGE:$COMMIT already exists." + exit 1 + fi + + build: + name: Package runtime (${{ matrix.arch }}) + needs: resolve + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + actions: read + contents: read + packages: write + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + artifact: msb-linux-x86_64 + - arch: arm64 + runner: ubuntu-24.04-arm + artifact: msb-linux-aarch64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Download runtime artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact }} + path: runtime + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ needs.resolve.outputs.run_id }} + + - name: Stage runtime + env: + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + + mapfile -t firmware < <(find runtime -maxdepth 1 -type f \ + -name 'libkrunfw.so.*.*.*' -print) + if [[ ! -f runtime/msb || ${#firmware[@]} -ne 1 ]]; then + echo "::error::Runtime artifact does not contain msb and one versioned libkrunfw." + exit 1 + fi + + destination="packaging/docker/build/$ARCH" + mkdir -p "$destination" + install -m 755 runtime/msb "$destination/msb" + install -m 644 "${firmware[0]}" "$destination/$(basename "${firmware[0]}")" + + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push runtime image by digest + id: push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: packaging/docker + platforms: linux/${{ matrix.arch }} + labels: org.opencontainers.image.revision=${{ needs.resolve.outputs.commit }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Export image digest + env: + ARCH: ${{ matrix.arch }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + mkdir -p digests + echo "$DIGEST" > "digests/$ARCH.txt" + + - name: Upload image digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: runtime-digest-${{ matrix.arch }} + path: digests/${{ matrix.arch }}.txt + + publish: + name: Publish commit tag + needs: [resolve, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + packages: write + steps: + - name: Download image digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: digests + pattern: runtime-digest-* + merge-multiple: true + + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create immutable commit manifest + env: + COMMIT: ${{ needs.resolve.outputs.commit }} + run: | + set -euo pipefail + + if docker buildx imagetools inspect "$IMAGE:$COMMIT" >/dev/null 2>&1; then + echo "::error::$IMAGE:$COMMIT was created while this workflow was running." + exit 1 + fi + + amd64_digest=$(cat digests/amd64.txt) + arm64_digest=$(cat digests/arm64.txt) + docker buildx imagetools create \ + -t "$IMAGE:$COMMIT" \ + "$IMAGE@$amd64_digest" \ + "$IMAGE@$arm64_digest" + + inspection=$(docker buildx imagetools inspect "$IMAGE:$COMMIT") + digest=$(awk '$1 == "Digest:" { print $2; exit }' <<<"$inspection") + if [[ -z "$digest" ]] || + ! grep -Fq 'linux/amd64' <<<"$inspection" || + ! grep -Fq 'linux/arm64' <<<"$inspection"; then + echo "$inspection" + echo "::error::Published manifest did not contain both expected platforms." + exit 1 + fi + + { + echo "## Runtime published" + echo + echo "- Image: \`$IMAGE:$COMMIT\`" + echo "- Digest: \`$digest\`" + echo "- Check run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ needs.resolve.outputs.run_id }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 05d375d82..42d32a804 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -251,7 +251,7 @@ jobs: working-directory: sdk/node-ts run: | node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false + npm ci npm run build:native -- --target ${{ inputs.napi_target }} node -e 'require("./native/index.cjs")' diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index fb8b4a1ef..45ae68275 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -241,7 +241,7 @@ jobs: working-directory: sdk/node-ts run: | node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false + npm ci npm run build:native -- --target ${{ inputs.napi_target }} node -e 'require("./native/index.cjs")' diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 7ebbdea31..42da6c1bf 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -270,7 +270,7 @@ jobs: . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1" Set-MsvcEnvironment -Architecture ${{ inputs.vs_arch }} -HostArchitecture ${{ inputs.vs_host_arch }} node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false + npm ci npm run build:native -- --target ${{ inputs.napi_target }} node -e "require('./native/index.cjs')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b38b0f52b..286f1ddc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -610,7 +610,7 @@ jobs: working-directory: sdk/node-ts run: | node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false + npm ci npm run build:native -- --target ${{ matrix.napi_target }} npm run build:ts @@ -832,7 +832,7 @@ jobs: . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1" Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }} node scripts/prune-platform-optional-deps.mjs - npm install --package-lock=false + npm ci npm run build:native -- --target ${{ matrix.napi_target }} npm run build:ts diff --git a/.github/workflows/test-platform.yml b/.github/workflows/test-platform.yml index 4900a6043..f0b429406 100644 --- a/.github/workflows/test-platform.yml +++ b/.github/workflows/test-platform.yml @@ -68,7 +68,7 @@ jobs: - name: Set up mold (Linux host link) if: inputs.os == 'linux' - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0 + uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # 2.41.0 - name: Test workspace run: cargo +stable test --workspace --exclude microsandbox-agentd diff --git a/Cargo.lock b/Cargo.lock index 11ef916a8..dbcdb6cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,12 +211,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" -[[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.8" @@ -394,7 +388,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] @@ -596,7 +590,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -800,11 +794,10 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.6" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", @@ -970,9 +963,9 @@ dependencies = [ [[package]] name = "cap-primitives" -version = "4.0.2" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" dependencies = [ "ambient-authority", "fs-set-times", @@ -988,9 +981,9 @@ dependencies = [ [[package]] name = "cap-std" -version = "4.0.2" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" dependencies = [ "cap-primitives", "io-extras", @@ -1214,7 +1207,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1722,15 +1715,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "defmt" -version = "0.3.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" -dependencies = [ - "defmt 1.0.1", -] - [[package]] name = "defmt" version = "1.0.1" @@ -1760,7 +1744,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1794,7 +1778,7 @@ dependencies = [ "swc_ecma_parser", "swc_eq_ignore_macros", "text_lines", - "thiserror", + "thiserror 2.0.20", "unicode-width", "url", ] @@ -2318,12 +2302,12 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -2495,7 +2479,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2630,9 +2614,9 @@ dependencies = [ [[package]] name = "granit-parser" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccd1be9ebf2bd5520dbfcfb72b70ef492f654061299a097056f3e73410e38ec" +checksum = "65ec0d45986cd51c847c75c5b69a00852c4fc84d0e5e79f041173f73437d0cdf" dependencies = [ "arraydeque", "smallvec", @@ -2809,7 +2793,7 @@ dependencies = [ "jni", "rand 0.10.2", "rustls", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tokio", "tokio-rustls", @@ -2830,7 +2814,7 @@ dependencies = [ "once_cell", "rand 0.10.2", "ring", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -2975,9 +2959,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3373,7 +3357,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3722,9 +3706,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.2" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" dependencies = [ "hashbrown 0.17.1", ] @@ -3815,7 +3799,7 @@ dependencies = [ [[package]] name = "microsandbox" -version = "0.6.16" +version = "0.6.17" dependencies = [ "anyhow", "astral-tokio-tar", @@ -3866,7 +3850,7 @@ dependencies = [ "tar", "tempfile", "test-utils", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-tungstenite", @@ -3880,20 +3864,20 @@ dependencies = [ [[package]] name = "microsandbox-agent-client" -version = "0.6.16" +version = "0.6.17" dependencies = [ "ciborium", "microsandbox-protocol", "serde", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", ] [[package]] name = "microsandbox-agentd" -version = "0.6.16" +version = "0.6.17" dependencies = [ "base64 0.23.1", "chrono", @@ -3903,14 +3887,14 @@ dependencies = [ "nix 0.31.3", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "tokio", "typed-path", ] [[package]] name = "microsandbox-cli" -version = "0.6.16" +version = "0.6.17" dependencies = [ "anyhow", "base64 0.23.1", @@ -3945,7 +3929,7 @@ dependencies = [ "serde_json", "tempfile", "test-utils", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -3955,7 +3939,7 @@ dependencies = [ [[package]] name = "microsandbox-db" -version = "0.6.16" +version = "0.6.17" dependencies = [ "async-trait", "sea-orm", @@ -3967,7 +3951,7 @@ dependencies = [ [[package]] name = "microsandbox-filesystem" -version = "0.6.16" +version = "0.6.17" dependencies = [ "libc", "microsandbox-utils", @@ -3980,7 +3964,7 @@ dependencies = [ [[package]] name = "microsandbox-go" -version = "0.6.16" +version = "0.6.17" dependencies = [ "base64 0.23.1", "cbindgen", @@ -3998,7 +3982,7 @@ dependencies = [ [[package]] name = "microsandbox-image" -version = "0.6.16" +version = "0.6.17" dependencies = [ "astral-tokio-tar", "async-compression", @@ -4017,7 +4001,7 @@ dependencies = [ "sha2 0.11.0", "tar", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -4026,19 +4010,19 @@ dependencies = [ [[package]] name = "microsandbox-metrics" -version = "0.6.16" +version = "0.6.17" dependencies = [ "chrono", "libc", "tempfile", - "thiserror", + "thiserror 2.0.20", "tracing", "windows-sys 0.61.2", ] [[package]] name = "microsandbox-metrics-collector" -version = "0.6.16" +version = "0.6.17" dependencies = [ "anyhow", "async-trait", @@ -4057,7 +4041,7 @@ dependencies = [ "sea-orm", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tonic", "tracing", @@ -4066,7 +4050,7 @@ dependencies = [ [[package]] name = "microsandbox-migration" -version = "0.6.16" +version = "0.6.17" dependencies = [ "sea-orm-migration", "serde_json", @@ -4075,7 +4059,7 @@ dependencies = [ [[package]] name = "microsandbox-network" -version = "0.6.16" +version = "0.6.17" dependencies = [ "base64 0.22.1", "bytes", @@ -4096,7 +4080,7 @@ dependencies = [ "msb_krun", "msb_krun_utils", "parking_lot", - "pem", + "pem 3.0.6", "percent-encoding", "rcgen", "resolv-conf", @@ -4108,10 +4092,11 @@ dependencies = [ "smoltcp", "socket2", "system-configuration", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tokio-rustls", + "tokio-socks", "tracing", "windows-sys 0.61.2", "zeroize", @@ -4119,7 +4104,7 @@ dependencies = [ [[package]] name = "microsandbox-node" -version = "0.6.16" +version = "0.6.17" dependencies = [ "bytes", "chrono", @@ -4136,7 +4121,7 @@ dependencies = [ [[package]] name = "microsandbox-protocol" -version = "0.6.16" +version = "0.6.17" dependencies = [ "chrono", "ciborium", @@ -4145,13 +4130,13 @@ dependencies = [ "serde_bytes", "serde_json", "strum 0.28.0", - "thiserror", + "thiserror 2.0.20", "tokio", ] [[package]] name = "microsandbox-py" -version = "0.6.16" +version = "0.6.17" dependencies = [ "chrono", "futures", @@ -4168,7 +4153,7 @@ dependencies = [ [[package]] name = "microsandbox-runtime" -version = "0.6.16" +version = "0.6.17" dependencies = [ "bytes", "chrono", @@ -4194,7 +4179,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "windows-sys 0.61.2", @@ -4203,7 +4188,7 @@ dependencies = [ [[package]] name = "microsandbox-types" -version = "0.6.16" +version = "0.6.17" dependencies = [ "chrono", "dprint-plugin-typescript", @@ -4212,7 +4197,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.20", "ts-rs", "typed-path", "utoipa", @@ -4221,16 +4206,16 @@ dependencies = [ [[package]] name = "microsandbox-types-macros" -version = "0.6.16" +version = "0.6.17" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] name = "microsandbox-utils" -version = "0.6.16" +version = "0.6.17" dependencies = [ "dirs", "libc", @@ -4243,7 +4228,7 @@ dependencies = [ [[package]] name = "microsandbox-vsock" -version = "0.6.16" +version = "0.6.17" dependencies = [ "libc", "msb_krun", @@ -4270,6 +4255,15 @@ name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -4321,7 +4315,7 @@ dependencies = [ "async-trait", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "msb-vm-memory", "nix 0.30.1", "page_size", @@ -4338,7 +4332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c31dfbf17e640f6d661ae17e098b87c019821fe3eb4aba0e9eeada4ba678096" dependencies = [ "libc", - "thiserror", + "thiserror 2.0.20", "winapi", ] @@ -4511,9 +4505,9 @@ dependencies = [ [[package]] name = "napi" -version = "3.12.1" +version = "3.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459197f1592f4c3dbbf9c1b13f5a4599a343e4ef66b96bc340e2a518b36a6662" +checksum = "58c5f4d5375213fdb7be2655e152386e82f026f9a5ba36a75556e11359aafe09" dependencies = [ "bitflags 2.11.1", "ctor", @@ -4844,7 +4838,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "unicase", @@ -4864,7 +4858,7 @@ dependencies = [ "serde_json", "strum 0.27.2", "strum_macros 0.27.2", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -4881,7 +4875,7 @@ dependencies = [ "serde_json", "strum 0.27.2", "strum_macros 0.27.2", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -4936,7 +4930,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror", + "thiserror 2.0.20", "tracing", ] @@ -4966,7 +4960,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", - "thiserror", + "thiserror 2.0.20", "tokio", "tonic", "tonic-types", @@ -4998,7 +4992,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.4", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -5115,7 +5109,7 @@ dependencies = [ "log", "rand 0.10.2", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.20", "tokio", "windows", "windows-strings", @@ -5179,6 +5173,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -5660,7 +5664,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5683,7 +5687,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5837,11 +5841,11 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.9" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ - "pem", + "pem 4.0.0", "ring", "rustls-pki-types", "time", @@ -5875,7 +5879,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -6106,9 +6110,9 @@ dependencies = [ [[package]] name = "russh" -version = "0.62.6" +version = "0.63.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b41043523e0edcbd4e31d00903e26f12994f63b21bae9904f7405c1ed92752a5" +checksum = "35bab1b87d915817d5d9cc352637cd40d5f0b298a48c6309af9156a4addc3031" dependencies = [ "aes 0.9.0", "aws-lc-rs", @@ -6169,7 +6173,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror", + "thiserror 2.0.20", "tokio", "typenum", "universal-hash", @@ -6202,7 +6206,7 @@ dependencies = [ "log", "serde", "serde_bytes", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -6467,7 +6471,7 @@ dependencies = [ "sqlx", "sqlx-core", "strum 0.28.0", - "thiserror", + "thiserror 2.0.20", "time", "tracing", "url", @@ -6483,7 +6487,7 @@ checksum = "5c2eee8405f16c1f337fe3a83389361caea83c928d14dbd666a480407072c365" dependencies = [ "arrow", "sea-query", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -6560,7 +6564,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -6697,9 +6701,9 @@ dependencies = [ [[package]] name = "serde-saphyr" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ec1f5cac0eb96063c64b28705255a7ed6e7d77f95c1d25e9f8b8c928006ce1" +checksum = "3afb591f9cdb6223c88ba39269aff895620c7f0716dc42b705b5733d5c7c0823" dependencies = [ "annotate-snippets", "encoding_rs_io", @@ -6736,7 +6740,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -7005,14 +7009,14 @@ dependencies = [ [[package]] name = "smoltcp" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f" dependencies = [ "bitflags 1.3.2", "byteorder", "cfg-if", - "defmt 0.3.100", + "defmt", "heapless", "managed", ] @@ -7103,7 +7107,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -7175,7 +7179,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "time", "tracing", "uuid", @@ -7213,7 +7217,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.20", "time", "tracing", "uuid", @@ -7240,7 +7244,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "time", "tracing", "url", @@ -7560,9 +7564,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -7657,23 +7661,23 @@ dependencies = [ [[package]] name = "test-init" -version = "0.6.16" +version = "0.6.17" dependencies = [ "libc", ] [[package]] name = "test-macros" -version = "0.6.16" +version = "0.6.17" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] name = "test-utils" -version = "0.6.16" +version = "0.6.17" dependencies = [ "tempfile", "test-macros", @@ -7688,13 +7692,33 @@ dependencies = [ "serde", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -7705,7 +7729,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -7819,6 +7843,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -8120,7 +8156,7 @@ checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ "chrono", "serde_json", - "thiserror", + "thiserror 2.0.20", "ts-rs-macros", ] @@ -8151,7 +8187,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1 0.11.0", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -8634,9 +8670,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.5" +version = "8.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" dependencies = [ "libc", ] @@ -9117,7 +9153,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 29db42f0a..194357b9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ members = [ [workspace.package] authors = ["Super Rad Company "] repository = "https://github.com/superradcompany/microsandbox" -version = "0.6.16" +version = "0.6.17" license = "Apache-2.0" edition = "2024" @@ -77,20 +77,20 @@ strip = false # Exact (`=`) pins: these crates share private, unstable APIs across patch # releases, so every published version must resolve its siblings to the same # version. Caret would let an older release pull newer siblings and break. -microsandbox = { version = "=0.6.16", path = "sdk/rust", default-features = false } -microsandbox-agent-client = { version = "=0.6.16", path = "packages/agent-client/rust" } -microsandbox-db = { version = "=0.6.16", path = "crates/db" } -microsandbox-filesystem = { version = "=0.6.16", path = "crates/filesystem", default-features = false } -microsandbox-image = { version = "=0.6.16", path = "crates/image" } -microsandbox-metrics = { version = "=0.6.16", path = "crates/metrics" } -microsandbox-types-macros = { version = "=0.6.16", path = "packages/microsandbox-types/macros" } -microsandbox-types = { version = "=0.6.16", path = "packages/microsandbox-types/rust" } -microsandbox-migration = { version = "=0.6.16", path = "crates/migration" } -microsandbox-network = { version = "=0.6.16", path = "crates/network" } -microsandbox-protocol = { version = "=0.6.16", path = "crates/protocol" } -microsandbox-runtime = { version = "=0.6.16", path = "crates/runtime", default-features = false } -microsandbox-utils = { version = "=0.6.16", path = "crates/utils" } -microsandbox-vsock = { version = "=0.6.16", path = "crates/vsock" } +microsandbox = { version = "=0.6.17", path = "sdk/rust", default-features = false } +microsandbox-agent-client = { version = "=0.6.17", path = "packages/agent-client/rust" } +microsandbox-db = { version = "=0.6.17", path = "crates/db" } +microsandbox-filesystem = { version = "=0.6.17", path = "crates/filesystem", default-features = false } +microsandbox-image = { version = "=0.6.17", path = "crates/image" } +microsandbox-metrics = { version = "=0.6.17", path = "crates/metrics" } +microsandbox-types-macros = { version = "=0.6.17", path = "packages/microsandbox-types/macros" } +microsandbox-types = { version = "=0.6.17", path = "packages/microsandbox-types/rust" } +microsandbox-migration = { version = "=0.6.17", path = "crates/migration" } +microsandbox-network = { version = "=0.6.17", path = "crates/network" } +microsandbox-protocol = { version = "=0.6.17", path = "crates/protocol" } +microsandbox-runtime = { version = "=0.6.17", path = "crates/runtime", default-features = false } +microsandbox-utils = { version = "=0.6.17", path = "crates/utils" } +microsandbox-vsock = { version = "=0.6.17", path = "crates/vsock" } msb_krun = "=0.1.32" msb_krun_utils = "=0.1.32" test-macros = { path = "crates/testing/macros" } @@ -139,7 +139,7 @@ serde_bytes = "0.11" serde_json = "1.0" serde-saphyr = { version = "1.0.1", default-features = false, features = ["deserialize"] } sha2 = "0.11.0" -smoltcp = { version = "0.13", default-features = false } +smoltcp = { version = "0.14", default-features = false } socket2 = "0.6" strum = { version = "0.28", features = ["derive"] } tar = "0.4" @@ -163,6 +163,7 @@ tokio = { version = "1.52", features = [ "sync", "time", ] } +tokio-socks = "0.5.3" tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" @@ -205,5 +206,5 @@ rcgen = { version = "0.14", features = ["x509-parser"] } lru = "0.18.0" parking_lot = "0.12" rpassword = "7" -russh = "0.62.4" +russh = "0.63.1" russh-sftp = "2.3.0" diff --git a/Dockerfile.agentd b/Dockerfile.agentd index b71594681..81aeb7851 100644 --- a/Dockerfile.agentd +++ b/Dockerfile.agentd @@ -15,6 +15,7 @@ resolver = "3" members = [ "crates/agentd", "crates/protocol", + "packages/microsandbox-types/macros", "packages/microsandbox-types/rust", ] @@ -33,6 +34,7 @@ ipnetwork = { version = "0.21.0", features = ["serde"] } libc = "0.2" microsandbox-protocol = { path = "crates/protocol" } microsandbox-types = { path = "packages/microsandbox-types/rust" } +microsandbox-types-macros = { path = "packages/microsandbox-types/macros" } nix = "0.31" serde = { version = "1.0", features = ["derive"] } serde_bytes = "0.11" diff --git a/README.md b/README.md index 0de6dea25..db4e14090 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,35 @@ Practical ways to put microsandbox to work:
+## people-darkpeople  Community Showcase + +####   Agent frameworks & runtimes + +> • **[Eve](https://eve.dev/docs/sandbox#microsandbox) by Vercel**: Agent framework that ships microsandbox as a sandbox backend.
+> • **[Agentic Coding Quickstart](https://github.com/GSA-TTS/agentic-coding-quickstart) by U.S. GSA**: From zero to a running AI coding agent with USAi in minutes.
+> • **[Condukt](https://github.com/tuist/condukt) and [Once](https://github.com/tuist/once) by Tuist**: Elixir agentic engine, and cacheable actions that run in fresh sandboxes.
+> • **[langchain-microsandbox](https://github.com/kenwoodjw/langchain-microsandbox) by kenwoodjw**: Microsandbox integration for LangChain Deep Agents.
+> • **[Smithers](https://github.com/smithersai/smithers) by Smithers**: Agent workflows with full observability, rewind, fork, and replay.
+> • **[wrap](https://github.com/tobi/wrap) by Tobi Lütke**: Run coding agents and project commands in isolated Arch Linux microVMs.
+> • **[Agent VM](https://github.com/wirenboard/agent-vm) by Wiren Board**: Run AI agents in safe VMs scoped to a local folder. + +####   Tools & infrastructure + +> • **[h5i](https://github.com/h5i-dev/h5i) by h5i**: Secure, auditable browser for AI agents, written in pure Rust.
+> • **[Devsy](https://github.com/devsy-org/devsy) by Devsy**: Deploy devcontainers onto any cloud, Kubernetes cluster, or Docker host.
+> • **[OpenWork](https://github.com/different-ai/openwork) by Different AI**: Open-source Claude Cowork alternative with a microsandbox image. + +####   Guides & showcases + +> • **[Awesome Microsandbox](https://github.com/ya-luotao/awesome-microsandbox) by ya-luotao**: Curated list of SDKs, integrations, tools, and resources.
+> • **[msb-omarchy](https://github.com/ya-luotao/msb-omarchy) by ya-luotao**: Omarchy desktop with graphics inside a microVM on Apple Silicon. + +
+ +Share a Project + +
+ ## agents-darkagents  AI Agents ####   Agent Skills @@ -415,23 +444,6 @@ Practical ways to put microsandbox to work:
-## people-darkpeople  Projects using microsandbox - -> ⏵ **[Eve by Vercel](https://eve.dev/docs/sandbox#microsandbox)**
-> ⏵ **[GSA TTS Agentic Coding Quickstart](https://github.com/GSA-TTS/agentic-coding-quickstart)**
-> ⏵ **[agent-compose by Chaitin](https://github.com/chaitin/agent-compose)**
-> ⏵ **[Condukt](https://github.com/tuist/condukt) and [Once](https://github.com/tuist/once) by Tuist**
-> ⏵ **[h5i](https://github.com/h5i-dev/h5i)**
-> ⏵ **[Smithers](https://github.com/smithersai/smithers)**
-> ⏵ **[sandboxed-lit by LlamaIndex](https://github.com/run-llama/sandboxed-lit)**
-> ⏵ **[Agentic Usability by PSPDFKit Labs](https://github.com/PSPDFKit-labs/agentic-usability)**
-> ⏵ **[Agent VM by Wiren Board](https://github.com/wirenboard/agent-vm)**
-> ⏵ **[Devsy](https://github.com/devsy-org/devsy)** - -> Built something with microsandbox? [Share it with us on Discord](https://discord.gg/T95Y3XnEAK). We’d love to feature it here. - -
- ## docs-darkdocs  Documentation For guides, API references, and examples, visit the [microsandbox documentation](https://docs.microsandbox.dev). diff --git a/crates/cli/lib/commands/common.rs b/crates/cli/lib/commands/common.rs index 582b75624..19e189199 100644 --- a/crates/cli/lib/commands/common.rs +++ b/crates/cli/lib/commands/common.rs @@ -4,13 +4,15 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use clap::{Arg, ArgAction, ArgMatches, Args, Command, FromArgMatches}; -use microsandbox::VolumeKind; use microsandbox::backend::{Backend, LocalBackend}; use microsandbox::sandbox::{ CpuPlacement, DeploymentProfile, DiskImageFormat, FlatClone, MountBuilder, Patch, RootDiskBuilder, Sandbox, SandboxBuilder, SandboxHandle, SecurityProfile, TransparentHugePagePolicy, VolumeMount, VsockSocketType, }; +use microsandbox::{OutboundProxy, VolumeKind}; +#[cfg(feature = "net")] +use microsandbox_network::{OutboundProxyBuilder, OutboundProxyConfig}; #[cfg(feature = "net")] use microsandbox_types::NetworkRateLimitDirection; @@ -496,6 +498,35 @@ pub struct SandboxOpts { #[arg(long)] pub trust_host_cas: bool, + /// Dial all outbound sandbox connections through this proxy. + /// Supports the socks4:// and socks5:// protocols. + #[cfg(feature = "net")] + #[arg(long, value_name = "socks[4|5]://IP:PORT")] + pub proxy: Option, + + /// Optional user ID for a SOCKS4 proxy. + #[cfg(feature = "net")] + #[arg(long, value_name = "USER_ID", requires = "proxy")] + pub socks4_user_id: Option, + + /// Username for SOCKS5 username/password authentication. + #[cfg(feature = "net")] + #[arg( + long, + value_name = "USERNAME", + requires_all = ["proxy", "socks5_password_env"] + )] + pub socks5_username: Option, + + /// Host environment variable containing the SOCKS5 password. + #[cfg(feature = "net")] + #[arg( + long, + value_name = "ENV_VAR", + requires_all = ["proxy", "socks5_username"] + )] + pub socks5_password_env: Option, + // --- TLS interception --- /// Intercept and inspect HTTPS traffic via a built-in TLS proxy. #[cfg(feature = "net")] @@ -618,6 +649,312 @@ enum CopyKind { //-------------------------------------------------------------------------------------------------- impl SandboxOpts { + /// Builds the protocol-specific proxy selected by the CLI flags. + #[cfg(feature = "net")] + fn build_outbound_proxy(&self) -> anyhow::Result> { + let Some(raw) = self.proxy.as_deref() else { + if self.socks4_user_id.is_some() + || self.socks5_username.is_some() + || self.socks5_password_env.is_some() + { + anyhow::bail!("proxy authentication flags require --proxy"); + } + return Ok(None); + }; + + match raw.parse::()? { + OutboundProxy::Socks4 { address, .. } => { + if self.socks5_username.is_some() || self.socks5_password_env.is_some() { + anyhow::bail!( + "--socks5-username and --socks5-password-env require a socks5:// proxy" + ); + } + + let proxy = OutboundProxyBuilder::new().socks4(address.to_string()); + let proxy = match self.socks4_user_id.as_deref() { + Some(user_id) => proxy.user_id(user_id), + None => proxy, + }; + Ok(Some(proxy.build()?)) + } + OutboundProxy::Socks5 { address, .. } => { + if self.socks4_user_id.is_some() { + anyhow::bail!("--socks4-user-id requires a socks4:// proxy"); + } + + let proxy = OutboundProxyBuilder::new().socks5(address.to_string()); + let proxy = match ( + self.socks5_username.as_deref(), + self.socks5_password_env.as_deref(), + ) { + (Some(username), Some(password_env)) => { + proxy.credentials(username, microsandbox::SecretSource::env(password_env)) + } + (Some(_), None) => { + anyhow::bail!("--socks5-username requires --socks5-password-env") + } + (None, Some(_)) => { + anyhow::bail!("--socks5-password-env requires --socks5-username") + } + (None, None) => proxy, + }; + Ok(Some(proxy.build()?)) + } + _ => anyhow::bail!("unsupported outbound proxy protocol"), + } + } + + /// Resolves the root disk specification selected by the CLI flags. + fn root_disk_spec(&self) -> anyhow::Result> { + self.root_disk + .as_deref() + .or(self.oci_upper_size.as_deref()) + .map(parse_root_disk_spec) + .transpose() + } + + /// Resolves script flags into a deduplicated list preserving argv order. + fn collect_scripts(&self) -> anyhow::Result> { + use std::collections::HashSet; + + let mut scripts = + Vec::with_capacity(self.script.len() + self.script_raw.len() + self.script_path.len()); + let mut seen = HashSet::new(); + + for spec in &self.script { + let (name, body) = parse_script_spec(spec, "script")?; + if !seen.insert(name.clone()) { + anyhow::bail!("script name '{name}' specified more than once"); + } + let decoded = decode_script_escapes(&body); + scripts.push((name, wrap_shell_script(self.shell.as_deref(), &decoded))); + } + for spec in &self.script_raw { + let (name, body) = parse_script_spec(spec, "script-raw")?; + if !seen.insert(name.clone()) { + anyhow::bail!("script name '{name}' specified more than once"); + } + scripts.push((name, body)); + } + for spec in &self.script_path { + let (name, content) = parse_script_path(spec)?; + if !seen.insert(name.clone()) { + anyhow::bail!("script name '{name}' specified more than once"); + } + scripts.push((name, content)); + } + + Ok(scripts) + } + + /// Returns true when any CLI flag requires building network configuration. + #[cfg(feature = "net")] + fn has_network_config(&self) -> bool { + self.no_dns_rebind_protection + || !self.dns_nameserver.is_empty() + || self.dns_query_timeout_ms.is_some() + || !self.net_rule.is_empty() + || !self.net.is_empty() + || self.no_net + || self.net_default.is_some() + || self.net_default_egress.is_some() + || self.net_default_ingress.is_some() + || self.net_ipv4_pool.is_some() + || self.net_ipv6_pool.is_some() + || self.net_egress_bandwidth.is_some() + || self.net_egress_bandwidth_burst.is_some() + || self.net_egress_ops.is_some() + || self.net_egress_ops_burst.is_some() + || self.net_ingress_bandwidth.is_some() + || self.net_ingress_bandwidth_burst.is_some() + || self.net_ingress_ops.is_some() + || self.net_ingress_ops_burst.is_some() + || self.max_connections.is_some() + || self.trust_host_cas + || self.tls_intercept + || !self.tls_intercept_port.is_empty() + || !self.tls_bypass.is_empty() + || self.no_block_quic + || self.tls_intercept_ca_cert.is_some() + || self.tls_intercept_ca_key.is_some() + || !self.tls_upstream_ca_cert.is_empty() + || !self.tls_upstream_ca_cert_for.is_empty() + || !self.tls_no_verify_upstream_for.is_empty() + || self.on_secret_violation.is_some() + } + + /// Builds one direction of the network rate limiter from its related flags. + #[cfg(feature = "net")] + fn build_rate_limiter( + &self, + direction: NetworkRateLimitDirection, + ) -> anyhow::Result> { + let (bandwidth, bandwidth_burst, ops, ops_burst) = match direction { + NetworkRateLimitDirection::Egress => ( + self.net_egress_bandwidth.as_deref(), + self.net_egress_bandwidth_burst.as_deref(), + self.net_egress_ops.as_deref(), + self.net_egress_ops_burst, + ), + NetworkRateLimitDirection::Ingress => ( + self.net_ingress_bandwidth.as_deref(), + self.net_ingress_bandwidth_burst.as_deref(), + self.net_ingress_ops.as_deref(), + self.net_ingress_ops_burst, + ), + }; + + if bandwidth.is_none() && bandwidth_burst.is_none() && ops.is_none() && ops_burst.is_none() + { + return Ok(None); + } + + let bandwidth = bandwidth + .map(|spec| { + parse_rate(&format!("--net-{direction}-bandwidth"), spec, |s| { + ui::parse_size_bytes(s).map_err(anyhow::Error::msg) + }) + }) + .transpose()?; + let bandwidth_burst = bandwidth_burst + .map(|s| { + ui::parse_size_bytes(s) + .map_err(|e| anyhow::anyhow!("--net-{direction}-bandwidth-burst: {e}")) + }) + .transpose()?; + let ops = ops + .map(|spec| { + parse_rate(&format!("--net-{direction}-ops"), spec, |s| { + s.parse::().map_err(anyhow::Error::from) + }) + }) + .transpose()?; + + Ok(Some(CliRateLimiter { + bandwidth, + bandwidth_burst, + ops, + ops_burst, + })) + } + + /// Builds the network policy selected by the related CLI flags. + #[cfg(feature = "net")] + fn build_network_policy( + &self, + ) -> anyhow::Result> { + use microsandbox_network::policy::{Action, NetworkPolicy, NetworkProfile}; + + use crate::net_rule::parse_rule_list; + + let no_flags = self.net.is_empty() + && self.net_rule.is_empty() + && !self.no_net + && self.net_default.is_none() + && self.net_default_egress.is_none() + && self.net_default_ingress.is_none(); + if no_flags { + return Ok(None); + } + + let mut rules = Vec::new(); + for arg in &self.net_rule { + let parsed = parse_rule_list(arg).map_err(anyhow::Error::from)?; + rules.extend(parsed); + } + + if !self.net.is_empty() { + let mut profiles = Vec::new(); + let mut terminal = None; + for raw in self + .net + .iter() + .flat_map(|arg| arg.split(',')) + .map(str::trim) + { + if raw.is_empty() { + anyhow::bail!( + "empty --net profile; expected public, private, host, all, or none" + ); + } + match raw { + "public" => profiles.push(NetworkProfile::Public), + "private" => profiles.push(NetworkProfile::Private), + "host" => profiles.push(NetworkProfile::Host), + "all" | "none" => { + if let Some(previous) = terminal { + if previous == raw { + anyhow::bail!("--net `{raw}` may only be specified once"); + } + anyhow::bail!( + "--net terminal profiles `all` and `none` cannot be combined" + ); + } + terminal = Some(raw); + } + other => anyhow::bail!( + "unknown --net profile {other:?}; expected public, private, host, all, or none" + ), + } + } + if let Some(terminal) = terminal { + if !profiles.is_empty() { + anyhow::bail!( + "--net `{terminal}` cannot be combined with public, private, or host" + ); + } + let mut policy = match terminal { + "all" => NetworkPolicy::allow_all(), + "none" => NetworkPolicy::none(), + _ => unreachable!("validated terminal profile"), + }; + policy.rules = rules; + return Ok(Some(policy)); + } + + let mut policy = NetworkPolicy::from_profiles(profiles); + rules.append(&mut policy.rules); + policy.rules = rules; + return Ok(Some(policy)); + } + + let parse_action = |label: &str, raw: &str| -> anyhow::Result { + match raw { + "allow" => Ok(Action::Allow), + "deny" => Ok(Action::Deny), + other => { + anyhow::bail!("unknown {label} value {other:?}; expected `allow` or `deny`") + } + } + }; + + let symmetric = if self.no_net { + Some(Action::Deny) + } else if let Some(raw) = self.net_default.as_deref() { + Some(parse_action("--net-default", raw)?) + } else { + None + }; + + let baseline = NetworkPolicy::default(); + let default_egress = match (symmetric, self.net_default_egress.as_deref()) { + (_, Some(raw)) => parse_action("--net-default-egress", raw)?, + (Some(action), None) => action, + (None, None) => baseline.default_egress, + }; + let default_ingress = match (symmetric, self.net_default_ingress.as_deref()) { + (_, Some(raw)) => parse_action("--net-default-ingress", raw)?, + (Some(action), None) => action, + (None, None) => baseline.default_ingress, + }; + + Ok(Some(NetworkPolicy { + default_egress, + default_ingress, + rules, + })) + } + /// Returns true if any creation-time configuration flag was set. pub fn has_creation_flags(&self) -> bool { let base = self.cpus.is_some() @@ -680,6 +1017,10 @@ impl SandboxOpts { || self.net_ingress_ops_burst.is_some() || self.max_connections.is_some() || self.trust_host_cas + || self.proxy.is_some() + || self.socks4_user_id.is_some() + || self.socks5_username.is_some() + || self.socks5_password_env.is_some() || self.tls_intercept || !self.tls_intercept_port.is_empty() || !self.tls_bypass.is_empty() @@ -966,12 +1307,7 @@ fn apply_sandbox_opts_inner( } // --- Scripts --- - for (name, content) in collect_scripts( - opts.shell.as_deref(), - &opts.script, - &opts.script_raw, - &opts.script_path, - )? { + for (name, content) in opts.collect_scripts()? { builder = builder.script(name, content); } @@ -991,8 +1327,7 @@ fn apply_sandbox_opts_inner( if let Some(ref pull) = opts.pull { builder = builder.pull_policy(parse_pull_policy(pull)?); } - if let Some(spec) = opts.root_disk.as_deref().or(opts.oci_upper_size.as_deref()) { - let spec = parse_root_disk_spec(spec)?; + if let Some(spec) = opts.root_disk_spec()? { builder = builder.root_disk_with(|d| spec.apply(d)); } if let Some(ref security) = opts.security { @@ -2038,40 +2373,10 @@ fn apply_network_opts( }); } + let proxy = opts.build_outbound_proxy()?; + // DNS, TLS, and other network configuration. - let has_network_config = opts.no_dns_rebind_protection - || !opts.dns_nameserver.is_empty() - || opts.dns_query_timeout_ms.is_some() - || !opts.net_rule.is_empty() - || !opts.net.is_empty() - || opts.no_net - || opts.net_default.is_some() - || opts.net_default_egress.is_some() - || opts.net_default_ingress.is_some() - || opts.net_ipv4_pool.is_some() - || opts.net_ipv6_pool.is_some() - || opts.net_egress_bandwidth.is_some() - || opts.net_egress_bandwidth_burst.is_some() - || opts.net_egress_ops.is_some() - || opts.net_egress_ops_burst.is_some() - || opts.net_ingress_bandwidth.is_some() - || opts.net_ingress_bandwidth_burst.is_some() - || opts.net_ingress_ops.is_some() - || opts.net_ingress_ops_burst.is_some() - || opts.max_connections.is_some() - || opts.trust_host_cas - || opts.tls_intercept - || !opts.tls_intercept_port.is_empty() - || !opts.tls_bypass.is_empty() - || opts.no_block_quic - || opts.tls_intercept_ca_cert.is_some() - || opts.tls_intercept_ca_key.is_some() - || !opts.tls_upstream_ca_cert.is_empty() - || !opts.tls_upstream_ca_cert_for.is_empty() - || !opts.tls_no_verify_upstream_for.is_empty() - || opts.on_secret_violation.is_some(); - - if has_network_config { + if opts.has_network_config() { let no_dns_rebind = opts.no_dns_rebind_protection; let dns_nameservers = opts .dns_nameserver @@ -2079,14 +2384,7 @@ fn apply_network_opts( .map(|s| s.parse::().map_err(anyhow::Error::from)) .collect::>>()?; let dns_query_timeout_ms = opts.dns_query_timeout_ms; - let network_policy = build_network_policy( - &opts.net, - &opts.net_rule, - opts.no_net, - opts.net_default.as_deref(), - opts.net_default_egress.as_deref(), - opts.net_default_ingress.as_deref(), - )?; + let network_policy = opts.build_network_policy()?; let replaces_configured_policy = !opts.net.is_empty() || opts.no_net || opts.net_default.is_some() @@ -2135,20 +2433,8 @@ fn apply_network_opts( .collect::>>()?; let no_verify_upstream_for = opts.tls_no_verify_upstream_for.clone(); let violation_action = parse_violation_action(&opts.on_secret_violation)?; - let egress_rate_limiter = parse_rate_limiter_flags( - NetworkRateLimitDirection::Egress, - opts.net_egress_bandwidth.as_deref(), - opts.net_egress_bandwidth_burst.as_deref(), - opts.net_egress_ops.as_deref(), - opts.net_egress_ops_burst, - )?; - let ingress_rate_limiter = parse_rate_limiter_flags( - NetworkRateLimitDirection::Ingress, - opts.net_ingress_bandwidth.as_deref(), - opts.net_ingress_bandwidth_burst.as_deref(), - opts.net_ingress_ops.as_deref(), - opts.net_ingress_ops_burst, - )?; + let egress_rate_limiter = opts.build_rate_limiter(NetworkRateLimitDirection::Egress)?; + let ingress_rate_limiter = opts.build_rate_limiter(NetworkRateLimitDirection::Ingress)?; builder = builder.network(move |mut n| { if no_dns_rebind || !dns_nameservers.is_empty() || dns_query_timeout_ms.is_some() { @@ -2247,6 +2533,10 @@ fn apply_network_opts( }); } + if let Some(proxy) = proxy { + builder = builder.proxy(|_| proxy); + } + Ok(builder) } @@ -2321,49 +2611,6 @@ impl CliRateLimiter { } } -/// Parse one direction's rate limit flags. Returns `None` when none of -/// the four flags is set. -#[cfg(feature = "net")] -fn parse_rate_limiter_flags( - direction: NetworkRateLimitDirection, - bandwidth: Option<&str>, - bandwidth_burst: Option<&str>, - ops: Option<&str>, - ops_burst: Option, -) -> anyhow::Result> { - if bandwidth.is_none() && bandwidth_burst.is_none() && ops.is_none() && ops_burst.is_none() { - return Ok(None); - } - - let bandwidth = bandwidth - .map(|spec| { - parse_rate(&format!("--net-{direction}-bandwidth"), spec, |s| { - ui::parse_size_bytes(s).map_err(anyhow::Error::msg) - }) - }) - .transpose()?; - let bandwidth_burst = bandwidth_burst - .map(|s| { - ui::parse_size_bytes(s) - .map_err(|e| anyhow::anyhow!("--net-{direction}-bandwidth-burst: {e}")) - }) - .transpose()?; - let ops = ops - .map(|spec| { - parse_rate(&format!("--net-{direction}-ops"), spec, |s| { - s.parse::().map_err(anyhow::Error::from) - }) - }) - .transpose()?; - - Ok(Some(CliRateLimiter { - bandwidth, - bandwidth_burst, - ops, - ops_burst, - })) -} - /// Parse a `VALUE/DURATION` rate spec like `1M/1s` or `1000/1s`. A bare /// value means per second. #[cfg(feature = "net")] @@ -2383,136 +2630,6 @@ fn parse_rate( Ok((value, duration)) } -/// Assemble a [`NetworkPolicy`] from `--net`, `--net-rule`, -/// `--net-default*`, and `--no-net`. Returns `None` when no flag is set. -/// Multiple profile and rule invocations concatenate in argv order. -/// -/// `--no-net` desugars to `--net-default deny`; clap rejects combining -/// it with the explicit defaults, so the four default-source params are -/// mutually exclusive on the caller side. -#[cfg(feature = "net")] -pub(crate) fn build_network_policy( - profile_args: &[String], - rule_args: &[String], - no_net: bool, - default_both: Option<&str>, - default_egress: Option<&str>, - default_ingress: Option<&str>, -) -> anyhow::Result> { - use microsandbox_network::policy::{Action, NetworkPolicy, NetworkProfile}; - - use crate::net_rule::parse_rule_list; - - let no_flags = profile_args.is_empty() - && rule_args.is_empty() - && !no_net - && default_both.is_none() - && default_egress.is_none() - && default_ingress.is_none(); - if no_flags { - return Ok(None); - } - - let mut rules = Vec::new(); - for arg in rule_args { - let parsed = parse_rule_list(arg).map_err(anyhow::Error::from)?; - rules.extend(parsed); - } - - if !profile_args.is_empty() { - let mut profiles = Vec::new(); - let mut terminal = None; - for raw in profile_args - .iter() - .flat_map(|arg| arg.split(',')) - .map(str::trim) - { - if raw.is_empty() { - anyhow::bail!("empty --net profile; expected public, private, host, all, or none"); - } - match raw { - "public" => profiles.push(NetworkProfile::Public), - "private" => profiles.push(NetworkProfile::Private), - "host" => profiles.push(NetworkProfile::Host), - "all" | "none" => { - if let Some(previous) = terminal { - if previous == raw { - anyhow::bail!("--net `{raw}` may only be specified once"); - } - anyhow::bail!( - "--net terminal profiles `all` and `none` cannot be combined" - ); - } - terminal = Some(raw); - } - other => anyhow::bail!( - "unknown --net profile {other:?}; expected public, private, host, all, or none" - ), - } - } - if let Some(terminal) = terminal { - if !profiles.is_empty() { - anyhow::bail!( - "--net `{terminal}` cannot be combined with public, private, or host" - ); - } - let mut policy = match terminal { - "all" => NetworkPolicy::allow_all(), - "none" => NetworkPolicy::none(), - _ => unreachable!("validated terminal profile"), - }; - policy.rules = rules; - return Ok(Some(policy)); - } - - let mut policy = NetworkPolicy::from_profiles(profiles); - rules.append(&mut policy.rules); - policy.rules = rules; - return Ok(Some(policy)); - } - - let parse_action = |label: &str, raw: &str| -> anyhow::Result { - match raw { - "allow" => Ok(Action::Allow), - "deny" => Ok(Action::Deny), - other => anyhow::bail!("unknown {label} value {other:?}; expected `allow` or `deny`"), - } - }; - - // `--no-net` and `--net-default` are siblings: both set egress and - // ingress symmetrically. clap enforces they're mutex with each - // other and with `--net-default-{egress,ingress}`, so at most one - // source resolves here. - let symmetric = if no_net { - Some(Action::Deny) - } else if let Some(raw) = default_both { - Some(parse_action("--net-default", raw)?) - } else { - None - }; - - // When the user sets no defaults explicitly, fall through to - // the default public-profile policy's direction defaults so low-level - // rule-only behavior remains deny-egress / allow-ingress. - let baseline = NetworkPolicy::default(); - let default_egress = match (symmetric, default_egress) { - (_, Some(raw)) => parse_action("--net-default-egress", raw)?, - (Some(action), None) => action, - (None, None) => baseline.default_egress, - }; - let default_ingress = match (symmetric, default_ingress) { - (_, Some(raw)) => parse_action("--net-default-ingress", raw)?, - (Some(action), None) => action, - (None, None) => baseline.default_ingress, - }; - - Ok(Some(NetworkPolicy { - default_egress, - default_ingress, - rules, - })) -} - /// Parse a port spec: /// - `HOST:GUEST` /// - `BIND_ADDR:HOST:GUEST` @@ -2727,47 +2844,6 @@ fn parse_deployment_profile(value: &str) -> anyhow::Result { } } -/// Resolve `--script` / `--script-raw` / `--script-path` specs into a -/// deduped list of `(name, content)` pairs preserving argv order: -/// inline shell snippets first, then raw inline, then path-backed. -/// Duplicate names across any source are rejected. `shell` is used to -/// generate the shebang for `--script` entries only. -fn collect_scripts( - shell: Option<&str>, - scripts: &[String], - raw_scripts: &[String], - paths: &[String], -) -> anyhow::Result> { - use std::collections::HashSet; - - let mut out = Vec::with_capacity(scripts.len() + raw_scripts.len() + paths.len()); - let mut seen: HashSet = HashSet::new(); - - for spec in scripts { - let (name, body) = parse_script_spec(spec, "script")?; - if !seen.insert(name.clone()) { - anyhow::bail!("script name '{name}' specified more than once"); - } - let decoded = decode_script_escapes(&body); - out.push((name, wrap_shell_script(shell, &decoded))); - } - for spec in raw_scripts { - let (name, body) = parse_script_spec(spec, "script-raw")?; - if !seen.insert(name.clone()) { - anyhow::bail!("script name '{name}' specified more than once"); - } - out.push((name, body)); - } - for spec in paths { - let (name, content) = parse_script_path(spec)?; - if !seen.insert(name.clone()) { - anyhow::bail!("script name '{name}' specified more than once"); - } - out.push((name, content)); - } - Ok(out) -} - /// Parse a `NAME=BODY` spec for `--script` / `--script-raw`. Splits on /// the first `=` so bodies may freely contain `=`. `flag` is used in /// the error message. @@ -3095,16 +3171,18 @@ mod tests { #[cfg(feature = "net")] #[test] - fn parse_rate_limiter_flags_maps_all_four_flags() { - let limiter = parse_rate_limiter_flags( - NetworkRateLimitDirection::Egress, - Some("1M/1s"), - Some("512K"), - Some("1000/1s"), - Some(500), - ) - .unwrap() - .expect("limiter should be present"); + fn build_rate_limiter_maps_all_four_flags() { + let opts = SandboxOpts { + net_egress_bandwidth: Some("1M/1s".into()), + net_egress_bandwidth_burst: Some("512K".into()), + net_egress_ops: Some("1000/1s".into()), + net_egress_ops_burst: Some(500), + ..Default::default() + }; + let limiter = opts + .build_rate_limiter(NetworkRateLimitDirection::Egress) + .unwrap() + .expect("limiter should be present"); assert_eq!( limiter.bandwidth, @@ -3115,7 +3193,8 @@ mod tests { assert_eq!(limiter.ops_burst, Some(500)); assert!( - parse_rate_limiter_flags(NetworkRateLimitDirection::Ingress, None, None, None, None,) + SandboxOpts::default() + .build_rate_limiter(NetworkRateLimitDirection::Ingress) .unwrap() .is_none() ); @@ -3252,6 +3331,93 @@ mod tests { ); } + #[cfg(feature = "net")] + #[test] + fn outbound_proxy_builds_socks4_user_id() { + let opts = SandboxOpts { + proxy: Some("socks4://127.0.0.1:1080".into()), + socks4_user_id: Some("sandbox".into()), + ..Default::default() + }; + + let proxy = opts.build_outbound_proxy().unwrap().unwrap(); + assert_eq!( + proxy, + OutboundProxy::Socks4 { + address: "127.0.0.1:1080".parse().unwrap(), + user_id: Some("sandbox".into()), + } + ); + } + + #[cfg(feature = "net")] + #[test] + fn outbound_proxy_builds_socks5_environment_credentials() { + let opts = SandboxOpts { + proxy: Some("socks5://127.0.0.1:1080".into()), + socks5_username: Some("sandbox".into()), + socks5_password_env: Some("SOCKS5_PASSWORD".into()), + ..Default::default() + }; + + let proxy = opts.build_outbound_proxy().unwrap().unwrap(); + let json = serde_json::to_value(proxy).unwrap(); + assert_eq!(json["protocol"], "socks5"); + assert_eq!(json["credentials"]["username"], "sandbox"); + assert_eq!(json["credentials"]["password"]["kind"], "env"); + assert_eq!(json["credentials"]["password"]["var"], "SOCKS5_PASSWORD"); + } + + #[cfg(feature = "net")] + #[test] + fn outbound_proxy_rejects_protocol_specific_auth_on_the_wrong_protocol() { + let socks4 = SandboxOpts { + proxy: Some("socks4://127.0.0.1:1080".into()), + socks5_username: Some("sandbox".into()), + socks5_password_env: Some("SOCKS5_PASSWORD".into()), + ..Default::default() + }; + assert!( + socks4 + .build_outbound_proxy() + .unwrap_err() + .to_string() + .contains("require a socks5:// proxy") + ); + + let socks5 = SandboxOpts { + proxy: Some("socks5://127.0.0.1:1080".into()), + socks4_user_id: Some("sandbox".into()), + ..Default::default() + }; + assert!( + socks5 + .build_outbound_proxy() + .unwrap_err() + .to_string() + .contains("requires a socks4:// proxy") + ); + } + + #[cfg(feature = "net")] + #[test] + fn outbound_proxy_rejects_incomplete_socks5_credentials() { + for opts in [ + SandboxOpts { + proxy: Some("socks5://127.0.0.1:1080".into()), + socks5_username: Some("sandbox".into()), + ..Default::default() + }, + SandboxOpts { + proxy: Some("socks5://127.0.0.1:1080".into()), + socks5_password_env: Some("SOCKS5_PASSWORD".into()), + ..Default::default() + }, + ] { + assert!(opts.build_outbound_proxy().is_err()); + } + } + #[cfg(feature = "net")] #[test] fn parse_scoped_upstream_ca_cert_accepts_pattern_and_path() { @@ -4372,6 +4538,21 @@ mod tests { // --- collect_scripts (duplicate logic) --- + fn script_opts( + shell: Option<&str>, + scripts: &[String], + raw_scripts: &[String], + paths: &[String], + ) -> SandboxOpts { + SandboxOpts { + shell: shell.map(str::to_owned), + script: scripts.to_vec(), + script_raw: raw_scripts.to_vec(), + script_path: paths.to_vec(), + ..Default::default() + } + } + // --- parse_port_mapping --- #[cfg(feature = "net")] @@ -4451,7 +4632,9 @@ mod tests { #[test] fn collect_script_wraps_with_default_shebang() { let scripts = vec!["start=echo hello".to_string()]; - let out = collect_scripts(None, &scripts, &[], &[]).unwrap(); + let out = script_opts(None, &scripts, &[], &[]) + .collect_scripts() + .unwrap(); assert_eq!( out, vec![("start".to_string(), "#!/bin/sh\necho hello\n".to_string())] @@ -4461,7 +4644,9 @@ mod tests { #[test] fn collect_script_decodes_newlines_in_body() { let scripts = vec![r#"start=echo hello\npython -c "print(123)""#.to_string()]; - let out = collect_scripts(None, &scripts, &[], &[]).unwrap(); + let out = script_opts(None, &scripts, &[], &[]) + .collect_scripts() + .unwrap(); assert_eq!( out[0].1, "#!/bin/sh\necho hello\npython -c \"print(123)\"\n" @@ -4471,28 +4656,32 @@ mod tests { #[test] fn collect_script_uses_absolute_shell_path() { let scripts = vec!["start=echo hi".to_string()]; - let out = collect_scripts(Some("/bin/bash"), &scripts, &[], &[]).unwrap(); + let out = script_opts(Some("/bin/bash"), &scripts, &[], &[]) + .collect_scripts() + .unwrap(); assert_eq!(out[0].1, "#!/bin/bash\necho hi\n"); } #[test] fn collect_script_uses_env_for_bare_shell() { let scripts = vec!["start=echo $BASH_VERSION".to_string()]; - let out = collect_scripts(Some("bash"), &scripts, &[], &[]).unwrap(); + let out = script_opts(Some("bash"), &scripts, &[], &[]) + .collect_scripts() + .unwrap(); assert_eq!(out[0].1, "#!/usr/bin/env bash\necho $BASH_VERSION\n"); } #[test] fn collect_script_raw_is_exact() { let raw = vec!["start=echo hello".to_string()]; - let out = collect_scripts(None, &[], &raw, &[]).unwrap(); + let out = script_opts(None, &[], &raw, &[]).collect_scripts().unwrap(); assert_eq!(out, vec![("start".to_string(), "echo hello".to_string())]); } #[test] fn collect_script_raw_preserves_escapes_literally() { let raw = vec![r"start=echo hello\nworld".to_string()]; - let out = collect_scripts(None, &[], &raw, &[]).unwrap(); + let out = script_opts(None, &[], &raw, &[]).collect_scripts().unwrap(); assert_eq!(out[0].1, r"echo hello\nworld"); } @@ -4500,7 +4689,9 @@ mod tests { fn collect_script_path_is_exact_file_contents() { let p = write_temp("#!/bin/sh\necho from-file\n"); let paths = vec![format!("start:{}", p.display())]; - let out = collect_scripts(None, &[], &[], &paths).unwrap(); + let out = script_opts(None, &[], &[], &paths) + .collect_scripts() + .unwrap(); assert_eq!(out[0].1, "#!/bin/sh\necho from-file\n"); let _ = std::fs::remove_file(&p); } @@ -4508,14 +4699,18 @@ mod tests { #[test] fn collect_script_preserves_unknown_escapes() { let scripts = vec![r"re=grep '\d\+' file".to_string()]; - let out = collect_scripts(None, &scripts, &[], &[]).unwrap(); + let out = script_opts(None, &scripts, &[], &[]) + .collect_scripts() + .unwrap(); assert_eq!(out[0].1, "#!/bin/sh\ngrep '\\d\\+' file\n"); } #[test] fn collect_script_always_ends_with_newline() { let scripts = vec!["start=echo hello".to_string()]; - let out = collect_scripts(None, &scripts, &[], &[]).unwrap(); + let out = script_opts(None, &scripts, &[], &[]) + .collect_scripts() + .unwrap(); assert!(out[0].1.ends_with('\n')); } @@ -4525,7 +4720,9 @@ mod tests { let scripts = vec!["a=echo a".to_string()]; let raw = vec!["b=echo b".to_string()]; let paths = vec![format!("c:{}", p.display())]; - let out = collect_scripts(None, &scripts, &raw, &paths).unwrap(); + let out = script_opts(None, &scripts, &raw, &paths) + .collect_scripts() + .unwrap(); assert_eq!(out.len(), 3); assert_eq!(out[0].0, "a"); assert_eq!(out[1], ("b".to_string(), "echo b".to_string())); @@ -4536,7 +4733,9 @@ mod tests { #[test] fn collect_rejects_duplicate_within_script() { let scripts = vec!["foo=echo a".to_string(), "foo=echo b".to_string()]; - let err = collect_scripts(None, &scripts, &[], &[]).unwrap_err(); + let err = script_opts(None, &scripts, &[], &[]) + .collect_scripts() + .unwrap_err(); assert!( err.to_string().contains("'foo' specified more than once"), "got: {err}" @@ -4550,7 +4749,9 @@ mod tests { format!("foo:{}", p.display()), format!("foo:{}", p.display()), ]; - let err = collect_scripts(None, &[], &[], &paths).unwrap_err(); + let err = script_opts(None, &[], &[], &paths) + .collect_scripts() + .unwrap_err(); assert!( err.to_string().contains("'foo' specified more than once"), "got: {err}" @@ -4565,19 +4766,25 @@ mod tests { let raw = vec!["foo=echo b".to_string()]; let paths = vec![format!("foo:{}", p.display())]; - let err = collect_scripts(None, &scripts, &raw, &[]).unwrap_err(); + let err = script_opts(None, &scripts, &raw, &[]) + .collect_scripts() + .unwrap_err(); assert!( err.to_string().contains("'foo' specified more than once"), "script vs script-raw: {err}" ); - let err = collect_scripts(None, &scripts, &[], &paths).unwrap_err(); + let err = script_opts(None, &scripts, &[], &paths) + .collect_scripts() + .unwrap_err(); assert!( err.to_string().contains("'foo' specified more than once"), "script vs script-path: {err}" ); - let err = collect_scripts(None, &[], &raw, &paths).unwrap_err(); + let err = script_opts(None, &[], &raw, &paths) + .collect_scripts() + .unwrap_err(); assert!( err.to_string().contains("'foo' specified more than once"), "script-raw vs script-path: {err}" @@ -4588,7 +4795,7 @@ mod tests { #[test] fn collect_empty_inputs_ok() { - let out = collect_scripts(None, &[], &[], &[]).unwrap(); + let out = script_opts(None, &[], &[], &[]).collect_scripts().unwrap(); assert!(out.is_empty()); } @@ -4597,17 +4804,40 @@ mod tests { #[cfg(feature = "net")] use microsandbox_network::policy::Action; + #[cfg(feature = "net")] + fn network_policy_opts( + profiles: &[String], + rules: &[String], + no_net: bool, + default: Option<&str>, + default_egress: Option<&str>, + default_ingress: Option<&str>, + ) -> SandboxOpts { + SandboxOpts { + net: profiles.to_vec(), + net_rule: rules.to_vec(), + no_net, + net_default: default.map(str::to_owned), + net_default_egress: default_egress.map(str::to_owned), + net_default_ingress: default_ingress.map(str::to_owned), + ..Default::default() + } + } + #[cfg(feature = "net")] #[test] fn build_policy_no_flags_returns_none() { - let p = build_network_policy(&[], &[], false, None, None, None).unwrap(); + let p = network_policy_opts(&[], &[], false, None, None, None) + .build_network_policy() + .unwrap(); assert!(p.is_none()); } #[cfg(feature = "net")] #[test] fn build_policy_net_default_deny_sets_both_directions() { - let p = build_network_policy(&[], &[], false, Some("deny"), None, None) + let p = network_policy_opts(&[], &[], false, Some("deny"), None, None) + .build_network_policy() .unwrap() .expect("policy"); assert_eq!(p.default_egress, Action::Deny); @@ -4618,7 +4848,8 @@ mod tests { #[cfg(feature = "net")] #[test] fn build_policy_net_default_allow_sets_both_directions() { - let p = build_network_policy(&[], &[], false, Some("allow"), None, None) + let p = network_policy_opts(&[], &[], false, Some("allow"), None, None) + .build_network_policy() .unwrap() .expect("policy"); assert_eq!(p.default_egress, Action::Allow); @@ -4628,7 +4859,8 @@ mod tests { #[cfg(feature = "net")] #[test] fn build_policy_no_net_desugars_to_deny_both() { - let p = build_network_policy(&[], &[], true, None, None, None) + let p = network_policy_opts(&[], &[], true, None, None, None) + .build_network_policy() .unwrap() .expect("policy"); assert_eq!(p.default_egress, Action::Deny); @@ -4639,7 +4871,8 @@ mod tests { #[test] fn build_policy_no_net_with_allow_rule_yields_allowlist() { let rules = vec!["allow@example.com".to_string()]; - let p = build_network_policy(&[], &rules, true, None, None, None) + let p = network_policy_opts(&[], &rules, true, None, None, None) + .build_network_policy() .unwrap() .expect("policy"); assert_eq!(p.default_egress, Action::Deny); @@ -4651,7 +4884,9 @@ mod tests { #[cfg(feature = "net")] #[test] fn build_policy_net_default_rejects_unknown_action() { - let err = build_network_policy(&[], &[], false, Some("maybe"), None, None).unwrap_err(); + let err = network_policy_opts(&[], &[], false, Some("maybe"), None, None) + .build_network_policy() + .unwrap_err(); assert!( err.to_string().contains("--net-default"), "expected --net-default in error, got: {err}" @@ -4666,7 +4901,8 @@ mod tests { // "rules alone keep the default direction actions" path now that the // --deny-domain* flip-to-allow exception is gone. let rules = vec!["allow@example.com".to_string()]; - let p = build_network_policy(&[], &rules, false, None, None, None) + let p = network_policy_opts(&[], &rules, false, None, None, None) + .build_network_policy() .unwrap() .expect("policy"); let baseline = microsandbox_network::policy::NetworkPolicy::default(); @@ -4680,7 +4916,8 @@ mod tests { use microsandbox_network::policy::{Destination, DestinationGroup, Protocol}; let profiles = vec!["host,private".to_string(), "public,private".to_string()]; - let p = build_network_policy(&profiles, &[], false, None, None, None) + let p = network_policy_opts(&profiles, &[], false, None, None, None) + .build_network_policy() .unwrap() .expect("policy"); assert_eq!(p.rules.len(), 4); @@ -4699,7 +4936,8 @@ mod tests { fn build_policy_places_explicit_rules_before_profile_rules() { let profiles = vec!["public".to_string()]; let rules = vec!["deny@dns".to_string()]; - let p = build_network_policy(&profiles, &rules, false, None, None, None) + let p = network_policy_opts(&profiles, &rules, false, None, None, None) + .build_network_policy() .unwrap() .expect("policy"); assert_eq!(p.rules[0].action, Action::Deny); @@ -4710,13 +4948,15 @@ mod tests { #[test] fn build_policy_terminal_all_and_none_reject_composition() { let rules = vec!["deny@private".to_string()]; - let all = build_network_policy(&["all".to_string()], &rules, false, None, None, None) + let all = network_policy_opts(&["all".to_string()], &rules, false, None, None, None) + .build_network_policy() .unwrap() .expect("policy"); assert_eq!(all.default_egress, Action::Allow); assert_eq!(all.rules.len(), 1); - let err = build_network_policy(&["none,public".to_string()], &[], false, None, None, None) + let err = network_policy_opts(&["none,public".to_string()], &[], false, None, None, None) + .build_network_policy() .unwrap_err(); assert!(err.to_string().contains("cannot be combined")); @@ -4724,14 +4964,16 @@ mod tests { vec!["all".to_string(), "none".to_string()], vec!["none,all".to_string()], ] { - let err = build_network_policy(&profiles, &[], false, None, None, None).unwrap_err(); + let err = network_policy_opts(&profiles, &[], false, None, None, None) + .build_network_policy() + .unwrap_err(); assert_eq!( err.to_string(), "--net terminal profiles `all` and `none` cannot be combined" ); } - let err = build_network_policy( + let err = network_policy_opts( &["all".to_string(), "all".to_string()], &[], false, @@ -4739,6 +4981,7 @@ mod tests { None, None, ) + .build_network_policy() .unwrap_err(); assert_eq!(err.to_string(), "--net `all` may only be specified once"); } diff --git a/crates/cli/lib/commands/run.rs b/crates/cli/lib/commands/run.rs index 972b1a4d2..507717d00 100644 --- a/crates/cli/lib/commands/run.rs +++ b/crates/cli/lib/commands/run.rs @@ -648,6 +648,64 @@ mod tests { assert_eq!(ignored_existing_inputs(&args), Some("--from-snapshot")); } + #[cfg(feature = "net")] + #[test] + fn existing_reuse_warns_for_proxy_creation_flag() { + let args = parse_run_args(&[ + "--name", + "box", + "--proxy", + "socks5://127.0.0.1:1080", + "alpine", + ]); + + assert_eq!(ignored_existing_inputs(&args), Some("creation flags")); + } + + #[cfg(feature = "net")] + #[test] + fn protocol_specific_proxy_authentication_flags_parse() { + let socks4 = parse_run_args(&[ + "--proxy", + "socks4://127.0.0.1:1080", + "--socks4-user-id", + "sandbox", + "alpine", + ]); + assert_eq!(socks4.sandbox.socks4_user_id.as_deref(), Some("sandbox")); + + let socks5 = parse_run_args(&[ + "--proxy", + "socks5://127.0.0.1:1080", + "--socks5-username", + "sandbox", + "--socks5-password-env", + "SOCKS5_PASSWORD", + "alpine", + ]); + assert_eq!(socks5.sandbox.socks5_username.as_deref(), Some("sandbox")); + assert_eq!( + socks5.sandbox.socks5_password_env.as_deref(), + Some("SOCKS5_PASSWORD") + ); + } + + #[cfg(feature = "net")] + #[test] + fn socks5_cli_credentials_require_the_complete_pair() { + for auth in [ + &["--socks5-username", "sandbox"][..], + &["--socks5-password-env", "SOCKS5_PASSWORD"][..], + ] { + let argv = ["msb", "--proxy", "socks5://127.0.0.1:1080"] + .into_iter() + .chain(auth.iter().copied()) + .chain(["alpine"]); + let error = TestCli::try_parse_from(argv).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + } + } + #[test] fn from_snap_is_an_alias_for_from_snapshot() { let args = parse_run_args(&["--name", "box", "--from-snap", "clean"]); diff --git a/crates/cli/lib/commands/self_cmd.rs b/crates/cli/lib/commands/self_cmd.rs index ceaaa2bc0..f0963bc15 100644 --- a/crates/cli/lib/commands/self_cmd.rs +++ b/crates/cli/lib/commands/self_cmd.rs @@ -3479,23 +3479,10 @@ mod tests { .unwrap(); Migrator::up(db.inner(), None).await.unwrap(); - // The newest owner-compatibility marker has no schema objects of its - // own. With no persisted sandboxes, its preflight permits rollback and - // removes only the migration record. - rollback_schema(db.inner(), 1).await.unwrap(); - - let rows = db - .query_all_raw(Statement::from_sql_and_values( - DatabaseBackend::Sqlite, - "SELECT version FROM seaql_migrations WHERE version = ?", - [schema_metadata::MOUNT_OWNER_CONFIG_MIGRATION_ID.into()], - )) - .await - .unwrap(); - assert!(rows.is_empty(), "mount owner marker should be rolled back"); - - // The network-slot migration leaves its compatible SQLite column and - // constraints in place, but removes the migration record. + // The backdated network-slot migration was released after the + // owner-compatibility marker, so it is the first migration rolled back. + // It leaves its compatible SQLite column and constraints in place, but + // removes the migration record. rollback_schema(db.inner(), 1).await.unwrap(); let rows = db @@ -3525,6 +3512,21 @@ mod tests { "network slot column should remain compatible after rollback" ); + // The owner-compatibility marker has no schema objects of its own. With + // no persisted sandboxes, its preflight permits rollback and removes + // only the migration record. + rollback_schema(db.inner(), 1).await.unwrap(); + + let rows = db + .query_all_raw(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "SELECT version FROM seaql_migrations WHERE version = ?", + [schema_metadata::MOUNT_OWNER_CONFIG_MIGRATION_ID.into()], + )) + .await + .unwrap(); + assert!(rows.is_empty(), "mount owner marker should be rolled back"); + // Shared CPU assignment rows downgrade first. Active sandboxes are // prohibited during schema rollback, so the allocation table is empty // and can safely return to its exclusive logical-CPU key. diff --git a/crates/migration/lib/lib.rs b/crates/migration/lib/lib.rs index 7e021aa47..d64591340 100644 --- a/crates/migration/lib/lib.rs +++ b/crates/migration/lib/lib.rs @@ -73,8 +73,11 @@ impl MigratorTrait for Migrator { Box::new(m20260808_000001_create_memory_allocation_nodes::Migration), Box::new(m20260810_000001_rebuild_sandbox_labels::Migration), Box::new(m20260813_000001_share_cpu_allocations::Migration), - Box::new(m20260818_000001_sandbox_network_slot::Migration), Box::new(m20260824_000001_mount_owner_config::Migration), + // This backdated migration first shipped in v0.6.16, after the + // v0.6.15 mount-owner marker. Keep release order here even though + // the identifiers sort differently. + Box::new(m20260818_000001_sandbox_network_slot::Migration), ] } } diff --git a/crates/migration/lib/schema_metadata.rs b/crates/migration/lib/schema_metadata.rs index 4e60f8f0a..cbcca95f2 100644 --- a/crates/migration/lib/schema_metadata.rs +++ b/crates/migration/lib/schema_metadata.rs @@ -237,21 +237,23 @@ pub const MIGRATION_METADATA: &[MigrationMetadata] = &[ summary: "restore exclusive logical CPU allocation rows", }, MigrationMetadata { - id: SANDBOX_NETWORK_SLOT_MIGRATION_ID, - // The column is deliberately left in place on rollback (SQLite has - // no DROP COLUMN on every supported version); `up` probes for it so a - // re-upgrade after this rollback succeeds. + id: MOUNT_OWNER_CONFIG_MIGRATION_ID, reversible: true, affects_cache: false, affects_user_data: false, - summary: "retain the compatible sandbox network slot column", + summary: "remove the compatibility marker after confirming no persisted mount ownership", }, MigrationMetadata { - id: MOUNT_OWNER_CONFIG_MIGRATION_ID, + // This backdated migration first shipped in v0.6.16. Keep it after + // the v0.6.15 mount-owner marker so released databases stay prefixes. + id: SANDBOX_NETWORK_SLOT_MIGRATION_ID, + // The column is deliberately left in place on rollback (SQLite has + // no DROP COLUMN on every supported version); `up` probes for it so a + // re-upgrade after this rollback succeeds. reversible: true, affects_cache: false, affects_user_data: false, - summary: "remove the compatibility marker after confirming no persisted mount ownership", + summary: "retain the compatible sandbox network slot column", }, ]; @@ -374,6 +376,16 @@ mod tests { assert!(canonical_applied_prefix(with_unknown).is_none()); } + #[test] + fn released_v0_6_15_migrations_remain_a_prefix() { + let applied: Vec<_> = migration_ids() + .take_while(|id| *id != SANDBOX_NETWORK_SLOT_MIGRATION_ID) + .collect(); + + assert_eq!(applied.last(), Some(&MOUNT_OWNER_CONFIG_MIGRATION_ID)); + assert!(canonical_applied_prefix(applied).is_some()); + } + #[test] fn frozen_0_6_0_baseline_is_current_prefix() { let metadata_ids: Vec<_> = migration_ids().collect(); diff --git a/crates/network/Cargo.toml b/crates/network/Cargo.toml index 163cfe67c..f0586bd89 100644 --- a/crates/network/Cargo.toml +++ b/crates/network/Cargo.toml @@ -59,6 +59,7 @@ thiserror = { workspace = true } time = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true } +tokio-socks = { workspace = true } tracing = { workspace = true } zeroize = { workspace = true } diff --git a/crates/network/lib/config/builder.rs b/crates/network/lib/config/builder.rs index 23ed8b53a..9198cc4fb 100644 --- a/crates/network/lib/config/builder.rs +++ b/crates/network/lib/config/builder.rs @@ -1036,6 +1036,12 @@ mod tests { assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp); } + #[test] + fn outbound_proxy_defaults_to_none() { + let cfg = NetworkBuilder::new().build().unwrap(); + assert_eq!(cfg.outbound_proxy, None); + } + #[test] fn network_builder_sets_global_passthrough_action() { let cfg = NetworkBuilder::new() diff --git a/crates/network/lib/config/mod.rs b/crates/network/lib/config/mod.rs index eaf7c8c78..0e64e1c4c 100644 --- a/crates/network/lib/config/mod.rs +++ b/crates/network/lib/config/mod.rs @@ -1,6 +1,7 @@ //! Network configuration types and fluent builders. pub mod builder; +mod resolver; mod types; //-------------------------------------------------------------------------------------------------- @@ -8,4 +9,5 @@ mod types; //-------------------------------------------------------------------------------------------------- pub use builder::*; +pub use resolver::*; pub use types::*; diff --git a/crates/network/lib/config/resolver.rs b/crates/network/lib/config/resolver.rs new file mode 100644 index 000000000..9a90a4839 --- /dev/null +++ b/crates/network/lib/config/resolver.rs @@ -0,0 +1,197 @@ +//! Resolution of source-backed network configuration values. + +use microsandbox_types::SecretSource; +use zeroize::Zeroizing; + +use crate::proxy::{ + OutboundProxy, OutboundProxyBuildError, ResolvedOutboundProxy, ResolvedSocks5Credentials, +}; + +use super::types::{NetworkConfig, ResolvedNetworkConfig}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Resolves one host-side source into secret material for a network launch. +#[doc(hidden)] +pub trait NetworkSecretResolver { + /// Resolves the configured source. + fn resolve( + &self, + source: &SecretSource, + ) -> Result, NetworkSecretResolveError>; +} + +/// Resolves environment-backed sources for local sandbox launches. +#[doc(hidden)] +#[derive(Debug, Clone, Copy, Default)] +pub struct EnvNetworkSecretResolver; + +/// Error returned by a network secret resolver. +#[doc(hidden)] +#[derive(Debug, thiserror::Error)] +pub enum NetworkSecretResolveError { + /// A required host environment variable is not set. + #[error("host environment variable {var} is not set")] + MissingEnvironmentVariable { + /// Missing host environment variable. + var: String, + }, + + /// A required host environment variable is empty. + #[error("host environment variable {var} is empty")] + EmptyEnvironmentVariable { + /// Empty host environment variable. + var: String, + }, + + /// The configured source type cannot be resolved by this resolver. + #[error("store-backed secret sources are not supported yet")] + UnsupportedStoreSource, + + /// A resolver-specific source lookup failed. + #[error("{message}")] + ResolutionFailed { + /// Resolver-provided failure description. + message: String, + }, +} + +/// Error returned when resolving source-backed network configuration. +#[doc(hidden)] +#[derive(Debug, thiserror::Error)] +pub enum NetworkConfigResolveError { + /// A source-backed network setting could not be resolved. + #[error("{subject}: {source}")] + SecretSource { + /// Network setting whose value could not be resolved. + subject: String, + /// Underlying source-resolution failure. + #[source] + source: NetworkSecretResolveError, + }, + + /// The resolved proxy configuration is invalid. + #[error(transparent)] + OutboundProxy(#[from] OutboundProxyBuildError), +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl NetworkConfig { + /// Resolves source-backed values into a configuration ready for the network runtime. + #[doc(hidden)] + pub fn resolve( + mut self, + resolver: &impl NetworkSecretResolver, + ) -> Result { + if !self.enabled { + return Ok(ResolvedNetworkConfig::new(self, None)); + } + + for secret in &mut self.secrets.secrets { + if let Some(source) = &secret.source { + secret.value = + Self::resolve_source(resolver, &format!("secret {}", secret.env_var), source)?; + } + } + + let outbound_proxy_credentials = match self.outbound_proxy.as_ref() { + Some(OutboundProxy::Socks5 { + credentials: Some(credentials), + .. + }) => { + let password = + Self::resolve_source(resolver, "SOCKS5 proxy", credentials.password_source())?; + Some(ResolvedSocks5Credentials::new( + credentials.username(), + password.as_str(), + )) + } + _ => None, + }; + let outbound_proxy = + ResolvedOutboundProxy::build(self.outbound_proxy.as_ref(), outbound_proxy_credentials)?; + + Ok(ResolvedNetworkConfig::new(self, outbound_proxy)) + } + + fn resolve_source( + resolver: &impl NetworkSecretResolver, + subject: &str, + source: &SecretSource, + ) -> Result, NetworkConfigResolveError> { + resolver + .resolve(source) + .map_err(|source| NetworkConfigResolveError::SecretSource { + subject: subject.to_string(), + source, + }) + } +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations +//-------------------------------------------------------------------------------------------------- + +impl NetworkSecretResolver for EnvNetworkSecretResolver { + fn resolve( + &self, + source: &SecretSource, + ) -> Result, NetworkSecretResolveError> { + match source { + SecretSource::Store { .. } => Err(NetworkSecretResolveError::UnsupportedStoreSource), + SecretSource::Env { var } => { + let value = std::env::var(var).map_err(|_| { + NetworkSecretResolveError::MissingEnvironmentVariable { var: var.clone() } + })?; + if value.is_empty() { + return Err(NetworkSecretResolveError::EmptyEnvironmentVariable { + var: var.clone(), + }); + } + Ok(Zeroizing::new(value)) + } + } + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use microsandbox_types::SecretSource; + + use super::EnvNetworkSecretResolver; + use crate::config::NetworkConfig; + use crate::proxy::{OutboundProxyBuilder, OutboundProxyConfig}; + + #[test] + fn network_resolution_reads_authenticated_proxy_credentials() { + const PASSWORD_VAR: &str = "MSB_NETWORK_CONFIG_RESOLVE_TEST_SOCKS5_PASSWORD"; + let config = NetworkConfig { + outbound_proxy: Some( + OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .credentials("sandbox", SecretSource::env(PASSWORD_VAR)) + .build() + .unwrap(), + ), + ..Default::default() + }; + + assert!(config.clone().resolve(&EnvNetworkSecretResolver).is_err()); + + // SAFETY: this test owns a purpose-specific environment variable. + unsafe { std::env::set_var(PASSWORD_VAR, "password") }; + let resolved = config.resolve(&EnvNetworkSecretResolver).unwrap(); + unsafe { std::env::remove_var(PASSWORD_VAR) }; + + assert!(format!("{resolved:?}").contains("[REDACTED]")); + } +} diff --git a/crates/network/lib/config/types.rs b/crates/network/lib/config/types.rs index f614e4a3c..e732a728c 100644 --- a/crates/network/lib/config/types.rs +++ b/crates/network/lib/config/types.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::dns::Nameserver; use crate::policy::NetworkPolicy; +use crate::proxy::{OutboundProxy, ResolvedOutboundProxy}; use crate::secrets::config::SecretsConfig; //-------------------------------------------------------------------------------------------------- @@ -80,6 +81,26 @@ pub struct NetworkConfig { /// this is explicitly enabled. Default: false. #[serde(default)] pub trust_host_cas: bool, + + /// Proxy that all outbound sandbox connections are dialed through. + /// + /// Applies to TLS-intercepted and bypassed/plain TCP traffic. SOCKS5 also + /// relays non-DNS UDP; SOCKS4 blocks it because that protocol has no UDP command. + #[serde(default)] + pub outbound_proxy: Option, +} + +/// Network configuration whose runtime-only values have been resolved. +#[doc(hidden)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ResolvedNetworkConfig { + /// Declarative network configuration with source-backed secret injection + /// values resolved for this launch. + config: NetworkConfig, + + /// Fully resolved outbound proxy, including runtime authentication + /// material when configured. + outbound_proxy: Option, } /// Optional overrides for the guest interface. @@ -164,6 +185,46 @@ pub enum PortProtocol { Udp, } +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl ResolvedNetworkConfig { + /// Creates a runtime configuration from its declarative configuration and + /// fully resolved outbound proxy. + pub(super) fn new( + config: NetworkConfig, + outbound_proxy: Option, + ) -> Self { + Self { + config, + outbound_proxy, + } + } + + /// Returns the declarative configuration with resolved injection values applied. + #[doc(hidden)] + pub fn config(&self) -> &NetworkConfig { + &self.config + } + + /// Returns mutable access for applying host-runtime configuration floors. + pub(crate) fn config_mut(&mut self) -> &mut NetworkConfig { + &mut self.config + } + + /// Returns the fully resolved outbound proxy used by the network runtime. + pub(crate) fn outbound_proxy(&self) -> Option<&ResolvedOutboundProxy> { + self.outbound_proxy.as_ref() + } + + /// Clears both the declarative and resolved outbound proxy state. + pub(crate) fn clear_outbound_proxy(&mut self) { + self.config.outbound_proxy = None; + self.outbound_proxy = None; + } +} + //-------------------------------------------------------------------------------------------------- // Trait Implementations //-------------------------------------------------------------------------------------------------- @@ -181,6 +242,7 @@ impl Default for NetworkConfig { max_connections: None, rate_limiter: None, trust_host_cas: false, + outbound_proxy: None, } } } @@ -220,6 +282,7 @@ mod tests { use super::{InterfaceOverrides, NetworkConfig, PortProtocol}; use crate::dns::Nameserver; use crate::policy::{Destination, NetworkPolicy, Rule}; + use crate::proxy::OutboundProxy; /// The engine's `policy`/`dns`/`interface` subdocuments must remain /// serde-compatible with the wire twins in `microsandbox_types` that the @@ -292,6 +355,94 @@ mod tests { ); } + /// `outbound_proxy` round-trips whole-config through the wire type the + /// same way `network_config_from_spec`/`network_spec_from_config` + /// (`sdk/rust/lib/sandbox/config.rs`) do in production: a full + /// `NetworkConfig` -> JSON -> `NetworkSpec` -> JSON -> `NetworkConfig` + /// hop, not just the field in isolation. + #[test] + fn outbound_proxy_round_trips_through_wire_network_spec() { + let config = NetworkConfig { + outbound_proxy: Some(OutboundProxy::Socks5 { + address: "127.0.0.1:1080".parse().unwrap(), + credentials: None, + }), + ..NetworkConfig::default() + }; + + let config_json = serde_json::to_value(&config).unwrap(); + let wire: microsandbox_types::NetworkSpec = + serde_json::from_value(config_json.clone()).unwrap(); + let wire_proxy = wire.outbound_proxy.as_ref().unwrap(); + assert_eq!( + wire_proxy, + µsandbox_types::OutboundProxy::Socks5 { + address: "127.0.0.1:1080".to_string(), + credentials: None, + } + ); + assert_eq!( + serde_json::to_value(wire_proxy).unwrap(), + serde_json::json!({ + "protocol": "socks5", + "address": "127.0.0.1:1080", + }) + ); + + let round_tripped: NetworkConfig = + serde_json::from_value(serde_json::to_value(&wire).unwrap()).unwrap(); + assert_eq!(round_tripped.outbound_proxy, config.outbound_proxy); + } + + #[test] + fn socks4_proxy_user_id_round_trips_through_wire_network_spec() { + let config = NetworkConfig { + outbound_proxy: Some(OutboundProxy::Socks4 { + address: "127.0.0.1:1080".parse().unwrap(), + user_id: Some("sandbox".to_string()), + }), + ..NetworkConfig::default() + }; + + let wire: microsandbox_types::NetworkSpec = + serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap(); + assert_eq!( + wire.outbound_proxy, + Some(microsandbox_types::OutboundProxy::Socks4 { + address: "127.0.0.1:1080".to_string(), + user_id: Some("sandbox".to_string()), + }) + ); + assert_eq!( + serde_json::to_value(wire.outbound_proxy.as_ref().unwrap()).unwrap(), + serde_json::json!({ + "protocol": "socks4", + "address": "127.0.0.1:1080", + "user_id": "sandbox", + }) + ); + + let round_tripped: NetworkConfig = + serde_json::from_value(serde_json::to_value(&wire).unwrap()).unwrap(); + assert_eq!(round_tripped.outbound_proxy, config.outbound_proxy); + } + + #[test] + fn outbound_proxy_omitted_when_unset() { + let config = NetworkConfig::default(); + let wire: microsandbox_types::NetworkSpec = + serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap(); + assert_eq!(wire.outbound_proxy, None); + assert!( + !serde_json::to_value(&wire) + .unwrap() + .as_object() + .unwrap() + .contains_key("outbound_proxy"), + "skip_serializing_if should omit an unset outbound_proxy from the wire form" + ); + } + /// A config persisted before rate limiters existed must keep /// deserializing, defaulting both directions to unlimited. #[test] diff --git a/crates/network/lib/dns/forwarder.rs b/crates/network/lib/dns/forwarder.rs index 49050533c..822e09bc0 100644 --- a/crates/network/lib/dns/forwarder.rs +++ b/crates/network/lib/dns/forwarder.rs @@ -25,15 +25,16 @@ //! [`DnsInterceptor`]: super::interceptor::DnsInterceptor use std::collections::HashSet; +use std::io; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use bytes::Bytes; use futures::StreamExt; -use hickory_net::proto::op::{DnsRequest, Message, Query, ResponseCode}; +use hickory_net::proto::op::{DnsRequest, Edns, Message, MessageType, OpCode, Query, ResponseCode}; use hickory_net::proto::rr::rdata::{A, AAAA, CNAME}; -use hickory_net::proto::rr::{RData, Record, RecordType}; +use hickory_net::proto::rr::{Name, RData, Record, RecordType}; use hickory_net::proto::serialize::binary::{BinDecodable, BinEncodable}; use hickory_net::xfer::DnsHandle; use tokio::sync::{OnceCell, watch}; @@ -185,6 +186,94 @@ impl DnsForwarder { original_dst: Option, transport: Transport, sni: Option<&str>, + ) -> Option { + self.forward_query(raw_query, original_dst, transport, sni) + .await + } + + /// Resolves a proxy transport endpoint through the configured resolver. + /// + /// Proxy infrastructure is not guest traffic, so guest domain policy, + /// rebind protection, active guest address families, and the guest DNS + /// cache do not apply here. + pub(crate) async fn resolve_proxy_domain(&self, domain: &str) -> io::Result> { + #[cfg(windows)] + if matches!(&self.configured, ConfiguredResolver::WindowsSystem(_)) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "proxy relay domain resolution requires an explicit DNS nameserver on Windows", + )); + } + + let name = Name::from_ascii(domain).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid proxy-supplied DNS name: {error}"), + ) + })?; + let (ipv4, ipv6) = tokio::join!( + self.resolve_proxy_domain_family(domain, name.clone(), ResolvedHostnameFamily::Ipv4), + self.resolve_proxy_domain_family(domain, name, ResolvedHostnameFamily::Ipv6), + ); + + let mut addresses = Vec::new(); + let mut last_error = None; + for result in [ipv4, ipv6] { + match result { + Ok(mut resolved) => addresses.append(&mut resolved), + Err(error) => last_error = Some(error), + } + } + + if addresses.is_empty() { + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AddrNotAvailable, + format!("proxy relay domain {domain:?} has no usable address"), + ) + })) + } else { + Ok(addresses) + } + } + + /// Resolves one address family for a proxy transport endpoint. + async fn resolve_proxy_domain_family( + &self, + domain: &str, + name: Name, + family: ResolvedHostnameFamily, + ) -> io::Result> { + let record_type = match family { + ResolvedHostnameFamily::Ipv4 => RecordType::A, + ResolvedHostnameFamily::Ipv6 => RecordType::AAAA, + }; + let mut query = Message::new(0, MessageType::Query, OpCode::Query); + query.metadata.recursion_desired = true; + query.add_query(Query::query(name, record_type)); + let mut edns = Edns::new(); + edns.set_max_payload(4096); + query.edns = Some(edns); + + let raw_query = query + .to_bytes() + .map_err(|error| io::Error::other(format!("failed to encode DNS query: {error}")))?; + let response = self + .forward_to_configured(&raw_query, &query, domain, Transport::Udp) + .await + .ok_or_else(|| io::Error::other("proxy relay DNS query failed"))?; + + Ok(extract_addrs_and_ttl(&response, family, domain) + .map_or_else(Vec::new, |(addresses, _)| addresses)) + } + + /// Processes a guest DNS query through policy and response filtering. + async fn forward_query( + &self, + raw_query: &[u8], + original_dst: Option, + transport: Transport, + sni: Option<&str>, ) -> Option { let query_msg = Message::from_bytes(raw_query).ok()?; let guest_id = query_msg.metadata.id; @@ -613,13 +702,17 @@ impl DnsForwarder { handle.borrow().clone() } - /// Build a forwarder for proxy tests whose queries are handled locally. + /// Build a forwarder for proxy tests, optionally over a test upstream. #[cfg(test)] - pub(crate) async fn for_proxy_test(shared: Arc, gateway: GatewayIps) -> Arc { + pub(crate) async fn for_proxy_test( + shared: Arc, + gateway: GatewayIps, + upstream: Option, + ) -> Arc { let config = Arc::new(NormalizedDnsConfig::from_config( crate::config::DnsConfig::default(), )); - let upstream = SocketAddr::from(([127, 0, 0, 1], 9)); + let upstream = upstream.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 9))); let udp = build_udp_client(upstream, config.query_timeout) .await .expect("test UDP client should initialize"); @@ -1042,6 +1135,29 @@ mod tests { }) } + #[tokio::test] + async fn proxy_domain_resolution_bypasses_guest_filters() { + let expected = Ipv4Addr::new(10, 0, 0, 7); + let (upstream, hits) = responding_udp(expected).await; + let mut forwarder = forwarder_over(&[upstream]).await; + let forwarder_mut = Arc::get_mut(&mut forwarder).expect("unique test forwarder"); + forwarder_mut.network_policy = Arc::new(NetworkPolicy::none()); + forwarder_mut.config = Arc::new(NormalizedDnsConfig { + rebind_protection: true, + nameservers: Vec::new(), + query_timeout: Duration::from_millis(300), + }); + + assert_eq!( + forwarder + .resolve_proxy_domain("relay.example.com") + .await + .unwrap(), + vec![IpAddr::V4(expected)] + ); + assert_eq!(hits.load(Ordering::SeqCst), 2); + } + /// The reported bug: only the first upstream was ever tried, so an /// unusable first nameserver made every lookup fail even though a /// later one worked. The guest is handed the gateway as its only diff --git a/crates/network/lib/dns/proxies/dot.rs b/crates/network/lib/dns/proxies/dot.rs index 16906bebf..8e36156d8 100644 --- a/crates/network/lib/dns/proxies/dot.rs +++ b/crates/network/lib/dns/proxies/dot.rs @@ -466,7 +466,7 @@ mod tests { ipv4: Some("100.96.0.1".parse().unwrap()), ipv6: None, }; - let forwarder = DnsForwarder::for_proxy_test(shared.clone(), gateway).await; + let forwarder = DnsForwarder::for_proxy_test(shared.clone(), gateway, None).await; let (guest_tls, mut client_tls) = connected_tls_pair(); let (from_guest_tx, from_smoltcp) = mpsc::channel(1); let (to_smoltcp, mut to_guest_rx) = mpsc::channel(8); diff --git a/crates/network/lib/lib.rs b/crates/network/lib/lib.rs index 22e1fe302..5661ab5de 100644 --- a/crates/network/lib/lib.rs +++ b/crates/network/lib/lib.rs @@ -12,6 +12,7 @@ )] mod addr; +pub mod proxy; pub mod config; pub mod dns; @@ -39,9 +40,17 @@ pub(crate) const HOST_ALIAS: &str = "host.microsandbox.internal"; // Re-Exports //-------------------------------------------------------------------------------------------------- +#[doc(hidden)] +pub use config::ResolvedNetworkConfig; +pub use proxy::{ + OutboundProxy, OutboundProxyBuildError, OutboundProxyBuilder, OutboundProxyConfig, + OutboundProxyParseError, OutboundProxyProtocol, Socks4ProxyBuilder, Socks5Credentials, + Socks5ProxyBuilder, +}; + pub use config::builder; pub use icmp::{error as icmp_error, relay as icmp_relay}; pub use netstack::{backend, device, poll as stack, shared}; pub use ports::publisher; -pub use tcp::{connection as conn, proxy}; +pub use tcp::connection as conn; pub use udp::{fragments as udp_fragments, relay as udp_relay}; diff --git a/crates/network/lib/netstack/poll.rs b/crates/network/lib/netstack/poll.rs index d7c20223f..a1ec5eba1 100644 --- a/crates/network/lib/netstack/poll.rs +++ b/crates/network/lib/netstack/poll.rs @@ -30,6 +30,7 @@ use crate::dns::{ use crate::icmp::relay::IcmpRelay; use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol}; use crate::ports::PortPublisher; +use crate::proxy::ResolvedOutboundProxy; use crate::secrets::handle::SecretsHandle; use crate::tcp::{connection::ConnectionTracker, proxy::TcpProxy, upstream::UpstreamTcpTarget}; use crate::tls::{proxy::TlsProxy, state::TlsState}; @@ -95,7 +96,7 @@ struct GatewayIcmpReply { } /// Resolved network parameters for the poll loop. Created by -/// `SmoltcpNetwork::new()` from `NetworkConfig` + sandbox slot. +/// `SmoltcpNetwork::new()` from a resolved network configuration and sandbox slot. pub struct PollLoopConfig { /// Gateway MAC address (smoltcp's identity on the virtual LAN). pub gateway_mac: [u8; 6], @@ -234,6 +235,7 @@ pub fn smoltcp_poll_loop( max_connections: Option, tokio_handle: tokio::runtime::Handle, secrets: SecretsHandle, + outbound_proxy: Option>, ) { let mut device = SmoltcpDevice::new(shared.clone(), config.mtu); let mut iface = create_interface(&mut device, &config); @@ -290,7 +292,9 @@ pub fn smoltcp_poll_loop( config.guest_mac, config.mtu, tokio_handle.clone(), + outbound_proxy.clone(), ); + udp_relay.attach_dns_forwarder(dns_forwarder_handle.clone()); let mut udp_fragments = Ipv4UdpFragmentReassembler::new(); let mut ipv6_udp_fragments = Ipv6UdpFragmentReassembler::new(); let icmp_relay = IcmpRelay::new( @@ -523,6 +527,11 @@ pub fn smoltcp_poll_loop( { // TLS-intercepted port — spawn TLS MITM proxy. let connect_target = resolve_tcp_host_target(conn.dst, config.gateway); + let connection_outbound_proxy = ResolvedOutboundProxy::select_for_destination( + &outbound_proxy, + conn.dst, + connect_target.primary(), + ); let proxy = TlsProxy::new( conn.dst, connect_target, @@ -532,6 +541,7 @@ pub fn smoltcp_poll_loop( tls_state.clone(), network_policy.clone(), conn.proxy_connect, + connection_outbound_proxy, ); tokio_handle.spawn(proxy.run()); continue; @@ -588,6 +598,11 @@ pub fn smoltcp_poll_loop( } // Plain TCP proxy. let connect_target = resolve_tcp_host_target(conn.dst, config.gateway); + let connection_outbound_proxy = ResolvedOutboundProxy::select_for_destination( + &outbound_proxy, + conn.dst, + connect_target.primary(), + ); let proxy = TcpProxy::new( conn.dst, connect_target, @@ -600,6 +615,7 @@ pub fn smoltcp_poll_loop( secrets.load(), tls_state.clone(), conn.proxy_connect, + connection_outbound_proxy, ); tokio_handle.spawn(proxy.run()); } @@ -1637,6 +1653,46 @@ mod tests { assert_eq!(resolve_host_dst(dst, gw), dst); } + #[test] + fn outbound_proxy_is_skipped_for_host_destination() { + let gw = test_gateway(); + let guest_dst = SocketAddr::new(IpAddr::V4(gw.ipv4.unwrap()), 8080); + let connect_target = resolve_tcp_host_target(guest_dst, gw); + let proxy = Some(Arc::new(ResolvedOutboundProxy::Socks5 { + address: "192.0.2.1:1080".parse().unwrap(), + credentials: None, + })); + + assert!( + ResolvedOutboundProxy::select_for_destination( + &proxy, + guest_dst, + connect_target.primary(), + ) + .is_none() + ); + } + + #[test] + fn outbound_proxy_is_preserved_for_external_destination() { + let gw = test_gateway(); + let guest_dst = "198.51.100.10:443".parse().unwrap(); + let connect_target = resolve_tcp_host_target(guest_dst, gw); + let proxy = Some(Arc::new(ResolvedOutboundProxy::Socks5 { + address: "192.0.2.1:1080".parse().unwrap(), + credentials: None, + })); + + assert!( + ResolvedOutboundProxy::select_for_destination( + &proxy, + guest_dst, + connect_target.primary(), + ) + .is_some() + ); + } + #[test] fn external_icmp_echo_requests_are_not_answered_locally() { fn drive_one_frame( diff --git a/crates/network/lib/network.rs b/crates/network/lib/network.rs index 5ca27d38c..03b0ef379 100644 --- a/crates/network/lib/network.rs +++ b/crates/network/lib/network.rs @@ -1,4 +1,4 @@ -//! `SmoltcpNetwork` — orchestration type that ties [`NetworkConfig`] to the +//! `SmoltcpNetwork` — orchestration type that ties [`crate::config::NetworkConfig`] to the //! smoltcp engine. //! //! This is the networking analog to `PassthroughFs`/`MemFs` on the filesystem side — the single @@ -19,7 +19,7 @@ use microsandbox_types::{ }; use msb_krun::backends::net::NetBackend; -use crate::config::{MAX_NETWORK_CONNECTIONS, NetworkConfig}; +use crate::config::{MAX_NETWORK_CONNECTIONS, ResolvedNetworkConfig}; use crate::netstack::{ backend::SmoltcpBackend, poll::{self, GatewayIps, PollLoopConfig}, @@ -43,15 +43,17 @@ const MULTI_TENANT_MAX_CONNECTIONS: usize = 256; // Types //-------------------------------------------------------------------------------------------------- -/// The networking engine. Created from [`NetworkConfig`] by the runtime. +/// The networking engine. Created from [`crate::config::NetworkConfig`] by the runtime. /// /// Owns the smoltcp poll thread and provides: /// - [`take_backend()`](Self::take_backend) — the `NetBackend` for `VmBuilder::net()` /// - [`guest_bootstrap_network()`](Self::guest_bootstrap_network) — typed guest network setup /// - [`ca_cert_pem()`](Self::ca_cert_pem) — CA certificate for TLS interception pub struct SmoltcpNetwork { - config: NetworkConfig, - deployment_profile: DeploymentProfile, + config: ResolvedNetworkConfig, + /// Host-owned policy floor derived from the deployment profile and + /// enforced in addition to the sandbox's configured network policy. + platform_policy: Option, shared: Arc, backend: Option, poll_handle: Option>, @@ -74,6 +76,12 @@ pub struct SmoltcpNetwork { secrets: SecretsHandle, } +#[derive(Clone, Copy)] +struct HostRoutes { + ipv4: bool, + ipv6: bool, +} + /// Errors that prevent the smoltcp network from being created safely. #[derive(Debug, thiserror::Error)] pub enum NetworkInitError { @@ -139,25 +147,17 @@ pub struct MetricsHandle { // Methods //-------------------------------------------------------------------------------------------------- -impl SmoltcpNetwork { - /// Create from user config + sandbox slot (for IP/MAC derivation). - /// - /// Each address family is enabled when either the user supplied an - /// explicit address or the host kernel has a route for that family; - /// otherwise the corresponding `guest_*`/`gateway_*` fields stay `None` - /// and the family is omitted from the smoltcp interface, env vars, and - /// downstream consumers. - /// - /// # Errors - /// - /// Returns an error when network configuration would allocate unsafe - /// resources or TLS interception cannot initialize. - /// - pub fn new(config: NetworkConfig, slot: u16) -> Result { - Self::new_with_profile(config, slot, DeploymentProfile::SingleTenant) +impl HostRoutes { + fn detect() -> Self { + Self { + ipv4: host_has_ipv4_route(), + ipv6: host_has_ipv6_route(), + } } +} - /// Create the network backend with an explicit host-runtime deployment profile. +impl SmoltcpNetwork { + /// Creates the network backend from a fully resolved runtime configuration. /// /// `MultiTenant` applies platform-owned configuration floors before any /// sockets, resolvers, or TLS state are created. The requested tenant policy @@ -168,44 +168,25 @@ impl SmoltcpNetwork { /// /// Returns an error when the effective network configuration would allocate /// unsafe resources or TLS interception cannot initialize. - pub fn new_with_profile( - mut config: NetworkConfig, + pub fn new( + config: ResolvedNetworkConfig, slot: u16, deployment_profile: DeploymentProfile, ) -> Result { - enforce_deployment_profile(&mut config, deployment_profile); - Self::new_with_profile_and_routes( - config, - slot, - deployment_profile, - host_has_ipv4_route(), - host_has_ipv6_route(), - ) + Self::build(config, slot, deployment_profile, HostRoutes::detect()) } - #[cfg(test)] - fn new_with_routes( - config: NetworkConfig, - slot: u16, - host_has_ipv4: bool, - host_has_ipv6: bool, - ) -> Result { - Self::new_with_profile_and_routes( - config, - slot, - DeploymentProfile::SingleTenant, - host_has_ipv4, - host_has_ipv6, - ) - } - - fn new_with_profile_and_routes( - config: NetworkConfig, + fn build( + mut config: ResolvedNetworkConfig, slot: u16, deployment_profile: DeploymentProfile, - host_has_ipv4: bool, - host_has_ipv6: bool, + host_routes: HostRoutes, ) -> Result { + enforce_deployment_profile(&mut config, deployment_profile); + let platform_policy = Self::platform_policy(deployment_profile); + let resolved_config = config; + let config = resolved_config.config(); + if let Some(configured) = config.max_connections && configured > MAX_NETWORK_CONNECTIONS { @@ -224,7 +205,7 @@ impl SmoltcpNetwork { let guest_ipv4 = match config.interface.ipv4_address { Some(address) => Some(address), - None if host_has_ipv4 => Some(derive_guest_ipv4( + None if host_routes.ipv4 => Some(derive_guest_ipv4( config .interface .ipv4_pool @@ -236,7 +217,7 @@ impl SmoltcpNetwork { let gateway_ipv4 = guest_ipv4.map(gateway_from_guest_ipv4); let guest_ipv6 = match config.interface.ipv6_address { Some(address) => Some(address), - None if host_has_ipv6 => Some(derive_guest_ipv6( + None if host_routes.ipv6 => Some(derive_guest_ipv6( config .interface .ipv6_pool @@ -293,8 +274,8 @@ impl SmoltcpNetwork { }; Ok(Self { - config, - deployment_profile, + config: resolved_config, + platform_policy, shared, backend: Some(backend), poll_handle: None, @@ -310,6 +291,15 @@ impl SmoltcpNetwork { }) } + fn platform_policy(deployment_profile: DeploymentProfile) -> Option { + match deployment_profile { + DeploymentProfile::SingleTenant => None, + DeploymentProfile::MultiTenant => { + Some(NetworkPolicy::from_profiles([NetworkProfile::Public])) + } + } + } + /// Get the gateway IPs for virtio-net configuration and domain-based policy rules. fn gateway_ips(&self) -> GatewayIps { GatewayIps { @@ -332,18 +322,15 @@ impl SmoltcpNetwork { guest_ipv6: self.guest_ipv6, mtu: self.mtu as usize, }; - let network_policy = self.config.policy.clone(); - let platform_policy = match self.deployment_profile { - DeploymentProfile::SingleTenant => None, - DeploymentProfile::MultiTenant => { - Some(NetworkPolicy::from_profiles([NetworkProfile::Public])) - } - }; - let dns_config = self.config.dns.clone(); + let config = self.config.config(); + let network_policy = config.policy.clone(); + let platform_policy = self.platform_policy.clone(); + let dns_config = config.dns.clone(); let tls_state = self.tls_state.clone(); - let published_ports = self.config.ports.clone(); - let max_connections = self.config.max_connections; + let published_ports = config.ports.clone(); + let max_connections = config.max_connections; let secrets = self.secrets.clone(); + let outbound_proxy = self.config.outbound_proxy().cloned().map(Arc::new); self.poll_handle = Some( std::thread::Builder::new() @@ -360,6 +347,7 @@ impl SmoltcpNetwork { max_connections, tokio_handle, secrets, + outbound_proxy, ); }) .expect("failed to spawn smoltcp poll thread"), @@ -408,7 +396,7 @@ impl SmoltcpNetwork { } // Auto-expose secret placeholders as environment variables. - for secret in &self.config.secrets.secrets { + for secret in &self.config.config().secrets.secrets { vars.push((secret.env_var.clone(), secret.placeholder.clone())); } @@ -453,6 +441,7 @@ impl SmoltcpNetwork { /// enter this payload. pub fn guest_secret_env(&self) -> Vec { self.config + .config() .secrets .secrets .iter() @@ -471,14 +460,14 @@ impl SmoltcpNetwork { } /// Host-trusted CA bundle to ship into the guest, if - /// [`NetworkConfig::trust_host_cas`] is enabled. + /// [`crate::config::NetworkConfig::trust_host_cas`] is enabled. /// /// Returned PEM may concatenate CAs that the Mozilla root bundle in /// the guest already trusts; duplicates are harmless and saved the /// cost of computing a delta. Returns `None` when the host store is /// empty or the feature is disabled. pub fn host_cas_cert_pem(&self) -> Option> { - if !self.config.trust_host_cas { + if !self.config.config().trust_host_cas { return None; } crate::tls::host_cas::collect_host_cas() @@ -535,11 +524,14 @@ impl MetricsHandle { /// the platform public-network policy and the tenant policy independently so a /// broad tenant allow can never outrank the platform floor, while a tenant deny /// still remains effective. -fn enforce_deployment_profile(config: &mut NetworkConfig, profile: DeploymentProfile) { +fn enforce_deployment_profile(config: &mut ResolvedNetworkConfig, profile: DeploymentProfile) { if profile == DeploymentProfile::SingleTenant { return; } + config.clear_outbound_proxy(); + + let config = config.config_mut(); let interface_overridden = config.interface.mac.is_some() || config.interface.mtu.is_some() || config.interface.ipv4_address.is_some() @@ -550,6 +542,7 @@ fn enforce_deployment_profile(config: &mut NetworkConfig, profile: DeploymentPro let had_custom_nameservers = !config.dns.nameservers.is_empty(); let disabled_rebind_protection = !config.dns.rebind_protection; let trusted_host_cas = config.trust_host_cas; + let had_outbound_proxy = config.outbound_proxy.is_some(); let connection_limit_clamped = config .max_connections .is_some_and(|limit| limit > MULTI_TENANT_MAX_CONNECTIONS); @@ -571,6 +564,7 @@ fn enforce_deployment_profile(config: &mut NetworkConfig, profile: DeploymentPro || had_custom_nameservers || disabled_rebind_protection || trusted_host_cas + || had_outbound_proxy || connection_limit_clamped { tracing::warn!( @@ -579,6 +573,7 @@ fn enforce_deployment_profile(config: &mut NetworkConfig, profile: DeploymentPro had_custom_nameservers, disabled_rebind_protection, trusted_host_cas, + had_outbound_proxy, connection_limit_clamped, "multi-tenant deployment profile overrode unsafe network configuration" ); @@ -693,9 +688,17 @@ fn host_has_ipv6_route() -> bool { #[cfg(test)] mod tests { use super::*; - use crate::config::{PortProtocol, PublishedPort}; + use crate::config::{EnvNetworkSecretResolver, NetworkConfig, PortProtocol, PublishedPort}; use crate::dns::Nameserver; + fn resolved(config: NetworkConfig) -> ResolvedNetworkConfig { + config.resolve(&EnvNetworkSecretResolver).unwrap() + } + + fn routes(ipv4: bool, ipv6: bool) -> HostRoutes { + HostRoutes { ipv4, ipv6 } + } + #[test] fn derive_addresses_slot_0() { assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]); @@ -724,10 +727,16 @@ mod tests { config.dns.nameservers = vec!["10.0.0.53".parse::().unwrap()]; config.dns.rebind_protection = false; config.trust_host_cas = true; + config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 { + address: "127.0.0.1:1080".parse().unwrap(), + credentials: None, + }); config.max_connections = Some(MULTI_TENANT_MAX_CONNECTIONS + 1); config.policy = NetworkPolicy::allow_all(); + let mut resolved = resolved(config); - enforce_deployment_profile(&mut config, DeploymentProfile::MultiTenant); + enforce_deployment_profile(&mut resolved, DeploymentProfile::MultiTenant); + let config = resolved.config(); assert!(config.interface.mac.is_none()); assert!(config.interface.mtu.is_none()); @@ -735,7 +744,10 @@ mod tests { assert!(config.dns.nameservers.is_empty()); assert!(config.dns.rebind_protection); assert!(!config.trust_host_cas); + assert!(config.outbound_proxy.is_none()); assert_eq!(config.max_connections, Some(MULTI_TENANT_MAX_CONNECTIONS)); + assert!(resolved.config().outbound_proxy.is_none()); + assert!(resolved.outbound_proxy().is_none()); // Tenant policy stays intact and is intersected with the platform // policy at evaluation time instead of being reordered or flattened. assert!(config.policy.default_egress.is_allow()); @@ -747,12 +759,19 @@ mod tests { config.interface.mtu = Some(9000); config.dns.rebind_protection = false; config.trust_host_cas = true; + config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 { + address: "127.0.0.1:1080".parse().unwrap(), + credentials: None, + }); - enforce_deployment_profile(&mut config, DeploymentProfile::SingleTenant); + let mut resolved = resolved(config); + enforce_deployment_profile(&mut resolved, DeploymentProfile::SingleTenant); + let config = resolved.config(); assert_eq!(config.interface.mtu, Some(9000)); assert!(!config.dns.rebind_protection); assert!(config.trust_host_cas); + assert!(config.outbound_proxy.is_some()); } #[test] @@ -861,8 +880,13 @@ mod tests { #[test] fn guest_env_vars_includes_ipv4_when_host_has_v4_route() { - let net = - SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false).unwrap(); + let net = SmoltcpNetwork::build( + resolved(NetworkConfig::default()), + 0, + DeploymentProfile::SingleTenant, + routes(true, false), + ) + .unwrap(); let vars = net.guest_env_vars(); assert_eq!(vars.len(), 3); @@ -876,7 +900,13 @@ mod tests { #[test] fn guest_env_vars_includes_ipv6_when_host_has_v6_route() { - let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, true).unwrap(); + let net = SmoltcpNetwork::build( + resolved(NetworkConfig::default()), + 0, + DeploymentProfile::SingleTenant, + routes(true, true), + ) + .unwrap(); let vars = net.guest_env_vars(); assert_eq!(vars.len(), 4); @@ -889,8 +919,13 @@ mod tests { #[test] fn guest_env_vars_omit_ipv6_without_host_route() { - let net = - SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false).unwrap(); + let net = SmoltcpNetwork::build( + resolved(NetworkConfig::default()), + 0, + DeploymentProfile::SingleTenant, + routes(true, false), + ) + .unwrap(); let vars = net.guest_env_vars(); assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6)); @@ -898,8 +933,13 @@ mod tests { #[test] fn guest_env_vars_omit_ipv4_without_host_route() { - let net = - SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, true).unwrap(); + let net = SmoltcpNetwork::build( + resolved(NetworkConfig::default()), + 0, + DeploymentProfile::SingleTenant, + routes(false, true), + ) + .unwrap(); let vars = net.guest_env_vars(); assert_eq!(vars.len(), 3); @@ -912,7 +952,13 @@ mod tests { fn explicit_ipv6_address_overrides_missing_host_v6_route() { let mut config = NetworkConfig::default(); config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap()); - let net = SmoltcpNetwork::new_with_routes(config, 0, true, false).unwrap(); + let net = SmoltcpNetwork::build( + resolved(config), + 0, + DeploymentProfile::SingleTenant, + routes(true, false), + ) + .unwrap(); let vars = net.guest_env_vars(); let v6 = vars @@ -924,8 +970,13 @@ mod tests { #[test] fn neither_family_active_emits_only_base_env_vars() { - let net = - SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, false).unwrap(); + let net = SmoltcpNetwork::build( + resolved(NetworkConfig::default()), + 0, + DeploymentProfile::SingleTenant, + routes(false, false), + ) + .unwrap(); let vars = net.guest_env_vars(); assert_eq!(vars.len(), 2); @@ -935,7 +986,13 @@ mod tests { #[test] fn guest_bootstrap_network_preserves_active_address_families() { - let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 7, true, true).unwrap(); + let net = SmoltcpNetwork::build( + resolved(NetworkConfig::default()), + 7, + DeploymentProfile::SingleTenant, + routes(true, true), + ) + .unwrap(); let bootstrap = net.guest_bootstrap_network(); @@ -949,8 +1006,13 @@ mod tests { #[test] fn guest_bootstrap_network_allows_no_active_address_family() { - let net = - SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, false).unwrap(); + let net = SmoltcpNetwork::build( + resolved(NetworkConfig::default()), + 0, + DeploymentProfile::SingleTenant, + routes(false, false), + ) + .unwrap(); let bootstrap = net.guest_bootstrap_network(); @@ -959,14 +1021,19 @@ mod tests { } #[test] - fn new_with_routes_rejects_excessive_max_connections() { + fn build_rejects_excessive_max_connections() { let mut config = NetworkConfig { max_connections: Some(MAX_NETWORK_CONNECTIONS + 1), ..NetworkConfig::default() }; config.tls.enabled = false; - let err = match SmoltcpNetwork::new_with_routes(config, 0, true, false) { + let err = match SmoltcpNetwork::build( + resolved(config), + 0, + DeploymentProfile::SingleTenant, + routes(true, false), + ) { Ok(_) => panic!("excessive max_connections should fail"), Err(err) => err, }; @@ -983,7 +1050,7 @@ mod tests { /// A stored config bypasses the builder's validation, so an invalid /// limiter must fail startup cleanly instead of panicking. #[test] - fn new_with_routes_rejects_invalid_rate_limiter() { + fn build_rejects_invalid_rate_limiter() { let mut config = NetworkConfig { rate_limiter: Some(microsandbox_types::NetworkRateLimiterConfig { egress: None, @@ -996,7 +1063,12 @@ mod tests { }; config.tls.enabled = false; - let err = match SmoltcpNetwork::new_with_routes(config, 0, true, false) { + let err = match SmoltcpNetwork::build( + resolved(config), + 0, + DeploymentProfile::SingleTenant, + routes(true, false), + ) { Ok(_) => panic!("empty rate limiter should fail"), Err(err) => err, }; diff --git a/crates/network/lib/policy/builder.rs b/crates/network/lib/policy/builder.rs index 1ae6bfa9c..758cd50a2 100644 --- a/crates/network/lib/policy/builder.rs +++ b/crates/network/lib/policy/builder.rs @@ -86,6 +86,13 @@ pub enum BuildError { #[error("invalid IPv6 pool `{raw}`: prefix must be /64 or shorter")] InvalidIpv6Pool { raw: String }, + /// An outbound proxy builder received an invalid configuration. + #[error("invalid outbound proxy: {reason}")] + InvalidOutboundProxy { + /// Protocol-specific builder error. + reason: String, + }, + /// The configured connection limit is above the network stack's hard cap. #[error("max_connections {configured} exceeds hard limit {limit}")] MaxConnectionsExceeded { diff --git a/crates/network/lib/proxy/mod.rs b/crates/network/lib/proxy/mod.rs new file mode 100644 index 000000000..0ab60e974 --- /dev/null +++ b/crates/network/lib/proxy/mod.rs @@ -0,0 +1,20 @@ +//! Outbound proxy configuration and transport implementations. + +mod socks; +mod types; + +//-------------------------------------------------------------------------------------------------- +// Re-Exports +//-------------------------------------------------------------------------------------------------- + +pub use crate::tcp::proxy::*; + +#[doc(hidden)] +pub use socks::ResolvedSocks5Credentials; +pub use socks::{Socks4ProxyBuilder, Socks5Credentials, Socks5ProxyBuilder}; +#[doc(hidden)] +pub use types::ResolvedOutboundProxy; +pub use types::{ + OutboundProxy, OutboundProxyBuildError, OutboundProxyBuilder, OutboundProxyConfig, + OutboundProxyParseError, OutboundProxyProtocol, +}; diff --git a/crates/network/lib/proxy/socks.rs b/crates/network/lib/proxy/socks.rs new file mode 100644 index 000000000..90e12616f --- /dev/null +++ b/crates/network/lib/proxy/socks.rs @@ -0,0 +1,1319 @@ +//! SOCKS outbound proxy builders, credentials, and transport implementations. + +use std::fmt; +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use microsandbox_types::SecretSource; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_socks::tcp::Socks4Stream; +use zeroize::Zeroizing; + +use super::types::{ + OutboundProxy, OutboundProxyBuildError, OutboundProxyBuilder, OutboundProxyConfig, + OutboundProxyProtocol, ResolvedOutboundProxy, +}; +use crate::dns::forwarder::{DnsForwarder, DnsForwarderHandle}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Environment-backed username/password credentials for a SOCKS5 proxy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Socks5Credentials { + username: String, + password: SecretSource, +} + +/// Resolved SOCKS5 credentials held only by the network runtime. +#[doc(hidden)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedSocks5Credentials { + username: String, + password: Zeroizing, +} + +/// Builds a SOCKS4 outbound proxy. +#[derive(Debug, Clone)] +pub struct Socks4ProxyBuilder { + address: String, + user_id: Option, +} + +/// Builds a SOCKS5 outbound proxy. +#[derive(Debug, Clone)] +pub struct Socks5ProxyBuilder { + address: String, + credentials: Option, +} + +/// Active SOCKS5 UDP association. +pub(crate) struct Socks5UdpAssociation { + _control: TcpStream, + socket: tokio::net::UdpSocket, + dns_forwarder: Option, +} + +/// SOCKS5 wire protocol operations shared by TCP and UDP proxying. +struct Socks5Protocol; + +/// Address returned by a SOCKS5 command reply. +enum Socks5ReplyAddress { + Socket(SocketAddr), + Domain { name: String, port: u16 }, +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl ResolvedOutboundProxy { + /// Builds the complete proxy used by the network runtime. + #[doc(hidden)] + pub fn build( + configured: Option<&OutboundProxy>, + resolved: Option, + ) -> Result, OutboundProxyBuildError> { + let Some(configured) = configured else { + if resolved.is_some() { + return Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "launch credentials require a configured SOCKS5 proxy", + }); + } + return Ok(None); + }; + + configured.validate()?; + match configured { + OutboundProxy::Socks4 { address, user_id } => { + if resolved.is_some() { + return Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "launch credentials require a configured SOCKS5 proxy", + }); + } + Ok(Some(Self::Socks4 { + address: *address, + user_id: user_id.clone(), + })) + } + OutboundProxy::Socks5 { + address, + credentials, + } => { + let credentials = match (credentials, resolved) { + (None, None) => None, + (None, Some(_)) => { + return Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "launch credentials require configured SOCKS5 credentials", + }); + } + (Some(_), None) => { + return Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "configured SOCKS5 credentials were not resolved at launch", + }); + } + (Some(configured), Some(resolved)) => { + if resolved.username != configured.username { + return Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "launch username does not match the durable configuration", + }); + } + resolved.validate()?; + Some(resolved) + } + }; + Ok(Some(Self::Socks5 { + address: *address, + credentials, + })) + } + } + } + + /// Connects to `destination` through this outbound proxy. + pub(crate) async fn connect(&self, destination: SocketAddr) -> io::Result { + match self { + Self::Socks4 { address, user_id } => match user_id { + Some(user_id) => { + Socks4Stream::connect_with_userid(*address, destination, user_id).await + } + None => Socks4Stream::connect(*address, destination).await, + } + .map(|stream| stream.into_inner()) + .map_err(io::Error::other), + Self::Socks5 { + address, + credentials, + } => { + let mut stream = TcpStream::connect(*address).await?; + Socks5Protocol::negotiate(&mut stream, credentials.as_ref()).await?; + // The bound address is informational for CONNECT. Parse it to + // validate the reply, but do not resolve proxy-supplied domains. + let _ = Socks5Protocol::command(&mut stream, 0x01, destination).await?; + Ok(stream) + } + } + } + + /// Opens a SOCKS5 UDP association for relaying datagrams. + pub(crate) async fn associate_udp( + &self, + dns_forwarder: Option, + ) -> io::Result { + let Self::Socks5 { + address, + credentials, + } = self + else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "SOCKS4 does not support UDP relay", + )); + }; + + let mut control = TcpStream::connect(*address).await?; + Socks5Protocol::negotiate(&mut control, credentials.as_ref()).await?; + + let control_local = control.local_addr()?; + // The UDP endpoint is not known until the proxy returns its relay. + // RFC 1928 requires an all-zero endpoint in that case. + let request_address = match control_local.ip() { + IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let relays = match Socks5Protocol::command(&mut control, 0x03, request_address).await? { + Socks5ReplyAddress::Socket(relay) => vec![relay], + Socks5ReplyAddress::Domain { name, port } => { + Socks5Protocol::resolve_domain(dns_forwarder.as_ref(), &name, port).await? + } + }; + Socks5UdpAssociation::connect(control, relays, dns_forwarder).await + } +} + +impl OutboundProxy { + fn validate(&self) -> Result<(), OutboundProxyBuildError> { + match self { + Self::Socks4 { user_id, .. } => Self::validate_socks4_user_id(user_id.as_deref()), + Self::Socks5 { credentials, .. } => credentials + .as_ref() + .map_or(Ok(()), Socks5Credentials::validate), + } + } + + fn validate_socks4_user_id(user_id: Option<&str>) -> Result<(), OutboundProxyBuildError> { + let Some(user_id) = user_id else { + return Ok(()); + }; + let reason = if user_id.is_empty() { + "must not be empty" + } else if user_id.len() > 255 { + "must be at most 255 bytes" + } else if user_id.contains('\0') { + "must not contain a null byte" + } else { + return Ok(()); + }; + + Err(OutboundProxyBuildError::InvalidSocks4UserId { reason }) + } +} + +impl ResolvedSocks5Credentials { + /// Creates credentials for the private launch contract. + #[doc(hidden)] + pub fn new(username: impl Into, password: impl Into) -> Self { + Self { + username: username.into(), + password: Zeroizing::new(password.into()), + } + } + + /// Validates the RFC 1929 one-octet credential lengths. + fn validate(&self) -> Result<(), OutboundProxyBuildError> { + let reason = if self.username.is_empty() { + "username must not be empty" + } else if self.username.len() > u8::MAX as usize { + "username must be at most 255 bytes" + } else if self.password.is_empty() { + "password must not be empty" + } else if self.password.len() > u8::MAX as usize { + "password must be at most 255 bytes" + } else { + return Ok(()); + }; + + Err(OutboundProxyBuildError::InvalidSocks5Credentials { reason }) + } +} + +impl Socks5Credentials { + pub(crate) fn username(&self) -> &str { + &self.username + } + + pub(crate) fn password_source(&self) -> &SecretSource { + &self.password + } + + /// Validates the durable SOCKS5 credential configuration. + fn validate(&self) -> Result<(), OutboundProxyBuildError> { + if self.username.is_empty() { + return Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "username must not be empty", + }); + } + if self.username.len() > u8::MAX as usize { + return Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "username must be at most 255 bytes", + }); + } + match &self.password { + SecretSource::Env { var } if var.is_empty() => { + Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "password environment variable must not be empty", + }) + } + SecretSource::Env { .. } => Ok(()), + SecretSource::Store { .. } => Err(OutboundProxyBuildError::InvalidSocks5Credentials { + reason: "store-backed password sources are not supported yet", + }), + } + } +} + +impl Socks5UdpAssociation { + /// Connects a UDP socket to the first usable relay address. + async fn connect( + control: TcpStream, + relays: Vec, + dns_forwarder: Option, + ) -> io::Result { + let peer_ip = control.peer_addr()?.ip(); + let mut last_error = None; + + for relay in relays { + let relay = if relay.ip().is_unspecified() { + SocketAddr::new(peer_ip, relay.port()) + } else { + relay + }; + let bind_address = match relay.ip() { + IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let socket = match tokio::net::UdpSocket::bind(bind_address).await { + Ok(socket) => socket, + Err(error) => { + last_error = Some(error); + continue; + } + }; + match socket.connect(relay).await { + Ok(()) => { + return Ok(Self { + _control: control, + socket, + dns_forwarder, + }); + } + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AddrNotAvailable, + "SOCKS5 proxy returned no usable UDP relay address", + ) + })) + } + + /// Sends one payload to `destination` through the UDP association. + pub(crate) async fn send_to( + &self, + payload: &[u8], + destination: SocketAddr, + ) -> io::Result { + let mut datagram = + Vec::with_capacity(Socks5Protocol::address_len(destination) + 3 + payload.len()); + datagram.extend_from_slice(&[0x00, 0x00, 0x00]); + Socks5Protocol::encode_address(&mut datagram, destination); + datagram.extend_from_slice(payload); + self.socket.send(&datagram).await.map(|_| payload.len()) + } + + /// Receives one payload and returns the remote endpoints encoded by the proxy. + pub(crate) async fn recv_from( + &self, + buffer: &mut [u8], + ) -> io::Result<(usize, Vec)> { + let received = self.socket.recv(buffer).await?; + let (header_len, source) = Socks5Protocol::decode_udp_header(&buffer[..received])?; + let sources = match source { + Socks5ReplyAddress::Socket(source) => vec![source], + Socks5ReplyAddress::Domain { name, port } => { + Socks5Protocol::resolve_domain(self.dns_forwarder.as_ref(), &name, port).await? + } + }; + let payload_len = received - header_len; + buffer.copy_within(header_len..received, 0); + Ok((payload_len, sources)) + } +} + +impl fmt::Debug for ResolvedSocks5Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolvedSocks5Credentials") + .field("username", &self.username) + .field("password", &"[REDACTED]") + .finish() + } +} + +impl OutboundProxyBuilder { + /// Creates a protocol selector. + pub fn new() -> Self { + Self + } + + /// Starts building a SOCKS4 outbound proxy. + pub fn socks4(self, address: impl Into) -> Socks4ProxyBuilder { + Socks4ProxyBuilder { + address: address.into(), + user_id: None, + } + } + + /// Starts building a SOCKS5 outbound proxy. + pub fn socks5(self, address: impl Into) -> Socks5ProxyBuilder { + Socks5ProxyBuilder { + address: address.into(), + credentials: None, + } + } +} + +impl Socks4ProxyBuilder { + /// Sets the optional user ID sent during the SOCKS4 handshake. + pub fn user_id(mut self, user_id: impl Into) -> Self { + self.user_id = Some(user_id.into()); + self + } +} + +impl Socks5ProxyBuilder { + /// Sets username authentication and a host-side password source. + pub fn credentials(mut self, username: impl Into, password: SecretSource) -> Self { + self.credentials = Some(Socks5Credentials { + username: username.into(), + password, + }); + self + } +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations +//-------------------------------------------------------------------------------------------------- + +impl OutboundProxyConfig for Socks4ProxyBuilder { + fn build(self) -> Result { + let address = + self.address + .parse() + .map_err(|source| OutboundProxyBuildError::InvalidAddress { + protocol: OutboundProxyProtocol::Socks4, + address: self.address, + source, + })?; + + OutboundProxy::validate_socks4_user_id(self.user_id.as_deref())?; + Ok(OutboundProxy::Socks4 { + address, + user_id: self.user_id, + }) + } +} + +impl OutboundProxyConfig for Socks5ProxyBuilder { + fn build(self) -> Result { + let address = + self.address + .parse() + .map_err(|source| OutboundProxyBuildError::InvalidAddress { + protocol: OutboundProxyProtocol::Socks5, + address: self.address, + source, + })?; + if let Some(credentials) = &self.credentials { + credentials.validate()?; + } + Ok(OutboundProxy::Socks5 { + address, + credentials: self.credentials, + }) + } +} + +impl OutboundProxyConfig for OutboundProxy { + fn build(self) -> Result { + self.validate()?; + Ok(self) + } +} + +impl Socks5Protocol { + /// Resolves a domain-form SOCKS5 endpoint through the internal DNS path. + async fn resolve_domain( + dns_forwarder: Option<&DnsForwarderHandle>, + name: &str, + port: u16, + ) -> io::Result> { + let dns_forwarder = dns_forwarder.ok_or_else(|| { + io::Error::other("DNS forwarder is unavailable for SOCKS5 UDP endpoint resolution") + })?; + let forwarder = DnsForwarder::wait(dns_forwarder.clone()) + .await + .ok_or_else(|| { + io::Error::other("DNS forwarder is unavailable for SOCKS5 UDP endpoint resolution") + })?; + Ok(forwarder + .resolve_proxy_domain(name) + .await? + .into_iter() + .map(|address| SocketAddr::new(address, port)) + .collect()) + } + + /// Negotiates a SOCKS5 authentication method and performs username/password + /// authentication when selected by the proxy. + async fn negotiate( + stream: &mut TcpStream, + credentials: Option<&ResolvedSocks5Credentials>, + ) -> io::Result<()> { + if let Some(credentials) = credentials { + credentials.validate().map_err(io::Error::other)?; + } + + match credentials { + Some(_) => stream.write_all(&[0x05, 0x02, 0x00, 0x02]).await?, + None => stream.write_all(&[0x05, 0x01, 0x00]).await?, + } + + let mut selection = [0u8; 2]; + stream.read_exact(&mut selection).await?; + if selection[0] != 0x05 { + return Err(Self::invalid_response("invalid method-selection version")); + } + + match selection[1] { + 0x00 => Ok(()), + 0x02 => { + let credentials = credentials.ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "SOCKS5 proxy requires username/password authentication", + ) + })?; + let username = credentials.username.as_bytes(); + let password = credentials.password.as_bytes(); + let mut request = Vec::with_capacity(3 + username.len() + password.len()); + request.extend_from_slice(&[0x01, username.len() as u8]); + request.extend_from_slice(username); + request.push(password.len() as u8); + request.extend_from_slice(password); + stream.write_all(&request).await?; + + let mut response = [0u8; 2]; + stream.read_exact(&mut response).await?; + if response[0] != 0x01 { + return Err(Self::invalid_response( + "invalid username/password response version", + )); + } + if response[1] != 0x00 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "SOCKS5 username/password authentication failed", + )); + } + Ok(()) + } + 0xff => Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "SOCKS5 proxy rejected all offered authentication methods", + )), + method => Err(Self::invalid_response(format!( + "SOCKS5 proxy selected unsupported authentication method {method:#04x}" + ))), + } + } + + /// Sends a SOCKS5 command and returns the bound address from its reply. + async fn command( + stream: &mut TcpStream, + command: u8, + destination: SocketAddr, + ) -> io::Result { + let mut request = Vec::with_capacity(3 + Self::address_len(destination)); + request.extend_from_slice(&[0x05, command, 0x00]); + Self::encode_address(&mut request, destination); + stream.write_all(&request).await?; + + let mut header = [0u8; 4]; + stream.read_exact(&mut header).await?; + if header[0] != 0x05 || header[2] != 0x00 { + return Err(Self::invalid_response("invalid SOCKS5 command reply")); + } + if header[1] != 0x00 { + return Err(io::Error::other(format!( + "SOCKS5 proxy command failed: {}", + Self::reply_message(header[1]) + ))); + } + + Self::read_address(stream, header[3]).await + } + + /// Reads a SOCKS5 address whose address-type byte was already consumed. + async fn read_address( + stream: &mut TcpStream, + address_type: u8, + ) -> io::Result { + let ip = match address_type { + 0x01 => { + let mut octets = [0u8; 4]; + stream.read_exact(&mut octets).await?; + IpAddr::V4(Ipv4Addr::from(octets)) + } + 0x04 => { + let mut octets = [0u8; 16]; + stream.read_exact(&mut octets).await?; + IpAddr::V6(Ipv6Addr::from(octets)) + } + 0x03 => { + let length = stream.read_u8().await? as usize; + let mut domain = vec![0u8; length]; + stream.read_exact(&mut domain).await?; + let domain = String::from_utf8(domain).map_err(|_| { + Self::invalid_response("SOCKS5 reply contains a non-UTF-8 domain") + })?; + let mut port = [0u8; 2]; + stream.read_exact(&mut port).await?; + return Ok(Socks5ReplyAddress::Domain { + name: domain, + port: u16::from_be_bytes(port), + }); + } + _ => return Err(Self::invalid_response("unsupported SOCKS5 address type")), + }; + + let mut port = [0u8; 2]; + stream.read_exact(&mut port).await?; + Ok(Socks5ReplyAddress::Socket(SocketAddr::new( + ip, + u16::from_be_bytes(port), + ))) + } + + /// Encodes a socket address in SOCKS5 address form. + fn encode_address(output: &mut Vec, address: SocketAddr) { + match address { + SocketAddr::V4(address) => { + output.push(0x01); + output.extend_from_slice(&address.ip().octets()); + output.extend_from_slice(&address.port().to_be_bytes()); + } + SocketAddr::V6(address) => { + output.push(0x04); + output.extend_from_slice(&address.ip().octets()); + output.extend_from_slice(&address.port().to_be_bytes()); + } + } + } + + /// Returns the encoded length of a SOCKS5 socket address. + fn address_len(address: SocketAddr) -> usize { + match address { + SocketAddr::V4(_) => 7, + SocketAddr::V6(_) => 19, + } + } + + /// Decodes a SOCKS5 UDP request header and returns its payload offset and endpoint. + fn decode_udp_header(datagram: &[u8]) -> io::Result<(usize, Socks5ReplyAddress)> { + if datagram.len() < 4 || datagram[..2] != [0x00, 0x00] { + return Err(Self::invalid_response("invalid SOCKS5 UDP header")); + } + if datagram[2] != 0x00 { + return Err(Self::invalid_response( + "fragmented SOCKS5 UDP datagrams are not supported", + )); + } + + let (endpoint, port_offset) = match datagram[3] { + 0x01 if datagram.len() >= 10 => ( + Socks5ReplyAddress::Socket(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new( + datagram[4], + datagram[5], + datagram[6], + datagram[7], + )), + u16::from_be_bytes([datagram[8], datagram[9]]), + )), + 8, + ), + 0x04 if datagram.len() >= 22 => { + let mut octets = [0u8; 16]; + octets.copy_from_slice(&datagram[4..20]); + ( + Socks5ReplyAddress::Socket(SocketAddr::new( + IpAddr::V6(Ipv6Addr::from(octets)), + u16::from_be_bytes([datagram[20], datagram[21]]), + )), + 20, + ) + } + 0x03 if datagram.len() >= 7 => { + let length = datagram[4] as usize; + let port_offset = 5 + length; + if length == 0 || datagram.len() < port_offset + 2 { + return Err(Self::invalid_response("invalid SOCKS5 UDP domain address")); + } + let name = std::str::from_utf8(&datagram[5..port_offset]) + .map_err(|_| Self::invalid_response("non-UTF-8 SOCKS5 UDP domain address"))? + .to_owned(); + ( + Socks5ReplyAddress::Domain { + name, + port: u16::from_be_bytes([ + datagram[port_offset], + datagram[port_offset + 1], + ]), + }, + port_offset, + ) + } + _ => return Err(Self::invalid_response("invalid SOCKS5 UDP address")), + }; + Ok((port_offset + 2, endpoint)) + } + + /// Converts a SOCKS5 reply code into a stable diagnostic. + fn reply_message(reply: u8) -> &'static str { + match reply { + 0x01 => "general server failure", + 0x02 => "connection not allowed by ruleset", + 0x03 => "network unreachable", + 0x04 => "host unreachable", + 0x05 => "connection refused", + 0x06 => "TTL expired", + 0x07 => "command not supported", + 0x08 => "address type not supported", + _ => "unknown error", + } + } + + /// Builds an invalid-data error for malformed SOCKS5 responses. + fn invalid_response(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use hickory_net::proto::op::{Message, MessageType, OpCode}; + use hickory_net::proto::rr::rdata::{A, AAAA}; + use hickory_net::proto::rr::{RData, Record, RecordType}; + use hickory_net::proto::serialize::binary::{BinDecodable, BinEncodable}; + use microsandbox_types::SecretSource; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, UdpSocket}; + use tokio::sync::watch; + + use super::{ + OutboundProxy, OutboundProxyBuildError, OutboundProxyBuilder, OutboundProxyConfig, + OutboundProxyProtocol, ResolvedOutboundProxy, ResolvedSocks5Credentials, + }; + use crate::dns::forwarder::DnsForwarder; + use crate::netstack::poll::GatewayIps; + use crate::netstack::shared::SharedState; + + async fn responding_dns(relay_ipv6: Ipv6Addr, source_ipv4: Ipv4Addr) -> SocketAddr { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = socket.local_addr().unwrap(); + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + let Ok((length, source)) = socket.recv_from(&mut buffer).await else { + continue; + }; + let Ok(query) = Message::from_bytes(&buffer[..length]) else { + continue; + }; + let mut response = + Message::new(query.metadata.id, MessageType::Response, OpCode::Query); + response.metadata.recursion_desired = query.metadata.recursion_desired; + response.metadata.recursion_available = true; + if let Some(question) = query.queries.first() { + response.add_query(question.clone()); + let domain = question.name().to_string(); + let answer = match (domain.trim_end_matches('.'), question.query_type()) { + ("relay.example.com", RecordType::AAAA) => { + Some(RData::AAAA(AAAA::from(relay_ipv6))) + } + ("source.example.com", RecordType::A) => { + Some(RData::A(A::from(source_ipv4))) + } + _ => None, + }; + if let Some(answer) = answer { + response.add_answer(Record::from_rdata( + question.name().clone(), + 60, + answer, + )); + } + } + if let Ok(bytes) = response.to_bytes() { + let _ = socket.send_to(&bytes, source).await; + } + } + }); + address + } + + #[test] + fn builder_creates_socks4_proxy_with_optional_user_id() { + let address = "127.0.0.1:1080".parse().unwrap(); + let without_user_id = OutboundProxyBuilder::new() + .socks4("127.0.0.1:1080") + .build() + .unwrap(); + let with_user_id = OutboundProxyBuilder::new() + .socks4("127.0.0.1:1080") + .user_id("sandbox") + .build() + .unwrap(); + + assert_eq!( + without_user_id, + OutboundProxy::Socks4 { + address, + user_id: None, + } + ); + assert_eq!( + with_user_id, + OutboundProxy::Socks4 { + address, + user_id: Some("sandbox".to_string()), + } + ); + } + + #[test] + fn builder_creates_socks5_proxy() { + let proxy = OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .build() + .unwrap(); + + assert_eq!( + proxy, + OutboundProxy::Socks5 { + address: "127.0.0.1:1080".parse().unwrap(), + credentials: None, + } + ); + } + + #[test] + fn builder_creates_socks5_proxy_with_password_source() { + let proxy = OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .credentials( + "sandbox", + SecretSource::Env { + var: "SOCKS5_PASSWORD".into(), + }, + ) + .build() + .unwrap(); + + let debug = format!("{proxy:?}"); + assert!(debug.contains("sandbox")); + assert!(debug.contains("SOCKS5_PASSWORD")); + + let json = serde_json::to_value(&proxy).unwrap(); + assert_eq!(json["credentials"]["username"], "sandbox"); + assert_eq!(json["credentials"]["password"]["kind"], "env"); + assert_eq!(json["credentials"]["password"]["var"], "SOCKS5_PASSWORD"); + assert!(json["credentials"].get("value").is_none()); + } + + #[test] + fn builder_rejects_invalid_socks5_credentials() { + for (username, password_env) in [ + (String::new(), "SOCKS5_PASSWORD".to_string()), + ("username".to_string(), String::new()), + ("u".repeat(256), "SOCKS5_PASSWORD".to_string()), + ] { + assert!( + OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .credentials(username, SecretSource::Env { var: password_env }) + .build() + .is_err() + ); + } + + assert!( + OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .credentials( + "username", + SecretSource::Store { + reference: "production/socks5-password".into(), + }, + ) + .build() + .is_err() + ); + } + + #[test] + fn resolved_proxy_build_validates_resolved_socks5_password() { + let proxy = OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .credentials( + "sandbox", + SecretSource::Env { + var: "SOCKS5_PASSWORD".into(), + }, + ) + .build() + .unwrap(); + assert!( + ResolvedOutboundProxy::build( + Some(&proxy), + Some(ResolvedSocks5Credentials::new("sandbox", "")), + ) + .is_err() + ); + assert!( + ResolvedOutboundProxy::build( + Some(&proxy), + Some(ResolvedSocks5Credentials::new("sandbox", "p".repeat(256),)), + ) + .is_err() + ); + ResolvedOutboundProxy::build( + Some(&proxy), + Some(ResolvedSocks5Credentials::new("sandbox", "password")), + ) + .unwrap(); + } + + #[test] + fn resolved_proxy_build_requires_matching_resolved_credentials() { + let authenticated = OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .credentials("sandbox", SecretSource::env("SOCKS5_PASSWORD")) + .build() + .unwrap(); + let unauthenticated = OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .build() + .unwrap(); + let resolved = || ResolvedSocks5Credentials::new("sandbox", "password"); + + assert!(ResolvedOutboundProxy::build(Some(&authenticated), None).is_err()); + assert!(ResolvedOutboundProxy::build(Some(&unauthenticated), Some(resolved())).is_err()); + assert!(ResolvedOutboundProxy::build(None, Some(resolved())).is_err()); + assert!( + ResolvedOutboundProxy::build( + Some(&authenticated), + Some(ResolvedSocks5Credentials::new("different-user", "password")), + ) + .is_err() + ); + } + + #[test] + fn uri_parses_and_formats_for_cli() { + let socks4: OutboundProxy = "socks4://127.0.0.1:1080".parse().unwrap(); + let socks5: OutboundProxy = "socks5://127.0.0.1:1080".parse().unwrap(); + + assert_eq!( + socks4, + OutboundProxy::Socks4 { + address: "127.0.0.1:1080".parse().unwrap(), + user_id: None, + } + ); + assert_eq!(socks4.to_string(), "socks4://127.0.0.1:1080"); + assert_eq!( + socks5, + OutboundProxy::Socks5 { + address: "127.0.0.1:1080".parse().unwrap(), + credentials: None, + } + ); + assert_eq!(socks5.to_string(), "socks5://127.0.0.1:1080"); + } + + #[test] + fn uri_rejects_unsupported_forms() { + for raw in [ + "127.0.0.1:1080", + "http://127.0.0.1:1080", + "socks4://user@127.0.0.1:1080", + "socks5://user@127.0.0.1:1080", + "socks5://127.0.0.1:1080/path", + "socks5://127.0.0.1:1080?option=value", + "socks5://127.0.0.1:1080#fragment", + ] { + assert!(raw.parse::().is_err(), "accepted {raw:?}"); + } + } + + #[test] + fn builder_rejects_invalid_socks4_user_ids() { + for user_id in [String::new(), "a\0b".to_string(), "a".repeat(256)] { + assert!( + OutboundProxyBuilder::new() + .socks4("127.0.0.1:1080") + .user_id(user_id) + .build() + .is_err() + ); + } + } + + #[test] + fn outbound_proxy_rejects_invalid_socks4_user_ids() { + for user_id in [String::new(), "a\0b".to_string(), "a".repeat(256)] { + let proxy = OutboundProxy::Socks4 { + address: "127.0.0.1:1080".parse().unwrap(), + user_id: Some(user_id), + }; + + assert!(proxy.build().is_err()); + } + } + + #[test] + fn invalid_address_error_uses_typed_protocol() { + let error = OutboundProxyBuilder::new() + .socks5("not-an-address") + .build() + .unwrap_err(); + + assert!(matches!( + error, + OutboundProxyBuildError::InvalidAddress { + protocol: OutboundProxyProtocol::Socks5, + .. + } + )); + } + + #[tokio::test] + async fn connects_through_socks4_proxy_with_user_id() { + let target: SocketAddr = "93.184.216.34:443".parse().unwrap(); + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = proxy_listener.local_addr().unwrap(); + let proxy_task = tokio::spawn(async move { + let (mut client, _) = proxy_listener.accept().await.unwrap(); + + let mut request = [0u8; 16]; + client.read_exact(&mut request).await.unwrap(); + assert_eq!(request[0], 0x04, "SOCKS version"); + assert_eq!(request[1], 0x01, "CONNECT command"); + assert_eq!(u16::from_be_bytes([request[2], request[3]]), 443); + assert_eq!(&request[4..8], &[93, 184, 216, 34]); + assert_eq!(&request[8..], b"sandbox\0"); + + client + .write_all(&[0x00, 0x5a, 0x01, 0xbb, 93, 184, 216, 34]) + .await + .unwrap(); + + let mut buf = [0u8; 5]; + client.read_exact(&mut buf).await.unwrap(); + client.write_all(&buf).await.unwrap(); + }); + + let mut stream = ResolvedOutboundProxy::Socks4 { + address: proxy_addr, + user_id: Some("sandbox".to_string()), + } + .connect(target) + .await + .unwrap(); + stream.write_all(b"hello").await.unwrap(); + let mut echoed = [0u8; 5]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(&echoed, b"hello"); + + proxy_task.await.unwrap(); + } + + #[tokio::test] + async fn connects_through_socks5_proxy() { + let target: SocketAddr = "93.184.216.34:443".parse().unwrap(); + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = proxy_listener.local_addr().unwrap(); + let proxy_task = tokio::spawn(async move { + let (mut client, _) = proxy_listener.accept().await.unwrap(); + + let mut greeting = [0u8; 3]; + client.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [0x05, 0x01, 0x00]); + client.write_all(&[0x05, 0x00]).await.unwrap(); + + let mut request = [0u8; 10]; + client.read_exact(&mut request).await.unwrap(); + assert_eq!(request[0], 0x05, "SOCKS version"); + assert_eq!(request[1], 0x01, "CONNECT command"); + assert_eq!(request[3], 0x01, "IPv4 address type"); + assert_eq!(&request[4..8], &[93, 184, 216, 34]); + assert_eq!(u16::from_be_bytes([request[8], request[9]]), 443); + + client + .write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + + let mut buf = [0u8; 5]; + client.read_exact(&mut buf).await.unwrap(); + client.write_all(&buf).await.unwrap(); + }); + + let mut stream = ResolvedOutboundProxy::Socks5 { + address: proxy_addr, + credentials: None, + } + .connect(target) + .await + .unwrap(); + stream.write_all(b"hello").await.unwrap(); + let mut echoed = [0u8; 5]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(&echoed, b"hello"); + + proxy_task.await.unwrap(); + } + + #[tokio::test] + async fn connect_does_not_resolve_domain_from_socks5_reply() { + let target: SocketAddr = "93.184.216.34:443".parse().unwrap(); + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = proxy_listener.local_addr().unwrap(); + let proxy_task = tokio::spawn(async move { + let (mut client, _) = proxy_listener.accept().await.unwrap(); + + let mut greeting = [0u8; 3]; + client.read_exact(&mut greeting).await.unwrap(); + client.write_all(&[0x05, 0x00]).await.unwrap(); + + let mut request = [0u8; 10]; + client.read_exact(&mut request).await.unwrap(); + let domain = b"does-not-resolve.invalid"; + let mut response = vec![0x05, 0x00, 0x00, 0x03, domain.len() as u8]; + response.extend_from_slice(domain); + response.extend_from_slice(&443u16.to_be_bytes()); + client.write_all(&response).await.unwrap(); + + let mut buf = [0u8; 5]; + client.read_exact(&mut buf).await.unwrap(); + client.write_all(&buf).await.unwrap(); + }); + + let mut stream = ResolvedOutboundProxy::Socks5 { + address: proxy_addr, + credentials: None, + } + .connect(target) + .await + .unwrap(); + stream.write_all(b"hello").await.unwrap(); + let mut echoed = [0u8; 5]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(&echoed, b"hello"); + + proxy_task.await.unwrap(); + } + + #[tokio::test] + async fn connects_through_authenticated_socks5_proxy() { + let target: SocketAddr = "93.184.216.34:443".parse().unwrap(); + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = proxy_listener.local_addr().unwrap(); + let proxy_task = tokio::spawn(async move { + let (mut client, _) = proxy_listener.accept().await.unwrap(); + + let mut greeting = [0u8; 4]; + client.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [0x05, 0x02, 0x00, 0x02]); + client.write_all(&[0x05, 0x02]).await.unwrap(); + + let mut auth = [0u8; 18]; + client.read_exact(&mut auth).await.unwrap(); + assert_eq!(&auth, b"\x01\x07sandbox\x08password"); + client.write_all(&[0x01, 0x00]).await.unwrap(); + + let mut request = [0u8; 10]; + client.read_exact(&mut request).await.unwrap(); + assert_eq!(request[1], 0x01, "CONNECT command"); + client + .write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + }); + + let configured = OutboundProxyBuilder::new() + .socks5(proxy_addr.to_string()) + .credentials( + "sandbox", + SecretSource::Env { + var: "SOCKS5_PASSWORD".into(), + }, + ) + .build() + .unwrap(); + let proxy = ResolvedOutboundProxy::build( + Some(&configured), + Some(ResolvedSocks5Credentials::new("sandbox", "password")), + ) + .unwrap() + .unwrap(); + proxy.connect(target).await.unwrap(); + + proxy_task.await.unwrap(); + } + + #[tokio::test] + async fn associates_and_relays_socks5_udp() { + let target: SocketAddr = "93.184.216.34:5353".parse().unwrap(); + let relay = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let relay_addr = relay.local_addr().unwrap(); + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = proxy_listener.local_addr().unwrap(); + + let proxy_task = tokio::spawn(async move { + let (mut control, _) = proxy_listener.accept().await.unwrap(); + let mut greeting = [0u8; 3]; + control.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [0x05, 0x01, 0x00]); + control.write_all(&[0x05, 0x00]).await.unwrap(); + + let mut request = [0u8; 10]; + control.read_exact(&mut request).await.unwrap(); + assert_eq!(request[1], 0x03, "UDP ASSOCIATE command"); + + let mut response = vec![0x05, 0x00, 0x00, 0x01]; + let SocketAddr::V4(relay_addr) = relay_addr else { + unreachable!() + }; + response.extend_from_slice(&relay_addr.ip().octets()); + response.extend_from_slice(&relay_addr.port().to_be_bytes()); + control.write_all(&response).await.unwrap(); + + let mut datagram = [0u8; 64]; + let (received, client) = relay.recv_from(&mut datagram).await.unwrap(); + assert_eq!(&datagram[..10], &[0, 0, 0, 1, 93, 184, 216, 34, 0x14, 0xe9]); + assert_eq!(&datagram[10..received], b"hello"); + relay.send_to(&datagram[..received], client).await.unwrap(); + }); + + let configured = OutboundProxyBuilder::new() + .socks5(proxy_addr.to_string()) + .build() + .unwrap(); + let proxy = ResolvedOutboundProxy::build(Some(&configured), None) + .unwrap() + .unwrap(); + let association = proxy.associate_udp(None).await.unwrap(); + association.send_to(b"hello", target).await.unwrap(); + let mut response = [0u8; 64]; + let (received, sources) = association.recv_from(&mut response).await.unwrap(); + assert_eq!(sources, vec![target]); + assert_eq!(&response[..received], b"hello"); + + proxy_task.await.unwrap(); + } + + #[tokio::test] + async fn udp_association_supports_domain_relay_in_another_address_family() { + let target: SocketAddr = "93.184.216.34:5353".parse().unwrap(); + let relay = UdpSocket::bind("[::1]:0").await.unwrap(); + let relay_addr = relay.local_addr().unwrap(); + let dns_upstream = + responding_dns(Ipv6Addr::LOCALHOST, Ipv4Addr::new(93, 184, 216, 34)).await; + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = proxy_listener.local_addr().unwrap(); + let proxy_task = tokio::spawn(async move { + let (mut control, _) = proxy_listener.accept().await.unwrap(); + + let mut greeting = [0u8; 3]; + control.read_exact(&mut greeting).await.unwrap(); + control.write_all(&[0x05, 0x00]).await.unwrap(); + + let mut request = [0u8; 10]; + control.read_exact(&mut request).await.unwrap(); + assert_eq!(request[1], 0x03, "UDP ASSOCIATE command"); + + let domain = b"relay.example.com"; + let mut response = vec![0x05, 0x00, 0x00, 0x03, domain.len() as u8]; + response.extend_from_slice(domain); + response.extend_from_slice(&relay_addr.port().to_be_bytes()); + control.write_all(&response).await.unwrap(); + + let mut datagram = [0u8; 64]; + let (_, client) = relay.recv_from(&mut datagram).await.unwrap(); + let source_domain = b"source.example.com"; + let mut response = vec![0x00, 0x00, 0x00, 0x03, source_domain.len() as u8]; + response.extend_from_slice(source_domain); + response.extend_from_slice(&target.port().to_be_bytes()); + response.extend_from_slice(b"hello"); + relay.send_to(&response, client).await.unwrap(); + }); + + let proxy = ResolvedOutboundProxy::Socks5 { + address: proxy_addr, + credentials: None, + }; + let forwarder = DnsForwarder::for_proxy_test( + Arc::new(SharedState::new(4)), + GatewayIps { + ipv4: Some("127.0.0.1".parse().unwrap()), + ipv6: None, + }, + Some(dns_upstream), + ) + .await; + let (_dns_tx, dns_forwarder) = watch::channel(Some(forwarder)); + let association = proxy.associate_udp(Some(dns_forwarder)).await.unwrap(); + association.send_to(b"hello", target).await.unwrap(); + let mut response = [0u8; 64]; + let (received, sources) = association.recv_from(&mut response).await.unwrap(); + assert_eq!(sources, vec![target]); + assert_eq!(&response[..received], b"hello"); + + proxy_task.await.unwrap(); + } +} diff --git a/crates/network/lib/proxy/types.rs b/crates/network/lib/proxy/types.rs new file mode 100644 index 000000000..a247e0350 --- /dev/null +++ b/crates/network/lib/proxy/types.rs @@ -0,0 +1,221 @@ +//! Outbound proxy configuration types and parsing. + +use std::fmt; +use std::net::{AddrParseError, SocketAddr}; +use std::str::FromStr; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use super::socks::Socks5Credentials; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Proxy used for outbound sandbox connections. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "protocol", rename_all = "lowercase")] +#[non_exhaustive] +pub enum OutboundProxy { + /// A SOCKS4 proxy at the given address. + Socks4 { + /// Proxy socket address. + address: SocketAddr, + /// Optional user ID sent during the SOCKS4 handshake. + #[serde(default, skip_serializing_if = "Option::is_none")] + user_id: Option, + }, + + /// A SOCKS5 proxy at the given address. + Socks5 { + /// Proxy socket address. + address: SocketAddr, + /// Optional durable username/password authentication configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + credentials: Option, + }, +} + +/// Fully resolved proxy used by the network runtime. +#[doc(hidden)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedOutboundProxy { + /// A SOCKS4 proxy ready for TCP connections. + Socks4 { + /// Proxy socket address. + address: SocketAddr, + /// Optional user ID sent during the SOCKS4 handshake. + user_id: Option, + }, + + /// A SOCKS5 proxy ready for TCP and UDP connections. + Socks5 { + /// Proxy socket address. + address: SocketAddr, + /// Optional resolved username/password authentication credentials. + credentials: Option, + }, +} + +/// Protocol used by an [`OutboundProxy`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum OutboundProxyProtocol { + /// SOCKS version 4. + Socks4, + /// SOCKS version 5. + Socks5, +} + +/// Selects the protocol for an [`OutboundProxy`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct OutboundProxyBuilder; + +/// Error returned when building an outbound proxy. +#[derive(Debug, Clone, thiserror::Error)] +pub enum OutboundProxyBuildError { + /// The proxy address is not a valid socket address. + #[error("invalid {protocol} proxy address {address:?}: {source}")] + InvalidAddress { + /// Proxy protocol whose address was invalid. + protocol: OutboundProxyProtocol, + /// Invalid address text. + address: String, + /// Socket-address parsing failure. + #[source] + source: AddrParseError, + }, + + /// A SOCKS4 user ID is empty, too long, or contains a null byte. + #[error("invalid SOCKS4 user ID: {reason}")] + InvalidSocks4UserId { + /// Explanation of the validation failure. + reason: &'static str, + }, + + /// A SOCKS5 username or password is empty or longer than 255 bytes. + #[error("invalid SOCKS5 credentials: {reason}")] + InvalidSocks5Credentials { + /// Explanation of the validation failure. + reason: &'static str, + }, +} + +/// Error returned when parsing an outbound proxy URI. +/// +/// URI parsing is intended for string-only interfaces such as the CLI. SDKs +/// should use [`OutboundProxyBuilder`] instead. +#[derive(Debug, Clone, thiserror::Error)] +pub enum OutboundProxyParseError { + /// The URI does not include a `scheme://` prefix. + #[error("outbound proxy URI must include a protocol, for example socks5://127.0.0.1:1080")] + MissingProtocol, + + /// The URI uses a proxy protocol that is not supported yet. + #[error( + "unsupported outbound proxy protocol {protocol:?}; supported protocols are socks4:// and socks5://" + )] + UnsupportedProtocol { + /// Unsupported URI scheme. + protocol: String, + }, + + /// The URI includes credentials, which are not supported. + #[error("outbound proxy credentials are not supported in the URI")] + CredentialsNotSupported, + + /// The URI includes a path, query, or fragment. + #[error("outbound proxy URI must not include a path, query, or fragment")] + ExtraComponentsNotSupported, + + /// The proxy address is not a valid socket address. + #[error(transparent)] + Build(#[from] OutboundProxyBuildError), +} + +/// Converts a protocol-specific proxy builder into an [`OutboundProxy`]. +/// +/// Sandbox-facing builders use this trait to finalize protocol-specific proxy +/// builders and collect their validation errors. +#[doc(hidden)] +pub trait OutboundProxyConfig { + /// Builds the outbound proxy. + fn build(self) -> Result; +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl ResolvedOutboundProxy { + /// Selects the configured proxy unless the host-side destination was + /// rewritten for a host-local connection. + pub(crate) fn select_for_destination( + configured: &Option>, + guest_dst: SocketAddr, + host_dst: SocketAddr, + ) -> Option> { + if guest_dst == host_dst { + configured.clone() + } else { + None + } + } +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations +//-------------------------------------------------------------------------------------------------- + +impl fmt::Display for OutboundProxy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Socks4 { address, .. } => write!(f, "socks4://{address}"), + Self::Socks5 { address, .. } => write!(f, "socks5://{address}"), + } + } +} + +impl fmt::Display for OutboundProxyProtocol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Socks4 => f.write_str("SOCKS4"), + Self::Socks5 => f.write_str("SOCKS5"), + } + } +} + +impl FromStr for OutboundProxy { + type Err = OutboundProxyParseError; + + fn from_str(raw: &str) -> Result { + let (protocol, address) = raw + .split_once("://") + .ok_or(OutboundProxyParseError::MissingProtocol)?; + let protocol = match protocol { + "socks4" => OutboundProxyProtocol::Socks4, + "socks5" => OutboundProxyProtocol::Socks5, + protocol => { + return Err(OutboundProxyParseError::UnsupportedProtocol { + protocol: protocol.to_string(), + }); + } + }; + if address.contains('@') { + return Err(OutboundProxyParseError::CredentialsNotSupported); + } + if address.contains(['/', '?', '#']) { + return Err(OutboundProxyParseError::ExtraComponentsNotSupported); + } + match protocol { + OutboundProxyProtocol::Socks4 => { + Ok(OutboundProxyBuilder::new().socks4(address).build()?) + } + OutboundProxyProtocol::Socks5 => { + Ok(OutboundProxyBuilder::new().socks5(address).build()?) + } + } + } +} diff --git a/crates/network/lib/tcp/proxy.rs b/crates/network/lib/tcp/proxy.rs index 4d5b7f7d6..9547b3b2b 100644 --- a/crates/network/lib/tcp/proxy.rs +++ b/crates/network/lib/tcp/proxy.rs @@ -17,9 +17,12 @@ use tokio::net::TcpStream; use tokio::sync::mpsc; use super::connection::ProxyConnectState; +#[cfg(test)] +use super::connection::ProxyConnectStatus; use super::upstream::UpstreamTcpTarget; use crate::netstack::shared::SharedState; use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol}; +use crate::proxy::ResolvedOutboundProxy; use crate::secrets::config::{SecretsConfig, SecretsConfigExt, ViolationAction}; use crate::secrets::handler::{ SecretsHandler, first_line_is_not_http_request, looks_like_http_request_prefix, @@ -74,6 +77,7 @@ pub(crate) struct TcpProxy { secrets: Arc, tls_state: Option>, proxy_connect: Arc, + outbound_proxy: Option>, } //-------------------------------------------------------------------------------------------------- @@ -138,6 +142,7 @@ impl TcpProxy { secrets: Arc, tls_state: Option>, proxy_connect: Arc, + outbound_proxy: Option>, ) -> Self { Self { guest_dst, @@ -149,6 +154,7 @@ impl TcpProxy { secrets, tls_state, proxy_connect, + outbound_proxy, } } @@ -179,6 +185,7 @@ impl TcpProxy { secrets, tls_state, proxy_connect, + outbound_proxy, } = self; // Pre-connect peek is only for domain policy: the hostname has to be known @@ -245,6 +252,7 @@ impl TcpProxy { network_policy, tls_state, proxy_connect, + outbound_proxy, None, ) .await; @@ -255,7 +263,9 @@ impl TcpProxy { // server-first protocol (SSH, SMTP, a database) sends nothing until it has // seen the server's banner; with the socket already open we can relay that // banner while we wait, instead of burning the peek budget pre-connect. - let stream = connect_target.connect(&proxy_connect, &shared).await?; + let stream = connect_target + .connect(&proxy_connect, &shared, outbound_proxy.as_deref()) + .await?; let connect_dst = stream.peer_addr().unwrap_or(connect_target.primary()); let (mut server_rx, mut server_tx) = stream.into_split(); @@ -301,6 +311,7 @@ impl TcpProxy { network_policy, tls_state, proxy_connect, + outbound_proxy, Some(proxy_stream), ) .await; @@ -381,6 +392,7 @@ impl TcpProxy { network_policy, tls_state, proxy_connect, + outbound_proxy, Some(proxy_stream), ) .await; @@ -478,6 +490,7 @@ pub fn spawn_tcp_proxy( secrets: Arc, tls_state: Option>, proxy_connect: Arc, + outbound_proxy: Option>, ) { let proxy = TcpProxy::new( guest_dst, @@ -489,6 +502,7 @@ pub fn spawn_tcp_proxy( secrets, tls_state, proxy_connect, + outbound_proxy, ); handle.spawn(proxy.run()); @@ -510,6 +524,7 @@ async fn handle_connect_tunnel( network_policy: Arc, tls_state: Arc, proxy_connect: Arc, + outbound_proxy: Option>, preconnected_proxy: Option, ) -> io::Result<()> { let proxy_dst = proxy_target.primary(); @@ -533,7 +548,11 @@ async fn handle_connect_tunnel( // Dial the proxy and forward the CONNECT request so it opens the tunnel. let mut proxy_stream = match preconnected_proxy { Some(stream) => stream, - None => proxy_target.connect(&proxy_connect, &shared).await?, + None => { + proxy_target + .connect(&proxy_connect, &shared, outbound_proxy.as_deref()) + .await? + } }; if !connect_req.target.is_intercepted(&tls_state) { @@ -610,6 +629,10 @@ async fn handle_connect_tunnel( tls_state, network_policy, proxy_connect, + // Unused: `upstream_stream` is already `Some` below, so the + // outbound proxy (already applied when dialing `proxy_stream` + // above) is never consulted again. + None, ) .with_upstream(proxy_stream) .with_expected_sni(expected_sni) @@ -1120,6 +1143,121 @@ mod tests { record } + #[tokio::test] + async fn connect_upstream_dials_target_directly_without_outbound_proxy() { + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accept = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 5]; + sock.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"hello"); + }); + + let shared = SharedState::new(4); + let proxy_connect = ProxyConnectState::new(); + let mut stream = UpstreamTcpTarget::direct(addr) + .connect(&proxy_connect, &shared, None) + .await + .unwrap(); + stream.write_all(b"hello").await.unwrap(); + + accept.await.unwrap(); + assert!(matches!( + proxy_connect.status(), + ProxyConnectStatus::Connected + )); + } + + #[tokio::test] + async fn early_http_connect_dials_proxy_through_configured_socks5_proxy() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + // This is the HTTP proxy the guest originally dialed. It is never + // contacted directly; the SOCKS5 request below must carry this address. + let http_proxy_addr: SocketAddr = "93.184.216.34:3128".parse().unwrap(); + let socks_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let outbound_proxy = ResolvedOutboundProxy::Socks5 { + address: socks_listener.local_addr().unwrap(), + credentials: None, + }; + let socks_task = tokio::spawn(async move { + let (mut client, _) = socks_listener.accept().await.unwrap(); + + let mut greeting = [0u8; 3]; + client.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [0x05, 0x01, 0x00]); + client.write_all(&[0x05, 0x00]).await.unwrap(); + + let mut socks_request = [0u8; 10]; + client.read_exact(&mut socks_request).await.unwrap(); + assert_eq!(socks_request[0..4], [0x05, 0x01, 0x00, 0x01]); + assert_eq!(&socks_request[4..8], &[93, 184, 216, 34]); + assert_eq!( + u16::from_be_bytes([socks_request[8], socks_request[9]]), + 3128 + ); + client + .write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + + let expected_connect = + b"CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + let mut connect_request = vec![0u8; expected_connect.len()]; + client.read_exact(&mut connect_request).await.unwrap(); + assert_eq!(&connect_request, expected_connect); + client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .unwrap(); + }); + + let connect_request = + b"CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n".to_vec(); + let (from_tx, from_rx) = mpsc::channel(1); + let (to_tx, mut to_rx) = mpsc::channel(1); + drop(from_tx); + + let tls_state = Arc::new( + TlsState::new( + microsandbox_types::TlsConfig::default(), + crate::secrets::handle::SecretsHandle::new(SecretsConfig::default()), + ) + .unwrap(), + ); + let proxy_connect = Arc::new(ProxyConnectState::new()); + + handle_connect_tunnel( + http_proxy_addr, + UpstreamTcpTarget::direct(http_proxy_addr), + connect_request, + from_rx, + to_tx, + Arc::new(SharedState::new(4)), + Arc::new(NetworkPolicy::default()), + tls_state, + proxy_connect.clone(), + Some(Arc::new(outbound_proxy)), + None, + ) + .await + .unwrap(); + + let response = to_rx.recv().await.unwrap(); + assert_eq!( + &response[..], + b"HTTP/1.1 200 Connection Established\r\n\r\n" + ); + socks_task.await.unwrap(); + assert!(matches!( + proxy_connect.status(), + ProxyConnectStatus::Connected + )); + } + #[test] fn could_be_connect_request_matches_split_prefixes_only() { assert!(could_be_connect_request(b"C")); @@ -1696,6 +1834,7 @@ mod tests { secrets, None, proxy_connect, + None, ) .try_run() .await @@ -1737,6 +1876,7 @@ mod tests { Arc::new(secrets), None, proxy_connect, + None, ) .try_run() .await @@ -1808,6 +1948,7 @@ mod tests { Arc::new(secrets), None, proxy_connect, + None, ) .try_run() .await @@ -1909,6 +2050,7 @@ mod tests { Arc::new(secrets), None, proxy_connect, + None, ) .try_run() .await diff --git a/crates/network/lib/tcp/upstream.rs b/crates/network/lib/tcp/upstream.rs index e43035558..acc794c1e 100644 --- a/crates/network/lib/tcp/upstream.rs +++ b/crates/network/lib/tcp/upstream.rs @@ -7,6 +7,7 @@ use tokio::net::TcpStream; use super::connection::ProxyConnectState; use crate::netstack::shared::SharedState; +use crate::proxy::ResolvedOutboundProxy; //-------------------------------------------------------------------------------------------------- // Types @@ -46,12 +47,21 @@ impl UpstreamTcpTarget { } /// Connect and publish the final outcome to the guest proxy state. + /// + /// When `outbound_proxy` is set, it dials `primary` through that proxy + /// instead of connecting directly; the address-family fallback only + /// applies to direct dials. pub(crate) async fn connect( self, proxy_connect: &ProxyConnectState, shared: &SharedState, + outbound_proxy: Option<&ResolvedOutboundProxy>, ) -> io::Result { - let stream = match self.dial().await { + let result = match outbound_proxy { + Some(proxy) => proxy.connect(self.primary).await, + None => self.dial().await, + }; + let stream = match result { Ok(stream) => stream, Err(error) => { proxy_connect.mark_upstream_connect_failed(); @@ -132,7 +142,7 @@ mod tests { let shared = SharedState::new(4); let stream = target - .connect(&proxy_connect, &shared) + .connect(&proxy_connect, &shared, None) .await .expect("IPv4 loopback fallback should connect"); diff --git a/crates/network/lib/tls/proxy.rs b/crates/network/lib/tls/proxy.rs index acf15e599..17405ac3c 100644 --- a/crates/network/lib/tls/proxy.rs +++ b/crates/network/lib/tls/proxy.rs @@ -19,6 +19,7 @@ use super::sni; use super::state::TlsState; use crate::netstack::shared::SharedState; use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol}; +use crate::proxy::ResolvedOutboundProxy; use crate::secrets::config::ViolationAction; use crate::secrets::handler::SecretsHandler; use crate::tcp::{connection::ProxyConnectState, upstream::UpstreamTcpTarget}; @@ -47,6 +48,7 @@ pub(crate) struct TlsProxy { tls_state: Arc, network_policy: Arc, proxy_connect: Arc, + outbound_proxy: Option>, /// Pre-connected upstream; when `Some`, skips dialing `connect_target`. upstream_stream: Option, /// Hostname from a CONNECT authority that must match the ClientHello SNI. @@ -73,6 +75,7 @@ impl TlsProxy { tls_state: Arc, network_policy: Arc, proxy_connect: Arc, + outbound_proxy: Option>, ) -> Self { Self { guest_dst, @@ -83,6 +86,7 @@ impl TlsProxy { tls_state, network_policy, proxy_connect, + outbound_proxy, upstream_stream: None, expected_sni: None, via_connect: false, @@ -139,6 +143,7 @@ impl TlsProxy { network_policy, proxy_connect, upstream_stream, + outbound_proxy, expected_sni, via_connect, initial_buf, @@ -200,6 +205,7 @@ impl TlsProxy { shared, proxy_connect, upstream_stream, + outbound_proxy, ) .await } else { @@ -216,6 +222,7 @@ impl TlsProxy { tls_state, proxy_connect, upstream_stream, + outbound_proxy, ) .await } @@ -227,6 +234,7 @@ impl TlsProxy { //-------------------------------------------------------------------------------------------------- /// Bypass mode: plain TCP splice, no TLS termination. +#[allow(clippy::too_many_arguments)] async fn bypass_relay( connect_target: UpstreamTcpTarget, initial_buf: Vec, @@ -235,10 +243,15 @@ async fn bypass_relay( shared: Arc, proxy_connect: Arc, upstream_stream: Option, + outbound_proxy: Option>, ) -> io::Result<()> { let mut server = match upstream_stream { Some(s) => s, - None => connect_target.connect(&proxy_connect, &shared).await?, + None => { + connect_target + .connect(&proxy_connect, &shared, outbound_proxy.as_deref()) + .await? + } }; server.write_all(&initial_buf).await?; @@ -293,6 +306,7 @@ pub(crate) async fn intercept_relay( tls_state: Arc, proxy_connect: Arc, upstream_stream: Option, + outbound_proxy: Option>, ) -> io::Result<()> { // Per-connection snapshot: live secret updates apply to later connections. let secrets = tls_state.secrets.load(); @@ -353,7 +367,11 @@ pub(crate) async fn intercept_relay( // Connect to real server with TLS. let server_stream = match upstream_stream { Some(s) => s, - None => connect_target.connect(&proxy_connect, &shared).await?, + None => { + connect_target + .connect(&proxy_connect, &shared, outbound_proxy.as_deref()) + .await? + } }; let server_name = ServerName::try_from(sni_name.to_string()) .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; diff --git a/crates/network/lib/udp/relay.rs b/crates/network/lib/udp/relay.rs index 33090f837..c800a0e0b 100644 --- a/crates/network/lib/udp/relay.rs +++ b/crates/network/lib/udp/relay.rs @@ -24,8 +24,10 @@ use tokio::io::Interest; use tokio::net::UdpSocket; use tokio::sync::mpsc; +use crate::dns::forwarder::DnsForwarderHandle; use crate::icmp::error::{construct_packet_too_big, ethernet_ip_payload}; use crate::netstack::shared::SharedState; +use crate::proxy::ResolvedOutboundProxy; //-------------------------------------------------------------------------------------------------- // Constants @@ -52,7 +54,10 @@ const MAX_IPV4_UDP_PAYLOAD_LEN: usize = 65_507; /// Maximum UDP payload carried by a non-jumbo IPv6 packet. const MAX_IPV6_UDP_PAYLOAD_LEN: usize = 65_527; -/// Buffer size for receiving responses from the real server. +/// Buffer size for receiving a complete outer UDP datagram. +/// +/// A SOCKS5 header is part of that outer datagram, so the protocol's maximum +/// UDP payload size already includes it and requires no additional capacity. const RECV_BUF_SIZE: usize = MAX_IPV6_UDP_PAYLOAD_LEN; /// Ethernet header length. @@ -95,6 +100,14 @@ pub struct UdpRelay { guest_mac: EthernetAddress, mtu: usize, tokio_handle: tokio::runtime::Handle, + outbound_proxy: Option>, + dns_forwarder: Option, +} + +/// Upstream transport selected for a UDP relay session. +enum UdpUpstream { + Direct, + Socks5(Arc), } /// A single UDP relay session. @@ -137,12 +150,14 @@ impl UdpRelay { /// * `guest_mac` - MAC address stamped as the destination on synthesized response frames. /// * `mtu` - Guest IP-level MTU. Large UDP replies are fragmented to fit it. /// * `tokio_handle` - Runtime the per-session relay tasks are spawned on. + /// * `outbound_proxy` - Optional proxy used for external UDP sessions. pub fn new( shared: Arc, gateway_mac: [u8; 6], guest_mac: [u8; 6], mtu: usize, tokio_handle: tokio::runtime::Handle, + outbound_proxy: Option>, ) -> Self { Self { shared, @@ -151,9 +166,16 @@ impl UdpRelay { guest_mac: EthernetAddress(guest_mac), mtu, tokio_handle, + outbound_proxy, + dns_forwarder: None, } } + /// Attaches the policy-aware resolver used for domain-form SOCKS5 relay addresses. + pub(crate) fn attach_dns_forwarder(&mut self, dns_forwarder: DnsForwarderHandle) { + self.dns_forwarder = Some(dns_forwarder); + } + /// Relay an outbound UDP datagram from the guest. /// /// # Arguments @@ -287,6 +309,8 @@ impl UdpRelay { guest_dst: SocketAddr, host_dst: SocketAddr, ) -> Option { + let upstream = self.select_upstream(guest_src, guest_dst, host_dst)?; + let (outbound_tx, outbound_rx) = mpsc::channel(OUTBOUND_CHANNEL_CAPACITY); let queued_bytes = Arc::new(AtomicUsize::new(0)); @@ -294,22 +318,42 @@ impl UdpRelay { let gateway_mac = self.gateway_mac; let guest_mac = self.guest_mac; let mtu = self.mtu; + let dns_forwarder = self.dns_forwarder.clone(); let task_queued_bytes = queued_bytes.clone(); - self.tokio_handle.spawn(async move { - if let Err(e) = udp_relay_task( - outbound_rx, - task_queued_bytes, - guest_src, - guest_dst, - host_dst, - shared, - gateway_mac, - guest_mac, - mtu, - ) - .await - { + let result = match upstream { + UdpUpstream::Socks5(outbound_proxy) => { + Self::relay_socks5_session( + outbound_rx, + task_queued_bytes, + guest_src, + guest_dst, + host_dst, + shared, + gateway_mac, + guest_mac, + mtu, + outbound_proxy, + dns_forwarder, + ) + .await + } + UdpUpstream::Direct => { + Self::relay_direct_session( + outbound_rx, + task_queued_bytes, + guest_src, + guest_dst, + host_dst, + shared, + gateway_mac, + guest_mac, + mtu, + ) + .await + } + }; + if let Err(e) = result { tracing::debug!( guest_src = %guest_src, guest_dst = %guest_dst, @@ -325,6 +369,33 @@ impl UdpRelay { last_active: Instant::now(), }) } + + /// Select the upstream transport for a UDP relay session. + fn select_upstream( + &self, + guest_src: SocketAddr, + guest_dst: SocketAddr, + host_dst: SocketAddr, + ) -> Option { + match ResolvedOutboundProxy::select_for_destination( + &self.outbound_proxy, + guest_dst, + host_dst, + ) { + None => Some(UdpUpstream::Direct), + Some(proxy) => match proxy.as_ref() { + ResolvedOutboundProxy::Socks5 { .. } => Some(UdpUpstream::Socks5(proxy)), + ResolvedOutboundProxy::Socks4 { .. } => { + tracing::debug!( + guest_src = %guest_src, + guest_dst = %guest_dst, + "UDP relay dropped datagram because SOCKS4 has no UDP transport", + ); + None + } + }, + } + } } impl UdpSession { @@ -357,166 +428,264 @@ impl UdpSession { } //-------------------------------------------------------------------------------------------------- -// Functions +// Methods: Relay Tasks //-------------------------------------------------------------------------------------------------- -/// Per-session UDP relay loop: forwards guest datagrams to a host socket, stamps the replies -/// back into frames the guest accepts, and exits on idle timeout or channel close. -/// -/// Binds an ephemeral host UDP socket in the address family of `host_dst` and `connect()`s it -/// to that peer. The `connect` restricts the socket to that peer's datagrams, which both sets -/// the default send target and filters spoofed inbound traffic. Responses are wrapped in a -/// synthesised ethernet frame (src IP = `guest_dst`, dst = `guest_src`) and pushed into -/// `rx_ring`. -/// -/// # Arguments -/// -/// * `outbound_rx` - Receives UDP payloads from the poll-loop side. Channel close signals -/// session drop. -/// * `guest_src` - Guest source address; stamped as the destination on reply frames. -/// * `guest_dst` - Destination the guest wrote on the datagram. Stamped as the source IP on -/// reply frames so the guest sees replies from the same address it dialed. -/// * `host_dst` - Address the host socket connects to. Equal to `guest_dst` for external -/// destinations; rewritten to loopback by [`crate::netstack::poll::resolve_host_dst`] when the guest -/// addressed the gateway. -/// * `shared` - Shared state; reply frames go into `rx_ring` and wake the poll thread. -/// * `gateway_mac` - Source MAC on reply frames (guest sees replies from the gateway's MAC). -/// * `guest_mac` - Destination MAC on reply frames. -/// * `mtu` - Guest IP-level MTU used to fragment large replies before injection. -/// -/// # Errors -/// -/// Returns [`std::io::Error`] when the initial `bind` or `connect` on -/// the host UDP socket fails, or when the host-side `recv` fails after -/// the socket was established. -#[allow(clippy::too_many_arguments)] -async fn udp_relay_task( - mut outbound_rx: mpsc::Receiver, - queued_bytes: Arc, - guest_src: SocketAddr, - guest_dst: SocketAddr, - host_dst: SocketAddr, - shared: Arc, - gateway_mac: EthernetAddress, - guest_mac: EthernetAddress, - mtu: usize, -) -> std::io::Result<()> { - let socket = open_udp_socket(host_dst)?; - // Connect to the destination to restrict accepted source addresses, - // preventing host-network entities from injecting spoofed datagrams. - socket.connect(host_dst).await?; +impl UdpRelay { + /// Relays one direct UDP session until it becomes idle or its channel closes. + /// + /// Binds an ephemeral host UDP socket in the address family of `host_dst` and `connect()`s it + /// to that peer. The `connect` restricts the socket to that peer's datagrams, which both sets + /// the default send target and filters spoofed inbound traffic. Responses are wrapped in a + /// synthesised ethernet frame (src IP = `guest_dst`, dst = `guest_src`) and pushed into + /// `rx_ring`. + /// + /// # Arguments + /// + /// * `outbound_rx` - Receives UDP payloads from the poll-loop side. Channel close signals + /// session drop. + /// * `guest_src` - Guest source address; stamped as the destination on reply frames. + /// * `guest_dst` - Destination the guest wrote on the datagram. Stamped as the source IP on + /// reply frames so the guest sees replies from the same address it dialed. + /// * `host_dst` - Address the host socket connects to. Equal to `guest_dst` for external + /// destinations; rewritten to loopback by + /// [`crate::netstack::poll::resolve_host_dst`] when the guest addressed the gateway. + /// * `shared` - Shared state; reply frames go into `rx_ring` and wake the poll thread. + /// * `gateway_mac` - Source MAC on reply frames (guest sees replies from the gateway's MAC). + /// * `guest_mac` - Destination MAC on reply frames. + /// * `mtu` - Guest IP-level MTU used to fragment large replies before injection. + /// + /// # Errors + /// + /// Returns [`std::io::Error`] when the initial `bind` or `connect` on + /// the host UDP socket fails, or when the host-side `recv` fails after + /// the socket was established. + #[allow(clippy::too_many_arguments)] + async fn relay_direct_session( + mut outbound_rx: mpsc::Receiver, + queued_bytes: Arc, + guest_src: SocketAddr, + guest_dst: SocketAddr, + host_dst: SocketAddr, + shared: Arc, + gateway_mac: EthernetAddress, + guest_mac: EthernetAddress, + mtu: usize, + ) -> std::io::Result<()> { + let socket = open_udp_socket(host_dst)?; + // Connect to the destination to restrict accepted source addresses, + // preventing host-network entities from injecting spoofed datagrams. + socket.connect(host_dst).await?; - let mut recv_buf = vec![0u8; RECV_BUF_SIZE]; - let mut pmtu_contexts = VecDeque::new(); - let timeout = SESSION_TIMEOUT; + let mut recv_buf = vec![0u8; RECV_BUF_SIZE]; + let mut pmtu_contexts = VecDeque::new(); + let timeout = SESSION_TIMEOUT; - loop { - tokio::select! { - // Outbound: guest → server. - data = outbound_rx.recv() => { - match data { - Some(datagram) => { - queued_bytes.fetch_sub(datagram.queued_len(), Ordering::AcqRel); - match socket.send(&datagram.payload).await { - Ok(_) => { - remember_pmtu_context(&mut pmtu_contexts, datagram.original_ip_packet); - } - Err(e) if is_message_size_error(&e) => { - inject_packet_too_big( - &shared, - datagram.original_ip_packet.as_ref(), - socket_path_mtu(&socket, host_dst).ok(), - gateway_mac, - guest_mac, - ); - } - Err(e) => { - tracing::debug!(error = %e, "UDP relay send failed"); + loop { + tokio::select! { + // Outbound: guest → server. + data = outbound_rx.recv() => { + match data { + Some(datagram) => { + queued_bytes.fetch_sub(datagram.queued_len(), Ordering::AcqRel); + match socket.send(&datagram.payload).await { + Ok(_) => { + remember_pmtu_context(&mut pmtu_contexts, datagram.original_ip_packet); + } + Err(e) if is_message_size_error(&e) => { + inject_packet_too_big( + &shared, + datagram.original_ip_packet.as_ref(), + socket_path_mtu(&socket, host_dst).ok(), + gateway_mac, + guest_mac, + ); + } + Err(e) => { + tracing::debug!(error = %e, "UDP relay send failed"); + } } } + // Channel closed — session dropped by poll loop. + None => break, } - // Channel closed — session dropped by poll loop. - None => break, } - } - // Inbound/error readiness: server → guest data or host PMTU feedback. - ready = socket.ready(Interest::READABLE | Interest::ERROR) => { - let ready = ready?; + // Inbound/error readiness: server → guest data or host PMTU feedback. + ready = socket.ready(Interest::READABLE | Interest::ERROR) => { + let ready = ready?; + + #[cfg(target_os = "linux")] + if ready.is_error() { + match drain_pmtu_errors(&socket) { + Ok(updates) => { + for mtu in updates { + if let Some(original_ip_packet) = + take_pmtu_context(&mut pmtu_contexts, mtu) + { + inject_packet_too_big( + &shared, + original_ip_packet.as_ref(), + Some(mtu), + gateway_mac, + guest_mac, + ); + } + } + } + Err(e) => tracing::debug!(error = %e, "UDP relay error queue drain failed"), + } + } - #[cfg(target_os = "linux")] - if ready.is_error() { - match drain_pmtu_errors(&socket) { - Ok(updates) => { - for mtu in updates { + if ready.is_readable() { + match socket.try_recv(&mut recv_buf) { + Ok(n) => { + if let Some(frames) = construct_udp_response_frames( + guest_dst, + guest_src, + &recv_buf[..n], + gateway_mac, + guest_mac, + mtu, + ) { + for frame in frames { + if !shared.push_rx_frame_and_wake(frame) { + tracing::debug!("UDP relay response dropped because rx_ring is full"); + break; + } + } + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => {} + Err(e) if is_message_size_error(&e) => { if let Some(original_ip_packet) = - take_pmtu_context(&mut pmtu_contexts, mtu) + take_pmtu_context_without_mtu(&mut pmtu_contexts) { inject_packet_too_big( &shared, original_ip_packet.as_ref(), - Some(mtu), + socket_path_mtu(&socket, host_dst).ok(), gateway_mac, guest_mac, ); } } + Err(e) => { + tracing::debug!(error = %e, "UDP relay recv failed"); + break; + } } - Err(e) => tracing::debug!(error = %e, "UDP relay error queue drain failed"), } } - if ready.is_readable() { - match socket.try_recv(&mut recv_buf) { - Ok(n) => { + // Idle timeout. + () = tokio::time::sleep(timeout) => { + break; + } + } + } + + Ok(()) + } + + /// Relays one SOCKS5 UDP association until it becomes idle or its channel closes. + /// + /// The TCP control connection and UDP association live for the same lifetime + /// as the guest UDP session. Replies are accepted only when the endpoint in + /// the SOCKS5 UDP header matches the destination selected by the guest. + #[allow(clippy::too_many_arguments)] + async fn relay_socks5_session( + mut outbound_rx: mpsc::Receiver, + queued_bytes: Arc, + guest_src: SocketAddr, + guest_dst: SocketAddr, + host_dst: SocketAddr, + shared: Arc, + gateway_mac: EthernetAddress, + guest_mac: EthernetAddress, + mtu: usize, + outbound_proxy: Arc, + dns_forwarder: Option, + ) -> io::Result<()> { + let association = outbound_proxy.associate_udp(dns_forwarder).await?; + let mut recv_buf = vec![0u8; RECV_BUF_SIZE]; + + loop { + tokio::select! { + data = outbound_rx.recv() => { + match data { + Some(datagram) => { + queued_bytes.fetch_sub(datagram.queued_len(), Ordering::AcqRel); + match association.send_to(&datagram.payload, host_dst).await { + Ok(_) => {} + Err(e) if is_message_size_error(&e) => { + tracing::debug!( + error = %e, + "SOCKS5 UDP relay dropped an oversized datagram", + ); + } + Err(e) => { + tracing::debug!(error = %e, "SOCKS5 UDP relay send failed"); + } + } + } + None => break, + } + } + + result = association.recv_from(&mut recv_buf) => { + match result { + Ok((received, sources)) if sources.contains(&host_dst) => { if let Some(frames) = construct_udp_response_frames( guest_dst, guest_src, - &recv_buf[..n], + &recv_buf[..received], gateway_mac, guest_mac, mtu, ) { for frame in frames { if !shared.push_rx_frame_and_wake(frame) { - tracing::debug!("UDP relay response dropped because rx_ring is full"); + tracing::debug!( + "SOCKS5 UDP relay response dropped because rx_ring is full" + ); break; } } } } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => {} - Err(e) if is_message_size_error(&e) => { - if let Some(original_ip_packet) = - take_pmtu_context_without_mtu(&mut pmtu_contexts) - { - inject_packet_too_big( - &shared, - original_ip_packet.as_ref(), - socket_path_mtu(&socket, host_dst).ok(), - gateway_mac, - guest_mac, - ); - } + Ok((_, sources)) => { + tracing::debug!( + expected = %host_dst, + actual = ?sources, + "SOCKS5 UDP relay dropped a response from an unexpected endpoint", + ); + } + Err(e) if e.kind() == io::ErrorKind::InvalidData => { + tracing::debug!( + error = %e, + "SOCKS5 UDP relay dropped a malformed response", + ); } Err(e) => { - tracing::debug!(error = %e, "UDP relay recv failed"); + tracing::debug!(error = %e, "SOCKS5 UDP relay receive failed"); break; } } } - } - // Idle timeout. - () = tokio::time::sleep(timeout) => { - break; + () = tokio::time::sleep(SESSION_TIMEOUT) => break, } } - } - Ok(()) + Ok(()) + } } +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + /// Construct an ethernet frame containing a UDP response for the guest. /// /// Builds Ethernet + IPv4/IPv6 + UDP headers using smoltcp's wire module. diff --git a/crates/runtime/lib/launch.rs b/crates/runtime/lib/launch.rs index e7e31a861..785d7da37 100644 --- a/crates/runtime/lib/launch.rs +++ b/crates/runtime/lib/launch.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use microsandbox_types::TransparentHugePagePolicy; #[cfg(feature = "net")] -use microsandbox_network::config::NetworkConfig; +use microsandbox_network::ResolvedNetworkConfig; use crate::vm::{MetricsSlotHandoff, StartupCommand}; @@ -116,9 +116,9 @@ pub struct LaunchConfig { /// Arguments to pass to the executable. pub exec_args: Vec, - /// Network configuration. Present only when the `net` feature is on. + /// Network launch configuration. Present only when the `net` feature is on. #[cfg(feature = "net")] - pub network: Option, + pub network: Option, /// Host-runtime isolation profile enforced by backend implementations. #[cfg(feature = "net")] diff --git a/crates/runtime/lib/vm.rs b/crates/runtime/lib/vm.rs index d78dbe45f..0c2e480a7 100644 --- a/crates/runtime/lib/vm.rs +++ b/crates/runtime/lib/vm.rs @@ -23,12 +23,16 @@ use microsandbox_filesystem::{ HostPermissions, PassthroughConfig, PassthroughFs, SingleFileFs, StatVirtualization, }; use microsandbox_metrics::{ActivateSlot, MetricsRegistry, ReleaseMode}; +#[cfg(feature = "net")] +use microsandbox_network::{ResolvedNetworkConfig, network::SmoltcpNetwork}; use microsandbox_protocol::{ bootstrap::{BootstrapBlockRoot, GuestBootstrap}, codec, message::{Message, MessageType}, }; use microsandbox_types::CpuPlacement; +#[cfg(feature = "net")] +use microsandbox_types::DeploymentProfile; #[cfg(windows)] use microsandbox_vsock::WindowsNamedPipePortBackend; #[cfg(unix)] @@ -371,13 +375,13 @@ pub struct VmConfig { /// Arguments to the executable. pub exec_args: Vec, - /// Network configuration for the smoltcp in-process stack. + /// Fully resolved network configuration for the smoltcp in-process stack. #[cfg(feature = "net")] - pub network: microsandbox_network::config::NetworkConfig, + pub network: ResolvedNetworkConfig, /// Host-runtime isolation profile enforced by the network backend. #[cfg(feature = "net")] - pub deployment_profile: microsandbox_types::DeploymentProfile, + pub deployment_profile: DeploymentProfile, /// Sandbox slot for deterministic network address derivation. #[cfg(feature = "net")] @@ -1739,7 +1743,7 @@ fn build_vm( #[cfg(unix)] if !vm.vsock.is_empty() { #[cfg(feature = "net")] - if vm.deployment_profile == microsandbox_types::DeploymentProfile::MultiTenant { + if vm.deployment_profile == DeploymentProfile::MultiTenant { return Err(RuntimeError::Custom( "host vsock routes are disabled for multi-tenant deployments".to_string(), )); @@ -1793,7 +1797,7 @@ fn build_vm( #[cfg(windows)] if !vm.vsock.is_empty() { #[cfg(feature = "net")] - if vm.deployment_profile == microsandbox_types::DeploymentProfile::MultiTenant { + if vm.deployment_profile == DeploymentProfile::MultiTenant { return Err(RuntimeError::Custom( "host vsock routes are disabled for multi-tenant deployments".to_string(), )); @@ -1827,26 +1831,24 @@ fn build_vm( // Network. #[cfg(feature = "net")] - if vm.network.enabled { + if vm.network.config().enabled { let _ = rustls::crypto::ring::default_provider().install_default(); vm.network + .config() .secrets .validate() .map_err(|err| RuntimeError::Custom(format!("invalid network secrets: {err}")))?; - let rate_limiters = to_krun_network_rate_limiters(&vm.network); + let rate_limiters = to_krun_network_rate_limiters(vm.network.config()); - let mut network = microsandbox_network::network::SmoltcpNetwork::new_with_profile( - vm.network.clone(), - vm.sandbox_slot, - vm.deployment_profile, - ) - .map_err(|err| RuntimeError::Custom(format!("initialize network: {err}")))?; + let mut network = + SmoltcpNetwork::new(vm.network.clone(), vm.sandbox_slot, vm.deployment_profile) + .map_err(|err| RuntimeError::Custom(format!("initialize network: {err}")))?; network_termination_handle = Some(network.termination_handle()); network_metrics_handle = Some(network.metrics_handle()); // Only sandboxes that booted with secrets can be live-reconfigured: // new placeholders cannot be introduced into a running guest, so a // secret-free boot never needs the secrets side of the control socket. - if !vm.network.secrets.secrets.is_empty() { + if !vm.network.config().secrets.secrets.is_empty() { network_secrets_handle = Some(network.secrets_handle()); } diff --git a/crates/testing/macros/src/lib.rs b/crates/testing/macros/src/lib.rs index 0f9ce204f..3d62d3244 100644 --- a/crates/testing/macros/src/lib.rs +++ b/crates/testing/macros/src/lib.rs @@ -32,7 +32,7 @@ use syn::{ItemFn, parse_macro_input}; /// /// The `#[ignore]` is automatic because every microsandbox integration test /// requires KVM (or libkrun on macOS) and must be opted into explicitly via -/// `--run-ignored=only`. +/// `--ignored`. /// /// `init_isolated_home` is a no-op unless `MSB_TEST_ISOLATE_HOME` is set, so /// local `cargo test` runs against the real `~/.microsandbox`. diff --git a/docs/cli/sandbox-commands.mdx b/docs/cli/sandbox-commands.mdx index 8b38a61ae..2178fb29d 100644 --- a/docs/cli/sandbox-commands.mdx +++ b/docs/cli/sandbox-commands.mdx @@ -65,6 +65,9 @@ Common flags: | `--deployment-profile` | Select `single-tenant` or `multi-tenant` host-runtime isolation; managed backends may override it | | `--net` | Add a high-level network profile (`public`, `private`, `host`, `all`, or `none`) | | `--net-rule` | Add a network policy rule | +| `--proxy` | Route eligible outbound traffic through a `socks4://IP:PORT` or `socks5://IP:PORT` proxy. Local-only. See [Proxy](#outbound-proxy) | +| `--socks4-user-id` | Optional SOCKS4 user ID. Requires a `socks4://` proxy | +| `--socks5-username`, `--socks5-password-env` | SOCKS5 username and the host environment variable containing its password. Both flags are required together | | `--secret` | Inject a host-held secret for an allowed destination | | `--tmpfs` | Mount an in-memory filesystem | | `--copy`, `--copy-file`, `--copy-dir`, `--mkdir`, `--rm` | Patch the rootfs before boot | @@ -96,6 +99,14 @@ Flat v1 uses ext4 and rejects rootfs patches and snapshots instead of silently c Published ports bind to `127.0.0.1` by default. Use an explicit bind address only when the sandbox service should be reachable beyond localhost. On Windows, opening a published port can trigger a Windows Defender Firewall prompt for `msb.exe`. For development, keep the bind on `127.0.0.1`, and allow private/public network access only when you intentionally bind beyond loopback. +### VSock + +Local-only + +Use the repeatable `--vsock HOST_PATH:PORT[/stream|/dgram]` flag to expose a host Unix socket or local Windows named pipe on guest host CID `2`. Stream is the default. Datagram routes preserve message boundaries and are unavailable on Windows. + +See [VSock](/networking/host-sockets) for guest connection details, limits, and SDK examples. + ### Network profiles Limited on cloud @@ -111,6 +122,25 @@ msb run alpine --net host --net-rule "deny@host:tcp:22" Explicit `--net-rule` entries are evaluated before profile-generated rules, so they can narrow or deny part of a profile. `--net` conflicts with `--no-net` and the `--net-default*` flags because those select a different policy baseline. +### Outbound proxy + +Local-only + +Use `--proxy` with `msb run` or `msb create` to configure one SOCKS proxy: + +```bash +msb run alpine --proxy socks4://127.0.0.1:1080 +msb run alpine --proxy socks4://127.0.0.1:1080 \ + --socks4-user-id sandbox + +msb run alpine --proxy socks5://127.0.0.1:1080 +msb run alpine --proxy socks5://127.0.0.1:1080 \ + --socks5-username sandbox \ + --socks5-password-env SOCKS5_PASSWORD +``` + +SOCKS4 carries TCP only. SOCKS5 carries TCP and non-DNS UDP. `--socks5-username` and `--socks5-password-env` must be supplied together and require a `socks5://` proxy. The named environment variable is read from the host when the sandbox starts; the password itself is never placed in the command arguments or durable configuration. Proxy URIs reject user information, paths, query parameters, and fragments. See [Proxy](/networking/outbound-proxy) for routing behavior and limits. + ### Network rule syntax `--net-rule` takes one or more comma-separated rule tokens. The token grammar is: diff --git a/docs/docs.json b/docs/docs.json index da03bd85d..b4816ef7a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -47,11 +47,11 @@ "sandboxes/commands", "sandboxes/filesystem", "sandboxes/secrets", - "sandboxes/tuning", - "sandboxes/ssh", - "sandboxes/volumes", "images/overview", + "sandboxes/volumes", + "sandboxes/ssh", "sandboxes/snapshots", + "sandboxes/tuning", "sandboxes/labels", "sandboxes/logs", "sandboxes/metrics", @@ -64,6 +64,7 @@ "pages": [ "networking/overview", "networking/dns", + "networking/outbound-proxy", "networking/tls", "networking/host-sockets" ] @@ -118,6 +119,8 @@ "sdk/typescript/filesystem", "sdk/typescript/volumes", "sdk/typescript/networking", + "sdk/typescript/proxies", + "sdk/typescript/vsock", "sdk/typescript/secrets", "sdk/typescript/snapshots", "sdk/typescript/images", @@ -135,6 +138,8 @@ "sdk/rust/filesystem", "sdk/rust/volumes", "sdk/rust/networking", + "sdk/rust/proxies", + "sdk/rust/vsock", "sdk/rust/secrets", "sdk/rust/snapshots", "sdk/rust/images", @@ -152,6 +157,8 @@ "sdk/python/filesystem", "sdk/python/volumes", "sdk/python/networking", + "sdk/python/proxies", + "sdk/python/vsock", "sdk/python/secrets", "sdk/python/snapshots", "sdk/python/images", @@ -170,6 +177,8 @@ "sdk/go/volumes", "sdk/go/images", "sdk/go/networking", + "sdk/go/proxies", + "sdk/go/vsock", "sdk/go/secrets", "sdk/go/snapshots", "sdk/go/agent-client" @@ -180,7 +189,8 @@ "icon": "gem", "expanded": false, "pages": [ - "sdk/ruby/sandbox" + "sdk/ruby/sandbox", + "sdk/ruby/vsock" ] } ] diff --git a/docs/images/networking/mitmproxy-socks5.png b/docs/images/networking/mitmproxy-socks5.png new file mode 100644 index 000000000..4289ff21e Binary files /dev/null and b/docs/images/networking/mitmproxy-socks5.png differ diff --git a/docs/images/overview.mdx b/docs/images/overview.mdx index a63f6f9c6..4de03ac12 100644 --- a/docs/images/overview.mdx +++ b/docs/images/overview.mdx @@ -314,3 +314,7 @@ sb2, err := m.CreateSandbox(ctx, "custom-vm-2", The filesystem type (`ext4`, `xfs`, etc.) must match what's actually on the disk image. The guest kernel mounts it using the specified filesystem driver. + +## Reference + +For exact image APIs, see [TypeScript](/sdk/typescript/images), [Rust](/sdk/rust/images), [Python](/sdk/python/images), or [Go](/sdk/go/images). For local image management, see [Image commands](/cli/image-commands). diff --git a/docs/networking/dns.mdx b/docs/networking/dns.mdx index 1867035b3..bc2cf6c0f 100644 --- a/docs/networking/dns.mdx +++ b/docs/networking/dns.mdx @@ -179,7 +179,11 @@ microsandbox records the IP addresses returned for each domain. A domain rule ma An application that connects directly to a hard-coded IP does not match a domain rule. Use an IP or CIDR rule for that traffic. -## See also +## Reference + +For exact DNS and network APIs, see [TypeScript](/sdk/typescript/networking#dnsbuilder), [Rust](/sdk/rust/networking#dnsbuilder), [Python](/sdk/python/networking#dnsconfig), or [Go](/sdk/go/networking#dnsconfig). For CLI fields and flags, see [Configuration file](/cli/configuration#network) and [Sandbox commands](/cli/sandbox-commands). + +## Related - [Network defenses](/security/network) explains rebinding protection and DNS-to-IP binding. - [TLS MITM](/networking/tls) explains inspection for HTTPS and DNS over TLS. diff --git a/docs/networking/host-sockets.mdx b/docs/networking/host-sockets.mdx index 11d07066d..9ab205946 100644 --- a/docs/networking/host-sockets.mdx +++ b/docs/networking/host-sockets.mdx @@ -1,6 +1,6 @@ --- -title: Host Socket -sidebarTitle: "Host Socket" +title: VSock +sidebarTitle: "VSock" description: Connect a sandbox to a Unix socket or Windows named pipe icon: "arrows-left-right" --- @@ -87,3 +87,7 @@ Use `vsock_dgram`, `vsockDgram`, or a datagram `VsockRoute` when you need datagr - Port `123` is reserved. Port `0` and `u32::MAX` are not valid. A route gives sandbox processes access to whatever the host service allows. Avoid exposing powerful services such as the Docker socket or an SSH agent unless the service has its own authentication and narrow permissions. + +## Reference + +For exact VSock APIs, see [TypeScript](/sdk/typescript/vsock), [Rust](/sdk/rust/vsock), [Python](/sdk/python/vsock), [Go](/sdk/go/vsock), or [Ruby](/sdk/ruby/vsock). For the CLI route syntax, see [Sandbox commands](/cli/sandbox-commands#vsock). diff --git a/docs/networking/outbound-proxy.mdx b/docs/networking/outbound-proxy.mdx new file mode 100644 index 000000000..1a7fd7fd7 --- /dev/null +++ b/docs/networking/outbound-proxy.mdx @@ -0,0 +1,172 @@ +--- +title: Proxy +description: Route outbound sandbox traffic through a SOCKS proxy +icon: "route" +--- + +Configure one SOCKS4 or SOCKS5 proxy for outbound sandbox traffic. The guest connects to its normal destination; microsandbox routes eligible traffic through the proxy on the host. + +Outbound proxies are local-only. Cloud sandbox creation rejects this setting. + +## Supported proxies + +| Proxy | Authentication | Traffic | Availability | +|-------|----------------|---------|--------------| +| SOCKS4 | None | TCP | SDKs and CLI | +| SOCKS4 | Optional user ID | TCP | SDKs and CLI | +| SOCKS5 | None | TCP and non-DNS UDP | SDKs and CLI | +| SOCKS5 | Optional username and password | TCP and non-DNS UDP | SDKs and CLI | + +Only one proxy can be configured for a sandbox. A SOCKS4 user ID identifies the caller; it is not a password. + +## Configure a proxy + + +```typescript TypeScript +import { Sandbox } from "microsandbox"; + +await using sb = await Sandbox.builder("proxied") + .image("python") + .proxy((p) => p.socks5("127.0.0.1:1080")) + .create(); +``` + +```rust Rust +use microsandbox::Sandbox; + +let sb = Sandbox::builder("proxied") + .image("python") + .proxy(|p| p.socks5("127.0.0.1:1080")) + .create() + .await?; +``` + +```python Python +from microsandbox import OutboundProxy, Sandbox + +sb = await Sandbox.create( + "proxied", + image="python", + proxy=OutboundProxy.socks5("127.0.0.1:1080"), +) +``` + +```go Go +sb, err := m.CreateSandbox(ctx, "proxied", + m.WithImage("python"), + m.WithProxy(m.SOCKS5Proxy("127.0.0.1:1080")), +) +``` + +```bash CLI +# Choose one: +msb create python --name proxied --proxy socks4://127.0.0.1:1080 +msb create python --name proxied --proxy socks5://127.0.0.1:1080 +``` + + +SDK addresses use `IP:port`. The `msb run` and `msb create` commands accept `--proxy socks4://IP:port` or `--proxy socks5://IP:port`. Proxy URIs reject user information, paths, query parameters, and fragments; protocol-specific authentication uses separate CLI flags. + +## SOCKS4 user ID + +Add an optional user ID through an SDK or `--socks4-user-id` in the CLI. + + +```typescript TypeScript +.proxy((p) => p.socks4("127.0.0.1:1080").userId("sandbox")) +``` + +```rust Rust +.proxy(|p| p.socks4("127.0.0.1:1080").user_id("sandbox")) +``` + +```python Python +proxy=OutboundProxy.socks4("127.0.0.1:1080", user_id="sandbox") +``` + +```go Go +m.WithProxy(m.SOCKS4Proxy( + "127.0.0.1:1080", + m.SOCKS4ProxyOptions{UserID: "sandbox"}, +)) +``` + +```bash CLI +msb run alpine \ + --proxy socks4://127.0.0.1:1080 \ + --socks4-user-id sandbox +``` + + +The user ID must contain 1–255 bytes and cannot contain a null byte. Omit it to use SOCKS4 without a user ID. + +## SOCKS5 credentials + +SOCKS5 supports optional username/password authentication. Passwords are loaded from a host environment variable rather than placed directly in configuration. + + +```typescript TypeScript +.proxy((proxy) => + proxy.socks5("127.0.0.1:1080").credentials( + "sandbox", + SecretSource.env("SOCKS5_PASSWORD"), + ), +) +``` + +```rust Rust +.proxy(|proxy| { + proxy.socks5("127.0.0.1:1080").credentials( + "sandbox", + SecretSource::env("SOCKS5_PASSWORD"), + ) +}) +``` + +```python Python +proxy = OutboundProxy.socks5("127.0.0.1:1080").credentials( + "sandbox", + SecretSource.env("SOCKS5_PASSWORD"), +) +``` + +```go Go +proxy := m.SOCKS5Proxy("127.0.0.1:1080").Credentials( + "sandbox", + m.SecretSourceEnv("SOCKS5_PASSWORD"), +) +m.WithProxy(proxy) +``` + +```bash CLI +msb run alpine \ + --proxy socks5://127.0.0.1:1080 \ + --socks5-username sandbox \ + --socks5-password-env SOCKS5_PASSWORD +``` + + +Environment variables are currently the only supported password source. The password is read from the host environment once each time the sandbox starts and reused for that run. Changing the environment variable affects the next start, not a sandbox that is already running. The username and resolved password must each contain 1–255 bytes; startup fails if the environment variable is missing, empty, or invalid. + +Durable configuration and the database store the environment-variable reference, such as `SOCKS5_PASSWORD`, but never the resolved password. + +SOCKS5 authentication does not encrypt credentials on the wire. Use a trusted local or private proxy, or protect the connection at the transport layer. + +## Behavior and limits + +- Network policy is evaluated against the sandbox's actual destination before the proxy connection is opened. +- SOCKS4 supports TCP only and cannot reach IPv6 destinations. Non-DNS UDP is blocked while SOCKS4 is configured. +- SOCKS5 uses `CONNECT` for TCP and `UDP ASSOCIATE` for non-DNS UDP. +- DNS uses microsandbox's DNS forwarder instead of the configured proxy. This includes plain DNS, DNS-over-TCP, and DNS-over-TLS. +- Connections to `host.microsandbox.internal` bypass the proxy and continue to target the microsandbox host. +- Each TCP connection opens its own proxy connection and handshake. Each UDP flow opens its own SOCKS5 control connection and UDP association. +- TLS interception and secret injection continue to work as configured. + +## Reference + +For exact proxy APIs, see [TypeScript](/sdk/typescript/proxies), [Rust](/sdk/rust/proxies), [Python](/sdk/python/proxies), or [Go](/sdk/go/proxies). For CLI flags, see [Sandbox commands](/cli/sandbox-commands#outbound-proxy). + +## Related + +- [TLS interception](/networking/tls) +- [Network overview](/networking/overview) diff --git a/docs/networking/overview.mdx b/docs/networking/overview.mdx index 741bc76c7..ed1198613 100644 --- a/docs/networking/overview.mdx +++ b/docs/networking/overview.mdx @@ -341,8 +341,14 @@ msb create python --name devbox --net "public,host" `loopback` means the sandbox's own `127.0.0.1`, not your laptop's localhost. Use `host` for `host.microsandbox.internal`. +## Reference + +For exact network APIs, see [TypeScript](/sdk/typescript/networking), [Rust](/sdk/rust/networking), [Python](/sdk/python/networking), or [Go](/sdk/go/networking). For CLI flags and configuration fields, see [Sandbox commands](/cli/sandbox-commands#network-profiles) and [Configuration file](/cli/configuration#network). + ## Next -- [DNS](/networking/dns): domain blocking, pinned nameservers, query timeouts -- [TLS MITM](/networking/tls): HTTPS inspection with an auto-generated CA -- [Security model](/security/overview): the trust boundary, and [network defenses](/security/network) for SSRF, rebinding, and metadata protection +- [DNS](/networking/dns): domain blocking, pinned nameservers, and query timeouts +- [Proxy](/networking/outbound-proxy): route outbound TCP and non-DNS UDP through an external SOCKS proxy +- [TLS MITM](/networking/tls): inspect HTTPS with an auto-generated CA +- [VSock](/networking/host-sockets): connect guest applications to local host IPC without opening a TCP port +- [Security model](/security/overview): understand the trust boundary, and [network defenses](/security/network) for SSRF, rebinding, and metadata protection diff --git a/docs/networking/tls.mdx b/docs/networking/tls.mdx index d38c4fd52..7ea583b08 100644 --- a/docs/networking/tls.mdx +++ b/docs/networking/tls.mdx @@ -195,8 +195,13 @@ TLS MITM covers configured ports, with port `443` enabled by default. TLS MITM a Use both when you need HTTPS inspection and also need raw TLS connections to trust corporate certificates. Raw TLS includes bypassed hosts, custom TLS ports, and protocols such as Postgres or Redis over TLS. -## See also +## Reference + +For exact TLS and network APIs, see [TypeScript](/sdk/typescript/networking#tlsbuilder), [Rust](/sdk/rust/networking#tlsbuilder), [Python](/sdk/python/networking#tlsconfig), or [Go](/sdk/go/networking#tlsconfig). For CLI fields and flags, see [Configuration file](/cli/configuration#network) and [Sandbox commands](/cli/sandbox-commands). + +## Related - [DNS](/networking/dns) explains DNS policy and DNS over TLS. +- [Proxy](/networking/outbound-proxy) explains routing outbound TCP and non-DNS UDP through an external SOCKS proxy. - [Network defenses](/security/network) explains SSRF protection. - [Secret handling](/security/secrets) explains credential injection. diff --git a/docs/sandboxes/bootstrap.mdx b/docs/sandboxes/bootstrap.mdx index aafee6ed1..084883583 100644 --- a/docs/sandboxes/bootstrap.mdx +++ b/docs/sandboxes/bootstrap.mdx @@ -410,3 +410,7 @@ msb run ghcr.io/superradcompany/debian-systemd:12 \ Most slim Docker base images do not include systemd or another full init. Use an image that ships the init you want, or build a small custom image that installs it. `--init` controls PID 1. `--entrypoint` and the trailing command normally control the workload you run after boot, so the two can be combined. An image-declared init entrypoint such as `/init` is the special case. With `--init auto` and attached `msb run`, microsandbox passes the trailing command to PID 1 as part of the image's own launch contract. + +## Reference + +For exact bootstrap, patch, and init APIs, see [TypeScript](/sdk/typescript/sandbox), [Rust](/sdk/rust/sandbox), [Python](/sdk/python/sandbox), or [Go](/sdk/go/sandbox). For CLI configuration and creation flags, see [Configuration file](/cli/configuration) and [Sandbox commands](/cli/sandbox-commands). diff --git a/docs/sandboxes/commands.mdx b/docs/sandboxes/commands.mdx index 272425181..f331eaa20 100644 --- a/docs/sandboxes/commands.mdx +++ b/docs/sandboxes/commands.mdx @@ -382,3 +382,7 @@ sessionID, err := handle.ID() ``` + +## Reference + +For exact execution APIs, see [TypeScript](/sdk/typescript/execution), [Rust](/sdk/rust/execution), [Python](/sdk/python/execution), or [Go](/sdk/go/execution). For command-line execution, see [`msb run`](/cli/sandbox-commands#msb-run) and [`msb exec`](/cli/sandbox-commands#msb-exec). diff --git a/docs/sandboxes/filesystem.mdx b/docs/sandboxes/filesystem.mdx index 0d6ca6fcc..141b90596 100644 --- a/docs/sandboxes/filesystem.mdx +++ b/docs/sandboxes/filesystem.mdx @@ -150,3 +150,7 @@ err := sb.FS().CopyFromHost(ctx, "./local-file.txt", "/app/remote-file.txt") If you need to transfer many files at once, consider using a [bind-mounted volume](/sandboxes/volumes) instead. Volumes give the guest direct filesystem access, whereas the filesystem API transfers each file individually. For bulk operations, volumes are significantly faster. + +## Reference + +For exact filesystem APIs, see [TypeScript](/sdk/typescript/filesystem), [Rust](/sdk/rust/filesystem), [Python](/sdk/python/filesystem), or [Go](/sdk/go/filesystem). For host-to-sandbox copies, see [`msb copy`](/cli/sandbox-commands#msb-copy). diff --git a/docs/sandboxes/labels.mdx b/docs/sandboxes/labels.mdx index c7883c251..0ebb74d28 100644 --- a/docs/sandboxes/labels.mdx +++ b/docs/sandboxes/labels.mdx @@ -136,3 +136,7 @@ It works the same on `ps`, `ls`, `start`, `stop`, `restart`, `ping`, `touch`, an Labels flow into a sandbox's metrics as dimensions, so you can break CPU, memory, and I/O down by any label you set, for example per user or per tenant. See [Labels and per-user views](/observability/msb-metrics#labels-and-per-user-views) for the PromQL detail. High-cardinality labels can increase the number of metric series your backend stores. Suppose an image label carries a commit SHA, build timestamp, or other noisy value. Run `msb-metrics` with `--exclude-label-key ` to drop that label from exported metrics while keeping it in the catalog. Use `--no-labels` to disable metric labels entirely. + +## Reference + +For exact label and sandbox-selection APIs, see [TypeScript](/sdk/typescript/sandbox), [Rust](/sdk/rust/sandbox), [Python](/sdk/python/sandbox), or [Go](/sdk/go/sandbox). For label-aware fleet commands, see [Sandbox commands](/cli/sandbox-commands). diff --git a/docs/sandboxes/lifecycle.mdx b/docs/sandboxes/lifecycle.mdx index f3ded2daf..134cd66ad 100644 --- a/docs/sandboxes/lifecycle.mdx +++ b/docs/sandboxes/lifecycle.mdx @@ -553,3 +553,7 @@ Concurrent callers are safe to use with the named lifecycle APIs. `create` remai ## Logs and diagnostics Use [`msb logs`](/cli/sandbox-commands#msb-logs) or the SDK `logs()` method to read captured output from running, stopped, or crashed sandboxes. For source semantics, boot errors, and diagnostic flows, see [Logs](/sandboxes/logs). + +## Reference + +For exact lifecycle APIs, see [TypeScript](/sdk/typescript/sandbox), [Rust](/sdk/rust/sandbox), [Python](/sdk/python/sandbox), or [Go](/sdk/go/sandbox). For lifecycle commands and the REST surface, see [Sandbox commands](/cli/sandbox-commands) and the [Cloud API](/api-reference/overview). diff --git a/docs/sandboxes/logs.mdx b/docs/sandboxes/logs.mdx index e86345bb3..48a2fe475 100644 --- a/docs/sandboxes/logs.mdx +++ b/docs/sandboxes/logs.mdx @@ -126,3 +126,7 @@ Sandbox logs live under `/logs/`: | `runtime.log` | Sandbox runtime diagnostics | | `kernel.log` | Guest kernel and agent console output | | `boot-error.json` | Structured startup failure details, when boot fails early | + +## Reference + +For exact log APIs, see [TypeScript](/sdk/typescript/sandbox), [Rust](/sdk/rust/sandbox), [Python](/sdk/python/sandbox), or [Go](/sdk/go/sandbox). For local log retrieval and follow mode, see [`msb logs`](/cli/sandbox-commands#msb-logs). diff --git a/docs/sandboxes/metrics.mdx b/docs/sandboxes/metrics.mdx index 1c558c2b3..0c34e72e6 100644 --- a/docs/sandboxes/metrics.mdx +++ b/docs/sandboxes/metrics.mdx @@ -134,4 +134,8 @@ for r in &reports { let report = sandbox_metrics_report_local(&local, "my-app").await?; ``` -Report APIs for TypeScript, Python, and Go will follow. +Metrics reports are currently available only in the Rust SDK. + +## Reference + +For exact per-sandbox metrics APIs, see [TypeScript](/sdk/typescript/sandbox), [Rust](/sdk/rust/sandbox), [Python](/sdk/python/sandbox), or [Go](/sdk/go/sandbox). For local reports and fleet snapshots, see [`msb metrics`](/cli/sandbox-commands#msb-metrics). diff --git a/docs/sandboxes/overview.mdx b/docs/sandboxes/overview.mdx index e01d435da..4ac4b8633 100644 --- a/docs/sandboxes/overview.mdx +++ b/docs/sandboxes/overview.mdx @@ -205,14 +205,23 @@ msb create --replace python --name worker When replacing a running sandbox, microsandbox attempts graceful shutdown before force-killing it. Use `replace_with_timeout` or `--replace-with-timeout` when the workload needs a longer grace period. +## Reference + +For the complete sandbox API, see [TypeScript](/sdk/typescript/sandbox), [Rust](/sdk/rust/sandbox), [Python](/sdk/python/sandbox), or [Go](/sdk/go/sandbox). For terminal and REST workflows, see [Sandbox commands](/cli/sandbox-commands) and the [Cloud API](/api-reference/overview). + ## Where to go next - [Lifecycle](/sandboxes/lifecycle): start, stop, detach, wait, and remove sandboxes +- [Commands](/sandboxes/commands): run commands and stream output +- [Filesystem](/sandboxes/filesystem): read, write, and transfer files +- [Secrets](/sandboxes/secrets): inject credentials without exposing values +- [Images](/images/overview): choose OCI, directory, or disk-image roots +- [Volumes](/sandboxes/volumes): persist or share data +- [SSH](/sandboxes/ssh): connect with SSH and SFTP clients +- [Snapshots](/sandboxes/snapshots): capture and reuse writable sandbox state - [Live Modify](/sandboxes/tuning): change settings after create - [Labels](/sandboxes/labels): organize, select, and attribute sandboxes -- [Secrets](/sandboxes/secrets): inject credentials without exposing values +- [Logs](/sandboxes/logs): inspect workload and runtime output +- [Metrics](/sandboxes/metrics): monitor sandbox resource usage - [Bootstrap](/sandboxes/bootstrap): prepare scripts, patches, and PID 1 -- [Commands](/sandboxes/commands): run commands and stream output -- [Volumes](/sandboxes/volumes): persist or share data - [Networking](/networking/overview): control egress, ingress, DNS, and ports -- [SDK reference](/sdk/overview): per-language API details diff --git a/docs/sandboxes/secrets.mdx b/docs/sandboxes/secrets.mdx index 02e1ff0b5..91ad57f0a 100644 --- a/docs/sandboxes/secrets.mdx +++ b/docs/sandboxes/secrets.mdx @@ -171,4 +171,6 @@ msb modify worker --secret-rm SERVICE_API_KEY # remove Secret modification is available through every SDK and the CLI. See [Live Modify](/sandboxes/tuning) for how changes are planned and applied. -For API details, see the SDK references: [TypeScript](/sdk/typescript/secrets) | [Rust](/sdk/rust/secrets) | [Python](/sdk/python/secrets) | [Go](/sdk/go/secrets). +## Reference + +For exact secret APIs, see [TypeScript](/sdk/typescript/secrets), [Rust](/sdk/rust/secrets), [Python](/sdk/python/secrets), or [Go](/sdk/go/secrets). For CLI creation and rotation, see [Configuration file](/cli/configuration#secrets) and [`msb modify`](/cli/sandbox-commands#msb-modify). diff --git a/docs/sandboxes/snapshots.mdx b/docs/sandboxes/snapshots.mdx index 0c983f3cd..39ac1ef52 100644 --- a/docs/sandboxes/snapshots.mdx +++ b/docs/sandboxes/snapshots.mdx @@ -317,3 +317,7 @@ msb snapshot inspect after-pip-install --verify - **Portable scratch state.** Capture a sandbox after a long setup, hand the artifact to a teammate or push it to shared storage, and let them boot from the same starting point. - **Local fork-by-copy.** Multiple sandboxes from one snapshot are independent; each copy of the upper layer diverges on its own. - **Disaster recovery.** Snapshot a sandbox before a risky migration; if it goes wrong, `msb rm` the broken one and `msb run --from-snapshot` from the pre-migration artifact. + +## Reference + +For exact snapshot APIs, see [TypeScript](/sdk/typescript/snapshots), [Rust](/sdk/rust/snapshots), [Python](/sdk/python/snapshots), or [Go](/sdk/go/snapshots). The CLI examples above cover creating, inspecting, transferring, and verifying snapshots; run `msb snapshot --help` for the complete command surface. diff --git a/docs/sandboxes/ssh.mdx b/docs/sandboxes/ssh.mdx index 7ca6bfa1b..35d5d58b8 100644 --- a/docs/sandboxes/ssh.mdx +++ b/docs/sandboxes/ssh.mdx @@ -288,4 +288,6 @@ persistent, err := sb.SSH().OpenClient(ctx, m.WithSSHClientInactivityTimeout(0)) ``` -For exact SDK signatures, see the SSH reference for [TypeScript](/sdk/typescript/ssh), [Rust](/sdk/rust/ssh), [Python](/sdk/python/ssh), or [Go](/sdk/go/ssh). +## Reference + +For exact SSH APIs, see [TypeScript](/sdk/typescript/ssh), [Rust](/sdk/rust/ssh), [Python](/sdk/python/ssh), or [Go](/sdk/go/ssh). For terminal workflows and serving SSH connections, see [SSH commands](/cli/ssh-commands). diff --git a/docs/sandboxes/volumes.mdx b/docs/sandboxes/volumes.mdx index 427087be2..f094176d6 100644 --- a/docs/sandboxes/volumes.mdx +++ b/docs/sandboxes/volumes.mdx @@ -452,3 +452,7 @@ msb create python --name full \ --tmpfs /tmp:1G:noexec,nosuid,nodev ``` + +## Reference + +For exact volume APIs, see [TypeScript](/sdk/typescript/volumes), [Rust](/sdk/rust/volumes), [Python](/sdk/python/volumes), or [Go](/sdk/go/volumes). For local volume management, see [Volume commands](/cli/volume-commands). diff --git a/docs/sdk/go/networking.mdx b/docs/sdk/go/networking.mdx index 17f3faf0b..f6d4784b3 100644 --- a/docs/sdk/go/networking.mdx +++ b/docs/sdk/go/networking.mdx @@ -4,7 +4,9 @@ description: Go SDK - Network API reference keywords: ["Go SDK", "Go networking", "microsandbox networking"] --- -Configure sandbox networking. See [Networking](/networking/overview) for usage and policy concepts. +Configure a sandbox's network stack: a first-match-wins egress/ingress policy, published ports, DNS interception, TLS interception, and secret-violation handling. See [Networking](/networking/overview) for the conceptual overview and [TLS Interception](/networking/tls) for proxy details. + +The Go SDK exposes networking as a [`NetworkConfig`](#networkconfig) struct passed to [`WithNetwork`](#m-withnetwork). Outbound proxies use the sibling [`WithProxy`](/sdk/go/proxies#m-withproxy) option. Common policy shapes come from the [`NetworkPolicy`](#networkpolicy) factory; custom firewalls are built by populating `NetworkConfig.Rules` directly. ## Functions diff --git a/docs/sdk/go/proxies.mdx b/docs/sdk/go/proxies.mdx new file mode 100644 index 000000000..cbf34bc76 --- /dev/null +++ b/docs/sdk/go/proxies.mdx @@ -0,0 +1,86 @@ +--- +title: Proxies +description: Go SDK - Proxy API reference +--- + +Configure one SOCKS4 or SOCKS5 proxy for outbound sandbox connections with [`WithProxy`](#m-withproxy). Proxy protocols are mutually exclusive. + +See [Proxy](/networking/outbound-proxy) for routing behavior, security considerations, and limits. + +Outbound proxies are local-only. Cloud sandbox creation rejects this setting. + +## Typical flow + +```go +sb, err := m.CreateSandbox(ctx, "worker", + m.WithImage("python"), + m.WithProxy(m.SOCKS5Proxy("127.0.0.1:1080")), +) +``` + +## Functions + +### m.WithProxy() + +```go +func WithProxy(proxy *OutboundProxy) SandboxOption +``` + +Set the single proxy used for outbound sandbox connections. + +

Parameters

+ +
+
+ +
Protocol-specific outbound proxy configuration.
+
+
+ +### m.SOCKS4Proxy() + +```go +func SOCKS4Proxy(address string, options ...SOCKS4ProxyOptions) *OutboundProxy +``` + +Construct a SOCKS4 proxy at `IP:port` for [`WithProxy`](#m-withproxy). Pass `SOCKS4ProxyOptions{UserID: "sandbox"}` to include the optional SOCKS4 user ID. It must contain 1–255 bytes and no null byte; it is an identifier, not a password. + +### m.SOCKS5Proxy() + +```go +func SOCKS5Proxy(address string) *OutboundProxy +``` + +Construct a SOCKS5 proxy at `IP:port` for [`WithProxy`](#m-withproxy). + +### (*OutboundProxy).Credentials() + +```go +func (p *OutboundProxy) Credentials(username string, password SecretSource) *OutboundProxy +``` + +Return a copy configured with SOCKS5 username/password authentication. Call this on a value returned by [`SOCKS5Proxy`](#m-socks5proxy) and pass `SecretSourceEnv("SOCKS5_PASSWORD")` as `password`. + +The host environment variable is read once each time the sandbox starts. Changing it affects the next start, not a sandbox that is already running. `ConfigJSON()` and the database contain the source reference but never the resolved password. The username and resolved password must each contain 1–255 bytes. + +### m.SecretSourceEnv() + +```go +func SecretSourceEnv(variable string) SecretSource +``` + +Create a host environment-variable reference for a SOCKS5 password. + +## OutboundProxy + +Opaque outbound proxy configuration constructed with a protocol-specific function. Pass it to [`WithProxy`](#m-withproxy). + +## SecretSource + +Opaque host-side secret source constructed with [`SecretSourceEnv`](#m-secretsourceenv). Durable configuration contains this source reference, not the resolved password. + +## SOCKS4ProxyOptions + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `UserID` | `string` | `""` | Optional user ID sent during the SOCKS4 handshake | diff --git a/docs/sdk/go/sandbox.mdx b/docs/sdk/go/sandbox.mdx index 1bf6ed956..882175ac2 100644 --- a/docs/sdk/go/sandbox.mdx +++ b/docs/sdk/go/sandbox.mdx @@ -1777,6 +1777,23 @@ Configure the network stack: profiles, custom rules, DNS, and TLS interception. +#### WithProxy() + +```go +func WithProxy(proxy *OutboundProxy) SandboxOption +``` + +Configure the single proxy used for outbound sandbox connections. See [Proxies](/sdk/go/proxies) for constructors and examples. + +

Parameters

+ +
+
+ +
Protocol-specific outbound proxy configuration.
+
+
+ #### WithSecrets() ```go diff --git a/docs/sdk/go/vsock.mdx b/docs/sdk/go/vsock.mdx new file mode 100644 index 000000000..9b136a1c6 --- /dev/null +++ b/docs/sdk/go/vsock.mdx @@ -0,0 +1,47 @@ +--- +title: VSock +description: Go SDK - VSock API reference +--- + +Expose a host Unix socket or local Windows named pipe to a sandbox over virtio-vsock. See [VSock](/networking/host-sockets) for guest connection details, platform support, and security considerations. + +VSock routes are local-only and unavailable with the multi-tenant deployment profile. + +## Typical flow + +```go +sandbox, err := m.CreateSandbox(ctx, "worker", + m.WithImage("alpine"), + m.WithVsock(m.VsockRoute{ + HostSocket: "/run/host-api.sock", + Port: 5000, + }), +) +``` + +## Functions + +### m.WithVsock() + +```go +func WithVsock(routes ...VsockRoute) SandboxOption +``` + +Append one or more guest-to-host VSock routes to the sandbox configuration. + +## VsockRoute + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `HostSocket` | `string` | — | Existing Unix socket or local Windows named-pipe path | +| `Port` | `uint32` | — | Guest-facing port on host CID `2` | +| `SocketType` | `VsockSocketType` | `""` (stream) | Stream or datagram message semantics | + +Use `VsockSocketTypeDgram` for a datagram route. Datagram routes are unavailable on Windows. Host paths must be absolute, and each socket type and port pair must be unique. + +## VsockSocketType + +| Constant | Value | Description | +|----------|-------|-------------| +| `VsockSocketTypeStream` | `"stream"` | Reliable, ordered byte stream | +| `VsockSocketTypeDgram` | `"dgram"` | Best-effort messages with preserved datagram boundaries | diff --git a/docs/sdk/python/networking.mdx b/docs/sdk/python/networking.mdx index 4fc0d8ba8..405db4d0d 100644 --- a/docs/sdk/python/networking.mdx +++ b/docs/sdk/python/networking.mdx @@ -4,7 +4,7 @@ description: Python SDK - Network API reference keywords: ["Python SDK", "Python networking", "microsandbox networking"] --- -Configure sandbox networking. See [Networking](/networking/overview) for usage and policy concepts. +Configure a sandbox's network stack: a first-match-wins egress/ingress policy, published ports, DNS interception, TLS interception, and secret-violation handling. Pass a [`Network`](#network) as the `network=` kwarg to [`Sandbox.create()`](/sdk/python/sandbox#sandbox-create). Outbound proxies use the sibling [`proxy=`](/sdk/python/proxies) kwarg. See [Networking](/networking/overview) for the conceptual overview and [TLS Interception](/networking/tls) for proxy details. ## Network diff --git a/docs/sdk/python/proxies.mdx b/docs/sdk/python/proxies.mdx new file mode 100644 index 000000000..4d5399fd5 --- /dev/null +++ b/docs/sdk/python/proxies.mdx @@ -0,0 +1,59 @@ +--- +title: Proxies +description: Python SDK - Proxy API reference +--- + +Configure one SOCKS4 or SOCKS5 proxy for outbound sandbox connections with the `proxy=` argument to [`Sandbox.create()`](/sdk/python/sandbox#sandbox-create). Proxy protocols are mutually exclusive. + +See [Proxy](/networking/outbound-proxy) for routing behavior, security considerations, and limits. + +Outbound proxies are local-only. Cloud sandbox creation rejects this setting. + +## Typical flow + +```python +from microsandbox import OutboundProxy, Sandbox + +sandbox = await Sandbox.create( + "worker", + image="python", + proxy=OutboundProxy.socks5("127.0.0.1:1080"), +) +``` + +## Sandbox.create() + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| proxy | [`OutboundProxy`](#outboundproxy)` \| None` | `None` | Single proxy used for outbound sandbox connections | + +## OutboundProxy + +Frozen proxy configuration passed through `Sandbox.create(proxy=...)`. + +| Class method | Returns | Description | +|--------------|---------|-------------| +| `socks4(address, *, user_id=None)` | `OutboundProxy` | Configure a SOCKS4 proxy at `IP:port` with an optional user ID | +| `socks5(address)` | `OutboundProxy` | Configure a SOCKS5 proxy at `IP:port` | + +The SOCKS4 `user_id` must contain 1–255 bytes and no null byte. It identifies the caller; it is not a password. + +### credentials() + +```python +credentials(username: str, password: SecretSource) -> OutboundProxy +``` + +Return a SOCKS5 proxy with username/password authentication. Pass `SecretSource.env("SOCKS5_PASSWORD")` as `password`. Calling this method on a SOCKS4 proxy raises `ValueError`. + +The host environment variable is read once each time the sandbox starts. Changing it affects the next start, not a sandbox that is already running. `config_json` and the database contain the source reference but never the resolved password. The username and resolved password must each contain 1–255 bytes. + +## SecretSource + +### env() + +```python +SecretSource.env(variable: str) -> SecretSource +``` + +Create a host environment-variable reference for a SOCKS5 password. Import `SecretSource` from `microsandbox`. diff --git a/docs/sdk/python/sandbox.mdx b/docs/sdk/python/sandbox.mdx index f7bf73996..a9d189201 100644 --- a/docs/sdk/python/sandbox.mdx +++ b/docs/sdk/python/sandbox.mdx @@ -107,7 +107,7 @@ The returned `Sandbox` is an async context manager. On the local backend, `async
-
Configuration fields: image, cpus, memory, volumes, ports, network, secrets, detached, and more.
+
Configuration fields: image, cpus, memory, volumes, ports, network, proxy, secrets, detached, and more.
@@ -1825,6 +1825,7 @@ The keyword arguments accepted by [`create()`](#sandbox-create) and [`create_wit | patches | `Sequence[`[`PatchConfig`](#patchconfig)`]` | `[]` | Rootfs modifications applied before boot | | ports | `Mapping[int, int] \| Sequence[`[`PortBinding`](/sdk/python/networking#portbinding)`]` | `{}` | Port mappings. Mapping form is TCP and binds to `127.0.0.1`; use `PortBinding` for explicit bind addresses or UDP | | network | [`Network`](/sdk/python/networking#network) | public profile | Network policy and configuration | +| proxy | [`OutboundProxy`](/sdk/python/proxies#outboundproxy)` \| None` | `None` | Single proxy used for outbound connections. See [Proxies](/sdk/python/proxies) | | secrets | `Sequence[`[`SecretEntry`](/sdk/python/secrets#secretentry)`]` | `[]` | Secret injection | | on_secret_violation | [`ViolationAction`](/sdk/python/secrets#violationaction)` \| `[`ViolationPolicy`](/sdk/python/secrets#violationpolicy) | `BLOCK_AND_LOG` | Sandbox-wide default action when a secret placeholder would leak to a non-eligible destination. Shorthand for `Network.on_secret_violation`; if both are provided, this top-level value takes precedence. See [Violation policy](/sdk/python/secrets#violationpolicy) | | detached | `bool` | `False` | If `True`, spawn the sandbox in detached mode; call [`detach()`](#sb-detach) before dropping the returned handle when it should keep running | diff --git a/docs/sdk/python/vsock.mdx b/docs/sdk/python/vsock.mdx new file mode 100644 index 000000000..1054da0ba --- /dev/null +++ b/docs/sdk/python/vsock.mdx @@ -0,0 +1,52 @@ +--- +title: VSock +description: Python SDK - VSock API reference +--- + +Expose a host Unix socket or local Windows named pipe to a sandbox over virtio-vsock. See [VSock](/networking/host-sockets) for guest connection details, platform support, and security considerations. + +VSock routes are local-only and unavailable with the multi-tenant deployment profile. + +## Typical flow + +```python +from microsandbox import Sandbox + +sandbox = await Sandbox.create( + "worker", + image="alpine", + vsock={"/run/host-api.sock": 5000}, +) +``` + +## Sandbox.create() + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `vsock` | `Mapping[str, int] \| Sequence[VsockRoute] \| None` | `None` | Host IPC routes exposed on guest host-CID ports | + +A mapping creates stream routes. Use typed [`VsockRoute`](#vsockroute) values for datagram routes or when the socket type should be explicit. + +## VsockRoute + +```python +VsockRoute(host_socket, port, socket_type=VsockSocketType.STREAM) +``` + +Frozen route configuration imported from `microsandbox`. + +| Class method | Returns | Description | +|--------------|---------|-------------| +| `stream(host_socket, port)` | `VsockRoute` | Create a stream route | +| `dgram(host_socket, port)` | `VsockRoute` | Create a datagram route | + +Datagram routes are unavailable on Windows. Host paths must be absolute, and each socket type and port pair must be unique. + +## VsockSocketType + +String enum imported from `microsandbox`. + +| Member | Value | Description | +|--------|-------|-------------| +| `STREAM` | `"stream"` | Reliable, ordered byte stream; the default | +| `DGRAM` | `"dgram"` | Best-effort messages with preserved datagram boundaries | diff --git a/docs/sdk/ruby/vsock.mdx b/docs/sdk/ruby/vsock.mdx new file mode 100644 index 000000000..5258037f6 --- /dev/null +++ b/docs/sdk/ruby/vsock.mdx @@ -0,0 +1,39 @@ +--- +title: VSock +description: Ruby SDK - VSock API reference +--- + +Expose a host Unix socket or local Windows named pipe to a sandbox over virtio-vsock. See [VSock](/networking/host-sockets) for guest connection details, platform support, and security considerations. + +VSock routes are local-only and unavailable with the multi-tenant deployment profile. + +## Typical flow + +```ruby +require "microsandbox" + +sandbox = Microsandbox::Sandbox.builder("worker") + .image("alpine") + .vsock("/run/host-api.sock", 5000) + .create +``` + +## SandboxBuilder + +### vsock() + +```ruby +builder.vsock(host_path, port) # => SandboxBuilder +``` + +Expose a host Unix stream socket or local Windows named pipe on host CID `2` at `port`. + +### vsock_dgram() + +```ruby +builder.vsock_dgram(host_path, port) # => SandboxBuilder +``` + +Expose a host Unix datagram socket while preserving datagram boundaries. Datagram routes are unavailable on Windows. + +Both methods can be called repeatedly to add routes. Host paths must be absolute, and each socket type and port pair must be unique. diff --git a/docs/sdk/rust/networking.mdx b/docs/sdk/rust/networking.mdx index 9cef1d59a..4af34628c 100644 --- a/docs/sdk/rust/networking.mdx +++ b/docs/sdk/rust/networking.mdx @@ -1591,6 +1591,7 @@ Errors surfaced by the builders' `build()` methods. The same enum covers [`Netwo | `InvalidCidr { rule_index, raw }` | `.cidr(&str)` got an unparseable value | | `InvalidIpv4Pool { raw }` | `ipv4_pool()` got a pool that can't hold a `/30` sandbox subnet | | `InvalidIpv6Pool { raw }` | `ipv6_pool()` got a pool that can't hold a `/64` sandbox prefix | +| `InvalidOutboundProxy { reason }` | [`SandboxBuilder::proxy()`](/sdk/rust/proxies#proxy) got an invalid proxy configuration | | `InvalidDomain { rule_index, raw, source }` | `.domain` / `.domain_suffix` got a value that failed `DomainName` parse | | `InvalidPortRange { rule_index, lo, hi }` | `.port_range(lo, hi)` had `lo > hi` | | `IngressDoesNotSupportIcmp { rule_index }` | ICMP protocol on a non-egress rule | diff --git a/docs/sdk/rust/proxies.mdx b/docs/sdk/rust/proxies.mdx new file mode 100644 index 000000000..a642de0a0 --- /dev/null +++ b/docs/sdk/rust/proxies.mdx @@ -0,0 +1,101 @@ +--- +title: Proxies +description: Rust SDK - Proxy API reference +--- + +Configure one SOCKS4 or SOCKS5 proxy for outbound sandbox connections with [`SandboxBuilder::proxy()`](#proxy). Proxy protocols are mutually exclusive. + +See [Proxy](/networking/outbound-proxy) for routing behavior, security considerations, and limits. + +Outbound proxies are local-only. Cloud sandbox creation rejects this setting. + +## Typical flow + +```rust +use microsandbox::Sandbox; + +let sandbox = Sandbox::builder("worker") + .image("python") + .proxy(|p| p.socks5("127.0.0.1:1080")) + .create() + .await?; +``` + +## SandboxBuilder + +### proxy() + +```rust +fn proxy

(self, configure: impl FnOnce(OutboundProxyBuilder) -> P) -> Self +where + P: OutboundProxyConfig +``` + +Select and configure the single outbound proxy for the sandbox. The callback receives an [`OutboundProxyBuilder`](#outboundproxybuilder) and must return one protocol-specific builder. + +Invalid proxy configuration is retained by the sandbox builder and returned from `build()` or `create()` as `MicrosandboxError::NetworkBuilder(BuildError::InvalidOutboundProxy)`. + +## OutboundProxyBuilder + +Protocol selector passed to [`SandboxBuilder::proxy()`](#proxy). + +### socks4() + +```rust +fn socks4(self, address: impl Into) -> Socks4ProxyBuilder +``` + +Select a SOCKS4 proxy at `IP:port`. The returned builder can optionally set a user ID. + +### socks5() + +```rust +fn socks5(self, address: impl Into) -> Socks5ProxyBuilder +``` + +Select a SOCKS5 proxy at `IP:port`. The parent sandbox builder validates and materializes the returned [`Socks5ProxyBuilder`](#socks5proxybuilder). + +## Socks4ProxyBuilder + +Protocol-specific builder returned by [`OutboundProxyBuilder::socks4()`](#socks4). + +### user_id() + +```rust +fn user_id(self, user_id: impl Into) -> Self +``` + +Set the optional SOCKS4 user ID. It must contain 1–255 bytes and no null byte. A user ID identifies the caller; it is not a password. + +## Socks5ProxyBuilder + +Protocol-specific builder returned by [`OutboundProxyBuilder::socks5()`](#socks5). It carries the proxy address and is finalized when returned from the `.proxy()` callback. + +### credentials() + +```rust +fn credentials(self, username: impl Into, password: SecretSource) -> Self +``` + +Set optional SOCKS5 username/password authentication. Pass `SecretSource::env("SOCKS5_PASSWORD")` as `password`; store-backed sources are not supported for proxy credentials. The username and resolved password must each contain 1–255 bytes. + +The host environment variable is read once each time the sandbox starts. Changing it affects the next start, not a sandbox that is already running. `config_json()` and the database contain the source reference but never the resolved password. + +## SecretSource + +### env() + +```rust +fn env(var: impl Into) -> SecretSource +``` + +Create a host environment-variable reference for a SOCKS5 password. Import `SecretSource` from `microsandbox`. + +## OutboundProxy + +Declarative outbound proxy configuration stored in the sandbox's durable network specification. `Socks5Credentials` contains a password source, not the resolved password. + +| Variant | Fields | Description | +|---------|--------|-------------| +| `Socks4` | `address: SocketAddr`, `user_id: Option` | SOCKS4 proxy address and optional user ID | +| `Socks5` | `address: SocketAddr`, `credentials: Option` | SOCKS5 proxy address and optional username/password credentials | diff --git a/docs/sdk/rust/sandbox.mdx b/docs/sdk/rust/sandbox.mdx index 11b631e8d..2fe72385c 100644 --- a/docs/sdk/rust/sandbox.mdx +++ b/docs/sdk/rust/sandbox.mdx @@ -1359,6 +1359,16 @@ Configure networking. See [Networking](/sdk/rust/networking) for the full builde +#### sandbox.proxy() + +```rust +fn proxy

(self, configure: impl FnOnce(OutboundProxyBuilder) -> P) -> Self +where + P: OutboundProxyConfig +``` + +Configure the single proxy used for outbound sandbox connections. See [Proxies](/sdk/rust/proxies) for the protocol builders and examples. + #### sandbox.patch() ```rust diff --git a/docs/sdk/rust/vsock.mdx b/docs/sdk/rust/vsock.mdx new file mode 100644 index 000000000..2b1d4c35c --- /dev/null +++ b/docs/sdk/rust/vsock.mdx @@ -0,0 +1,67 @@ +--- +title: VSock +description: Rust SDK - VSock API reference +--- + +Expose a host Unix socket or local Windows named pipe to a sandbox over virtio-vsock. See [VSock](/networking/host-sockets) for guest connection details, platform support, and security considerations. + +VSock routes are local-only and unavailable with the multi-tenant deployment profile. + +## Typical flow + +```rust +use microsandbox::Sandbox; + +let sandbox = Sandbox::builder("worker") + .image("alpine") + .vsock("/run/host-api.sock", 5000) + .create() + .await?; +``` + +## SandboxBuilder + +### vsock() + +```rust +fn vsock(self, host_path: impl AsRef, port: u32) -> Self +``` + +Expose a host Unix stream socket or local Windows named pipe on host CID `2` at `port`. + +### vsock_dgram() + +```rust +fn vsock_dgram(self, host_path: impl AsRef, port: u32) -> Self +``` + +Expose a host Unix datagram socket while preserving datagram boundaries. Datagram routes are unavailable on Windows. + +### vsock_route() + +```rust +fn vsock_route(self, route: VsockRouteSpec) -> Self +``` + +Add a fully specified route using [`VsockRouteSpec`](#vsockroutespec). + +## VsockRouteSpec + +Import path: `microsandbox::sandbox::VsockRouteSpec`. + +| Field | Type | Description | +|-------|------|-------------| +| `host_socket` | `PathBuf` | Existing Unix socket or local Windows named-pipe path | +| `port` | `u32` | Guest-facing port on host CID `2` | +| `socket_type` | `VsockSocketType` | `Stream` or `Dgram` message semantics | + +Host paths must be absolute, and each socket type and port pair must be unique. + +## VsockSocketType + +Import path: `microsandbox::sandbox::VsockSocketType`. + +| Variant | Description | +|---------|-------------| +| `Stream` | Reliable, ordered byte stream; the default | +| `Dgram` | Best-effort messages with preserved datagram boundaries | diff --git a/docs/sdk/typescript/networking.mdx b/docs/sdk/typescript/networking.mdx index ac9a2c3e0..b97d10b5c 100644 --- a/docs/sdk/typescript/networking.mdx +++ b/docs/sdk/typescript/networking.mdx @@ -1922,6 +1922,7 @@ Built network configuration produced by `NetworkBuilder.build()`. Keys are camel | rateLimiter | [`NetworkRateLimiterConfig`](#networkratelimiterconfig) ` \| null` | Local rate limits grouped by direction | | interface | `{ ipv4Pool?, ipv6Pool?, ipv4Address?, ipv6Address?, mac?, mtu? }` | Optional interface overrides | | trustHostCAs | `boolean` | Ship host CAs into the guest | +| outboundProxy | [`OutboundProxy`](/sdk/typescript/proxies#outboundproxy)` \| null` | Canonical outbound proxy configuration stored in the sandbox network spec; `null` when unset | ### NetworkProfile diff --git a/docs/sdk/typescript/proxies.mdx b/docs/sdk/typescript/proxies.mdx new file mode 100644 index 000000000..b5f6820cd --- /dev/null +++ b/docs/sdk/typescript/proxies.mdx @@ -0,0 +1,108 @@ +--- +title: Proxies +description: TypeScript SDK - Proxy API reference +--- + +Configure one SOCKS4 or SOCKS5 proxy for outbound sandbox connections with [`SandboxBuilder.proxy()`](#proxy). Proxy protocols are mutually exclusive. + +See [Proxy](/networking/outbound-proxy) for routing behavior, security considerations, and limits. + +Outbound proxies are local-only. Cloud sandbox creation rejects this setting. + +## Typical flow + +```typescript +import { Sandbox } from "microsandbox"; + +await using sandbox = await Sandbox.builder("worker") + .image("python") + .proxy((p) => p.socks5("127.0.0.1:1080")) + .create(); +``` + +## SandboxBuilder + +### proxy() + +```typescript +proxy(configure: (proxy: OutboundProxyBuilder) => Socks4ProxyBuilder | Socks5ProxyBuilder): this +``` + +Select and configure the single outbound proxy for the sandbox. The callback receives an [`OutboundProxyBuilder`](#outboundproxybuilder) and must return one protocol-specific builder. Invalid addresses throw while applying the callback. + +## OutboundProxyBuilder + +Protocol selector passed to [`SandboxBuilder.proxy()`](#proxy). + +### socks4() + +```typescript +socks4(address: string): Socks4ProxyBuilder +``` + +Select a SOCKS4 proxy at `IP:port`. The returned builder can optionally set a user ID. + +### socks5() + +```typescript +socks5(address: string): Socks5ProxyBuilder +``` + +Select a SOCKS5 proxy at `IP:port`. The parent sandbox builder validates and materializes the returned [`Socks5ProxyBuilder`](#socks5proxybuilder). + +## Socks4ProxyBuilder + +Protocol-specific builder returned by [`OutboundProxyBuilder.socks4()`](#socks4). + +### userId() + +```typescript +userId(userId: string): this +``` + +Set the optional SOCKS4 user ID. It must contain 1–255 bytes and no null byte. A user ID identifies the caller; it is not a password. + +## Socks5ProxyBuilder + +Protocol-specific builder returned by [`OutboundProxyBuilder.socks5()`](#socks5). It carries the proxy address and is finalized when returned from the `.proxy()` callback. + +### credentials() + +```typescript +credentials(username: string, password: SecretSource): this +``` + +Set optional SOCKS5 username/password authentication. Pass `SecretSource.env("SOCKS5_PASSWORD")` as `password`. The username and resolved password must each contain 1–255 bytes. + +The host environment variable is read once each time the sandbox starts. Changing it affects the next start, not a sandbox that is already running. `configJson` and the database contain the source reference but never the resolved password. + +## SecretSource + +### env() + +```typescript +env(variable: string): SecretSource +``` + +Create a host environment-variable reference for a SOCKS5 password. Import `SecretSource` from `microsandbox`. + +## OutboundProxy + +Discriminated outbound proxy value stored in the sandbox's durable network specification. A SOCKS5 password remains a [`SecretSource`](#secretsource) reference in this value; the resolved password is runtime-only. + +```typescript +type OutboundProxy = + | { + readonly protocol: "socks4"; + readonly address: string; + readonly userId?: string; + } + | { + readonly protocol: "socks5"; + readonly address: string; + readonly credentials?: { + readonly username: string; + readonly password: SecretSource; + }; + }; +``` diff --git a/docs/sdk/typescript/sandbox.mdx b/docs/sdk/typescript/sandbox.mdx index 607689e1f..7e4966d0b 100644 --- a/docs/sdk/typescript/sandbox.mdx +++ b/docs/sdk/typescript/sandbox.mdx @@ -1469,6 +1469,23 @@ Configure DNS, TLS, policy, and secrets. See [Networking](/sdk/typescript/networ +#### sandboxBuilder.proxy() + +```typescript +proxy(configure: (proxy: OutboundProxyBuilder) => Socks4ProxyBuilder | Socks5ProxyBuilder): this +``` + +Configure the single proxy used for outbound sandbox connections. See [Proxies](/sdk/typescript/proxies) for the protocol builders and examples. + +

Parameters

+ +
+
+
configurefunction
+
Callback that selects and configures one proxy protocol.
+
+
+ #### sandboxBuilder.patch() ```typescript diff --git a/docs/sdk/typescript/vsock.mdx b/docs/sdk/typescript/vsock.mdx new file mode 100644 index 000000000..e17e65e0e --- /dev/null +++ b/docs/sdk/typescript/vsock.mdx @@ -0,0 +1,39 @@ +--- +title: VSock +description: TypeScript SDK - VSock API reference +--- + +Expose a host Unix socket or local Windows named pipe to a sandbox over virtio-vsock. See [VSock](/networking/host-sockets) for guest connection details, platform support, and security considerations. + +VSock routes are local-only and unavailable with the multi-tenant deployment profile. + +## Typical flow + +```typescript +import { Sandbox } from "microsandbox"; + +await using sandbox = await Sandbox.builder("worker") + .image("alpine") + .vsock("/run/host-api.sock", 5000) + .create(); +``` + +## SandboxBuilder + +### vsock() + +```typescript +vsock(hostPath: string, port: number): this +``` + +Expose a host Unix stream socket or local Windows named pipe on host CID `2` at `port`. + +### vsockDgram() + +```typescript +vsockDgram(hostPath: string, port: number): this +``` + +Expose a host Unix datagram socket while preserving datagram boundaries. Datagram routes are unavailable on Windows. + +Both methods can be called repeatedly to add routes. Host paths must be absolute, and each socket type and port pair must be unique. diff --git a/docs/style.css b/docs/style.css index ab4274594..6ce007fcc 100644 --- a/docs/style.css +++ b/docs/style.css @@ -189,8 +189,7 @@ nav[aria-label="Pages"] a > div:has(> img[src*="/pi.svg"]) { --msb-agent-icon: u .msb-platform-strip { grid-template-columns: 1fr; } } -/* Introduction handoff: a compact editorial link gives Examples enough - * presence to be discovered without the oversized chrome of a generic card. */ +/* Introduction handoff to the examples library. */ .msb-examples-cta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -203,6 +202,7 @@ nav[aria-label="Pages"] a > div:has(> img[src*="/pi.svg"]) { --msb-agent-icon: u color: inherit; background: linear-gradient(135deg, rgba(167, 112, 239, 0.09), rgba(167, 112, 239, 0.025) 62%, transparent); box-shadow: 0 1px 2px rgba(49, 31, 83, 0.04); + font-weight: 400; text-decoration: none; transition: border-color 90ms ease-out, box-shadow 90ms ease-out; } @@ -238,20 +238,21 @@ nav[aria-label="Pages"] a > div:has(> img[src*="/pi.svg"]) { --msb-agent-icon: u margin-bottom: 0.15rem; color: #8f5cda; font-size: 0.65rem; - font-weight: 700; - letter-spacing: 0.09em; + font-weight: 650; + letter-spacing: 0.07em; text-transform: uppercase; } .msb-examples-cta-title { font-size: 0.93rem; - font-weight: 650; + font-weight: 500; } .msb-examples-cta-copy > span:last-child { overflow: hidden; margin-top: 0.2rem; font-size: 0.78rem; + font-weight: 400; opacity: 0.68; text-overflow: ellipsis; white-space: nowrap; @@ -263,7 +264,7 @@ nav[aria-label="Pages"] a > div:has(> img[src*="/pi.svg"]) { --msb-agent-icon: u gap: 0.4rem; color: #7b4bc2; font-size: 0.78rem; - font-weight: 650; + font-weight: 600; white-space: nowrap; } diff --git a/examples/README.md b/examples/README.md index c3816f828..05fe9a0df 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,8 @@ Examples showing how to use the microsandbox SDK in TypeScript, Rust, Python, Go Each language directory has its own README with setup and run instructions. +Most configuration-focused examples intentionally replace their fixed-name sandbox so every run uses the image, resources, mounts, networking, and secrets shown in the source. The interactive `shell-attach` example instead uses connect-or-create because preserving its workspace across runs is useful. Examples that generate a unique name use strict creation. + | Language | Directory | README | |----------|-----------|--------| | TypeScript | [`typescript/`](./typescript/) | [typescript/README.md](./typescript/README.md) | @@ -34,7 +36,7 @@ git submodule update --init --recursive | `volume-disk` | Mount raw / qcow2 disk images at arbitrary guest paths | | `fs-read-stream` | Stream a large file from the sandbox in chunks | | `metrics-stream` | Subscribe to streaming resource metrics | -| `shell-attach` | Interactive terminal session inside a sandbox | +| `shell-attach` | Reusable interactive terminal session using connect-or-create | | `net-basic` | DNS resolution, HTTP fetch, interface status | | `net-dns` | DNS filtering — block domains and suffixes | | `net-policy` | Network policies — public-only, allow-all, no-network | diff --git a/examples/python/README.md b/examples/python/README.md index 8537ad17e..3f5bc4291 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -33,6 +33,8 @@ uv run --project sdk/python python examples/python/net-basic/main.py uv run --project sdk/python python examples/python/fs-read-stream/main.py ``` +Configuration-focused examples intentionally replace their fixed-name sandbox so reruns match the source. `shell-attach` uses `connect_or_create` instead, preserving its interactive workspace and configuration across runs. + ## Examples | Example | Description | @@ -49,7 +51,7 @@ uv run --project sdk/python python examples/python/fs-read-stream/main.py | `snapshot-fork` | Snapshot a stopped sandbox and boot a fresh one from it | | `fs-read-stream` | Streaming file read | | `metrics-stream` | Streaming resource metrics | -| `shell-attach` | Interactive shell attach | +| `shell-attach` | Reusable interactive shell using `connect_or_create` | | `net-basic` | Basic networking | | `net-dns` | DNS filtering | | `net-policy` | Network policies | diff --git a/examples/python/cloud-backend/main.py b/examples/python/cloud-backend/main.py index 9f022d62c..75676d2c6 100644 --- a/examples/python/cloud-backend/main.py +++ b/examples/python/cloud-backend/main.py @@ -3,7 +3,13 @@ import asyncio import time -from microsandbox import BackendKind, LogReadSource, Sandbox, SandboxStatus, default_backend_kind +from microsandbox import ( + BackendKind, + LogReadSource, + Sandbox, + SandboxStatus, + default_backend_kind, +) def configure_cloud_backend(): @@ -39,7 +45,6 @@ async def main(): "for i in 1 2 3; do echo python-cloud-$i; sleep 1; done", ], max_duration=60, - replace=True, ) output = await sandbox.shell("printf 'cloud exec from python\\n'; uname -m") diff --git a/examples/python/lifecycle-convergence/main.py b/examples/python/lifecycle-convergence/main.py index cb868ce12..8a8ea8c54 100644 --- a/examples/python/lifecycle-convergence/main.py +++ b/examples/python/lifecycle-convergence/main.py @@ -5,7 +5,7 @@ import os import time -from microsandbox import Sandbox, SandboxReplacedError +from microsandbox import Sandbox, SandboxNotFoundError, SandboxReplacedError NAME = os.environ.get("MSB_E2E_NAME", f"lifecycle-python-{os.getpid()}") RACE_NAME = f"{NAME}-race" @@ -24,7 +24,7 @@ async def cleanup(name: str = NAME) -> None: try: current = await Sandbox.get(name) await current.destroy(force=True, timeout=5.0) - except Exception: + except SandboxNotFoundError: # The unique example sandbox normally does not exist before or after a run. pass @@ -140,6 +140,14 @@ async def create_candidate(marker: str) -> Sandbox: if await read_marker(reused) != "original": raise RuntimeError("existing configuration did not win") + # Strict start resumes an existing stopped identity without accepting creation options. + await reused.stop() + resumed = await measured("start", lambda: Sandbox.start(NAME)) + if await resumed.id != original_id: + raise RuntimeError("start changed the persisted identity") + if await read_marker(resumed) != "original": + raise RuntimeError("start lost persisted configuration") + handle = await Sandbox.get(NAME) connected = await measured("connect_or_start", handle.connect_or_start) if await connected.id != original_id: @@ -191,7 +199,7 @@ async def reject_stale_receiver(): "platform": PLATFORM, "sandbox": NAME, "identity": original_id, - "checks": 16, + "checks": 17, "timings_ms": timings, "result": "pass", }, diff --git a/examples/python/shell-attach/main.py b/examples/python/shell-attach/main.py index 24fe4659b..9e757c215 100644 --- a/examples/python/shell-attach/main.py +++ b/examples/python/shell-attach/main.py @@ -9,14 +9,15 @@ async def main(): - print("Creating sandbox (image=alpine)") + print("Connecting to or creating sandbox (image=alpine on first creation)") - sb = await Sandbox.create( + # An interactive workspace is useful across runs. Creation options seed only + # the first creation; later runs preserve its files and configuration. + sb = await Sandbox.connect_or_create( "attach-example", image="alpine", cpus=1, memory=512, - replace=True, ) print("Attaching to shell (press Ctrl+] to detach)...") diff --git a/examples/rust/README.md b/examples/rust/README.md index 2f8ffb4d9..b04aa4095 100644 --- a/examples/rust/README.md +++ b/examples/rust/README.md @@ -22,6 +22,8 @@ cargo run -p net-basic cargo run -p fs-read-stream ``` +Configuration-focused examples intentionally replace their fixed-name sandbox so reruns match the source. `shell-attach` uses `connect_or_create` instead, preserving its interactive workspace and configuration across runs. + ## Examples | Example | Command | Description | @@ -38,7 +40,7 @@ cargo run -p fs-read-stream | `snapshot-fork` | `cargo run -p snapshot-fork` | Snapshot a stopped sandbox and boot a fresh one from it | | `fs-read-stream` | `cargo run -p fs-read-stream` | Streaming file read | | `metrics-stream` | `cargo run -p metrics-stream` | Streaming resource metrics | -| `shell-attach` | `cargo run -p shell-attach` | Interactive shell attach | +| `shell-attach` | `cargo run -p shell-attach` | Reusable interactive shell using `connect_or_create` | | `net-basic` | `cargo run -p net-basic` | Basic networking | | `net-dns` | `cargo run -p net-dns` | DNS filtering | | `net-policy` | `cargo run -p net-policy` | Network policies | diff --git a/examples/rust/cloud-backend/bin/main.rs b/examples/rust/cloud-backend/bin/main.rs index fed209c5d..88c45f5a5 100644 --- a/examples/rust/cloud-backend/bin/main.rs +++ b/examples/rust/cloud-backend/bin/main.rs @@ -25,7 +25,6 @@ async fn main() -> anyhow::Result<()> { "for i in 1 2 3; do echo rust-cloud-$i; sleep 1; done", ]) .max_duration(60) - .replace() .create() .await?; diff --git a/examples/rust/lifecycle-convergence/bin/main.rs b/examples/rust/lifecycle-convergence/bin/main.rs index 8f9bfc746..d7ae992fa 100644 --- a/examples/rust/lifecycle-convergence/bin/main.rs +++ b/examples/rust/lifecycle-convergence/bin/main.rs @@ -192,6 +192,16 @@ async fn run(name: &str) -> Result } assert_marker(&read_marker(&reused).await?, "original")?; + // Strict start resumes an existing stopped identity without accepting creation options. + reused.stop().await?; + let started = Instant::now(); + let resumed = Sandbox::start(name).await?; + timings.insert("start", elapsed_ms(started)); + if resumed.id() != original_id { + return Err("start changed the persisted identity".into()); + } + assert_marker(&read_marker(&resumed).await?, "original")?; + let handle = Sandbox::get(name).await?; let started = Instant::now(); let connected = handle.connect_or_start().await?; @@ -251,7 +261,7 @@ async fn run(name: &str) -> Result "platform": platform, "sandbox": name, "identity": original_id.as_str(), - "checks": 16, + "checks": 17, "timings_ms": timings, "result": "pass" })) diff --git a/examples/rust/shell-attach/bin/main.rs b/examples/rust/shell-attach/bin/main.rs index 545ed6284..5e368dd9a 100644 --- a/examples/rust/shell-attach/bin/main.rs +++ b/examples/rust/shell-attach/bin/main.rs @@ -6,14 +6,15 @@ use microsandbox::Sandbox; #[tokio::main] async fn main() -> Result<(), Box> { - println!("Creating sandbox (image=alpine)"); + println!("Connecting to or creating sandbox (image=alpine on first creation)"); + // An interactive workspace is useful across runs. Builder options seed only the first creation; + // later runs reconnect to the persisted sandbox and preserve its files and configuration. let sandbox = Sandbox::builder("attach-example") .image("alpine") .cpus(1) .memory(512) - .replace() - .create() + .connect_or_create() .await?; println!("Attaching to shell (press Ctrl+] to detach)..."); diff --git a/examples/typescript/README.md b/examples/typescript/README.md index 2c912fc2a..2716bf94c 100644 --- a/examples/typescript/README.md +++ b/examples/typescript/README.md @@ -29,6 +29,8 @@ npm install npm start ``` +Configuration-focused examples intentionally replace their fixed-name sandbox so reruns match the source. `shell-attach` uses `connectOrCreate` instead, preserving its interactive workspace and configuration across runs. + ## Examples | Example | Description | @@ -45,7 +47,7 @@ npm start | `snapshot-fork` | Snapshot a stopped sandbox and boot a fresh one from it | | `fs-read-stream` | Streaming file read | | `metrics-stream` | Streaming resource metrics | -| `shell-attach` | Interactive shell attach | +| `shell-attach` | Reusable interactive shell using `connectOrCreate` | | `net-basic` | Basic networking | | `net-dns` | DNS filtering | | `net-policy` | Network policies | diff --git a/examples/typescript/cloud-backend/main.ts b/examples/typescript/cloud-backend/main.ts index 8bb352989..6784f398a 100644 --- a/examples/typescript/cloud-backend/main.ts +++ b/examples/typescript/cloud-backend/main.ts @@ -35,7 +35,6 @@ const sandbox = await Sandbox.builder(name) "for i in 1 2 3; do echo typescript-cloud-$i; sleep 1; done", ]) .maxDuration(60) - .replace() .create(); try { diff --git a/examples/typescript/cloud-backend/package.json b/examples/typescript/cloud-backend/package.json index 8bc65487a..a2270ac86 100644 --- a/examples/typescript/cloud-backend/package.json +++ b/examples/typescript/cloud-backend/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/fs-read-stream/package.json b/examples/typescript/fs-read-stream/package.json index 6a343f64b..9872f2f7a 100644 --- a/examples/typescript/fs-read-stream/package.json +++ b/examples/typescript/fs-read-stream/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/init-handoff/package.json b/examples/typescript/init-handoff/package.json index 9fd9a29a5..41158a4fc 100644 --- a/examples/typescript/init-handoff/package.json +++ b/examples/typescript/init-handoff/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/lifecycle-convergence/main.ts b/examples/typescript/lifecycle-convergence/main.ts index 91a23edec..0436ea5a3 100644 --- a/examples/typescript/lifecycle-convergence/main.ts +++ b/examples/typescript/lifecycle-convergence/main.ts @@ -127,6 +127,12 @@ try { assert(reused.id === originalId, "connectOrCreate changed the persisted identity"); assert((await readMarker(reused)) === "original", "existing configuration did not win"); + // Strict start resumes an existing stopped identity without accepting creation options. + await reused.stop(); + const resumed = await measured("start", () => Sandbox.start(name)); + assert(resumed.id === originalId, "start changed the persisted identity"); + assert((await readMarker(resumed)) === "original", "start lost persisted configuration"); + const handle = await Sandbox.get(name); const connected = await measured("connect_or_start", () => handle.connectOrStart()); assert(connected.id === originalId, "connectOrStart changed the persisted identity"); @@ -169,7 +175,7 @@ try { platform, sandbox: name, identity: originalId, - checks: 16, + checks: 17, timings_ms: timings, result: "pass", }), diff --git a/examples/typescript/net-basic/package.json b/examples/typescript/net-basic/package.json index 96a5a9ccc..e0fd0df77 100644 --- a/examples/typescript/net-basic/package.json +++ b/examples/typescript/net-basic/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/net-dns/package.json b/examples/typescript/net-dns/package.json index a2eec1447..55ed37d1c 100644 --- a/examples/typescript/net-dns/package.json +++ b/examples/typescript/net-dns/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/net-policy/package.json b/examples/typescript/net-policy/package.json index 8e2c1fd33..6cea49ea3 100644 --- a/examples/typescript/net-policy/package.json +++ b/examples/typescript/net-policy/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/net-ports/package.json b/examples/typescript/net-ports/package.json index 0421b3580..58a998cce 100644 --- a/examples/typescript/net-ports/package.json +++ b/examples/typescript/net-ports/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/net-secrets/package.json b/examples/typescript/net-secrets/package.json index 7fd6a3311..789812cdb 100644 --- a/examples/typescript/net-secrets/package.json +++ b/examples/typescript/net-secrets/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/net-tls/package.json b/examples/typescript/net-tls/package.json index 376940dfd..089f9d4c8 100644 --- a/examples/typescript/net-tls/package.json +++ b/examples/typescript/net-tls/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/root-bind/package.json b/examples/typescript/root-bind/package.json index 7af8f62b5..78641b8dd 100644 --- a/examples/typescript/root-bind/package.json +++ b/examples/typescript/root-bind/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/root-block/package.json b/examples/typescript/root-block/package.json index 5351f968e..3e624f9e5 100644 --- a/examples/typescript/root-block/package.json +++ b/examples/typescript/root-block/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/root-oci/package.json b/examples/typescript/root-oci/package.json index 3bc625cf3..d11e518bc 100644 --- a/examples/typescript/root-oci/package.json +++ b/examples/typescript/root-oci/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/rootfs-patch/package.json b/examples/typescript/rootfs-patch/package.json index 39283890b..493514223 100644 --- a/examples/typescript/rootfs-patch/package.json +++ b/examples/typescript/rootfs-patch/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/shell-attach/main.ts b/examples/typescript/shell-attach/main.ts index 0248c2c41..0a1349f84 100644 --- a/examples/typescript/shell-attach/main.ts +++ b/examples/typescript/shell-attach/main.ts @@ -1,13 +1,14 @@ import { Sandbox } from "microsandbox"; -console.log("Creating sandbox (image=alpine)"); +console.log("Connecting to or creating sandbox (image=alpine on first creation)"); +// An interactive workspace is useful across runs. Builder options seed only the first creation; +// later runs reconnect to the persisted sandbox and preserve its files and configuration. await using sandbox = await Sandbox.builder("attach-example") .image("alpine") .cpus(1) .memory(512) - .replace() - .create(); + .connectOrCreate(); console.log("Attaching to shell (press Ctrl+] to detach)..."); diff --git a/examples/typescript/snapshot-fork/package.json b/examples/typescript/snapshot-fork/package.json index 76c969ce3..7be022dcc 100644 --- a/examples/typescript/snapshot-fork/package.json +++ b/examples/typescript/snapshot-fork/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/volume-disk/package.json b/examples/typescript/volume-disk/package.json index b2e749c26..c67aeb6ee 100644 --- a/examples/typescript/volume-disk/package.json +++ b/examples/typescript/volume-disk/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/examples/typescript/volume-named/package.json b/examples/typescript/volume-named/package.json index c21ceb57f..3b7d4d580 100644 --- a/examples/typescript/volume-named/package.json +++ b/examples/typescript/volume-named/package.json @@ -7,7 +7,7 @@ "start": "npx tsx main.ts" }, "dependencies": { - "microsandbox": "0.6.16" + "microsandbox": "0.6.17" }, "devDependencies": { "tsx": "^4" diff --git a/mcp b/mcp index f42dd7c34..45f7bfd0f 160000 --- a/mcp +++ b/mcp @@ -1 +1 @@ -Subproject commit f42dd7c3427b7c83b9f3a8cb27d664a7559c67d0 +Subproject commit 45f7bfd0f86ebceed1c8e529ce99f5960a9cdae2 diff --git a/packages/agent-client/rust/README.md b/packages/agent-client/rust/README.md index 0dcdb92c7..2183d77cd 100644 --- a/packages/agent-client/rust/README.md +++ b/packages/agent-client/rust/README.md @@ -10,19 +10,19 @@ Use this crate when you already have an agent relay endpoint and want direct pro ```toml [dependencies] -microsandbox-agent-client = "0.6.16" -microsandbox-protocol = "0.6.16" +microsandbox-agent-client = "0.6.17" +microsandbox-protocol = "0.6.17" ``` The crate is transport-agnostic by default. Enable exactly the transport adapter you need: ```toml # Local microsandbox relay sockets. -microsandbox-agent-client = { version = "0.6.16", features = ["uds"] } +microsandbox-agent-client = { version = "0.6.17", features = ["uds"] } # Any caller-owned byte-stream transport (e.g. a pre-authenticated WebSocket # adapted to bytes). -microsandbox-agent-client = { version = "0.6.16", features = ["stream"] } +microsandbox-agent-client = { version = "0.6.17", features = ["stream"] } ``` The high-level `microsandbox` SDK enables `uds` explicitly because local sandboxes are reached through Unix domain sockets. @@ -99,7 +99,7 @@ async fn example() -> Result<(), Box> { Enable the `stream` feature: ```toml -microsandbox-agent-client = { version = "0.6.16", features = ["stream"] } +microsandbox-agent-client = { version = "0.6.17", features = ["stream"] } ``` Drive the client over any `AsyncRead + AsyncWrite` — the caller owns the dial and any authentication, then hands over the connected stream: diff --git a/packages/agent-client/typescript/package-lock.json b/packages/agent-client/typescript/package-lock.json index 4b0a123ab..1e88aeb8a 100644 --- a/packages/agent-client/typescript/package-lock.json +++ b/packages/agent-client/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsandbox/agent-client", - "version": "0.6.16", + "version": "0.6.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsandbox/agent-client", - "version": "0.6.16", + "version": "0.6.17", "license": "Apache-2.0", "dependencies": { "cbor-x": "^1.6.0" diff --git a/packages/agent-client/typescript/package.json b/packages/agent-client/typescript/package.json index 9ce2f0d75..02249ea78 100644 --- a/packages/agent-client/typescript/package.json +++ b/packages/agent-client/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@microsandbox/agent-client", - "version": "0.6.16", + "version": "0.6.17", "type": "module", "license": "Apache-2.0", "engines": { diff --git a/packages/microsandbox-types/rust/README.md b/packages/microsandbox-types/rust/README.md index 6459e33e5..ca82125f9 100644 --- a/packages/microsandbox-types/rust/README.md +++ b/packages/microsandbox-types/rust/README.md @@ -25,7 +25,7 @@ Backend-private materialized state stays out: registry credentials, local CA pat ```toml [dependencies] -microsandbox-types = "0.6.16" +microsandbox-types = "0.6.17" ``` ```rust diff --git a/packages/microsandbox-types/rust/lib/cloud.rs b/packages/microsandbox-types/rust/lib/cloud.rs index d69516a8b..4b974e0ff 100644 --- a/packages/microsandbox-types/rust/lib/cloud.rs +++ b/packages/microsandbox-types/rust/lib/cloud.rs @@ -1001,6 +1001,7 @@ impl TryFrom for SandboxSpec { max_connections: spec.network.max_connections, rate_limiter: None, trust_host_cas: false, + outbound_proxy: None, }; let runtime = SandboxRuntimeOptions { workdir: spec.runtime.workdir, diff --git a/packages/microsandbox-types/rust/lib/domain.rs b/packages/microsandbox-types/rust/lib/domain.rs index cfbdf9124..2065cd0d9 100644 --- a/packages/microsandbox-types/rust/lib/domain.rs +++ b/packages/microsandbox-types/rust/lib/domain.rs @@ -589,6 +589,52 @@ pub struct NetworkSpec { /// Whether to copy trusted host CAs into the guest at boot. pub trust_host_cas: bool, + + /// Proxy used for outbound sandbox connections and supported datagram flows. + /// + #[serde(skip_serializing_if = "Option::is_none")] + pub outbound_proxy: Option, +} + +/// Proxy configuration for outbound sandbox connections. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[serde(tag = "protocol", rename_all = "lowercase")] +#[non_exhaustive] +pub enum OutboundProxy { + /// A SOCKS4 proxy at the given `IP:port` address. + Socks4 { + /// Proxy socket address. + address: String, + /// Optional user ID sent during the SOCKS4 handshake. + #[serde(default, skip_serializing_if = "Option::is_none")] + user_id: Option, + }, + + /// A SOCKS5 proxy at the given `IP:port` address. + Socks5 { + /// Proxy socket address. + address: String, + /// Optional username/password authentication credentials. + #[serde(default, skip_serializing_if = "Option::is_none")] + credentials: Option, + }, +} + +/// Environment-backed username/password credentials for a SOCKS5 proxy. +/// +/// This durable configuration contains only the host-side password source. +/// The resolved password is carried by the private launch contract instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct Socks5Credentials { + /// SOCKS5 authentication username. + pub username: String, + + /// Host-side source for the SOCKS5 authentication password. + pub password: SecretSource, } /// A published port mapping between host and guest. @@ -1659,6 +1705,7 @@ impl Default for NetworkSpec { max_connections: None, rate_limiter: None, trust_host_cas: false, + outbound_proxy: None, } } } diff --git a/packages/microsandbox-types/rust/lib/lib.rs b/packages/microsandbox-types/rust/lib/lib.rs index 4adabbed8..022742be9 100644 --- a/packages/microsandbox-types/rust/lib/lib.rs +++ b/packages/microsandbox-types/rust/lib/lib.rs @@ -34,16 +34,16 @@ pub use domain::{ InterfaceOverridesPatch, LogSource, MAX_SECRET_PLACEHOLDER_BYTES, MemoryPlacement, MountOptions, NamedVolumeCreate, NamedVolumeMode, NetworkPolicy, NetworkRateLimitDirection, NetworkRateLimiterConfig, NetworkRateLimiterConfigPatch, NetworkSpec, NetworkSpecPatch, - NumaPlacement, OciRootfsSource, Patch, PlacementProfile, PortProtocol, PortRange, Protocol, - PublishedPortSpec, PullPolicy, RateLimitConfigError, RateLimiterConfig, Rlimit, RlimitResource, - RootDisk, RootfsSource, Rule, SandboxConfigPatch, SandboxLogLevel, SandboxPolicy, - SandboxPolicyPatch, SandboxResources, SandboxResourcesPatch, SandboxRuntimeOptions, - SandboxRuntimeOptionsPatch, SandboxSpec, ScopedUpstreamCaCert, ScopedVerifyUpstream, - SecretConfigError, SecretEntry, SecretInjection, SecretsConfig, SecretsConfigPatch, - SecurityProfile, SnapshotSpec, StatVirtualization, TlsConfig, TlsConfigPatch, - TokenBucketConfig, TransparentHugePagePolicy, ViolationAction, VolumeKind, VolumeMount, - VolumeSpec, VsockRouteSpec, VsockSocketType, VsockSpec, VsockSpecPatch, - canonicalize_volume_mounts, + NumaPlacement, OciRootfsSource, OutboundProxy, Patch, PlacementProfile, PortProtocol, + PortRange, Protocol, PublishedPortSpec, PullPolicy, RateLimitConfigError, RateLimiterConfig, + Rlimit, RlimitResource, RootDisk, RootfsSource, Rule, SandboxConfigPatch, SandboxLogLevel, + SandboxPolicy, SandboxPolicyPatch, SandboxResources, SandboxResourcesPatch, + SandboxRuntimeOptions, SandboxRuntimeOptionsPatch, SandboxSpec, ScopedUpstreamCaCert, + ScopedVerifyUpstream, SecretConfigError, SecretEntry, SecretInjection, SecretsConfig, + SecretsConfigPatch, SecurityProfile, SnapshotSpec, Socks5Credentials, StatVirtualization, + TlsConfig, TlsConfigPatch, TokenBucketConfig, TransparentHugePagePolicy, ViolationAction, + VolumeKind, VolumeMount, VolumeSpec, VsockRouteSpec, VsockSocketType, VsockSpec, + VsockSpecPatch, canonicalize_volume_mounts, }; pub use error::{TypesError, TypesResult}; pub use modify::{ diff --git a/packages/microsandbox-types/rust/lib/modify.rs b/packages/microsandbox-types/rust/lib/modify.rs index 9ac4b3d0c..8dd62caea 100644 --- a/packages/microsandbox-types/rust/lib/modify.rs +++ b/packages/microsandbox-types/rust/lib/modify.rs @@ -153,6 +153,13 @@ pub enum SecretSource { }, } +impl SecretSource { + /// Creates a host environment-variable source. + pub fn env(var: impl Into) -> Self { + Self::Env { var: var.into() } + } +} + /// Serializable dry-run or apply plan for a sandbox modification. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] diff --git a/packages/microsandbox-types/typescript/package-lock.json b/packages/microsandbox-types/typescript/package-lock.json index eb64acc85..e25387470 100644 --- a/packages/microsandbox-types/typescript/package-lock.json +++ b/packages/microsandbox-types/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsandbox/types", - "version": "0.6.16", + "version": "0.6.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsandbox/types", - "version": "0.6.16", + "version": "0.6.17", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.6" diff --git a/packages/microsandbox-types/typescript/package.json b/packages/microsandbox-types/typescript/package.json index b435fb7ce..b5d531b0e 100644 --- a/packages/microsandbox-types/typescript/package.json +++ b/packages/microsandbox-types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@microsandbox/types", - "version": "0.6.16", + "version": "0.6.17", "type": "module", "main": "./dist/cloud.js", "types": "./dist/cloud.d.ts", diff --git a/scripts/smoke/cli/previous_release_upgrade.py b/scripts/smoke/cli/previous_release_upgrade.py new file mode 100755 index 000000000..c7d29e597 --- /dev/null +++ b/scripts/smoke/cli/previous_release_upgrade.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +"""Smoke-test local database upgrades from recent microsandbox releases.""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import sqlite3 +import stat +import subprocess +import sys +import tempfile +import urllib.request +from pathlib import Path +from typing import Any + + +ROOT_DIR = Path(__file__).resolve().parents[3] +DEFAULT_REPOSITORY = "superradcompany/microsandbox" + + +class SmokeError(Exception): + """An expected smoke-test failure with a concise user-facing message.""" + + +def github_request(url: str) -> urllib.request.Request: + """Build an authenticated GitHub request when CI provides a token.""" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "microsandbox-upgrade-smoke", + "X-GitHub-Api-Version": "2022-11-28", + } + if token := os.environ.get("GH_TOKEN"): + headers["Authorization"] = f"Bearer {token}" + return urllib.request.Request(url, headers=headers) + + +def load_json(url: str) -> Any: + """Load JSON from the GitHub API.""" + with urllib.request.urlopen(github_request(url), timeout=30) as response: + return json.load(response) + + +def released_versions(repository: str) -> list[str]: + """Return the two latest stable release tags, or an explicit override.""" + if override := os.environ.get("MSB_UPGRADE_FROM_VERSIONS"): + return override.split() + + releases = load_json(f"https://api.github.com/repos/{repository}/releases?per_page=10") + return [ + release["tag_name"] + for release in releases + if not release["draft"] and not release["prerelease"] + ][:2] + + +def platform_asset() -> str: + """Return the release asset name for the current host.""" + systems = {"Darwin": "darwin", "Linux": "linux"} + machines = { + "arm64": "aarch64", + "aarch64": "aarch64", + "x86_64": "x86_64", + "amd64": "x86_64", + } + try: + system = systems[platform.system()] + machine = machines[platform.machine().lower()] + except KeyError as error: + raise SmokeError( + f"unsupported smoke-test platform: {platform.system()} {platform.machine()}" + ) from error + return f"msb-{system}-{machine}" + + +def download_release_binary(repository: str, version: str, destination: Path) -> None: + """Download one released msb binary for this host.""" + release = load_json( + f"https://api.github.com/repos/{repository}/releases/tags/{version}" + ) + asset_name = platform_asset() + asset = next( + (candidate for candidate in release["assets"] if candidate["name"] == asset_name), + None, + ) + if asset is None: + raise SmokeError(f"release {version} has no {asset_name} asset") + + with urllib.request.urlopen( + github_request(asset["browser_download_url"]), timeout=30 + ) as response, destination.open("wb") as output: + shutil.copyfileobj(response, output) + destination.chmod( + destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + + +def run_msb(binary: Path, *arguments: str, home: Path | None = None) -> None: + """Run msb with output hidden unless the command fails.""" + environment = os.environ.copy() + if home is not None: + environment["MSB_HOME"] = str(home) + result = subprocess.run( + [str(binary), *arguments], + env=environment, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + raise SmokeError(f"{binary} {' '.join(arguments)} exited with {result.returncode}") + + +def schema_baseline(binary: Path) -> dict[str, Any]: + """Read the hidden schema compatibility metadata from an msb binary.""" + result = subprocess.run( + [str(binary), "__schema-baseline", "--json"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + raise SmokeError(f"failed to read schema baseline from {binary}") + return json.loads(result.stdout) + + +def verify_migration_set( + version: str, + old_baseline: dict[str, Any], + candidate_baseline: dict[str, Any], + database_path: Path, +) -> None: + """Verify released identifiers survive and the candidate fully migrates.""" + old_migrations = old_baseline["migrations"] + candidate_migrations = candidate_baseline["migrations"] + if len(old_migrations) != len(set(old_migrations)): + raise SmokeError(f"{version} reports duplicate migration identifiers") + if len(candidate_migrations) != len(set(candidate_migrations)): + raise SmokeError("candidate reports duplicate migration identifiers") + + missing = sorted(set(old_migrations) - set(candidate_migrations)) + if missing: + raise SmokeError( + f"candidate removed migrations shipped by {version}: {', '.join(missing)}" + ) + + with sqlite3.connect(database_path) as database: + applied = { + row[0] for row in database.execute("SELECT version FROM seaql_migrations") + } + expected = set(candidate_migrations) + if applied != expected: + raise SmokeError( + "candidate database migration set does not match its schema baseline; " + f"missing={sorted(expected - applied)}, " + f"unexpected={sorted(applied - expected)}" + ) + + +def verify_upgrade( + repository: str, + version: str, + candidate: Path, + candidate_baseline: dict[str, Any], + smoke_root: Path, +) -> None: + """Create a released database and open it twice with the candidate.""" + release_dir = smoke_root / version + release_dir.mkdir(parents=True) + old_msb = release_dir / "msb" + old_home = release_dir / "home" + + download_release_binary(repository, version, old_msb) + old_baseline = schema_baseline(old_msb) + run_msb(old_msb, "list", home=old_home) + + # Opening twice verifies both the upgrade and its steady-state/idempotent path. + run_msb(candidate, "list", home=old_home) + run_msb(candidate, "list", home=old_home) + verify_migration_set( + version, + old_baseline, + candidate_baseline, + old_home / "db" / "msb.db", + ) + print(f"upgrade smoke passed: {version} -> candidate") + + +def main() -> int: + """Run the previous-release upgrade smoke test.""" + candidate = Path(os.environ.get("MSB_BIN", ROOT_DIR / "build" / "msb")) + repository = os.environ.get("MSB_UPGRADE_SMOKE_REPO", DEFAULT_REPOSITORY) + if not candidate.is_file() or not os.access(candidate, os.X_OK): + raise SmokeError(f"msb binary is not executable: {candidate}") + + versions = released_versions(repository) + if not versions: + raise SmokeError("no released versions found for upgrade smoke test") + + candidate_baseline = schema_baseline(candidate) + with tempfile.TemporaryDirectory(prefix="msb-upgrade-smoke-") as temp_dir: + smoke_root = Path(temp_dir) + for version in versions: + verify_upgrade( + repository, + version, + candidate, + candidate_baseline, + smoke_root, + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, KeyError, ValueError, SmokeError) as error: + raise SystemExit(f"error: {error}") from error diff --git a/sdk/go/internal/ffi/ffi.go b/sdk/go/internal/ffi/ffi.go index 67ef08e07..0193cec93 100644 --- a/sdk/go/internal/ffi/ffi.go +++ b/sdk/go/internal/ffi/ffi.go @@ -1588,15 +1588,16 @@ type CreateOptions struct { RegistryInsecure bool `json:"registry_insecure,omitempty"` // RegistryCACerts holds PEM-encoded CA root certificate bundles trusted // when pulling. - RegistryCACerts []string `json:"registry_ca_certs,omitempty"` - Ports map[uint16]uint16 `json:"ports,omitempty"` - PortsUDP map[uint16]uint16 `json:"ports_udp,omitempty"` - PortBindings []PortBindingOptions `json:"port_bindings,omitempty"` - Vsock []VsockRouteOptions `json:"vsock,omitempty"` - Network *NetworkOptions `json:"network,omitempty"` - Secrets []SecretOptions `json:"secrets,omitempty"` - Patches []PatchOptions `json:"patches,omitempty"` - Volumes map[string]MountSpec `json:"volumes,omitempty"` + RegistryCACerts []string `json:"registry_ca_certs,omitempty"` + Ports map[uint16]uint16 `json:"ports,omitempty"` + PortsUDP map[uint16]uint16 `json:"ports_udp,omitempty"` + PortBindings []PortBindingOptions `json:"port_bindings,omitempty"` + Vsock []VsockRouteOptions `json:"vsock,omitempty"` + Network *NetworkOptions `json:"network,omitempty"` + Proxy *OutboundProxyOptions `json:"proxy,omitempty"` + Secrets []SecretOptions `json:"secrets,omitempty"` + Patches []PatchOptions `json:"patches,omitempty"` + Volumes map[string]MountSpec `json:"volumes,omitempty"` } // InitOptions describes a guest PID-1 init handoff. @@ -1684,6 +1685,21 @@ type TokenBucketOptions struct { OneTimeBurst uint64 `json:"one_time_burst,omitempty"` } +// OutboundProxyOptions is the JSON representation of an outbound proxy. +type OutboundProxyOptions struct { + Protocol string `json:"protocol"` + Address string `json:"address"` + UserID string `json:"user_id,omitempty"` + Username *string `json:"username,omitempty"` + PasswordSource *SecretSourceOptions `json:"password_source,omitempty"` +} + +// SecretSourceOptions is the JSON representation of a host-side secret source. +type SecretSourceOptions struct { + Kind string `json:"kind"` + Var string `json:"var,omitempty"` +} + // PortBindingOptions publishes a host port on a specific host bind address. type PortBindingOptions struct { Bind string `json:"bind,omitempty"` diff --git a/sdk/go/native/src/lib.rs b/sdk/go/native/src/lib.rs index b2edf304b..b5d1630b2 100644 --- a/sdk/go/native/src/lib.rs +++ b/sdk/go/native/src/lib.rs @@ -932,6 +932,25 @@ struct NetworkOpts { trust_host_cas: Option, } +#[derive(serde::Deserialize)] +struct OutboundProxyOpts { + protocol: String, + address: String, + #[serde(default)] + user_id: Option, + #[serde(default)] + username: Option, + #[serde(default)] + password_source: Option, +} + +#[derive(serde::Deserialize)] +struct SecretSourceOpts { + kind: String, + #[serde(default)] + var: Option, +} + #[derive(serde::Deserialize)] struct SecretOpts { env_var: String, @@ -1064,6 +1083,8 @@ struct SandboxCreateOpts { #[serde(default)] registry_ca_certs: Vec, network: Option, + /// Proxy that all outbound sandbox connections are dialed through. + proxy: Option, /// Top-level ports shorthand: {host_port: guest_port} (TCP). #[serde(default)] ports: HashMap, @@ -2333,6 +2354,55 @@ pub unsafe extern "C" fn msb_sandbox_create( if let Some(ref net) = opts.network { builder = apply_network(builder, net)?; } + if let Some(proxy) = opts.proxy { + if proxy.protocol != "socks5" + && (proxy.username.is_some() || proxy.password_source.is_some()) + { + return Err(FfiError::invalid_argument( + "credentials are only supported for SOCKS5 proxies", + )); + } + if let Some(source) = &proxy.password_source + && source.kind != "env" + { + return Err(FfiError::invalid_argument(format!( + "unsupported SOCKS5 password source {:?}; only env is supported", + source.kind + ))); + } + builder = match proxy.protocol.as_str() { + "socks4" => builder.proxy(move |p| { + let proxy_builder = p.socks4(proxy.address); + match proxy.user_id { + Some(user_id) => proxy_builder.user_id(user_id), + None => proxy_builder, + } + }), + "socks5" => builder.proxy(move |p| { + let proxy_builder = p.socks5(proxy.address); + match (proxy.username, proxy.password_source) { + (Some(username), Some(password)) if password.kind == "env" => { + proxy_builder.credentials( + username, + microsandbox::sandbox::SecretSource::env( + password.var.unwrap_or_default(), + ), + ) + } + (None, None) => proxy_builder, + _ => proxy_builder.credentials( + String::new(), + microsandbox::sandbox::SecretSource::env(String::new()), + ), + } + }), + protocol => { + return Err(FfiError::invalid_argument(format!( + "unsupported outbound proxy protocol {protocol:?}" + ))); + } + }; + } // Secrets. for s in &opts.secrets { builder = apply_secret(builder, s)?; diff --git a/sdk/go/options.go b/sdk/go/options.go index 4c29ff914..bfb88410b 100644 --- a/sdk/go/options.go +++ b/sdk/go/options.go @@ -74,6 +74,7 @@ type SandboxConfig struct { PortBindings []PortBinding // explicit bind address host→guest ports Vsock []VsockRoute // host local IPC → guest host-CID port Network *NetworkConfig + Proxy *OutboundProxy Secrets []SecretEntry Patches []PatchConfig Volumes map[string]MountConfig // guest path → mount config @@ -979,6 +980,11 @@ func WithNetwork(net *NetworkConfig) SandboxOption { return func(o *SandboxConfig) { o.Network = net } } +// WithProxy sets the single proxy used for outbound sandbox connections. +func WithProxy(proxy *OutboundProxy) SandboxOption { + return func(o *SandboxConfig) { o.Proxy = proxy } +} + // WithSecrets appends credential secrets to the sandbox. Secrets never enter // the VM; the network proxy substitutes them at the transport layer. func WithSecrets(secrets ...SecretEntry) SandboxOption { @@ -1040,6 +1046,61 @@ type RegistryAuth struct { // Network // --------------------------------------------------------------------------- +// OutboundProxy configures the single proxy used for outbound connections. +// Construct one with a protocol-specific function such as SOCKS5Proxy. +type OutboundProxy struct { + protocol string + address string + userID string + username string + password SecretSource + hasCredentials bool +} + +// SecretSource identifies a host-side source for secret material. +type SecretSource struct { + kind string + varName string +} + +// SecretSourceEnv resolves secret material from a host environment variable. +func SecretSourceEnv(variable string) SecretSource { + return SecretSource{kind: "env", varName: variable} +} + +// SOCKS4ProxyOptions configures optional SOCKS4 handshake fields. +type SOCKS4ProxyOptions struct { + // UserID is the optional user ID sent during the SOCKS4 handshake. + UserID string +} + +// SOCKS4Proxy configures a SOCKS4 outbound proxy at address. +func SOCKS4Proxy(address string, options ...SOCKS4ProxyOptions) *OutboundProxy { + proxy := &OutboundProxy{protocol: "socks4", address: address} + if len(options) > 0 { + proxy.userID = options[0].UserID + } + return proxy +} + +// SOCKS5Proxy configures a SOCKS5 outbound proxy at address. +func SOCKS5Proxy(address string) *OutboundProxy { + return &OutboundProxy{protocol: "socks5", address: address} +} + +// Credentials returns a copy configured with SOCKS5 username authentication +// and a host-side password source. +func (p *OutboundProxy) Credentials(username string, password SecretSource) *OutboundProxy { + if p == nil { + return nil + } + proxy := *p + proxy.username = username + proxy.password = password + proxy.hasCredentials = true + return &proxy +} + // NetworkConfig configures the sandbox network stack. type NetworkConfig struct { // Rules are custom ordered allow/deny rules (first match wins). Use diff --git a/sdk/go/options_test.go b/sdk/go/options_test.go index 85bb130ea..7660e7dfc 100644 --- a/sdk/go/options_test.go +++ b/sdk/go/options_test.go @@ -383,6 +383,29 @@ func TestWithNetworkNilClearsPolicy(t *testing.T) { } } +func TestWithProxy(t *testing.T) { + proxy := SOCKS5Proxy("127.0.0.1:1080") + var o SandboxConfig + WithProxy(proxy)(&o) + if o.Proxy != proxy { + t.Error("WithProxy should set the Proxy pointer") + } +} + +func TestSOCKS4ProxyOptions(t *testing.T) { + proxy := SOCKS4Proxy("127.0.0.1:1080", SOCKS4ProxyOptions{UserID: "sandbox"}) + if proxy.protocol != "socks4" || proxy.address != "127.0.0.1:1080" || proxy.userID != "sandbox" { + t.Fatalf("SOCKS4Proxy: got %+v", proxy) + } +} + +func TestSOCKS5ProxyCredentials(t *testing.T) { + proxy := SOCKS5Proxy("127.0.0.1:1080").Credentials("sandbox", SecretSourceEnv("SOCKS5_PASSWORD")) + if proxy.protocol != "socks5" || proxy.username != "sandbox" || proxy.password.kind != "env" || proxy.password.varName != "SOCKS5_PASSWORD" || !proxy.hasCredentials { + t.Fatalf("SOCKS5Proxy credentials: got %+v", proxy) + } +} + func TestNetworkPolicyFactory(t *testing.T) { if got := NetworkPolicy.None(); got.DefaultEgress != PolicyActionDeny || got.DefaultIngress != PolicyActionDeny { t.Fatalf("None defaults = %q/%q", got.DefaultEgress, got.DefaultIngress) diff --git a/sdk/go/sandbox.go b/sdk/go/sandbox.go index 55f8ae330..9c4b73571 100644 --- a/sdk/go/sandbox.go +++ b/sdk/go/sandbox.go @@ -199,6 +199,7 @@ func buildFFICreateOptions(o SandboxConfig) ffi.CreateOptions { if o.Network != nil { ffiOpts.Network = buildFFINetwork(o.Network) } + ffiOpts.Proxy = buildFFIOutboundProxy(o.Proxy) for _, s := range o.Secrets { ffiOpts.Secrets = append(ffiOpts.Secrets, ffi.SecretOptions{ @@ -398,6 +399,25 @@ func buildFFINetwork(n *NetworkConfig) *ffi.NetworkOptions { return out } +func buildFFIOutboundProxy(proxy *OutboundProxy) *ffi.OutboundProxyOptions { + if proxy == nil { + return nil + } + result := &ffi.OutboundProxyOptions{ + Protocol: proxy.protocol, + Address: proxy.address, + UserID: proxy.userID, + } + if proxy.hasCredentials { + result.Username = &proxy.username + result.PasswordSource = &ffi.SecretSourceOptions{ + Kind: proxy.password.kind, + Var: proxy.password.varName, + } + } + return result +} + func buildFFINetworkRateLimiter(l *NetworkRateLimiterConfig) *ffi.NetworkRateLimiterOptions { if l == nil { return nil diff --git a/sdk/go/sandbox_options_test.go b/sdk/go/sandbox_options_test.go index df3673a6d..f7546ad7e 100644 --- a/sdk/go/sandbox_options_test.go +++ b/sdk/go/sandbox_options_test.go @@ -753,6 +753,7 @@ func TestFFIWireShape_NetworkCustomRules(t *testing.T) { IPv4Pool: "172.31.240.0/24", IPv6Pool: "fd7a:115c:a1e0:100::/56", }), + WithProxy(SOCKS5Proxy("127.0.0.1:1080")), ) net := mustField(t, got, "network").(map[string]any) @@ -785,6 +786,21 @@ func TestFFIWireShape_NetworkCustomRules(t *testing.T) { if len(ns) != 1 || ns[0] != "1.1.1.1:53" { t.Fatalf("dns.nameservers = %v", ns) } + proxy := mustField(t, got, "proxy").(map[string]any) + if proxy["protocol"] != "socks5" || proxy["address"] != "127.0.0.1:1080" { + t.Fatalf("proxy = %#v", proxy) + } +} + +func TestFFIWireShape_SOCKS4Proxy(t *testing.T) { + got := marshalCreateOptions(t, + WithImage("alpine"), + WithProxy(SOCKS4Proxy("127.0.0.1:1080", SOCKS4ProxyOptions{UserID: "sandbox"})), + ) + proxy := mustField(t, got, "proxy").(map[string]any) + if proxy["protocol"] != "socks4" || proxy["address"] != "127.0.0.1:1080" || proxy["user_id"] != "sandbox" { + t.Fatalf("proxy = %#v", proxy) + } } func TestBuildFFINetworkRateLimiters(t *testing.T) { @@ -903,6 +919,18 @@ func TestFFIWireShape_NetworkRateLimiters(t *testing.T) { } } +func TestFFIWireShape_SOCKS5Credentials(t *testing.T) { + got := marshalCreateOptions(t, + WithImage("alpine"), + WithProxy(SOCKS5Proxy("127.0.0.1:1080").Credentials("sandbox", SecretSourceEnv("SOCKS5_PASSWORD"))), + ) + proxy := mustField(t, got, "proxy").(map[string]any) + passwordSource, ok := proxy["password_source"].(map[string]any) + if proxy["username"] != "sandbox" || !ok || passwordSource["kind"] != "env" || passwordSource["var"] != "SOCKS5_PASSWORD" { + t.Fatalf("proxy credentials = %#v", proxy) + } +} + // The Rust side relies on serde(default), so zero-valued Go scalar fields must // not reach the wire. Explicit optional values use pointers when zero is valid // on the wire for validation. @@ -914,7 +942,7 @@ func TestFFIWireShape_EmptyConfigOmitsOptionalFields(t *testing.T) { "thp", "hostname", "user", "replace", "detached", "env", "scripts", "ports", "ports_udp", "vsock", "network", "secrets", "patches", "volumes", - "init", "registry_auth", "registry_insecure", "registry_ca_certs", "root_disk", + "proxy", "init", "registry_auth", "registry_insecure", "registry_ca_certs", "root_disk", } { if _, present := got[key]; present { body, _ := json.Marshal(got) diff --git a/sdk/go/setup.go b/sdk/go/setup.go index c5a7b85ad..a3453cde0 100644 --- a/sdk/go/setup.go +++ b/sdk/go/setup.go @@ -24,7 +24,7 @@ import ( // embedded FFI library and the downloaded msb+libkrunfw artefacts are // both pinned to this version. Bump when cutting a new SDK release so // it matches published binaries. -const sdkVersion = "0.6.16" +const sdkVersion = "0.6.17" // libkrunfwABI is the major SONAME version of libkrunfw that msb links // against. diff --git a/sdk/node-ts/README.md b/sdk/node-ts/README.md index 5eb15cb69..70b1feb0e 100644 --- a/sdk/node-ts/README.md +++ b/sdk/node-ts/README.md @@ -13,7 +13,7 @@ For the full API reference and longer guides, use the docs site: ## Features - Hardware VM isolation with a guest Linux kernel -- ESM-first TypeScript API with generated native bindings +- ESM-first API with CommonJS `require()` support and generated native bindings - Collected and streaming command execution - Guest filesystem read, write, list, copy, stat, and stream operations - Named volumes, bind mounts, tmpfs mounts, and disk-image mounts @@ -28,12 +28,16 @@ For the full API reference and longer guides, use the docs site: - Linux with KVM, macOS with Apple Silicon, or Windows 11 with WHP enabled - Windows support is currently preview; see the [Windows troubleshooting guide](https://docs.microsandbox.dev/troubleshooting/windows) for WHP and runtime setup notes. -The package root is ESM-only for normal imports: +The package root supports both ESM imports and CommonJS `require()` on Node.js 22+: ```typescript import { Sandbox } from "microsandbox"; ``` +```javascript +const { Sandbox } = require("microsandbox"); +``` + ## Supported Platforms | Platform | Architecture | Platform package | diff --git a/sdk/node-ts/native/index.cjs b/sdk/node-ts/native/index.cjs index a880f1632..503e89e37 100644 --- a/sdk/node-ts/native/index.cjs +++ b/sdk/node-ts/native/index.cjs @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-android-arm64') const bindingPackageVersion = require('@superradcompany/microsandbox-android-arm64/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-android-arm-eabi') const bindingPackageVersion = require('@superradcompany/microsandbox-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-win32-x64-gnu') const bindingPackageVersion = require('@superradcompany/microsandbox-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-win32-x64-msvc') const bindingPackageVersion = require('@superradcompany/microsandbox-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-win32-ia32-msvc') const bindingPackageVersion = require('@superradcompany/microsandbox-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-win32-arm64-msvc') const bindingPackageVersion = require('@superradcompany/microsandbox-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-darwin-universal') const bindingPackageVersion = require('@superradcompany/microsandbox-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-darwin-x64') const bindingPackageVersion = require('@superradcompany/microsandbox-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-darwin-arm64') const bindingPackageVersion = require('@superradcompany/microsandbox-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-freebsd-x64') const bindingPackageVersion = require('@superradcompany/microsandbox-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-freebsd-arm64') const bindingPackageVersion = require('@superradcompany/microsandbox-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-x64-musl') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-x64-gnu') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-arm64-musl') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-arm64-gnu') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-arm-musleabihf') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-arm-gnueabihf') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-loong64-musl') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-loong64-gnu') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-riscv64-musl') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-riscv64-gnu') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-ppc64-gnu') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-linux-s390x-gnu') const bindingPackageVersion = require('@superradcompany/microsandbox-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-openharmony-arm64') const bindingPackageVersion = require('@superradcompany/microsandbox-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-openharmony-x64') const bindingPackageVersion = require('@superradcompany/microsandbox-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@superradcompany/microsandbox-openharmony-arm') const bindingPackageVersion = require('@superradcompany/microsandbox-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.6.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.16 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.6.17' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.6.17 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -612,6 +612,8 @@ module.exports.NetworkPolicyBuilder = nativeBinding.NetworkPolicyBuilder module.exports.JsNetworkPolicyBuilder = nativeBinding.JsNetworkPolicyBuilder module.exports.NetworkRateLimiterBuilder = nativeBinding.NetworkRateLimiterBuilder module.exports.JsNetworkRateLimiterBuilder = nativeBinding.JsNetworkRateLimiterBuilder +module.exports.OutboundProxyBuilder = nativeBinding.OutboundProxyBuilder +module.exports.JsOutboundProxyBuilder = nativeBinding.JsOutboundProxyBuilder module.exports.PatchBuilder = nativeBinding.PatchBuilder module.exports.JsPatchBuilder = nativeBinding.JsPatchBuilder module.exports.PullProgressCreate = nativeBinding.PullProgressCreate @@ -647,6 +649,10 @@ module.exports.SnapshotBuilder = nativeBinding.SnapshotBuilder module.exports.JsSnapshotBuilder = nativeBinding.JsSnapshotBuilder module.exports.SnapshotHandle = nativeBinding.SnapshotHandle module.exports.JsSnapshotHandle = nativeBinding.JsSnapshotHandle +module.exports.Socks4ProxyBuilder = nativeBinding.Socks4ProxyBuilder +module.exports.JsSocks4ProxyBuilder = nativeBinding.JsSocks4ProxyBuilder +module.exports.Socks5ProxyBuilder = nativeBinding.Socks5ProxyBuilder +module.exports.JsSocks5ProxyBuilder = nativeBinding.JsSocks5ProxyBuilder module.exports.SshClient = nativeBinding.SshClient module.exports.JsSshClient = nativeBinding.JsSshClient module.exports.SshServer = nativeBinding.SshServer diff --git a/sdk/node-ts/native/index.d.ts b/sdk/node-ts/native/index.d.ts index 5175f3c1e..15727d4fd 100644 --- a/sdk/node-ts/native/index.d.ts +++ b/sdk/node-ts/native/index.d.ts @@ -586,6 +586,16 @@ export declare class NetworkRateLimiterBuilder { } export type JsNetworkRateLimiterBuilder = NetworkRateLimiterBuilder +/** Selects the protocol for an outbound proxy. */ +export declare class OutboundProxyBuilder { + constructor() + /** Select a SOCKS4 proxy at `address`. */ + socks4(address: string): Socks4ProxyBuilder + /** Select a SOCKS5 proxy at `address`. */ + socks5(address: string): Socks5ProxyBuilder +} +export type JsOutboundProxyBuilder = OutboundProxyBuilder + /** Fluent builder for an ordered list of pre-boot rootfs patches. */ export declare class PatchBuilder { constructor() @@ -1178,6 +1188,8 @@ export declare class SandboxBuilder { disableNetwork(): this /** Configure networking via a callback. */ network(configure: (arg: NetworkBuilder) => NetworkBuilder): this + /** Configure the single proxy used for outbound sandbox connections. */ + proxy(configure: (arg: OutboundProxyBuilder) => Socks4ProxyBuilder | Socks5ProxyBuilder): this /** Publish a TCP port from host -> guest. */ port(hostPort: number, guestPort: number): this /** Publish a TCP port from host -> guest on a specific host bind address. */ @@ -1595,6 +1607,20 @@ export declare class SnapshotHandle { } export type JsSnapshotHandle = SnapshotHandle +/** Builds a SOCKS4 outbound proxy. */ +export declare class Socks4ProxyBuilder { + /** Set the optional user ID sent during the SOCKS4 handshake. */ + userId(userId: string): this +} +export type JsSocks4ProxyBuilder = Socks4ProxyBuilder + +/** Builds a SOCKS5 outbound proxy. */ +export declare class Socks5ProxyBuilder { + /** Set username authentication and a host-side password source. */ + credentials(username: string, password: SecretSourceInput): this +} +export type JsSocks5ProxyBuilder = Socks5ProxyBuilder + /** Native in-process SSH client session. */ export declare class SshClient { /** Run an SSH exec request and collect stdout, stderr, and exit status. */ @@ -2336,6 +2362,14 @@ export interface SecretModifySpec { allowedHosts?: Array } +/** Host-side source for secret material. */ +export interface SecretSourceInput { + /** Source kind. Currently only `env` is supported for proxy credentials. */ + kind: string + /** Host environment variable name. */ + var: string +} + /** * Set the process-wide default backend. * diff --git a/sdk/node-ts/native/lib.rs b/sdk/node-ts/native/lib.rs index cb85ad043..9eb8911a8 100644 --- a/sdk/node-ts/native/lib.rs +++ b/sdk/node-ts/native/lib.rs @@ -20,6 +20,7 @@ mod metrics; mod mount_builder; mod network_builder; mod network_policy_builder; +mod outbound_proxy_builder; mod patch_builder; mod pull_progress; mod rate_limiter_builder; diff --git a/sdk/node-ts/native/outbound_proxy_builder.rs b/sdk/node-ts/native/outbound_proxy_builder.rs new file mode 100644 index 000000000..12fe866ee --- /dev/null +++ b/sdk/node-ts/native/outbound_proxy_builder.rs @@ -0,0 +1,168 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use napi::bindgen_prelude::*; +use napi_derive::napi; + +use microsandbox::sandbox::SecretSource; +use microsandbox_network::{ + OutboundProxy, OutboundProxyBuilder as RustOutboundProxyBuilder, OutboundProxyConfig, + Socks4ProxyBuilder as RustSocks4ProxyBuilder, Socks5ProxyBuilder as RustSocks5ProxyBuilder, +}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Selects the protocol for an outbound proxy. +#[napi(js_name = "OutboundProxyBuilder")] +pub struct JsOutboundProxyBuilder { + inner: Option, + selection: SharedOutboundProxySelection, +} + +pub(crate) type SharedOutboundProxySelection = Rc>>; + +pub(crate) enum OutboundProxySelection { + Socks4(RustSocks4ProxyBuilder), + Socks5(RustSocks5ProxyBuilder), +} + +/// Builds a SOCKS4 outbound proxy. +#[napi(js_name = "Socks4ProxyBuilder")] +pub struct JsSocks4ProxyBuilder { + selection: SharedOutboundProxySelection, +} + +/// Builds a SOCKS5 outbound proxy. +#[napi(js_name = "Socks5ProxyBuilder")] +pub struct JsSocks5ProxyBuilder { + selection: SharedOutboundProxySelection, +} + +/// Host-side source for secret material. +#[napi(object, js_name = "SecretSourceInput")] +pub struct JsSecretSourceInput { + /// Source kind. Currently only `env` is supported for proxy credentials. + pub kind: String, + /// Host environment variable name. + pub var: String, +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +#[napi] +impl JsOutboundProxyBuilder { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: Some(RustOutboundProxyBuilder::new()), + selection: Rc::new(RefCell::new(None)), + } + } + + pub(crate) fn selection(&self) -> SharedOutboundProxySelection { + Rc::clone(&self.selection) + } + + /// Select a SOCKS4 proxy at `address`. + #[napi] + pub fn socks4(&mut self, address: String) -> Result { + let builder = self + .inner + .take() + .ok_or_else(|| napi::Error::from_reason("OutboundProxyBuilder already consumed"))?; + self.selection.replace(Some(OutboundProxySelection::Socks4( + builder.socks4(address), + ))); + Ok(JsSocks4ProxyBuilder { + selection: Rc::clone(&self.selection), + }) + } + + /// Select a SOCKS5 proxy at `address`. + #[napi] + pub fn socks5(&mut self, address: String) -> Result { + let builder = self + .inner + .take() + .ok_or_else(|| napi::Error::from_reason("OutboundProxyBuilder already consumed"))?; + self.selection.replace(Some(OutboundProxySelection::Socks5( + builder.socks5(address), + ))); + Ok(JsSocks5ProxyBuilder { + selection: Rc::clone(&self.selection), + }) + } +} + +#[napi] +impl JsSocks4ProxyBuilder { + /// Set the optional user ID sent during the SOCKS4 handshake. + #[napi] + pub fn user_id(&mut self, user_id: String) -> Result<&Self> { + let builder = self + .selection + .take() + .ok_or_else(|| napi::Error::from_reason("Socks4ProxyBuilder already consumed"))?; + let OutboundProxySelection::Socks4(builder) = builder else { + return Err(napi::Error::from_reason( + "Socks4ProxyBuilder selection was replaced", + )); + }; + self.selection.replace(Some(OutboundProxySelection::Socks4( + builder.user_id(user_id), + ))); + Ok(self) + } +} + +#[napi] +impl JsSocks5ProxyBuilder { + /// Set username authentication and a host-side password source. + #[napi] + pub fn credentials( + &mut self, + username: String, + password: JsSecretSourceInput, + ) -> Result<&Self> { + if password.kind != "env" { + return Err(napi::Error::from_reason(format!( + "unsupported SOCKS5 password source {:?}; only env is supported", + password.kind + ))); + } + let builder = self + .selection + .take() + .ok_or_else(|| napi::Error::from_reason("Socks5ProxyBuilder already consumed"))?; + let OutboundProxySelection::Socks5(builder) = builder else { + return Err(napi::Error::from_reason( + "Socks5ProxyBuilder selection was replaced", + )); + }; + self.selection.replace(Some(OutboundProxySelection::Socks5( + builder.credentials(username, SecretSource::env(password.var)), + ))); + Ok(self) + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +pub(crate) fn take_selected_proxy( + selection: &SharedOutboundProxySelection, +) -> Result { + let selection = selection.borrow_mut().take().ok_or_else(|| { + napi::Error::from_reason("proxy callback must select a SOCKS4 or SOCKS5 proxy builder") + })?; + match selection { + OutboundProxySelection::Socks4(builder) => builder.build(), + OutboundProxySelection::Socks5(builder) => builder.build(), + } + .map_err(|error| napi::Error::from_reason(error.to_string())) +} diff --git a/sdk/node-ts/native/sandbox_builder.rs b/sdk/node-ts/native/sandbox_builder.rs index 2acce509c..c51bb44c9 100644 --- a/sdk/node-ts/native/sandbox_builder.rs +++ b/sdk/node-ts/native/sandbox_builder.rs @@ -7,8 +7,9 @@ use napi_derive::napi; use microsandbox::sandbox::LogLevel as RustLogLevel; use microsandbox::sandbox::{ CpuPlacement as RustCpuPlacement, DeploymentProfile as RustDeploymentProfile, - PullPolicy as RustPullPolicy, Sandbox as RustSandbox, SandboxBuilder as RustSandboxBuilder, - SecurityProfile as RustSecurityProfile, TransparentHugePagePolicy as RustThpPolicy, + OutboundProxy as RustOutboundProxy, PullPolicy as RustPullPolicy, Sandbox as RustSandbox, + SandboxBuilder as RustSandboxBuilder, SecurityProfile as RustSecurityProfile, + TransparentHugePagePolicy as RustThpPolicy, }; use microsandbox::size::Mebibytes; @@ -19,6 +20,9 @@ use crate::image_builder::JsImageBuilder; use crate::init_options_builder::JsInitOptionsBuilder; use crate::mount_builder::JsMountBuilder; use crate::network_builder::JsNetworkBuilder; +use crate::outbound_proxy_builder::{ + JsOutboundProxyBuilder, JsSocks4ProxyBuilder, JsSocks5ProxyBuilder, take_selected_proxy, +}; use crate::patch_builder::JsPatchBuilder; use crate::pull_progress::JsPullProgressStream; use crate::registry_builder::JsRegistryConfigBuilder; @@ -31,7 +35,14 @@ use crate::tls_builder::JsTlsBuilder; // re-emit references to these classes (otherwise they'd appear as // the Rust struct names in `index.d.ts`). #[allow(dead_code)] -type _NapiHints = (JsDnsBuilder, JsTlsBuilder, JsSecretBuilder); +type _NapiHints = ( + JsDnsBuilder, + JsTlsBuilder, + JsSecretBuilder, + JsOutboundProxyBuilder, + JsSocks4ProxyBuilder, + JsSocks5ProxyBuilder, +); //-------------------------------------------------------------------------------------------------- // Types @@ -46,6 +57,7 @@ type _NapiHints = (JsDnsBuilder, JsTlsBuilder, JsSecretBuilder); #[napi(js_name = "SandboxBuilder")] pub struct JsSandboxBuilder { inner: Option, + outbound_proxy: Option, } //-------------------------------------------------------------------------------------------------- @@ -59,6 +71,7 @@ impl JsSandboxBuilder { pub fn new(name: String) -> Self { Self { inner: Some(microsandbox::Sandbox::builder(name)), + outbound_proxy: None, } } @@ -499,7 +512,31 @@ impl JsSandboxBuilder { let mut returned = configure.call(initial)?; let net_builder = returned.take_inner_builder()?; let prev = self.take_inner(); - self.inner = Some(prev.network(|_default| net_builder)); + let mut next = prev.network(|_default| net_builder); + if let Some(proxy) = self.outbound_proxy.clone() { + next = next.proxy(|_| proxy); + } + self.inner = Some(next); + Ok(self) + } + + /// Configure the single proxy used for outbound sandbox connections. + #[napi( + ts_args_type = "configure: (arg: OutboundProxyBuilder) => Socks4ProxyBuilder | Socks5ProxyBuilder" + )] + pub fn proxy( + &mut self, + env: &Env, + configure: Function, Unknown<'_>>, + ) -> Result<&Self> { + let selector = JsOutboundProxyBuilder::new(); + let selection = selector.selection(); + let initial = selector.into_instance(env)?; + configure.call(initial)?; + let proxy = take_selected_proxy(&selection)?; + let prev = self.take_inner(); + self.inner = Some(prev.proxy(|_| proxy.clone())); + self.outbound_proxy = Some(proxy); Ok(self) } diff --git a/sdk/node-ts/npm/darwin-arm64/package.json b/sdk/node-ts/npm/darwin-arm64/package.json index 9d439dbe1..bf3138275 100644 --- a/sdk/node-ts/npm/darwin-arm64/package.json +++ b/sdk/node-ts/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superradcompany/microsandbox-darwin-arm64", - "version": "0.6.16", + "version": "0.6.17", "description": "Bundled msb + libkrunfw + napi binding for microsandbox on macOS arm64.", "os": ["darwin"], "cpu": ["arm64"], diff --git a/sdk/node-ts/npm/linux-arm64-gnu/package.json b/sdk/node-ts/npm/linux-arm64-gnu/package.json index 88bdd3c94..7074c93ca 100644 --- a/sdk/node-ts/npm/linux-arm64-gnu/package.json +++ b/sdk/node-ts/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@superradcompany/microsandbox-linux-arm64-gnu", - "version": "0.6.16", + "version": "0.6.17", "description": "Bundled msb + libkrunfw + napi binding for microsandbox on Linux arm64 (glibc).", "os": ["linux"], "cpu": ["arm64"], diff --git a/sdk/node-ts/npm/linux-x64-gnu/package.json b/sdk/node-ts/npm/linux-x64-gnu/package.json index 742117420..36a79d876 100644 --- a/sdk/node-ts/npm/linux-x64-gnu/package.json +++ b/sdk/node-ts/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@superradcompany/microsandbox-linux-x64-gnu", - "version": "0.6.16", + "version": "0.6.17", "description": "Bundled msb + libkrunfw + napi binding for microsandbox on Linux x86_64 (glibc).", "os": ["linux"], "cpu": ["x64"], diff --git a/sdk/node-ts/npm/win32-arm64-msvc/package.json b/sdk/node-ts/npm/win32-arm64-msvc/package.json index 9c4bbb0ce..899f3de5d 100644 --- a/sdk/node-ts/npm/win32-arm64-msvc/package.json +++ b/sdk/node-ts/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@superradcompany/microsandbox-win32-arm64-msvc", - "version": "0.6.16", + "version": "0.6.17", "description": "Bundled msb + libkrunfw + napi binding for microsandbox on Windows arm64.", "os": ["win32"], "cpu": ["arm64"], diff --git a/sdk/node-ts/npm/win32-x64-msvc/package.json b/sdk/node-ts/npm/win32-x64-msvc/package.json index 8aca76772..7362c09a6 100644 --- a/sdk/node-ts/npm/win32-x64-msvc/package.json +++ b/sdk/node-ts/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@superradcompany/microsandbox-win32-x64-msvc", - "version": "0.6.16", + "version": "0.6.17", "description": "Bundled msb + libkrunfw + napi binding for microsandbox on Windows x64.", "os": ["win32"], "cpu": ["x64"], diff --git a/sdk/node-ts/package-lock.json b/sdk/node-ts/package-lock.json index fd534c394..8552b53e4 100644 --- a/sdk/node-ts/package-lock.json +++ b/sdk/node-ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "microsandbox", - "version": "0.6.16", + "version": "0.6.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microsandbox", - "version": "0.6.16", + "version": "0.6.17", "license": "Apache-2.0", "bin": { "microsandbox": "bin/microsandbox.cjs", @@ -22,11 +22,11 @@ "node": ">= 22" }, "optionalDependencies": { - "@superradcompany/microsandbox-darwin-arm64": "0.6.16", - "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.16", - "@superradcompany/microsandbox-linux-x64-gnu": "0.6.16", - "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.16", - "@superradcompany/microsandbox-win32-x64-msvc": "0.6.16" + "@superradcompany/microsandbox-darwin-arm64": "0.6.17", + "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.17", + "@superradcompany/microsandbox-linux-x64-gnu": "0.6.17", + "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.17", + "@superradcompany/microsandbox-win32-x64-msvc": "0.6.17" } }, "node_modules/@emnapi/core": { diff --git a/sdk/node-ts/package.json b/sdk/node-ts/package.json index f82aec7b2..62fc8deb7 100644 --- a/sdk/node-ts/package.json +++ b/sdk/node-ts/package.json @@ -1,6 +1,6 @@ { "name": "microsandbox", - "version": "0.6.16", + "version": "0.6.17", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -53,11 +53,11 @@ "vitest": "^4.1" }, "optionalDependencies": { - "@superradcompany/microsandbox-darwin-arm64": "0.6.16", - "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.16", - "@superradcompany/microsandbox-linux-x64-gnu": "0.6.16", - "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.16", - "@superradcompany/microsandbox-win32-x64-msvc": "0.6.16" + "@superradcompany/microsandbox-darwin-arm64": "0.6.17", + "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.17", + "@superradcompany/microsandbox-linux-x64-gnu": "0.6.17", + "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.17", + "@superradcompany/microsandbox-win32-x64-msvc": "0.6.17" }, "files": [ "dist", diff --git a/sdk/node-ts/src/index.ts b/sdk/node-ts/src/index.ts index 48be48c92..f955f44c1 100644 --- a/sdk/node-ts/src/index.ts +++ b/sdk/node-ts/src/index.ts @@ -18,6 +18,8 @@ export { withDefaultBackend, } from "./runtime.js"; export type { BackendInfo, DefaultBackend } from "./runtime.js"; +export { SecretSource } from "./network-config.js"; +export type { OutboundProxy } from "./network-config.js"; export type { DeploymentProfile } from "./deployment-profile.js"; // Sandbox lifecycle and execution @@ -358,6 +360,9 @@ export const DnsBuilder = napi.DnsBuilder; export const TlsBuilder = napi.TlsBuilder; export const SecretBuilder = napi.SecretBuilder; export const NetworkBuilder = napi.NetworkBuilder; +export const OutboundProxyBuilder = napi.OutboundProxyBuilder; +export const Socks4ProxyBuilder = napi.Socks4ProxyBuilder; +export const Socks5ProxyBuilder = napi.Socks5ProxyBuilder; export const MountBuilder = napi.MountBuilder; export const PatchBuilder = napi.PatchBuilder; export const RegistryConfigBuilder = napi.RegistryConfigBuilder; @@ -369,10 +374,16 @@ export const InitOptionsBuilder = napi.InitOptionsBuilder; export const AttachOptionsBuilder = napi.AttachOptionsBuilder; import type { NapiNetworkPolicyBuilder, + NapiOutboundProxyBuilder, NapiRootDiskBuilder, NapiRuleBuilder, NapiRuleDestinationBuilder, + NapiSocks4ProxyBuilder, + NapiSocks5ProxyBuilder, } from "./internal/napi.js"; +export type OutboundProxyBuilder = NapiOutboundProxyBuilder; +export type Socks4ProxyBuilder = NapiSocks4ProxyBuilder; +export type Socks5ProxyBuilder = NapiSocks5ProxyBuilder; export const NetworkPolicyBuilder = napi.NetworkPolicyBuilder; export type NetworkPolicyBuilder = NapiNetworkPolicyBuilder; export const RuleBuilder = napi.RuleBuilder; diff --git a/sdk/node-ts/src/internal/napi.ts b/sdk/node-ts/src/internal/napi.ts index cb1967c86..a1278034c 100644 --- a/sdk/node-ts/src/internal/napi.ts +++ b/sdk/node-ts/src/internal/napi.ts @@ -54,6 +54,9 @@ export interface NativeBindings { readonly SecretBuilder: NapiBuilderCtor; readonly ViolationActionBuilder: NapiBuilderCtor; readonly NetworkBuilder: NapiBuilderCtor; + readonly OutboundProxyBuilder: NapiBuilderCtor; + readonly Socks4ProxyBuilder: { prototype: NapiSocks4ProxyBuilder }; + readonly Socks5ProxyBuilder: { prototype: NapiSocks5ProxyBuilder }; readonly NetworkPolicyBuilder: NapiBuilderCtor; readonly RuleBuilder: NapiBuilderCtor; readonly RuleDestinationBuilder: NapiBuilderCtor; @@ -212,6 +215,11 @@ export interface NapiSandboxBuilderSetters { disableNetwork(): this; // eslint-disable-next-line @typescript-eslint/no-explicit-any network(configure: (b: any) => any): this; + proxy( + configure: ( + b: NapiOutboundProxyBuilder, + ) => NapiSocks4ProxyBuilder | NapiSocks5ProxyBuilder, + ): this; port(host: number, guest: number): this; portBind(bind: string, host: number, guest: number): this; portUdp(host: number, guest: number): this; @@ -994,6 +1002,22 @@ export interface NapiNetworkBuilder { build(): NetworkConfig; } +export interface NapiOutboundProxyBuilder { + socks4(address: string): NapiSocks4ProxyBuilder; + socks5(address: string): NapiSocks5ProxyBuilder; +} + +export interface NapiSocks4ProxyBuilder { + userId(userId: string): this; +} + +export interface NapiSocks5ProxyBuilder { + credentials( + username: string, + password: { kind: "env"; var: string }, + ): this; +} + export interface NapiRateLimiterBuilder { bandwidth(sizeBytes: number, refillTimeMs: number): this; bandwidthBurst(sizeBytes: number): this; @@ -1006,6 +1030,7 @@ export interface NapiNetworkRateLimiterBuilder { ingress(configure: (b: NapiRateLimiterBuilder) => NapiRateLimiterBuilder): this; } + export interface NapiInterfaceOverridesBuilder { mac(mac: string): this; mtu(mtu: number): this; diff --git a/sdk/node-ts/src/network-config.ts b/sdk/node-ts/src/network-config.ts index f2a34d90b..6511660ee 100644 --- a/sdk/node-ts/src/network-config.ts +++ b/sdk/node-ts/src/network-config.ts @@ -68,6 +68,20 @@ export interface SecretInjection { readonly body?: boolean; } +/** Host-side source for secret material. */ +export interface SecretSource { + readonly kind: "env"; + readonly var: string; +} + +/** Constructors for host-side secret sources. */ +export const SecretSource = { + /** Resolve the secret from this host environment variable at sandbox start. */ + env(variable: string): SecretSource { + return { kind: "env", var: variable }; + }, +} as const; + /** A single secret entry — built via `SecretBuilder`. */ export interface SecretEntry { readonly envVar: string; @@ -80,6 +94,22 @@ export interface SecretEntry { readonly injection: SecretInjection; } +/** Proxy used for outbound sandbox connections. */ +export type OutboundProxy = + | { + readonly protocol: "socks4"; + readonly address: string; + readonly userId?: string; + } + | { + readonly protocol: "socks5"; + readonly address: string; + readonly credentials?: { + readonly username: string; + readonly password: SecretSource; + }; + }; + /** Built network configuration produced by `NetworkBuilder.build()`. */ export interface NetworkConfig { readonly enabled: boolean; @@ -100,4 +130,6 @@ export interface NetworkConfig { readonly mtu?: number | null; }; readonly trustHostCAs: boolean; + /** Canonical proxy configuration for outbound connections. */ + readonly outboundProxy: OutboundProxy | null; } diff --git a/sdk/node-ts/tests/unit/builders.test.ts b/sdk/node-ts/tests/unit/builders.test.ts index 587bb4b64..59bc43113 100644 --- a/sdk/node-ts/tests/unit/builders.test.ts +++ b/sdk/node-ts/tests/unit/builders.test.ts @@ -12,6 +12,7 @@ import { RootDiskBuilder, Sandbox, SecretBuilder, + SecretSource, Stdin, } from "../../dist/index.js"; @@ -381,6 +382,60 @@ describe("SandboxBuilder.build", () => { expect((cfg.resources as { thp: string }).thp).toBe("always"); }); + it("renders a configured outbound proxy in canonical form", async () => { + const cfg = await Sandbox.builder("x") + .image("alpine") + .proxy((p) => p.socks5("127.0.0.1:1080")) + .network((n) => n.maxConnections(64)) + .build(); + + expect(cfg.network).toMatchObject({ + outboundProxy: { + protocol: "socks5", + address: "127.0.0.1:1080", + }, + maxConnections: 64, + }); + }); + + it("renders SOCKS5 credentials in canonical form", async () => { + const cfg = await Sandbox.builder("x") + .image("alpine") + .proxy((p) => + p + .socks5("127.0.0.1:1080") + .credentials("sandbox", SecretSource.env("SOCKS5_PASSWORD")), + ) + .build(); + + expect(cfg.network?.outboundProxy).toEqual({ + protocol: "socks5", + address: "127.0.0.1:1080", + credentials: { + username: "sandbox", + password: { + kind: "env", + var: "SOCKS5_PASSWORD", + }, + }, + }); + }); + + it("renders a SOCKS4 proxy with an optional user ID", async () => { + const cfg = await Sandbox.builder("x") + .image("alpine") + .proxy((p) => p.socks4("127.0.0.1:1080").userId("sandbox")) + .build(); + + expect(cfg.network).toMatchObject({ + outboundProxy: { + protocol: "socks4", + address: "127.0.0.1:1080", + userId: "sandbox", + }, + }); + }); + it("collects volumes through the MountBuilder callback", async () => { const cfg = await Sandbox.builder("x") .image("alpine") @@ -652,6 +707,22 @@ describe("NetworkBuilder ports", () => { }); }); +describe("SandboxBuilder outbound proxy", () => { + it("rejects invalid addresses", () => { + expect(() => + Sandbox.builder("x").proxy((p) => p.socks5("not-an-address")), + ).toThrow(/invalid SOCKS5 proxy address/); + }); + + it("rejects invalid SOCKS4 user IDs", () => { + expect(() => + Sandbox.builder("x").proxy((p) => + p.socks4("127.0.0.1:1080").userId(""), + ), + ).toThrow(/invalid SOCKS4 user ID/); + }); +}); + describe("NetworkBuilder rate limiters", () => { it("maps bucket values through build()", () => { const cfg = new NetworkBuilder() diff --git a/sdk/node-ts/tests/unit/package-exports.test.ts b/sdk/node-ts/tests/unit/package-exports.test.ts new file mode 100644 index 000000000..0ea38c3a9 --- /dev/null +++ b/sdk/node-ts/tests/unit/package-exports.test.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const PACKAGE_ROOT = fileURLToPath(new URL("../..", import.meta.url)); + +function loadPackageRoot(inputType: "commonjs" | "module", source: string): string { + return execFileSync(process.execPath, [`--input-type=${inputType}`, "--eval", source], { + cwd: PACKAGE_ROOT, + encoding: "utf8", + }); +} + +describe("package root exports", () => { + it("loads through ESM import", () => { + const output = loadPackageRoot( + "module", + 'import { Sandbox } from "microsandbox"; process.stdout.write(typeof Sandbox);', + ); + expect(output).toBe("function"); + }); + + it("loads through CommonJS require", () => { + const output = loadPackageRoot( + "commonjs", + 'const { Sandbox } = require("microsandbox"); process.stdout.write(typeof Sandbox);', + ); + expect(output).toBe("function"); + }); +}); diff --git a/sdk/python/microsandbox/__init__.py b/sdk/python/microsandbox/__init__.py index 02b99f9cc..9b683ccec 100644 --- a/sdk/python/microsandbox/__init__.py +++ b/sdk/python/microsandbox/__init__.py @@ -131,6 +131,7 @@ NetworkPolicy, NetworkProfile, NetworkRateLimiter, + OutboundProxy, Patch, PatchConfig, PatchKind, @@ -162,6 +163,7 @@ SecretInjection, SecretModifySpec, SecretPlannedChange, + SecretSource, SecurityProfile, Size, SnapshotFormat, @@ -271,6 +273,8 @@ "NetworkPolicy", "NetworkProfile", "NetworkRateLimiter", + "OutboundProxy", + "SecretSource", "Rule", "Destination", "NetworkDestination", diff --git a/sdk/python/microsandbox/types.py b/sdk/python/microsandbox/types.py index 4efab23c3..84a03e488 100644 --- a/sdk/python/microsandbox/types.py +++ b/sdk/python/microsandbox/types.py @@ -1541,6 +1541,79 @@ def _to_dict(self) -> dict: } +@dataclass(frozen=True, slots=True) +class SecretSource: + """Host-side source for secret material.""" + + kind: Literal["env"] + var: str + + def __post_init__(self) -> None: + if self.kind != "env": + raise ValueError("only environment-backed secret sources are supported") + if not self.var: + raise ValueError("secret source environment variable must not be empty") + + @classmethod + def env(cls, variable: str) -> SecretSource: + """Resolve the secret from this host environment variable.""" + return cls(kind="env", var=variable) + + def _to_dict(self) -> dict: + return {"kind": self.kind, "var": self.var} + + +@dataclass(frozen=True, slots=True) +class OutboundProxy: + """Proxy used for outbound sandbox connections.""" + + protocol: Literal["socks4", "socks5"] + address: str + user_id: str | None = None + username: str | None = None + password: SecretSource | None = None + + def __post_init__(self) -> None: + if self.protocol != "socks4" and self.user_id is not None: + raise ValueError("user_id is only supported for SOCKS4 proxies") + if self.protocol != "socks5" and (self.username is not None or self.password is not None): + raise ValueError("credentials are only supported for SOCKS5 proxies") + if (self.username is None) != (self.password is None): + raise ValueError("SOCKS5 username and password must be provided together") + + @classmethod + def socks4(cls, address: str, *, user_id: str | None = None) -> OutboundProxy: + """Create a SOCKS4 outbound proxy.""" + return cls(protocol="socks4", address=address, user_id=user_id) + + @classmethod + def socks5(cls, address: str) -> OutboundProxy: + """Create a SOCKS5 outbound proxy.""" + return cls(protocol="socks5", address=address) + + def credentials(self, username: str, password: SecretSource) -> OutboundProxy: + """Set username authentication and a host-side password source.""" + if self.protocol != "socks5": + raise ValueError("credentials are only supported for SOCKS5 proxies") + return OutboundProxy( + protocol=self.protocol, + address=self.address, + username=username, + password=password, + ) + + def _to_dict(self) -> dict: + value = {"protocol": self.protocol, "address": self.address} + if self.user_id is not None: + value["user_id"] = self.user_id + if self.username is not None and self.password is not None: + value["credentials"] = { + "username": self.username, + "password": self.password._to_dict(), + } + return value + + @dataclass(frozen=True, slots=True) class TokenBucket: """One token bucket of a rate limiter. diff --git a/sdk/python/src/helpers.rs b/sdk/python/src/helpers.rs index 6c9d9fab0..63dac8190 100644 --- a/sdk/python/src/helpers.rs +++ b/sdk/python/src/helpers.rs @@ -1,6 +1,6 @@ use microsandbox::sandbox::{ CpuPlacement, DeploymentProfile, NetworkPolicy, Patch, PullPolicy, SandboxBuilder, - SecurityProfile, TransparentHugePagePolicy, + SecretSource, SecurityProfile, TransparentHugePagePolicy, }; use microsandbox::{LogLevel, RegistryAuth}; use microsandbox_network::dns::Nameserver; @@ -50,6 +50,7 @@ const KNOWN_CREATE_KWARGS: &[&str] = &[ "ports", "vsock", "network", + "proxy", "secrets", "on_secret_violation", "detached", @@ -568,6 +569,69 @@ pub fn sandbox_builder_from_args( builder = apply_network(builder, &net_dict)?; } + // Outbound proxy. + if let Some(proxy) = kwargs.get_item("proxy")? + && !proxy.is_none() + { + let proxy = config_dict(&proxy, "OutboundProxy")?; + let protocol = extract_required::(&proxy, "protocol")?; + let address = extract_required::(&proxy, "address")?; + builder = match protocol.as_str() { + "socks4" => { + let user_id = extract_opt::(&proxy, "user_id")?; + builder.proxy(move |p| { + let proxy = p.socks4(address); + match user_id { + Some(user_id) => proxy.user_id(user_id), + None => proxy, + } + }) + } + "socks5" => { + let credentials = proxy + .get_item("credentials")? + .filter(|value| !value.is_none()) + .map(|value| config_dict(&value, "SOCKS5 credentials")) + .transpose()?; + let username = credentials + .as_ref() + .map(|value| extract_required::(value, "username")) + .transpose()?; + let password = credentials + .as_ref() + .map(|value| { + let password = value.get_item("password")?.ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "SOCKS5 credentials requires password", + ) + })?; + let source = config_dict(&password, "SOCKS5 password source")?; + let kind = extract_required::(&source, "kind")?; + if kind != "env" { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "unsupported SOCKS5 password source {kind:?}; only env is supported" + ))); + } + let var = extract_required::(&source, "var")?; + Ok(SecretSource::env(var)) + }) + .transpose()?; + builder.proxy(move |p| { + let proxy = p.socks5(address); + match (username, password) { + (Some(username), Some(password)) => proxy.credentials(username, password), + _ => proxy, + } + }) + } + _ => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "unsupported outbound proxy protocol {protocol:?}" + ))); + } + }; + } + // Secrets. if let Some(secrets) = kwargs.get_item("secrets")?.filter(|v| !v.is_none()) { let secrets_iter = secrets.try_iter().map_err(|_| { diff --git a/sdk/python/tests/test_outbound_proxy.py b/sdk/python/tests/test_outbound_proxy.py new file mode 100644 index 000000000..eda9271e5 --- /dev/null +++ b/sdk/python/tests/test_outbound_proxy.py @@ -0,0 +1,82 @@ +"""Tests for outbound proxy configuration.""" + +import pytest + +from microsandbox import OutboundProxy, Sandbox, SecretSource + + +def test_socks5_proxy_serializes_as_structured_config() -> None: + proxy = OutboundProxy.socks5("127.0.0.1:1080") + + assert proxy._to_dict() == { + "protocol": "socks5", + "address": "127.0.0.1:1080", + } + + +def test_socks5_proxy_serializes_environment_backed_credentials() -> None: + proxy = OutboundProxy.socks5("127.0.0.1:1080").credentials( + "sandbox", SecretSource.env("SOCKS5_PASSWORD") + ) + + assert proxy._to_dict() == { + "protocol": "socks5", + "address": "127.0.0.1:1080", + "credentials": { + "username": "sandbox", + "password": { + "kind": "env", + "var": "SOCKS5_PASSWORD", + }, + }, + } + + +def test_socks4_proxy_serializes_optional_user_id() -> None: + assert OutboundProxy.socks4("127.0.0.1:1080")._to_dict() == { + "protocol": "socks4", + "address": "127.0.0.1:1080", + } + assert OutboundProxy.socks4( + "127.0.0.1:1080", user_id="sandbox" + )._to_dict() == { + "protocol": "socks4", + "address": "127.0.0.1:1080", + "user_id": "sandbox", + } + + +def test_socks5_proxy_rejects_socks4_user_id() -> None: + with pytest.raises(ValueError, match="only supported for SOCKS4"): + OutboundProxy(protocol="socks5", address="127.0.0.1:1080", user_id="sandbox") + + +def test_socks4_proxy_rejects_socks5_credentials() -> None: + with pytest.raises(ValueError, match="only supported for SOCKS5"): + OutboundProxy.socks4("127.0.0.1:1080").credentials( + "sandbox", SecretSource.env("SOCKS5_PASSWORD") + ) + + +def _native_create_error(**kwargs: object) -> Exception: + try: + Sandbox.create("proxy-parse-probe", image="alpine", **kwargs) + except Exception as exc: + return exc + raise AssertionError("expected Sandbox.create to raise outside an event loop") + + +def test_native_create_accepts_top_level_proxy() -> None: + baseline = _native_create_error() + error = _native_create_error(proxy=OutboundProxy.socks5("127.0.0.1:1080")) + + assert type(error) is type(baseline), f"top-level proxy rejected: {error!r}" + + +def test_native_create_accepts_socks4_proxy() -> None: + baseline = _native_create_error() + error = _native_create_error( + proxy=OutboundProxy.socks4("127.0.0.1:1080", user_id="sandbox") + ) + + assert type(error) is type(baseline), f"top-level SOCKS4 proxy rejected: {error!r}" diff --git a/sdk/ruby/Rakefile b/sdk/ruby/Rakefile index ac539b3ef..17ce0fea9 100644 --- a/sdk/ruby/Rakefile +++ b/sdk/ruby/Rakefile @@ -129,6 +129,12 @@ namespace :cargo do microsandbox = { path = "../rust" } TOML File.write(PATCH_STATE, patch_config_digest) + + # tinyvec 1.13.0 does not import the vec! macro for alloc-only builds. + # Keep the temporary patched graph on the version already proven by the + # standalone lockfile until an upstream fix supersedes the broken release. + sh "cargo", "update", "--manifest-path", "ext/microsandbox/Cargo.toml", + "-p", "tinyvec", "--precise", "1.12.0" end desc "Undo cargo:patch_workspace: drop the patch config, restore the lockfile" diff --git a/sdk/ruby/ext/microsandbox/Cargo.toml b/sdk/ruby/ext/microsandbox/Cargo.toml index fe38cd69d..d8a839ad5 100644 --- a/sdk/ruby/ext/microsandbox/Cargo.toml +++ b/sdk/ruby/ext/microsandbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "microsandbox-ruby" -version = "0.6.16" +version = "0.6.17" edition = "2024" rust-version = "1.85" description = "Ruby bindings for the microsandbox Rust SDK." @@ -22,5 +22,5 @@ strip = false [dependencies] magnus = "0.8.2" -microsandbox_core = { package = "microsandbox", version = "=0.6.16", default-features = false, features = ["net", "ssh"] } +microsandbox_core = { package = "microsandbox", version = "=0.6.17", default-features = false, features = ["net", "ssh"] } tokio = { version = "1.52", features = ["rt-multi-thread", "time"] } diff --git a/sdk/ruby/ext/microsandbox/src/lib.rs b/sdk/ruby/ext/microsandbox/src/lib.rs index 266313aa8..ba747a15e 100644 --- a/sdk/ruby/ext/microsandbox/src/lib.rs +++ b/sdk/ruby/ext/microsandbox/src/lib.rs @@ -27,6 +27,7 @@ use microsandbox_core::{ NetworkPolicy, PullPolicy, RestartOptions, RlimitResource, Sandbox as CoreSandbox, SandboxBuilder, SandboxFsOps, SandboxHandle as CoreSandboxHandle, SandboxMetrics, SandboxPage, SandboxPingResult, SandboxStatus, SandboxStopResult, SandboxTouchResult, + SecretSource, }, snapshot::{Snapshot, SnapshotHandle}, volume::{Volume, VolumeHandle, VolumeKind}, @@ -430,6 +431,39 @@ fn apply_secret_options( Ok(builder) } +#[derive(Clone)] +enum RubyOutboundProxyConfig { + Socks4 { + address: String, + user_id: Option, + }, + Socks5 { + address: String, + credentials: Option<(String, SecretSource)>, + }, +} + +fn apply_outbound_proxy(builder: SandboxBuilder, proxy: &RubyOutboundProxy) -> SandboxBuilder { + match proxy.inner.borrow().clone() { + RubyOutboundProxyConfig::Socks4 { + address, + user_id: Some(user_id), + } => builder.proxy(|proxy| proxy.socks4(address).user_id(user_id)), + RubyOutboundProxyConfig::Socks4 { + address, + user_id: None, + } => builder.proxy(|proxy| proxy.socks4(address)), + RubyOutboundProxyConfig::Socks5 { + address, + credentials: Some((username, password)), + } => builder.proxy(|proxy| proxy.socks5(address).credentials(username, password)), + RubyOutboundProxyConfig::Socks5 { + address, + credentials: None, + } => builder.proxy(|proxy| proxy.socks5(address)), + } +} + // ------------------------------------------------------------------------------------------------- // Duration / timeout // ------------------------------------------------------------------------------------------------- @@ -567,6 +601,7 @@ fn apply_builder_options( "root_disk", "disable_network", "network", + "proxy", "secrets", "quiet_logs", "entrypoint", @@ -645,6 +680,9 @@ fn apply_builder_options( builder = builder.network(|n| n.policy(policy)); } } + if let Some(proxy) = keyword::>(kwargs, "proxy")? { + builder = apply_outbound_proxy(builder, &proxy); + } if let Some(v) = kwargs.get(symbol("secrets")) { builder = apply_secret_options(ruby, builder, v)?; } @@ -777,6 +815,16 @@ struct RubySandboxBuilder { inner: std::cell::RefCell>, } +#[magnus::wrap(class = "Microsandbox::OutboundProxy", free_immediately, size)] +struct RubyOutboundProxy { + inner: std::cell::RefCell, +} + +#[magnus::wrap(class = "Microsandbox::SecretSource", free_immediately, size)] +struct RubySecretSource { + inner: SecretSource, +} + #[magnus::wrap(class = "Microsandbox::ExecOutput", free_immediately, size)] struct RubyExecOutput { inner: ExecOutput, @@ -907,6 +955,12 @@ impl RubySandboxBuilder { fn init(this: typed_data::Obj, v: String) -> Result<(), Error> { put_builder(&this, |b| b.init(v)) } + fn proxy( + this: typed_data::Obj, + proxy: typed_data::Obj, + ) -> Result<(), Error> { + put_builder(&this, |builder| apply_outbound_proxy(builder, &proxy)) + } fn vsock(this: typed_data::Obj, host_path: String, port: u32) -> Result<(), Error> { put_builder(&this, |b| b.vsock(host_path, port)) } @@ -1733,6 +1787,72 @@ fn sandbox_builder(name: String) -> RubySandboxBuilder { } } +fn outbound_proxy_socks4(address: String) -> RubyOutboundProxy { + RubyOutboundProxy { + inner: std::cell::RefCell::new(RubyOutboundProxyConfig::Socks4 { + address, + user_id: None, + }), + } +} + +fn outbound_proxy_socks5(address: String) -> RubyOutboundProxy { + RubyOutboundProxy { + inner: std::cell::RefCell::new(RubyOutboundProxyConfig::Socks5 { + address, + credentials: None, + }), + } +} + +fn secret_source_env(ruby: &Ruby, variable: String) -> Result { + if variable.is_empty() { + return Err(argument_error( + ruby, + "secret source environment variable must not be empty", + )); + } + Ok(RubySecretSource { + inner: SecretSource::env(variable), + }) +} + +impl RubyOutboundProxy { + fn user_id(ruby: &Ruby, this: typed_data::Obj, user_id: String) -> Result<(), Error> { + match &mut *this.inner.borrow_mut() { + RubyOutboundProxyConfig::Socks4 { + user_id: configured, + .. + } => { + *configured = Some(user_id); + Ok(()) + } + RubyOutboundProxyConfig::Socks5 { .. } => Err(argument_error( + ruby, + "user_id is only supported for SOCKS4 proxies", + )), + } + } + + fn credentials( + ruby: &Ruby, + this: typed_data::Obj, + username: String, + password: typed_data::Obj, + ) -> Result<(), Error> { + match &mut *this.inner.borrow_mut() { + RubyOutboundProxyConfig::Socks4 { .. } => Err(argument_error( + ruby, + "credentials are only supported for SOCKS5 proxies", + )), + RubyOutboundProxyConfig::Socks5 { credentials, .. } => { + *credentials = Some((username, password.inner.clone())); + Ok(()) + } + } + } +} + fn sandbox_create(ruby: &Ruby, args: &[Value]) -> Result { let parsed = scan_args::<(String,), (), (), (), RHash, ()>(args)?; let builder = apply_builder_options( @@ -2185,6 +2305,16 @@ fn init(ruby: &Ruby) -> Result<(), Error> { function!(set_default_backend_profile, 1), )?; + // -- Proxy --------------------------------------------------------------- + let secret_source = module.define_class("SecretSource", ruby.class_object())?; + secret_source.define_singleton_method("env", function!(secret_source_env, 1))?; + + let outbound_proxy = module.define_class("OutboundProxy", ruby.class_object())?; + outbound_proxy.define_singleton_method("socks4", function!(outbound_proxy_socks4, 1))?; + outbound_proxy.define_singleton_method("socks5", function!(outbound_proxy_socks5, 1))?; + outbound_proxy.define_method("user_id!", method!(RubyOutboundProxy::user_id, 1))?; + outbound_proxy.define_method("credentials!", method!(RubyOutboundProxy::credentials, 2))?; + // -- Sandbox ------------------------------------------------------------- let sandbox = module.define_class("Sandbox", ruby.class_object())?; sandbox.define_singleton_method("builder", function!(sandbox_builder, 1))?; @@ -2317,6 +2447,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { builder.define_method("quiet_logs!", method!(RubySandboxBuilder::quiet_logs, 0))?; builder.define_method("entrypoint!", method!(RubySandboxBuilder::entrypoint, 1))?; builder.define_method("init!", method!(RubySandboxBuilder::init, 1))?; + builder.define_method("proxy!", method!(RubySandboxBuilder::proxy, 1))?; builder.define_method("vsock!", method!(RubySandboxBuilder::vsock, 2))?; builder.define_method("vsock_dgram!", method!(RubySandboxBuilder::vsock_dgram, 2))?; builder.define_method("create", method!(RubySandboxBuilder::create, 0))?; diff --git a/sdk/ruby/lib/microsandbox.rb b/sdk/ruby/lib/microsandbox.rb index 7802663c1..c3c53ceef 100644 --- a/sdk/ruby/lib/microsandbox.rb +++ b/sdk/ruby/lib/microsandbox.rb @@ -22,7 +22,7 @@ class SandboxBuilder %i[ image cpus max_cpus memory max_memory workdir shell hostname user detached ephemeral max_duration idle_timeout replace root_disk - disable_network quiet_logs entrypoint init vsock vsock_dgram + disable_network quiet_logs entrypoint init proxy vsock vsock_dgram ].each do |name| define_method(name) do |*args| public_send(:"#{name}!", *args) @@ -46,6 +46,18 @@ def replace_with_timeout(seconds) end end + class OutboundProxy + def user_id(value) + user_id!(value) + self + end + + def credentials(username, password) + credentials!(username, password) + self + end + end + class Filesystem def initialize(sandbox) @sandbox = sandbox diff --git a/sdk/ruby/lib/microsandbox/version.rb b/sdk/ruby/lib/microsandbox/version.rb index 29eefa3e6..f68e7be88 100644 --- a/sdk/ruby/lib/microsandbox/version.rb +++ b/sdk/ruby/lib/microsandbox/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Microsandbox - VERSION = "0.6.16" + VERSION = "0.6.17" end diff --git a/sdk/ruby/test/microsandbox_test.rb b/sdk/ruby/test/microsandbox_test.rb index 61b3afea5..1e4c36a07 100644 --- a/sdk/ruby/test/microsandbox_test.rb +++ b/sdk/ruby/test/microsandbox_test.rb @@ -14,6 +14,9 @@ def test_installation_probe_returns_boolean end def test_builder_configuration_is_chainable + proxy = Microsandbox::OutboundProxy.socks5("127.0.0.1:1080") + .credentials("sandbox", Microsandbox::SecretSource.env("SOCKS5_PASSWORD")) + builder = Microsandbox::Sandbox.builder("ruby-test") .image("alpine") .cpus(2) @@ -21,12 +24,60 @@ def test_builder_configuration_is_chainable .env("GREETING", "hello") .label("suite", "ruby") .workdir("/tmp") + .proxy(proxy) .vsock("/run/host-api.sock", 5000) .vsock_dgram("/run/events.sock", 5001) assert_instance_of Microsandbox::SandboxBuilder, builder end + def test_socks4_proxy_user_id_is_chainable + proxy = Microsandbox::OutboundProxy.socks4("127.0.0.1:1080").user_id("sandbox") + + assert_instance_of Microsandbox::OutboundProxy, proxy + end + + def test_proxy_authentication_is_protocol_specific + password = Microsandbox::SecretSource.env("SOCKS5_PASSWORD") + + assert_raise(ArgumentError) do + Microsandbox::OutboundProxy.socks4("127.0.0.1:1080").credentials("sandbox", password) + end + assert_raise(ArgumentError) do + Microsandbox::OutboundProxy.socks5("127.0.0.1:1080").user_id("sandbox") + end + end + + def test_secret_source_rejects_an_empty_environment_variable + assert_raise(ArgumentError) { Microsandbox::SecretSource.env("") } + end + + def test_create_accepts_proxy_keyword + proxy = Microsandbox::OutboundProxy.socks5("not-an-address") + + error = assert_raise(Microsandbox::Error) do + Microsandbox::Sandbox.create("ruby-test", proxy: proxy) + end + + assert_match(/invalid SOCKS5 proxy address/, error.message) + end + + def test_create_applies_protocol_specific_proxy_authentication + socks4 = Microsandbox::OutboundProxy.socks4("127.0.0.1:1080").user_id("") + socks4_error = assert_raise(Microsandbox::Error) do + Microsandbox::Sandbox.create("ruby-test", proxy: socks4) + end + + password = Microsandbox::SecretSource.env("SOCKS5_PASSWORD") + socks5 = Microsandbox::OutboundProxy.socks5("127.0.0.1:1080").credentials("", password) + socks5_error = assert_raise(Microsandbox::Error) do + Microsandbox::Sandbox.create("ruby-test", proxy: socks5) + end + + assert_match(/invalid SOCKS4 user ID/, socks4_error.message) + assert_match(/invalid SOCKS5 credentials/, socks5_error.message) + end + def test_unknown_create_keyword_is_rejected_before_runtime_start error = assert_raise(ArgumentError) do Microsandbox::Sandbox.create("ruby-test", unsupported_option: true) diff --git a/sdk/rust/lib/backend/cloud/sandbox.rs b/sdk/rust/lib/backend/cloud/sandbox.rs index 5938b0466..6f24f4349 100644 --- a/sdk/rust/lib/backend/cloud/sandbox.rs +++ b/sdk/rust/lib/backend/cloud/sandbox.rs @@ -511,6 +511,9 @@ fn reject_dropped_cloud_create_fields(config: &SandboxConfig) -> MicrosandboxRes if config.spec.network.rate_limiter.is_some() { return Err(unsupported("network.rate_limiter")); } + if config.spec.network.outbound_proxy.is_some() { + return Err(unsupported("network.outbound_proxy")); + } if config .spec @@ -1182,6 +1185,18 @@ mod tests { assert!(matches!(err, MicrosandboxError::Unsupported { .. })); } + #[cfg(feature = "net")] + #[test] + fn cloud_create_request_rejects_outbound_proxy() { + let mut config = base_cloud_config(); + config.spec.network.outbound_proxy = Some(microsandbox_types::OutboundProxy::Socks5 { + address: "127.0.0.1:1080".to_string(), + credentials: None, + }); + + assert_unsupported_config_field(config, "network.outbound_proxy"); + } + #[test] fn sandbox_config_from_cloud_round_trips_d13_fields() { // The cloud response carries the wire `CloudSandboxSpec`, which converts diff --git a/sdk/rust/lib/backend/local/mod.rs b/sdk/rust/lib/backend/local/mod.rs index b01779307..a166ab291 100644 --- a/sdk/rust/lib/backend/local/mod.rs +++ b/sdk/rust/lib/backend/local/mod.rs @@ -1126,6 +1126,73 @@ mod tests { assert!(err.to_string().contains("database schema is newer")); } + #[tokio::test] + async fn test_connect_and_migrate_upgrades_v0_6_15_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let db_dir = tmp.path().join("db"); + let db_path = db_dir.join(microsandbox_utils::DB_FILENAME); + std::fs::create_dir_all(&db_dir).unwrap(); + + let db = microsandbox_db::connection::DbWriteConnection::open( + &db_path, + Duration::from_secs(5), + Duration::from_secs(5), + ) + .await + .unwrap(); + let released_prefix_len = schema_metadata::migration_ids() + .position(|id| id == schema_metadata::SHARED_CPU_ALLOCATION_MIGRATION_ID) + .unwrap() + + 1; + Migrator::up(db.inner(), Some(released_prefix_len as u32)) + .await + .unwrap(); + + // v0.6.15 ended with this schema-free compatibility marker. Insert + // its migration row to reproduce a database last opened by v0.6.15. + db.inner() + .execute_raw(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO seaql_migrations (version, applied_at) VALUES (?, ?)", + [ + schema_metadata::MOUNT_OWNER_CONFIG_MIGRATION_ID.into(), + 1_i64.into(), + ], + )) + .await + .unwrap(); + drop(db); + + let pools = connect_and_migrate( + &db_dir, + &DatabaseConfig::default(), + &tmp.path().join("snapshots"), + ) + .await + .unwrap(); + let network_slot_migration = pools + .read() + .query_one_raw(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "SELECT version FROM seaql_migrations WHERE version = ?", + [schema_metadata::SANDBOX_NETWORK_SLOT_MIGRATION_ID.into()], + )) + .await + .unwrap(); + let network_slot_column = pools + .read() + .query_one_raw(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT name FROM pragma_table_info('sandbox') WHERE name = 'network_slot'" + .to_owned(), + )) + .await + .unwrap(); + + assert!(network_slot_migration.is_some()); + assert!(network_slot_column.is_some()); + } + #[tokio::test] async fn test_connect_and_migrate_accepts_equal_migration_timestamps() { let tmp = tempfile::tempdir().unwrap(); diff --git a/sdk/rust/lib/lib.rs b/sdk/rust/lib/lib.rs index e071c8949..454597bcb 100644 --- a/sdk/rust/lib/lib.rs +++ b/sdk/rust/lib/lib.rs @@ -71,8 +71,8 @@ pub use sandbox::{ #[cfg(feature = "net")] pub use sandbox::{ DnsConfigPatch, HostPattern, InterfaceOverridesPatch, Nameserver, NetworkAction, NetworkPolicy, - NetworkProfile, NetworkRateLimiterConfigPatch, NetworkRule, PublishedPort, SecretInjection, - SecretsConfigPatch, TlsConfigPatch, + NetworkProfile, NetworkRateLimiterConfigPatch, NetworkRule, OutboundProxy, PublishedPort, + SecretInjection, SecretsConfigPatch, Socks5Credentials, TlsConfigPatch, }; pub use snapshot::{ CheckpointSnapshotState, FileSnapshotState, SaveOpts, Snapshot, SnapshotBuilder, diff --git a/sdk/rust/lib/runtime/spawn.rs b/sdk/rust/lib/runtime/spawn.rs index e5928c289..c0a1d1ee5 100644 --- a/sdk/rust/lib/runtime/spawn.rs +++ b/sdk/rust/lib/runtime/spawn.rs @@ -56,6 +56,8 @@ use windows_sys::Win32::System::Threading::{ use microsandbox_image::{Digest, GlobalCache}; use microsandbox_metrics::{MetricsRegistry, ReserveSlot, SlotReservation}; +#[cfg(feature = "net")] +use microsandbox_network::{ResolvedNetworkConfig, config::EnvNetworkSecretResolver}; use microsandbox_protocol::{ bootstrap::{ BootstrapBlockRoot, BootstrapBlockRootUpper, BootstrapDirMount, BootstrapDiskMount, @@ -277,14 +279,14 @@ pub async fn spawn_sandbox( mode: SpawnMode, lifecycle_guard: Option, ) -> MicrosandboxResult<(ProcessHandle, PathBuf)> { - // Reference-model secrets store only a host-side source reference in the - // durable config; resolve the actual values now so they travel to the - // sandbox process on the private launch-config fd without ever being - // persisted. - #[cfg(feature = "net")] - let resolved_config = crate::sandbox::config::resolve_config_secret_sources(config)?; + // Durable configuration stores only host-side source references. Resolve + // them into the private runtime configuration before the sandbox process + // is spawned. #[cfg(feature = "net")] - let config = resolved_config.as_ref().unwrap_or(config); + let resolved_network = config + .local_network_config()? + .resolve(&EnvNetworkSecretResolver) + .map_err(|error| MicrosandboxError::InvalidConfig(error.to_string()))?; // libkrunfw is process-level (one dylib per process address space). The // resolver consults MSB_LIBKRUNFW_PATH env, then SDK_LIBKRUNFW_PATH static, @@ -475,6 +477,8 @@ pub async fn spawn_sandbox( sandbox_id, #[cfg(feature = "net")] network_slot, + #[cfg(feature = "net")] + resolved_network, &db_path, global.database.connect_timeout_secs, &log_dir, @@ -505,7 +509,6 @@ pub async fn spawn_sandbox( ); launch.block_writeback_limit_bytes = writeback_limit_bytes; launch.block_writeback_pool_bytes = writeback_pool_bytes; - #[cfg(unix)] let config_file = match write_launch_config_fd(&launch) { Ok(file) => file, @@ -2377,6 +2380,7 @@ fn sandbox_cli_args( config: &SandboxConfig, sandbox_id: i32, #[cfg(feature = "net")] network_slot: NetworkSlot, + #[cfg(feature = "net")] resolved_network: ResolvedNetworkConfig, db_path: &Path, db_connect_timeout_secs: u64, log_dir: &Path, @@ -2756,11 +2760,7 @@ fn sandbox_cli_args( // Network configuration travels as a typed value inside the JSON payload. #[cfg(feature = "net")] { - launch.network = Some( - config - .local_network_config() - .expect("sandbox network spec should decode to local network config"), - ); + launch.network = Some(resolved_network); launch.sandbox_slot = network_slot.get(); } @@ -2925,6 +2925,17 @@ mod tests { NetworkSlot::try_from(1).unwrap() } + #[cfg(feature = "net")] + fn test_resolved_network( + config: &SandboxConfig, + ) -> microsandbox_network::ResolvedNetworkConfig { + config + .local_network_config() + .unwrap() + .resolve(µsandbox_network::config::EnvNetworkSecretResolver) + .unwrap() + } + /// Return the typed launch payload generated for a sandbox configuration. fn render_launch(config: &SandboxConfig) -> LaunchConfig { let local = test_local_backend(); @@ -2934,6 +2945,8 @@ mod tests { 42, #[cfg(feature = "net")] test_network_slot(), + #[cfg(feature = "net")] + test_resolved_network(config), Path::new("/tmp/msb.db"), 30, Path::new("/tmp/logs"), @@ -3155,7 +3168,7 @@ mod tests { pair( &mut out, "--network-config", - serde_json::to_string(net).unwrap(), + serde_json::to_string(net.config()).unwrap(), ); pair(&mut out, "--sandbox-slot", launch.sandbox_slot.to_string()); } @@ -3182,6 +3195,8 @@ mod tests { 42, #[cfg(feature = "net")] test_network_slot(), + #[cfg(feature = "net")] + test_resolved_network(config), Path::new("/tmp/msb.db"), 30, Path::new("/tmp/logs"), @@ -3285,6 +3300,8 @@ mod tests { 42, #[cfg(feature = "net")] test_network_slot(), + #[cfg(feature = "net")] + test_resolved_network(config), Path::new("/tmp/msb.db"), 30, Path::new("/tmp/logs"), @@ -3315,6 +3332,8 @@ mod tests { 42, #[cfg(feature = "net")] test_network_slot(), + #[cfg(feature = "net")] + test_resolved_network(config), Path::new("/tmp/msb.db"), 30, Path::new("/tmp/logs"), @@ -3399,6 +3418,8 @@ mod tests { 42, #[cfg(feature = "net")] test_network_slot(), + #[cfg(feature = "net")] + test_resolved_network(&config), Path::new("/tmp/msb.db"), 30, Path::new("/tmp/logs"), @@ -3476,6 +3497,8 @@ mod tests { 42, #[cfg(feature = "net")] test_network_slot(), + #[cfg(feature = "net")] + test_resolved_network(&config), Path::new("/tmp/msb.db"), 30, Path::new("/tmp/logs"), diff --git a/sdk/rust/lib/sandbox/builder.rs b/sdk/rust/lib/sandbox/builder.rs index f10d1628b..951c6c8f1 100644 --- a/sdk/rust/lib/sandbox/builder.rs +++ b/sdk/rust/lib/sandbox/builder.rs @@ -11,6 +11,8 @@ use microsandbox_image::{PullProgressHandle, RegistryAuth}; use microsandbox_network::builder::{NetworkBuilder, SecretBuilder}; #[cfg(feature = "net")] use microsandbox_network::policy::Rule; +#[cfg(feature = "net")] +use microsandbox_network::{OutboundProxyBuilder, OutboundProxyConfig}; use microsandbox_types::{ CpuPlacement, EnvVar, PullPolicy, SandboxConfigPatch, VsockRouteSpec, VsockSocketType, }; @@ -686,6 +688,47 @@ impl SandboxBuilder { self } + /// Configure the single proxy used for outbound sandbox connections. + /// + /// Supports SOCKS4 for TCP and SOCKS5 for TCP and non-DNS UDP. The + /// proxy applies uniformly to TLS-intercepted and bypassed/plain TCP. + #[cfg(feature = "net")] + pub fn proxy

(mut self, configure: impl FnOnce(OutboundProxyBuilder) -> P) -> Self + where + P: OutboundProxyConfig, + { + use microsandbox_network::policy::BuildError::InvalidOutboundProxy; + + let proxy = match configure(OutboundProxyBuilder::new()).build() { + Ok(proxy) => proxy, + Err(error) => { + if self.build_error.is_none() { + self.build_error = Some(MicrosandboxError::from(InvalidOutboundProxy { + reason: error.to_string(), + })); + } + return self; + } + }; + + match self.config.local_network_config() { + Ok(mut network) => { + network.outbound_proxy = Some(proxy); + if let Err(err) = self.config.set_local_network_config(network) + && self.build_error.is_none() + { + self.build_error = Some(err); + } + } + Err(err) => { + if self.build_error.is_none() { + self.build_error = Some(err); + } + } + } + self + } + /// Prepend explicit rules while preserving a configured policy's defaults and existing rules. #[cfg(feature = "net")] #[doc(hidden)] @@ -1738,13 +1781,13 @@ mod tests { use crate::sandbox::{MAX_HOSTNAME_BYTES, MAX_SANDBOX_NAME_BYTES, RlimitResource}; #[cfg(feature = "net")] use microsandbox_network::secrets::config::{HostPattern, SecretEntry, SecretInjection}; - #[cfg(feature = "net")] - use microsandbox_types::PortProtocol; use microsandbox_types::{ CpuPlacement, DeploymentProfile, SandboxLogLevel, TransparentHugePagePolicy, VolumeMount, VsockSocketType, }; #[cfg(feature = "net")] + use microsandbox_types::{PortProtocol, SecretSource}; + #[cfg(feature = "net")] use std::net::{IpAddr, Ipv4Addr}; #[test] @@ -2444,6 +2487,26 @@ mod tests { assert_eq!(network.max_connections, Some(128)); } + #[cfg(feature = "net")] + #[tokio::test] + async fn test_builder_sets_outbound_proxy() { + let config = SandboxBuilder::new("test") + .image("alpine") + .proxy(|p| p.socks5("127.0.0.1:1080")) + .build() + .await + .unwrap(); + + let network = config.local_network_config().unwrap(); + assert_eq!( + network.outbound_proxy, + Some(microsandbox_network::OutboundProxy::Socks5 { + address: "127.0.0.1:1080".parse().unwrap(), + credentials: None, + }) + ); + } + #[cfg(feature = "net")] #[tokio::test] async fn test_builder_network_rate_limiters_land_in_the_spec() { @@ -2485,6 +2548,64 @@ mod tests { assert!(rate_limiter.ingress.is_none()); } + #[cfg(feature = "net")] + #[tokio::test] + async fn test_builder_sets_socks5_credentials() { + let config = SandboxBuilder::new("test") + .image("alpine") + .proxy(|p| { + p.socks5("127.0.0.1:1080").credentials( + "sandbox", + SecretSource::Env { + var: "SOCKS5_PASSWORD".into(), + }, + ) + }) + .build() + .await + .unwrap(); + + let network = config.local_network_config().unwrap(); + let json = serde_json::to_value(network.outbound_proxy).unwrap(); + assert_eq!(json["credentials"]["username"], "sandbox"); + assert_eq!(json["credentials"]["password"]["kind"], "env"); + assert_eq!(json["credentials"]["password"]["var"], "SOCKS5_PASSWORD"); + assert!(json["credentials"].get("value").is_none()); + } + + #[cfg(feature = "net")] + #[tokio::test] + async fn test_builder_sets_socks4_outbound_proxy_with_user_id() { + let config = SandboxBuilder::new("test") + .image("alpine") + .proxy(|p| p.socks4("127.0.0.1:1080").user_id("sandbox")) + .build() + .await + .unwrap(); + + let network = config.local_network_config().unwrap(); + assert_eq!( + network.outbound_proxy, + Some(microsandbox_network::OutboundProxy::Socks4 { + address: "127.0.0.1:1080".parse().unwrap(), + user_id: Some("sandbox".to_string()), + }) + ); + } + + #[cfg(feature = "net")] + #[tokio::test] + async fn test_builder_rejects_invalid_outbound_proxy() { + let error = SandboxBuilder::new("test") + .image("alpine") + .proxy(|p| p.socks5("not-an-address")) + .build() + .await + .unwrap_err(); + + assert!(error.to_string().contains("invalid SOCKS5 proxy address")); + } + #[cfg(feature = "net")] #[tokio::test] async fn test_builder_rejects_invalid_rate_limiter() { diff --git a/sdk/rust/lib/sandbox/config.rs b/sdk/rust/lib/sandbox/config.rs index c8a0556ad..d1d9212eb 100644 --- a/sdk/rust/lib/sandbox/config.rs +++ b/sdk/rust/lib/sandbox/config.rs @@ -600,65 +600,6 @@ impl SandboxConfig { } } -/// Resolve reference-model secret entries (host-side `source` references) into -/// concrete values for this spawn. -/// -/// The durable sandbox config stores only the source reference; the resolved -/// value exists in the returned copy, which travels to the sandbox process -/// over the private launch-config fd and never returns to the database. -/// Returns `None` when no entry needs resolution so callers can skip the -/// config clone. -#[cfg(feature = "net")] -pub(crate) fn resolve_config_secret_sources( - config: &SandboxConfig, -) -> crate::MicrosandboxResult> { - use microsandbox_network::secrets::config::SecretSource; - - if !config.spec.network.enabled { - return Ok(None); - } - let mut network = config.local_network_config()?; - let mut resolved_any = false; - for secret in &mut network.secrets.secrets { - let Some(source) = &secret.source else { - continue; - }; - match source { - SecretSource::Env { var } => { - let value = std::env::var(var).map_err(|_| { - crate::MicrosandboxError::InvalidConfig(format!( - "secret {}: host environment variable {var} is not set", - secret.env_var - )) - })?; - if value.is_empty() { - return Err(crate::MicrosandboxError::InvalidConfig(format!( - "secret {}: host environment variable {var} is empty", - secret.env_var - ))); - } - // Move the plaintext into the zeroizing wrapper; the source - // `String` is consumed by the move, leaving no separate copy. - secret.value = zeroize::Zeroizing::new(value); - resolved_any = true; - } - SecretSource::Store { .. } => { - return Err(crate::MicrosandboxError::InvalidConfig(format!( - "secret {}: store-backed secret sources are not supported yet", - secret.env_var - ))); - } - } - } - if !resolved_any { - return Ok(None); - } - - let mut resolved = config.clone(); - resolved.set_local_network_config(network)?; - Ok(Some(resolved)) -} - //-------------------------------------------------------------------------------------------------- // Trait Implementations //-------------------------------------------------------------------------------------------------- @@ -1791,6 +1732,50 @@ mod tests { config } + #[cfg(feature = "net")] + fn config_with_socks5_password_source() -> SandboxConfig { + use microsandbox_network::{OutboundProxyBuilder, OutboundProxyConfig}; + use microsandbox_types::SecretSource; + + let mut config = SandboxConfig::default(); + config.spec.network.enabled = true; + let mut network = config.local_network_config().unwrap(); + network.outbound_proxy = Some( + OutboundProxyBuilder::new() + .socks5("127.0.0.1:1080") + .credentials("sandbox", SecretSource::env("MSB_TEST_SOCKS5_PASSWORD")) + .build() + .unwrap(), + ); + config.set_local_network_config(network).unwrap(); + config + } + + #[cfg(feature = "net")] + #[test] + fn socks5_password_uses_resolved_network_launch_type() { + let _env_guard = crate::test_support::lock_env(); + let config = config_with_socks5_password_source(); + let durable_json = serde_json::to_string(&config).unwrap(); + assert!(durable_json.contains("MSB_TEST_SOCKS5_PASSWORD")); + assert!(!durable_json.contains(SECRET_SENTINEL)); + + // SAFETY: every environment-mutating SDK unit test holds the shared lock. + unsafe { std::env::set_var("MSB_TEST_SOCKS5_PASSWORD", SECRET_SENTINEL) }; + let resolved = config + .local_network_config() + .unwrap() + .resolve(µsandbox_network::config::EnvNetworkSecretResolver) + .unwrap(); + let launch_json = serde_json::to_string(&resolved).unwrap(); + assert!(launch_json.contains(SECRET_SENTINEL)); + + let persisted_json = serde_json::to_string(&config.clone_for_persistence()).unwrap(); + assert!(persisted_json.contains("MSB_TEST_SOCKS5_PASSWORD")); + assert!(!persisted_json.contains(SECRET_SENTINEL)); + unsafe { std::env::remove_var("MSB_TEST_SOCKS5_PASSWORD") }; + } + /// The create path persists a source reference, never the resolved value: /// the durable config JSON and the active_config snapshot carry the /// `{kind: env, var: ...}` reference and zero occurrences of the value. @@ -1825,12 +1810,15 @@ mod tests { unsafe { std::env::set_var("MSB_TEST_RESOLVE_SOURCE", SECRET_SENTINEL) }; let config = config_with_source_secret(Some("MSB_TEST_RESOLVE_SOURCE")); - let resolved = super::resolve_config_secret_sources(&config) + let resolved = config + .local_network_config() .unwrap() - .expect("a source entry must be resolved"); - - let network = resolved.local_network_config().unwrap(); - assert_eq!(network.secrets.secrets[0].value.as_str(), SECRET_SENTINEL); + .resolve(µsandbox_network::config::EnvNetworkSecretResolver) + .unwrap(); + assert_eq!( + resolved.config().secrets.secrets[0].value.as_str(), + SECRET_SENTINEL + ); // The durable input still stores only the reference. let durable = config.local_network_config().unwrap(); assert!(durable.secrets.secrets[0].value.is_empty()); @@ -1839,21 +1827,21 @@ mod tests { } /// Back-compat: a legacy config that inlined the value (no `source`) still - /// spawns. The resolver treats a present non-empty value as the material - /// and returns `None` so the caller reuses the config as-is. + /// spawns. The resolver leaves the present non-empty value in the + /// declarative launch config and has no separate value to apply. #[cfg(feature = "net")] #[test] fn spawn_resolver_preserves_legacy_inlined_value() { let config = config_with_source_secret(None); - let resolved = super::resolve_config_secret_sources(&config).unwrap(); - assert!( - resolved.is_none(), - "legacy inlined values need no resolution" + let resolved = config + .local_network_config() + .unwrap() + .resolve(µsandbox_network::config::EnvNetworkSecretResolver) + .unwrap(); + assert_eq!( + resolved.config().secrets.secrets[0].value.as_str(), + SECRET_SENTINEL ); - - // The legacy value is still usable directly from the durable config. - let network = config.local_network_config().unwrap(); - assert_eq!(network.secrets.secrets[0].value.as_str(), SECRET_SENTINEL); } #[test] fn test_sandbox_config_deserializes_legacy_readonly_mounts() { diff --git a/sdk/rust/lib/sandbox/mod.rs b/sdk/rust/lib/sandbox/mod.rs index 770b1b3a6..9098e747d 100644 --- a/sdk/rust/lib/sandbox/mod.rs +++ b/sdk/rust/lib/sandbox/mod.rs @@ -127,6 +127,8 @@ pub use microsandbox_network::dns::Nameserver; pub use microsandbox_network::policy::{ Action as NetworkAction, NetworkPolicy, NetworkProfile, Rule as NetworkRule, }; +#[cfg(feature = "net")] +pub use microsandbox_network::{OutboundProxy, Socks5Credentials}; pub use microsandbox_runtime::logging::LogLevel; pub use microsandbox_types::{CpuPlacement, PullPolicy}; #[cfg(feature = "net")] diff --git a/sdk/rust/lib/sandbox/ssh.rs b/sdk/rust/lib/sandbox/ssh.rs index 36e7f1e17..fcf440254 100644 --- a/sdk/rust/lib/sandbox/ssh.rs +++ b/sdk/rust/lib/sandbox/ssh.rs @@ -1519,7 +1519,7 @@ impl russh::client::Handler for SshClientHandler { async fn check_server_key( &mut self, - _server_public_key: &russh::keys::ssh_key::PublicKey, + _server_public_key: &russh::keys::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/sdk/rust/tests/outbound_proxy.rs b/sdk/rust/tests/outbound_proxy.rs new file mode 100644 index 000000000..c15d792d1 --- /dev/null +++ b/sdk/rust/tests/outbound_proxy.rs @@ -0,0 +1,224 @@ +//! Integration tests for outbound proxy routing. +//! +//! These tests require KVM (or libkrun on macOS). The [`msb_test`] attribute +//! marks them ignored for ordinary workspace test runs. + +use std::io; +use std::net::{Ipv4Addr, SocketAddr}; + +use microsandbox::{Sandbox, SecretSource}; +use test_utils::msb_test; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, UdpSocket}; +use tokio::task::JoinHandle; + +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +const PASSWORD_ENV: &str = "MSB_TEST_SOCKS5_PROXY_PASSWORD"; +const PROXY_USERNAME: &str = "sandbox"; +const PROXY_PASSWORD: &str = "proxy-password"; +const UDP_TARGET_IP: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 10); +const UDP_TARGET_PORT: u16 = 19090; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Minimal authenticated SOCKS5 proxy that serves one UDP association. +struct AuthenticatedUdpProxy { + address: SocketAddr, + handle: JoinHandle>, +} + +/// Removes one test-only host environment variable when dropped. +struct EnvGuard(&'static str); + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl AuthenticatedUdpProxy { + async fn start() -> io::Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let address = listener.local_addr()?; + let relay = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let relay_address = relay.local_addr()?; + + let handle = tokio::spawn(async move { + let (mut control, _) = listener.accept().await?; + + let mut greeting = [0u8; 4]; + control.read_exact(&mut greeting).await?; + if greeting != [0x05, 0x02, 0x00, 0x02] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected SOCKS5 greeting: {greeting:?}"), + )); + } + control.write_all(&[0x05, 0x02]).await?; + + let (username, password) = Self::read_credentials(&mut control).await?; + if username != PROXY_USERNAME || password != PROXY_PASSWORD { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "unexpected SOCKS5 credentials", + )); + } + control.write_all(&[0x01, 0x00]).await?; + + Self::read_command(&mut control).await?; + + let SocketAddr::V4(relay_address) = relay_address else { + unreachable!("fixture binds an IPv4 relay") + }; + let mut reply = vec![0x05, 0x00, 0x00, 0x01]; + reply.extend_from_slice(&relay_address.ip().octets()); + reply.extend_from_slice(&relay_address.port().to_be_bytes()); + control.write_all(&reply).await?; + + let mut datagram = [0u8; 128]; + let (received, client) = relay.recv_from(&mut datagram).await?; + let (header_len, target) = Self::decode_udp_header(&datagram[..received])?; + if target != SocketAddr::from((UDP_TARGET_IP, UDP_TARGET_PORT)) + || &datagram[header_len..received] != b"ping" + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected proxied UDP datagram", + )); + } + + let mut response = datagram[..header_len].to_vec(); + response.extend_from_slice(b"pong"); + relay.send_to(&response, client).await?; + Ok(()) + }); + + Ok(Self { address, handle }) + } + + async fn read_credentials(control: &mut tokio::net::TcpStream) -> io::Result<(String, String)> { + let version = control.read_u8().await?; + if version != 0x01 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid username/password authentication version", + )); + } + let username_len = control.read_u8().await? as usize; + let mut username = vec![0u8; username_len]; + control.read_exact(&mut username).await?; + let password_len = control.read_u8().await? as usize; + let mut password = vec![0u8; password_len]; + control.read_exact(&mut password).await?; + + let username = String::from_utf8(username) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let password = String::from_utf8(password) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + Ok((username, password)) + } + + async fn read_command(control: &mut tokio::net::TcpStream) -> io::Result { + let mut request = [0u8; 10]; + control.read_exact(&mut request).await?; + if request[..4] != [0x05, 0x03, 0x00, 0x01] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected SOCKS5 command: {request:?}"), + )); + } + Ok(SocketAddr::from(( + Ipv4Addr::new(request[4], request[5], request[6], request[7]), + u16::from_be_bytes([request[8], request[9]]), + ))) + } + + fn decode_udp_header(datagram: &[u8]) -> io::Result<(usize, SocketAddr)> { + if datagram.len() < 10 || datagram[..4] != [0x00, 0x00, 0x00, 0x01] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid SOCKS5 UDP datagram", + )); + } + Ok(( + 10, + SocketAddr::from(( + Ipv4Addr::new(datagram[4], datagram[5], datagram[6], datagram[7]), + u16::from_be_bytes([datagram[8], datagram[9]]), + )), + )) + } + + fn address(&self) -> SocketAddr { + self.address + } + + async fn join(self) -> io::Result<()> { + tokio::time::timeout(std::time::Duration::from_secs(10), self.handle) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy fixture timed out"))? + .map_err(io::Error::other)? + } +} + +impl EnvGuard { + fn set(name: &'static str, value: &str) -> Self { + // SAFETY: this test owns a unique environment-variable name. + unsafe { std::env::set_var(name, value) }; + Self(name) + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: this test owns a unique environment-variable name. + unsafe { std::env::remove_var(self.0) }; + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[msb_test] +async fn authenticated_socks5_proxy_routes_udp() { + let proxy = AuthenticatedUdpProxy::start().await.expect("proxy fixture"); + let _password = EnvGuard::set(PASSWORD_ENV, PROXY_PASSWORD); + let name = "socks5-authenticated-udp"; + let sandbox = Sandbox::builder(name) + .image("mirror.gcr.io/library/python:3.12-alpine") + .cpus(1) + .memory(256) + .proxy(|p| { + p.socks5(proxy.address().to_string()) + .credentials(PROXY_USERNAME, SecretSource::env(PASSWORD_ENV)) + }) + .replace() + .create() + .await + .expect("create sandbox"); + + let output = sandbox + .shell(format!( + "python -c 'import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(5); s.sendto(b\"ping\", (\"{}\", {})); print(s.recv(4).decode(), end=\"\")'", + UDP_TARGET_IP, UDP_TARGET_PORT, + )) + .await + .expect("send proxied UDP datagram"); + proxy.join().await.expect("proxy fixture completed"); + assert_eq!( + output.stdout().expect("UTF-8 stdout"), + "pong", + "guest command failed with status {:?} and stderr: {}", + output.status(), + output.stderr().expect("UTF-8 stderr"), + ); + + drop(sandbox); + let handle = Sandbox::get(name).await.expect("get sandbox"); + handle.stop().await.expect("stop sandbox"); + let _ = Sandbox::remove(name).await; +} diff --git a/skills b/skills index 7edae24c6..fe5255e87 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 7edae24c65ef10df661ccc3347d76c902521f766 +Subproject commit fe5255e8795fdacad4f626a03e945426895103c8