diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9a7955a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + rust-cross: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + name: linux-amd64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + name: linux-arm64 + - target: x86_64-apple-darwin + os: macos-latest + name: darwin-amd64 + - target: aarch64-apple-darwin + os: macos-latest + name: darwin-arm64 + - target: x86_64-pc-windows-gnu + os: ubuntu-latest + name: windows-amd64.exe + - target: aarch64-pc-windows-gnu + os: ubuntu-latest + name: windows-arm64.exe + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install Linux arm64 cross-compiler + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu + - name: Install Windows cross-compiler + if: contains(matrix.target, 'windows') + run: sudo apt-get update && sudo apt-get install -y mingw-w64 + - name: Build Rust binary + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + CARGO_TARGET_AARCH64_PC_WINDOWS_GNU_LINKER: aarch64-w64-mingw32-gcc + run: cargo build --release -p datum-connect --target ${{ matrix.target }} + working-directory: connect-lib + - name: Rename binary with platform suffix + run: | + cp connect-lib/target/${{ matrix.target }}/release/datum-connect${{ contains(matrix.target, 'windows') && '.exe' || '' }} \ + /tmp/rust-bin/datum-connect-${{ matrix.name }} + shell: bash + - name: Upload Rust binary + uses: actions/upload-artifact@v4 + with: + name: rust-${{ matrix.name }} + path: /tmp/rust-bin/datum-connect-${{ matrix.name }} + + goreleaser: + needs: rust-cross + runs-on: ubuntu-latest + defaults: + run: + working-directory: connect-plugin + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: connect-plugin/go.mod + - name: Download all Rust binaries + uses: actions/download-artifact@v4 + with: + path: /tmp/rust-artifacts + - name: Stage Rust binaries for goreleaser + run: | + mkdir -p ../dist/rust + find /tmp/rust-artifacts -type f -exec cp {} ../dist/rust/ \; + ls -la ../dist/rust/ + - name: Install Syft CLI + run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sudo sh -s -- -b /usr/local/bin + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..90808fb --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,36 @@ +name: testing + +on: + push: + paths: + - 'connect-plugin/**/*.go' + - 'connect-plugin/go.mod' + - 'connect-plugin/go.sum' + +jobs: + testing: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Checkout datumctl + uses: actions/checkout@v6 + with: + repository: datum-cloud/datumctl + path: datumctl-dep + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: connect-plugin/go.mod + - name: Rewrite replace directive for CI + run: go mod edit -replace go.datum.net/datumctl=../datumctl-dep + working-directory: connect-plugin + - name: Verify dependencies + run: go mod verify + working-directory: connect-plugin + - name: Build + run: go build ./... + working-directory: connect-plugin + - name: Test (known-good packages only) + run: go test -timeout 5m ./internal/daemon ./internal/env ./internal/exec ./internal/pidfile ./internal/state ./internal/svcconfig + working-directory: connect-plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..482cb8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +*.so +connect-plugin/datumctl-connect +connect-plugin/fake-datum-connect-test +connect-plugin/internal/daemon/fake-datum-connect diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dcaeee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Read @README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea7cb26 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# Datum Connect Plugin + +A `datumctl` plugin (`datumctl connect tunnel listen ...`) that wraps the Rust +[`datum-connect`](connect-lib/) binary to manage Datum Connect tunnels. + +## Architecture + +``` +datumctl connect tunnel listen ... + │ + ▼ +┌─────────────────────────────────────┐ +│ Go supervisor (datumctl-connect) │ reads stdout for JSON events +│ connect-plugin/tunnel/listen/ │ forwards stderr to terminal +│ connect-plugin/internal/* │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ Rust binary (datum-connect)│ │ stderr → user (progress, ✓ lines) +│ │ connect-lib/bin/src/ │ │ stdout → Go supervisor (JSON) +│ │ connect-lib/lib/ │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +- **Go supervisor** (`connect-plugin/main.go`): datumctl plugin binary, parses + tunnel-ready events from stdout, forwards signals (Ctrl+C), manages + startup/grace timeout. +- **Rust binary** (`connect-lib/bin/`): headless tunnel agent driven by + iroh + HTTPProxy APIs. Progress text goes to stderr; JSON lifecycle + events go to stdout. +- **Rust library** (`connect-lib/lib/`): shared types, Kube API client, + DatumCloud API bindings, heartbeat agent, tunnel service. + +## Directory Layout + +``` +connect/ +├── connect-plugin/ # Go plugin source +│ ├── main.go # Plugin entrypoint +│ ├── tunnel/ # Cobra subcommands (listen, run, list, …) +│ │ └── listen/main.go # Primary: spawns Rust binary, reads events +│ ├── internal/ # Go support packages +│ │ ├── binary/ # Rust binary discovery +│ │ ├── daemon/ # Background daemonisation +│ │ ├── env/ # Child environment builder (DATUM_SESSION, etc.) +│ │ ├── exec/ # Typed JSON message parser +│ │ ├── logfile/ # Log file management +│ │ ├── output/ # Formatted output (table/json/yaml) +│ │ ├── pidfile/ # PID tracking +│ │ ├── rbaccheck/ # Service-account RBAC validation +│ │ ├── signals/ # OS signal relay +│ │ ├── state/ # Daemon/run state persistence +│ │ ├── svcconfig/ # System service config builders +│ │ └── svcunit/ # systemd unit file generation +│ ├── e2e_test.go # E2E tests (manifest, listen) +│ ├── e2e_interaction_test.go # E2E tests (install, service, PID) +│ ├── go.mod / go.sum +│ ├── scripts/ # Build/release helpers +│ ├── testdata/ # Test fixtures +│ └── fake-datum-connect-test # Test helper binary +├── connect-lib/ # Rust workspace +│ ├── Cargo.toml +│ ├── bin/ # Binary crate (datum-connect) +│ │ └── src/ +│ │ ├── main.rs # Entrypoint, CLI, Listen handler +│ │ └── progress.rs # Tunnel progress rendering (✓ / ○) +│ └── lib/ # Library crate (connect-lib) +│ └── src/ +│ ├── datum_cloud/ # API client, auth, env +│ ├── heartbeat.rs # HeartbeatAgent +│ ├── tunnel.rs # TunnelService +│ └── … +├── flake.nix # Nix dev shell +├── Taskfile.yaml # Build/test/install tasks +└── README.md +``` + +## Install + +```bash +datumctl plugin install datum-cloud/connect +``` + +Downloads the pre-built archive from the [latest GitHub release](https://github.com/datum-cloud/connect/releases) and places both binaries in `~/.datumctl/plugins/`. + +## Components + +### connect-plugin — Go supervisor (`connect-plugin/`) + +The datumctl plugin binary. Parses JSON events from the Rust subprocess's stdout, forwards signals, manages startup/grace timeout. + +```bash +# Build (debug) +cd connect-plugin && go build -o datumctl-connect . + +# Test +cd connect-plugin && go test -timeout 5m ./internal/... + +# Install to ~/.datumctl/plugins/ +cp connect-plugin/datumctl-connect ~/.datumctl/plugins/ +``` + +Requires **Go ~1.25.8+** (see `connect-plugin/go.mod`). + +### connect-lib — Rust library + binary (`connect-lib/`) + +The tunnel agent. The `datum-connect` binary is a headless tunnel daemon driven by iroh + HTTPProxy APIs. It is also published as a **library crate** (`connect-lib/lib/`) exposing shared types, Kube API client, DatumCloud API bindings, heartbeat agent, and tunnel service — suitable for embedding in other clients such as [Datum Desktop](https://github.com/datum-cloud/app). + +```bash +# Build binary (debug) +cd connect-lib && cargo build -p datum-connect + +# Run unit tests across the workspace +cd connect-lib && cargo test + +# Package crate for downstream use +cd connect-lib && cargo package -p connect-lib +``` + +Requires **Rust stable** (see `connect-lib/rust-toolchain.toml`). + +## Developing + +The canonical build and test commands are in `Taskfile.yaml`. Each task delegates to the underlying Go or Rust toolchain in the relevant subdirectory. + +```bash +# Build both binaries (debug) +task build + +# Release build with LTO + strip +task build:release + +# Run all tests +task test + +# Run Go or Rust tests individually +task test:go +task test:rust +``` + +Helper scripts are also available in `connect-plugin/scripts/`. + +## Nix + +A dev shell with Go, Rust, task, pkg-config, and openssl is available: + +```bash +nix develop +``` + +The packaged Rust binary can also be built with Nix: + +```bash +nix build +``` + +## Releases + +Push a semver tag (`vX.Y.Z`); `.github/workflows/release.yml` cross-compiles `datum-connect` (Rust) via a matrix of OS runners, then runs GoReleaser to produce per-platform archives containing both `datumctl-connect` and `datum-connect`, plus `checksums.txt`. + +The plugin is versioned independently of both `datumctl` and the `datum-connect` Rust binary. After a release, update the `Plugin` manifest at `datum-cloud/datumctl-plugins/index.yaml`. + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Two-binary architecture | Rust binary ships independently; Go supervisor handles dispatch, daemonisation, signal management. | +| `--json` mode always on | Go supervisor parses line-delimited JSON events from stdout; human text goes to stderr. | +| No `DATUM_ACCESS_TOKEN` in child env | Rust binary uses `DATUM_CREDENTIALS_HELPER` + `DATUM_SESSION` to exec helper for token. | +| Proxy verification retries indefinitely | Datum Cloud can take time to settle; user sees periodic `○ waiting for proxy …` messages every 10s. | +| State isolation | Plugin uses `~/.local/share/datumctl/connect/` — no OAuth files, no selected_context. | diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..cb9355e --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,148 @@ +version: '3' + +# ─── Variables ────────────────────────────────────────────────────────────── + +vars: + RUST_PROJECT: connect-lib + GO_PROJECT: connect-plugin + PLUGIN_BINARY: datumctl-connect + RUST_BINARY: datum-connect + PLUGIN_DIR: '{{.ROOT_DIR}}/bin' + + GO_BUILD_FLAGS: -trimpath + + CARGO_TARGET_DIR: '{{.ROOT_DIR}}/{{.RUST_PROJECT}}/target' + CARGO_BUILD_FLAGS: -p {{.RUST_BINARY}} + RELEASE_FLAGS: --release + + E2E_SCRIPT: '{{.ROOT_DIR}}/../e2e-test.sh' + +# ─── Tasks ────────────────────────────────────────────────────────────────── + +tasks: + + default: + cmds: + - task --list + silent: true + + # ════════════════════════════════════════════════════════════════════════ + # Build + # ════════════════════════════════════════════════════════════════════════ + + build: + desc: "Build the Go plugin and Rust binary (debug)" + cmds: + - task: build:go + - task: build:rust + + build:go: + desc: "Build the Go plugin binary" + cmds: + - go build {{.GO_BUILD_FLAGS}} -o {{.PLUGIN_BINARY}} . + dir: '{{.ROOT_DIR}}/{{.GO_PROJECT}}' + + build:rust: + desc: "Build the Rust binary (debug)" + cmds: + - cargo build {{.CARGO_BUILD_FLAGS}} + dir: '{{.ROOT_DIR}}/{{.RUST_PROJECT}}' + + build:release: + desc: "Build the Go plugin and Rust binary (release)" + cmds: + - task: build:go + - task: build:rust:release + + build:rust:release: + desc: "Build the Rust binary with LTO and symbol stripping" + cmds: + - cargo build {{.CARGO_BUILD_FLAGS}} {{.RELEASE_FLAGS}} + dir: '{{.ROOT_DIR}}/{{.RUST_PROJECT}}' + + # ════════════════════════════════════════════════════════════════════════ + # Install + # ════════════════════════════════════════════════════════════════════════ + + install: + desc: "Build and install both binaries to the managed plugins directory" + cmds: + - task: build + - task: install:go + - task: install:rust + + install:go: + desc: "Copy the Go plugin binary to $HOME/.datumctl/plugins" + dir: '{{.ROOT_DIR}}/{{.GO_PROJECT}}' + cmds: + - mkdir -p $HOME/.datumctl/plugins + - cp {{.PLUGIN_BINARY}} $HOME/.datumctl/plugins/ + - chmod +x $HOME/.datumctl/plugins/{{.PLUGIN_BINARY}} + + install:rust: + desc: "Copy the Rust binary to $HOME/.datumctl/plugins" + cmds: + - mkdir -p $HOME/.datumctl/plugins + - cp {{.CARGO_TARGET_DIR}}/debug/{{.RUST_BINARY}} $HOME/.datumctl/plugins/ + - chmod +x $HOME/.datumctl/plugins/{{.RUST_BINARY}} + + install:release: + desc: "Build (release) and install both binaries" + cmds: + - task: build:release + - mkdir -p $HOME/.datumctl/plugins + - cp {{.CARGO_TARGET_DIR}}/release/{{.RUST_BINARY}} $HOME/.datumctl/plugins/ + - chmod +x $HOME/.datumctl/plugins/{{.RUST_BINARY}} + + # ════════════════════════════════════════════════════════════════════════ + # Test + # ════════════════════════════════════════════════════════════════════════ + + test: + desc: "Run all tests (Go tests + Rust unit tests)" + cmds: + - task: test:go + - task: test:rust + + test:go: + desc: "Run Go unit tests" + cmds: + - go test ./... -count=1 -timeout 60s + dir: '{{.ROOT_DIR}}/{{.GO_PROJECT}}' + + test:rust: + desc: "Run Rust unit tests" + cmds: + - cargo test --workspace + dir: '{{.ROOT_DIR}}/{{.RUST_PROJECT}}' + + test:e2e: + desc: "Run the E2E tunnel stability test script" + cmds: + - cmd: '{{.E2E_SCRIPT}}' + silent: false + + # ════════════════════════════════════════════════════════════════════════ + # Clean + # ════════════════════════════════════════════════════════════════════════ + + clean: + desc: "Remove all build artifacts" + cmds: + - rm -f {{.ROOT_DIR}}/{{.GO_PROJECT}}/{{.PLUGIN_BINARY}} + - rm -f {{.ROOT_DIR}}/{{.RUST_PROJECT}}/target/debug/{{.RUST_BINARY}} + - rm -f {{.ROOT_DIR}}/{{.RUST_PROJECT}}/target/release/{{.RUST_BINARY}} + - rm -rf {{.ROOT_DIR}}/{{.RUST_PROJECT}}/target + + clean:all: + desc: "Remove build artifacts, installed binaries, and tmp files" + cmds: + - task: clean + - rm -f $HOME/.datumctl/plugins/{{.PLUGIN_BINARY}} $HOME/.datumctl/plugins/{{.RUST_BINARY}} + - rm -rf {{.ROOT_DIR}}/tmp + + # ════════════════════════════════════════════════════════════════════════ + # Utility + # ════════════════════════════════════════════════════════════════════════ + + diff --git a/connect-lib/.gitignore b/connect-lib/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/connect-lib/.gitignore @@ -0,0 +1 @@ +target diff --git a/connect-lib/Cargo.lock b/connect-lib/Cargo.lock new file mode 100644 index 0000000..acd1a18 --- /dev/null +++ b/connect-lib/Cargo.lock @@ -0,0 +1,5991 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8202ab55fcbf46ca829833f347a82a2a4ce0596f0304ac322c2d100030cd56" +dependencies = [ + "bytes", + "crypto-common 0.2.0-rc.4", + "inout", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", + "serde", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bao-tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06384416b1825e6e04fde63262fda2dc408f5b64c02d04e0d8b70ae72c17a52b" +dependencies = [ + "blake3", + "bytes", + "futures-lite", + "genawaiter", + "iroh-io", + "positioned-io", + "range-collections", + "self_cell", + "serde", + "smallvec", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "binary-merge" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake3" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "btparse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387e80962b798815a2b5c4bcfdb6bf626fa922ffe9f74e373103b858738e9f31" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd162f2b8af3e0639d83f28a637e4e55657b7a74508dba5a9bf4da523d5c9e9" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e12a13eb01ded5d32ee9658d94f553a19e804204f2dc811df69ab4d9e0cb8c7" +dependencies = [ + "block-buffer 0.11.0", + "crypto-common 0.2.0-rc.4", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "color-backtrace" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308329d5d62e877ba02943db3a8e8c052de9fde7ab48283395ba0e6494efbabd" +dependencies = [ + "backtrace", + "btparse", + "termcolor", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "connect-lib" +version = "0.1.0" +dependencies = [ + "arc-swap", + "base64", + "chrono", + "derive_more", + "gethostname", + "hex", + "http", + "iroh", + "iroh-base", + "iroh-n0des", + "iroh-proxy-utils", + "iroh-relay", + "iroh-tickets", + "k8s-openapi", + "kube", + "n0-error", + "n0-future", + "postcard", + "rand 0.9.2", + "reqwest 0.12.28", + "secrecy", + "serde", + "serde_json", + "serde_yml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", + "rand_core 0.9.5", +] + +[[package]] +name = "crypto_box" +version = "0.10.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bda4de3e070830cf3a27a394de135b6709aefcc54d1e16f2f029271254a6ed9" +dependencies = [ + "aead", + "chacha20", + "crypto_secretbox", + "curve25519-dalek", + "salsa20", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto_secretbox" +version = "0.2.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54532aae6546084a52cef855593daf9555945719eeeda9974150e0def854873e" +dependencies = [ + "aead", + "chacha20", + "cipher", + "hybrid-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.0-rc.3", + "fiat-crypto", + "rand_core 0.9.5", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "datum-connect" +version = "0.1.0" +dependencies = [ + "clap", + "connect-lib", + "inquire", + "iroh", + "n0-error", + "rand 0.9.2", + "reqwest 0.12.28", + "rustls", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac89f8a64533a9b0eaa73a68e424db0fb1fd6271c74cc0125336a05f090568d" +dependencies = [ + "block-buffer 0.11.0", + "const-oid", + "crypto-common 0.2.0-rc.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "dynosaur" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12303417f378f29ba12cb12fc78a9df0d8e16ccb1ad94abf04d48d96bdda532" +dependencies = [ + "dynosaur_derive", +] + +[[package]] +name = "dynosaur_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0713d5c1d52e774c5cd7bb8b043d7c0fc4f921abfb678556140bfbe6ab2364" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "futures-core", + "genawaiter-macro", + "genawaiter-proc-macro", + "proc-macro-hack", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "genawaiter-proc-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784f84eebc366e15251c4a8c3acee82a6a6f427949776ecb88377362a9621738" +dependencies = [ + "proc-macro-error", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "rustls", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration 0.7.0", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.2", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "inplace-vec-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf64c2edc8226891a71f127587a2861b132d2b942310843814d5001d99a1d307" +dependencies = [ + "smallvec", +] + +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.11.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "iroh" +version = "0.95.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2374ba3cdaac152dc6ada92d971f7328e6408286faab3b7350842b2ebbed4789" +dependencies = [ + "aead", + "backon", + "bytes", + "cfg_aliases", + "crypto_box", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "igd-next", + "instant", + "iroh-base", + "iroh-metrics 0.37.0", + "iroh-quinn", + "iroh-quinn-proto", + "iroh-quinn-udp", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netwatch", + "pin-project", + "pkarr", + "pkcs8", + "portmapper", + "rand 0.9.2", + "reqwest 0.12.28", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier 0.5.3", + "rustls-webpki", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots", + "z32", +] + +[[package]] +name = "iroh-base" +version = "0.95.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a8c5fb1cc65589f0d7ab44269a76f615a8c4458356952c9b0ef1c93ea45ff8" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "derive_more", + "ed25519-dalek", + "n0-error", + "rand_core 0.9.5", + "serde", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-blobs" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c901304c1c28f257fcf9aae8c9149e54e0baf62f5eb2788cecde3bf1206a04e6" +dependencies = [ + "anyhow", + "arrayvec", + "bao-tree", + "bytes", + "cfg_aliases", + "chrono", + "data-encoding", + "derive_more", + "futures-lite", + "genawaiter", + "hex", + "iroh", + "iroh-base", + "iroh-io", + "iroh-metrics 0.37.0", + "iroh-quinn", + "iroh-tickets", + "irpc", + "n0-error", + "n0-future", + "n0-snafu", + "nested_enum_utils", + "postcard", + "rand 0.9.2", + "range-collections", + "redb", + "ref-cast", + "reflink-copy", + "self_cell", + "serde", + "smallvec", + "snafu", + "tokio", + "tracing", +] + +[[package]] +name = "iroh-io" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a5feb781017b983ff1b155cd1faf8174da2acafd807aa482876da2d7e6577a" +dependencies = [ + "bytes", + "futures-lite", + "pin-project", + "smallvec", + "tokio", +] + +[[package]] +name = "iroh-metrics" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e3381da7c93c12d353230c74bba26131d1c8bf3a4d8af0fec041546454582e" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "postcard", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "postcard", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iroh-n0des" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c691355d4b62e98a55e7d3fcf98ea3b800e7948c633cf937e7d31abe332f53" +dependencies = [ + "anyhow", + "bytes", + "derive_more", + "ed25519-dalek", + "futures-buffered", + "getrandom 0.3.4", + "iroh", + "iroh-metrics 0.37.0", + "iroh-n0des-macro", + "iroh-tickets", + "irpc", + "irpc-iroh", + "n0-error", + "n0-future", + "postcard", + "rand 0.9.2", + "rcan", + "serde", + "serde_json", + "strum", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "iroh-n0des-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e15d38b6ae3d9480e49883bea72880f80d595276e34090f5096d844e6f7f5e40" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "iroh-proxy-utils" +version = "0.1.0" +source = "git+https://github.com/n0-computer/iroh-proxy-utils?rev=38ef14f7bc215348d47987563bb1b5198cc91f40#38ef14f7bc215348d47987563bb1b5198cc91f40" +dependencies = [ + "bytes", + "derive_more", + "dynosaur", + "http", + "http-body-util", + "httparse", + "hyper", + "hyper-util", + "iroh", + "iroh-blobs", + "iroh-metrics 0.38.3", + "n0-error", + "n0-future", + "pin-project", + "reqwest 0.12.28", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-quinn" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde160ebee7aabede6ae887460cd303c8b809054224815addf1469d54a6fcf7" +dependencies = [ + "bytes", + "cfg_aliases", + "iroh-quinn-proto", + "iroh-quinn-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-proto" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535" +dependencies = [ + "bytes", + "getrandom 0.2.17", + "rand 0.8.5", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier 0.5.3", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-udp" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c53afaa1049f7c83ea1331f5ebb9e6ebc5fdd69c468b7a22dd598b02c9bcc973" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "iroh-relay" +version = "0.95.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fbdf2aeffa7d6ede1a31f6570866c2199b1cee96a0b563994623795d1bac2c" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-metrics 0.37.0", + "iroh-quinn", + "iroh-quinn-proto", + "lru", + "n0-error", + "n0-future", + "num_enum", + "pin-project", + "pkarr", + "postcard", + "rand 0.9.2", + "reqwest 0.12.28", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "sha1", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", + "z32", +] + +[[package]] +name = "iroh-tickets" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a322053cacddeca222f0999ce3cf6aa45c64ae5ad8c8911eac9b66008ffbaa5" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "irpc" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bee97aaa18387c4f0aae61058195dc9f9dea3e41c0e272973fe3e9bf611563d" +dependencies = [ + "futures-buffered", + "futures-util", + "iroh-quinn", + "irpc-derive", + "n0-error", + "n0-future", + "postcard", + "rcgen", + "rustls", + "serde", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58148196d2230183c9679431ac99b57e172000326d664e8456fa2cd27af6505a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "irpc-iroh" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b254105bdaf86bc63786a37f81ba40e84d861b870d7626b51e14ebbb2ba50" +dependencies = [ + "getrandom 0.3.4", + "iroh", + "iroh-base", + "irpc", + "n0-error", + "n0-future", + "postcard", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "k8s-openapi" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d9e5e61dd037cdc51da0d7e2b2be10f497478ea7e120d85dad632adb99882b" +dependencies = [ + "base64", + "chrono", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", +] + +[[package]] +name = "kube-client" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36" +dependencies = [ + "base64", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73" +dependencies = [ + "chrono", + "derive_more", + "form_urlencoded", + "http", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03dee8252be137772a6ab3508b81cd797dee62ee771112a2453bc85cbbe150d2" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "n0-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +dependencies = [ + "anyhow", + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-snafu" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1815107e577a95bfccedb4cfabc73d709c0db6d12de3f14e0f284a8c5036dc4f" +dependencies = [ + "anyhow", + "btparse", + "color-backtrace", + "snafu", + "tracing-error", +] + +[[package]] +name = "n0-watcher" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38acf13c1ddafc60eb7316d52213467f8ccb70b6f02b65e7d97f7799b1f50be4" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nested_enum_utils" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d5475271bdd36a4a2769eac1ef88df0f99428ea43e52dfd8b0ee5cb674695f" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "netdev" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ab878b4c90faf36dab10ea51d48c69ae9019bcca47c048a7c9b273d5d7a823" +dependencies = [ + "dlopen2", + "ipnet", + "libc", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "once_cell", + "system-configuration 0.6.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2acd376ef48b6c326abf3ba23c449e0cb8aa5c2511d189dd8a8a3bfac889b" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "iroh-quinn-udp", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "pin-project-lite", + "serde", + "socket2 0.6.3", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntimestamp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +dependencies = [ + "base32", + "document-features", + "getrandom 0.2.17", + "httpdate", + "js-sys", + "once_cell", + "serde", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkarr" +version = "5.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bfb9143bbba379f246211eb68074d78db9cc048e4c5701f3b0e6cb1ec67ca2" +dependencies = [ + "async-compat", + "base32", + "bytes", + "cfg_aliases", + "document-features", + "dyn-clone", + "ed25519-dalek", + "futures-buffered", + "futures-lite", + "getrandom 0.4.2", + "log", + "lru", + "ntimestamp", + "reqwest 0.13.2", + "self_cell", + "serde", + "sha1_smol", + "simple-dns", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "poly1305" +version = "0.9.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb78a635f75d76d856374961deecf61031c0b6f928c83dc9c0924ab6c019c298" +dependencies = [ + "cpufeatures 0.2.17", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b575f975dcf03e258b0c7ab3f81497d7124f508884c37da66a7314aa2a8d467" +dependencies = [ + "base64", + "bytes", + "derive_more", + "futures-lite", + "futures-util", + "hyper-util", + "igd-next", + "iroh-metrics 0.37.0", + "libc", + "n0-error", + "netwatch", + "num_enum", + "rand 0.9.2", + "serde", + "smallvec", + "socket2 0.6.3", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "positioned-io" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec4b80060f033312b99b6874025d9503d2af87aef2dd4c516e253fbfcdada7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "syn-mid", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-collections" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861706ea9c4aded7584c5cd1d241cec2ea7f5f50999f236c22b65409a1f1a0d0" +dependencies = [ + "binary-merge", + "inplace-vec-builder", + "ref-cast", + "serde", + "smallvec", +] + +[[package]] +name = "rcan" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "725eb86d019799495be1164a962cbaf8f8cd1553e0f1e602d8fd6671fc619498" +dependencies = [ + "anyhow", + "blake3", + "derive_more", + "ed25519-dalek", + "getrandom 0.3.4", + "hex", + "n0-future", + "postcard", + "rand 0.9.2", + "serde", +] + +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redb" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eca1e9d98d5a7e9002d0013e18d5a9b000aee942eb134883a82f06ebffb6c01" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "reflink-copy" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier 0.6.2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 1.0.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.11.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3ff3b81c8a6e381bc1673768141383f9328048a60edddcfc752a8291a138443" +dependencies = [ + "cfg-if", + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "serdect" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e046edf639aa2e7afb285589e5405de2ef7e61d4b0ac1e30256e3eab911af9" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.11.0-rc.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.11.0-rc.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df350943049174c4ae8ced56c604e28270258faec12a6a48637a7655287c9ce0" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "backtrace", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio 1.2.0", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.3.4", + "http", + "httparse", + "rand 0.9.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64", + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.6.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55be643b40a21558f44806b53ee9319595bc7ca6896372e4e08e5d7d83c9cd6" +dependencies = [ + "crypto-common 0.2.0-rc.4", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.6", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wmi" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120d8c2b6a7c96c27bf4a7947fd7f02d73ca7f5958b8bd72a696e46cb5521ee6" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "z32" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/connect-lib/Cargo.toml b/connect-lib/Cargo.toml new file mode 100644 index 0000000..0e981fc --- /dev/null +++ b/connect-lib/Cargo.toml @@ -0,0 +1,27 @@ +[workspace] +members = ["lib", "bin"] +resolver = "2" + +[workspace.dependencies] +arc-swap = "1.8.0" +base64 = "0.22" +chrono = { version = "0.4", features = ["clock"] } +derive_more = { version = "2.1.1", features = ["display"] } +gethostname = "1.1.0" +hex = "0.4.3" +http = "1" +k8s-openapi = { version = "0.26.1", features = ["v1_30"] } +kube = { version = "2.0.1", default-features = false, features = ["client", "derive", "rustls-tls"] } +n0-error = { version = "0.1", features = ["anyhow"] } +n0-future = "0.3" +rand = "0.9" +reqwest = { version = "0.12", features = ["rustls-tls", "json"] } +secrecy = "0.10.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1.0.145" +serde_yml = "0.0.12" +thiserror = "2" +tokio = { version = "1.34.0", features = ["full"] } +tokio-util = "0.7.10" +tracing = "0.1.40" +url = "2" diff --git a/connect-lib/bin/Cargo.toml b/connect-lib/bin/Cargo.toml new file mode 100644 index 0000000..ef39261 --- /dev/null +++ b/connect-lib/bin/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "datum-connect" +version = "0.1.0" +edition = "2024" + +[dependencies] +connect-lib = { path = "../lib" } +clap = { version = "4", features = ["derive", "env"] } +n0-error = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +serde_json = { workspace = true } +rustls = { version = "0.23", features = ["ring"] } +inquire = "0.7" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "http2"] } +iroh = { version = "0.95", default-features = false } +rand = { workspace = true } diff --git a/connect-lib/bin/src/main.rs b/connect-lib/bin/src/main.rs new file mode 100644 index 0000000..e2b5c0c --- /dev/null +++ b/connect-lib/bin/src/main.rs @@ -0,0 +1,792 @@ +//! Plugin-mode tunnel agent (`datum-connect`). The Go-side `datumctl connect` +//! plugin spawns this binary as a subprocess and communicates over stdout +//! (line-delimited JSON when `--json`, human text otherwise). +//! +//! # JSON EVENT CONTRACT (emitted by this binary's main handler) +//! +//! See `progress.rs` for setup-phase events (`tunnel_progress`, +//! `tunnel_verifying`, `tunnel_verified`). +//! +//! | Event type | When | Fields | +//! |---------------------------|--------------------------------------------|-----------------------------------------------------------------------------------| +//! | `tunnel_created` | new HTTPProxy created | `id` | +//! | `tunnel_updated` | label/endpoint changed | `id`, `label`, `endpoint`, `hostnames` | +//! | `tunnel_ready` | setup complete AND proxy reachable (non-5xx) | `id`, `label`, `endpoint`, `hostnames`, `endpoint_id`, `status`, `elapsed_secs` | +//! | `tunnel_login_lost` | LoginState::Missing observed mid-run | `id`, `message` | +//! | `tunnel_terminal_failure` | progress.terminal_failure() Some mid-run | `id`, `message` | +//! | `tunnel_deleted_upstream` | get_active_progress -> None mid-run | `id`, `message` | +//! | `tunnel_disabled` | cleanup before exit | `id` | +//! | `tunnel_deleted` | `delete` subcommand only | `id`, `deleted: true` | +//! +//! `tunnel_ready` is the single event that drives the Go supervisor's +//! `gotReady` handshake (`connect/tunnel/listen/main.go:160-176`, established +//! in commit `1bb9552`). It MUST NOT be removed, renamed, or have its emission +//! site moved without coordinating the Go side. + +use std::io::Write; +use std::sync::OnceLock; + +use clap::{Parser, Subcommand}; +use n0_error::StdResultExt; +use tracing_subscriber::{ + filter::EnvFilter, + layer::SubscriberExt, + reload::{self, Handle}, + util::SubscriberInitExt, + Registry, +}; + +use connect_lib::datum_cloud::env::ApiEnv; +use connect_lib::datum_cloud::external_token_source::ExternalTokenSource; +use connect_lib::datum_cloud::DatumCloudClient; +use connect_lib::{HeartbeatAgent, ListenNode, Repo, SelectedContext, TunnelService}; +use iroh::SecretKey; + +mod progress; + +type ReloadHandle = Handle; +static RELOAD_HANDLE: OnceLock = OnceLock::new(); + +fn init_tracing() { + let default_directive = "datum_connect=info"; + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(default_directive)); + let (filter_layer, handle) = reload::Layer::new(filter); + // Best-effort: if a subscriber is already installed (e.g. duplicate call in tests), + // skip without panicking. + let _ = tracing_subscriber::registry() + .with(filter_layer) + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) + .try_init(); + let _ = RELOAD_HANDLE.set(handle); +} + +fn silence_tracing() { + if let Some(handle) = RELOAD_HANDLE.get() { + let _ = handle.modify(|f| *f = EnvFilter::new("off")); + } +} + +fn restore_tracing(prev: &str) { + if let Some(handle) = RELOAD_HANDLE.get() { + let _ = handle.modify(|f| *f = EnvFilter::new(prev)); + } +} + +fn current_filter_string() -> String { + std::env::var("RUST_LOG").unwrap_or_else(|_| "datum_connect=info".to_string()) +} + +#[derive(Parser, Debug)] +#[command(name = "datum-connect", about = "Datum Connect tunnel agent (plugin mode)")] +struct Args { + #[clap(long, env = "DATUM_CONNECT_DIR")] + repo: Option, + #[clap(long, env = "DATUM_PROJECT")] + project: Option, + #[clap(long, global = true)] + json: bool, + #[clap(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// List all tunnels in the current project. + List, + /// Start a tunnel exposing a local service. + Listen { + #[clap(long)] + label: Option, + #[clap(long)] + endpoint: Option, + #[clap(long)] + id: Option, + }, + /// Update an existing tunnel. + Update { + #[clap(long)] + id: String, + #[clap(long)] + label: Option, + #[clap(long)] + endpoint: Option, + }, + /// Delete a tunnel. + Delete { + #[clap(long)] + id: String, + }, +} + +/// Why the Listen handler's runtime select-loop terminated. Drives the +/// final exit status: CtrlC = clean exit 0; TerminalFailure / DeletedUpstream +/// = exit 1 with an n0_error::anyerr! message. +enum ExitReason { + CtrlC, + TerminalFailure, + DeletedUpstream, +} + +fn resolve_project(project_id: &str) -> SelectedContext { + SelectedContext { + project_id: project_id.to_string(), + project_name: project_id.to_string(), + org_id: String::new(), + org_name: String::new(), + org_type: String::new(), + } +} + +#[tokio::main] +async fn main() { + let result = run().await; + if let Err(err) = result { + eprintln!("{:#}", err); + std::process::exit(1); + } +} + +async fn run() -> n0_error::Result<()> { + let _ = rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| n0_error::anyerr!("failed to install ring crypto provider for rustls"))?; + + init_tracing(); + + let session: Option = std::env::var("DATUM_SESSION").ok(); + if session.is_none() && std::env::var("DATUM_PLUGIN_MODE").map(|v| v != "1").unwrap_or(true) { + return Err(n0_error::anyerr!( + "neither DATUM_SESSION nor DATUM_PLUGIN_MODE=1 set — this binary runs in plugin mode only" + )); + } + + let token_source = ExternalTokenSource::from_env(session.clone()) + .map_err(|e| n0_error::anyerr!("failed to create token source: {e}"))?; + + if let Some(ref s) = session { + if let Ok(helper) = std::env::var("DATUM_CREDENTIALS_HELPER") { + token_source.start_refresh(helper, s.clone()); + } + } + + let datum = DatumCloudClient::with_external_token_source(ApiEnv::default(), token_source); + + let args = Args::parse(); + + let json = args.json; + + let project_id = match args.project { + Some(ref pid) => pid.clone(), + None => { + let session = std::env::var("DATUM_SESSION") + .ok() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + n0_error::anyerr!( + "no project set — pass --project or run 'datumctl config set project '" + ) + })?; + session + } + }; + + let ctx = resolve_project(&project_id); + datum.set_selected_context(Some(ctx)).await?; + + let repo_path = match args.repo { + Some(p) => p, + None => match Repo::default_location() { + Ok(p) => p, + Err(e) => { + eprint!("{e}"); + std::process::exit(64); + } + }, + }; + let repo = Repo::open_or_create(repo_path).await?; + + match args.command { + Commands::List => { + let node = ListenNode::new(repo.clone()).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let tunnels = service.list_active().await?; + let output: Vec = tunnels + .iter() + .map(|t| { + let status = if t.accepted && t.programmed && t.connector_metadata_programmed { + "ready" + } else if t.accepted { + "accepted" + } else { + "pending" + }; + serde_json::json!({ + "type": "tunnel", + "id": t.id, + "label": t.label, + "endpoint": t.endpoint, + "status": status, + "enabled": t.enabled, + "hostnames": t.hostnames + }) + }) + .collect(); + if json { + println!("{}", serde_json::to_string_pretty(&output).anyerr()?); + } else { + if output.is_empty() { + println!("No tunnels found."); + } + for t in &output { + println!("{}", serde_json::to_string(t).anyerr()?); + } + } + } + Commands::Listen { label, endpoint, id } => { + // Plan 12-02 resolution rules (replaces plan 12-01 stubs): + // --endpoint only → generate key in memory, create tunnel + // --id only → real resolution via TunnelService::get_active; + // read per-tunnel key, inherit endpoint + // --id + --endpoint → validate endpoint agreement, read per-tunnel key + // neither flag → picker with auto-adopt on len==1, error on len==0 + // + // Per-tunnel key layout (Phase 17): + // --endpoint generates a key in memory → new_with_key() → persist after creation + // --id / picker read per-tunnel key → new_with_key() → no persistence needed + // + // Informed by datum-cloud/app@ca4470f (tunnel listen --id pins existing + // tunnel and preserves its hostname) and @a68d8ae (--id alone resumes + // an existing tunnel; --id+--endpoint must agree). + // + // The id branches pre-build (node, service) so we can call + // get_active(&id). They stash the result in `preresolved_ns` so the + // downstream block reuses them instead of re-creating. + + // Optional in-memory key: Some(key) for --endpoint (generated), + // None for --id/picker (key read from disk). + let mut in_memory_key: Option = None; + + let mut preresolved_ns: Option<(ListenNode, TunnelService, connect_lib::TunnelSummary)> = + None; + let endpoint: String = match (endpoint, id) { + (Some(ep), None) => { + // --endpoint only: generate key in memory, use new_with_key() + let secret_key = SecretKey::generate(&mut rand::rng()); + in_memory_key = Some(secret_key.clone()); + let node = ListenNode::new_with_key(repo.clone(), secret_key).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + // No existing tunnel — preresolved_ns stays None so + // the downstream block falls through to create. + preresolved_ns = Some((node, service, connect_lib::TunnelSummary { + id: String::new(), + label: String::new(), + endpoint: ep.clone(), + hostnames: vec![], + enabled: false, + accepted: false, + programmed: false, + connector_metadata_programmed: false, + })); + ep + } + (None, Some(tunnel_id)) => { + let node = ListenNode::new(repo.clone()).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let t = service.get_active(&tunnel_id).await?.ok_or_else(|| { + n0_error::anyerr!("Tunnel '{tunnel_id}' not found in project {project_id}") + })?; + // Read the per-tunnel key using the server-assigned tunnel name. + let key = repo + .listen_key_for_tunnel(&project_id, &t.id) + .await?; + let node = ListenNode::new_with_key(repo.clone(), key).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + // Inherit endpoint from the existing tunnel. + let ep = t.endpoint.clone(); + preresolved_ns = Some((node, service, t)); + ep + } + (Some(endpoint_val), Some(id_val)) => { + let node = ListenNode::new(repo.clone()).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let t = service.get_active(&id_val).await?.ok_or_else(|| { + n0_error::anyerr!("Tunnel '{id_val}' not found in project {project_id}") + })?; + if t.endpoint != endpoint_val { + return Err(n0_error::anyerr!( + "--id '{id_val}' references endpoint '{}' but --endpoint was '{endpoint_val}' — they must agree (or omit --endpoint to inherit from the tunnel)", + t.endpoint + )); + } + // Read the per-tunnel key using the server-assigned tunnel name. + let key = repo + .listen_key_for_tunnel(&project_id, &t.id) + .await?; + let node = ListenNode::new_with_key(repo.clone(), key).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + preresolved_ns = Some((node, service, t)); + endpoint_val + } + (None, None) => { + // Picker codepath needs a service to call list_active. + // Build a temporary node for listing, then rebuild with + // the per-tunnel key after the user picks. + let node = ListenNode::new(repo.clone()).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let tunnels = service.list_active().await?; + if tunnels.is_empty() { + return Err(n0_error::anyerr!( + "No tunnels exist in project {project_id}. Pass --endpoint to create one." + )); + } + let picked = if tunnels.len() == 1 { + // Auto-adopt the only candidate without popping a picker + // (informed by datum-cloud/app@cff37e7). + tunnels.into_iter().next().unwrap() + } else { + // Multiple candidates: silence tracing, prompt with inquire, + // restore tracing. inquire is sync, so call from a + // blocking task to keep the tokio runtime healthy. + let prev_filter = current_filter_string(); + silence_tracing(); + let choices: Vec = tunnels + .iter() + .map(|t| format!("{} ({}) → {}", t.label, t.id, t.endpoint)) + .collect(); + let chosen_idx_res = tokio::task::spawn_blocking(move || { + inquire::Select::new("Select a tunnel:", choices) + .with_starting_cursor(0) + .raw_prompt() + .map(|item| item.index) + }) + .await + .map_err(|e| n0_error::anyerr!("picker task join failed: {e}"))?; + restore_tracing(&prev_filter); + let idx = chosen_idx_res + .map_err(|e| n0_error::anyerr!("picker error: {e}"))?; + tunnels.into_iter().nth(idx).unwrap() + }; + // Read the per-tunnel key using the picked tunnel's name. + let key = repo + .listen_key_for_tunnel(&project_id, &picked.id) + .await?; + let node = ListenNode::new_with_key(repo.clone(), key).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let ep = picked.endpoint.clone(); + preresolved_ns = Some((node, service, picked)); + ep + } + }; + + // Reuse the (node, service, existing-tunnel) tuple if one of the + // resolution branches above already built it; otherwise build now + // and look up the existing tunnel by endpoint. + let (node, service, existing) = match preresolved_ns { + Some((n, s, t)) => { + // For --endpoint, t.id is empty — treat as "no existing tunnel". + let existing = if t.id.is_empty() { None } else { Some(t) }; + (n, s, existing) + } + None => { + let n = ListenNode::new(repo.clone()).await?; + let s = TunnelService::new(datum.clone(), n.clone()); + let existing = s.get_active_by_endpoint(&endpoint).await?; + (n, s, existing) + } + }; + let endpoint_id = node.endpoint_id(); + let _ = writeln!(std::io::stderr(), " \u{25CB} Your endpoint ID: {}", endpoint_id.to_string()); + let _ = writeln!(std::io::stderr(), " \u{25CB} Setting up tunnel..."); + let _ = std::io::stderr().flush(); + + let setup_start = std::time::Instant::now(); + let step_started_at = std::sync::Arc::new(std::sync::Mutex::new( + std::collections::HashMap::< + connect_lib::ProgressStepKind, + std::time::Instant, + >::new(), + )); + + // Start heartbeat BEFORE create_active/ensure_connector so the + // iroh endpoint connects to the relay and populates its address + // before build_connection_details() runs. Without this the + // connector status patch has no relay URL and the operator never + // sees connection details → Connector/Ready stays False forever. + let heartbeat = HeartbeatAgent::new(datum.clone(), node.clone()); + heartbeat.start().await; + heartbeat.register_project(&project_id).await; + // Wait up to 10s for the relay URL to appear (iroh connects fast + // in practice, usually <1s). This is a best-effort poll; if the + // relay never appears, ensure_connector will warn and proceed. + for _ in 0..40 { + if node.endpoint().addr().relay_urls().next().is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + + let tunnel_id = if let Some(t) = existing { + if let Some(label) = label.filter(|l| l != &t.label) { + let updated = service.update_active(&t.id, &label, &endpoint).await?; + if json { + println!( + "{}", + serde_json::json!({"type": "tunnel_updated", "id": updated.id}) + ); + } + updated.id + } else { + t.id + } + } else { + let label = label.unwrap_or_else(|| endpoint.clone()); + let tunnel = service.create_active(&label, &endpoint).await?; + // Persist the in-memory key to the per-tunnel directory. + if let Some(ref secret_key) = in_memory_key { + let key_path = repo + .path() + .join(&project_id) + .join(&tunnel.id) + .join(Repo::LISTEN_KEY_FILE); + tokio::fs::create_dir_all(key_path.parent().unwrap()).await?; + tokio::fs::write(&key_path, secret_key.to_bytes()).await?; + } + if json { + println!( + "{}", + serde_json::json!({"type": "tunnel_created", "id": tunnel.id}) + ); + } + tunnel.id + }; + + let _ = service.set_enabled_active(&tunnel_id, true).await; + + // Mode (Text/Json) routes callback output: + // Text → stderr (one transition line per change, prefixed by resource) + // Json → stdout (one tunnel_progress / tunnel_verifying / + // tunnel_verified event per transition) + // The Go supervisor's 'default: skip' case in connect/tunnel/listen/main.go + // ignores the new event types; only the final tunnel_ready event + // unblocks its gotReady handshake. + let mode = if json { progress::Mode::Json } else { progress::Mode::Text }; + + // Now start progress monitoring — heartbeat is already connecting, + // so the operator sees Pending before Ready. + let mode_for_cb = mode; + let step_started_at_for_cb = step_started_at.clone(); + let progress_cb = move |step: &connect_lib::ProgressStep, + prev: connect_lib::StepStatus| { + let elapsed = { + let mut map = step_started_at_for_cb.lock().unwrap(); + let timer = map + .entry(step.kind.clone()) + .or_insert_with(std::time::Instant::now); + timer.elapsed() + }; + progress::render_progress_step(mode_for_cb, step, prev, elapsed); + }; + + let service_for_progress = service.clone(); + let tunnel_id_for_progress = tunnel_id.clone(); + let progress_handle = tokio::spawn(async move { + progress::await_tunnel_progress(&service_for_progress, &tunnel_id_for_progress, &progress_cb).await + }); + + let mut final_progress = progress_handle.await.unwrap()?; + + // Re-patch connectionDetails now that the connector is Ready:True. + // This triggers the replicator to re-mirror the upstream-status + // annotation to the downstream cluster with the current Ready:True + // state, which in turn triggers Envoy Gateway to re-translate xDS + // so the extension server injects the iroh cluster config. + // Without this, if the annotation was captured at Ready:False + // (race between replicator and lease renewal), the extension + // server serves 503 indefinitely. + let _ = service.refresh_connection_details().await; + + // Hostnames are written by the gateway controller shortly after + // Programmed=True. Poll until one appears (usually <1s). + if final_progress.hostnames.is_empty() { + for _ in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + if let Ok(Some(p)) = service.get_active_progress(&tunnel_id).await { + if !p.hostnames.is_empty() { + final_progress = p; + break; + } + } + } + } + let hostname = final_progress + .hostnames + .first() + .cloned() + .ok_or_else(|| { + n0_error::anyerr!("Tunnel {tunnel_id} has no hostname after Ready") + })?; + + // Re-fetch the up-to-date TunnelSummary for the tunnel_ready + // payload (existing contract — id, label, endpoint, hostnames, + // endpoint_id, status, elapsed_secs). + let tunnel = service + .get_active(&tunnel_id) + .await? + .ok_or_else(|| n0_error::anyerr!("Tunnel {tunnel_id} not found after setup"))?; + + let elapsed = setup_start.elapsed().as_secs(); + if json { + println!( + "{}", + serde_json::json!({ + "type": "tunnel_ready", + "id": tunnel.id, + "label": tunnel.label, + "endpoint": tunnel.endpoint, + "hostnames": tunnel.hostnames, + "endpoint_id": endpoint_id.to_string(), + "status": "ready", + "elapsed_secs": elapsed + }) + ); + } else { + for hostname in &tunnel.hostnames { + println!("Tunnel ready after {} sec: https://{}", elapsed, hostname); + } + } + + // --- Mid-session watch loop (Plan 12-04) --- + // After tunnel_ready, watch three signals concurrently: + // 1. ctrl_c — user-initiated clean shutdown (exit 0) + // 2. login_state — credential expiry/revocation guidance + // (text or JSON; does NOT exit so user can read) + // 3. 10s poll — detect mid-session terminal failure + // (e.g. iroh-DNS collision flips post-Ready) + // or upstream deletion (HTTPProxy removed) + // + // Cleanup (set_enabled_active false + tunnel_disabled) runs for + // ALL exit paths via the post-loop block. Informed by upstream + // datum-cloud/app@6264818 (runtime select-loop precedent). + let mut login_rx = datum.login_state_watch(); + let mut runtime_poll = + tokio::time::interval(std::time::Duration::from_secs(10)); + // First tick fires immediately; consume it so the first real poll + // happens 10s after tunnel_ready (not concurrently with it). + runtime_poll.tick().await; + + let exit_reason: ExitReason = loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + break ExitReason::CtrlC; + } + res = login_rx.changed() => { + if res.is_err() { + // Sender dropped — treat as a transient error, continue. + continue; + } + let state = login_rx.borrow().clone(); + if state == connect_lib::LoginState::Missing { + let guidance = + "Datum login has expired or been revoked. \ + Stop this command and run `datum login` to refresh credentials. \ + The tunnel will continue running on cached credentials until they expire."; + if json { + println!( + "{}", + serde_json::json!({ + "type": "tunnel_login_lost", + "id": tunnel_id, + "message": guidance + }) + ); + } else { + eprintln!("{}", guidance); + } + // Do NOT break — keep the tunnel running so the user has time to read. + } + } + _ = runtime_poll.tick() => { + match service.get_active_progress(&tunnel_id).await { + Ok(Some(progress)) => { + if let Some(failed) = progress.terminal_failure() { + let msg = progress::format_terminal_failure(failed); + if json { + println!( + "{}", + serde_json::json!({ + "type": "tunnel_terminal_failure", + "id": tunnel_id, + "message": msg + }) + ); + } else { + eprintln!("{}", msg); + } + break ExitReason::TerminalFailure; + } + } + Ok(None) => { + let msg = format!( + "Tunnel {tunnel_id} no longer exists on the server" + ); + if json { + println!( + "{}", + serde_json::json!({ + "type": "tunnel_deleted_upstream", + "id": tunnel_id, + "message": &msg + }) + ); + } else { + eprintln!("{}", msg); + } + break ExitReason::DeletedUpstream; + } + Err(e) => { + tracing::warn!("transient progress query error: {e}"); + } + } + } + } + }; + + // --- Cleanup (runs for all exit paths) --- + let outcome = service.delete_active(&tunnel_id).await; + match &outcome { + Ok(o) => { + if json { + let mut resources = Vec::new(); + if let Some(ref name) = o.http_proxy { + resources.push(serde_json::json!({"type": "HTTPProxy", "name": name})); + } + if let Some(ref name) = o.connector_ad { + resources.push(serde_json::json!({"type": "ConnectorAdvertisement", "name": name})); + } + if let Some(ref name) = o.traffic_protection_policy { + resources.push(serde_json::json!({"type": "TrafficProtectionPolicy", "name": name})); + } + if let Some(ref name) = o.connector { + resources.push(serde_json::json!({"type": "Connector", "name": name})); + } + println!( + "{}", + serde_json::json!({ + "type": "tunnel_deleted", + "id": tunnel_id, + "deleted": true, + "resources": resources + }) + ); + } else { + println!("Deleted tunnel {}", tunnel_id); + if let Some(ref name) = o.http_proxy { + println!(" HTTPProxy {}", name); + } + if let Some(ref name) = o.connector_ad { + println!(" ConnectorAdvertisement {}", name); + } + if let Some(ref name) = o.traffic_protection_policy { + println!(" TrafficProtectionPolicy {}", name); + } + if let Some(ref name) = o.connector { + println!(" Connector {}", name); + } + } + } + Err(e) => { + tracing::warn!("failed to delete tunnel on shutdown: {e}"); + if json { + println!( + "{}", + serde_json::json!({"type": "tunnel_deleted", "id": tunnel_id}) + ); + } + } + } + + // Non-zero exit for terminal failures. + return match exit_reason { + ExitReason::CtrlC => Ok(()), + ExitReason::TerminalFailure => { + Err(n0_error::anyerr!("tunnel exited with terminal failure")) + } + ExitReason::DeletedUpstream => { + Err(n0_error::anyerr!("tunnel deleted upstream")) + } + }; + } + Commands::Update { id, label, endpoint } => { + let node = ListenNode::new(repo.clone()).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let current = service + .get_active(&id) + .await? + .ok_or_else(|| n0_error::anyerr!("Tunnel {} not found", id))?; + let new_label = label.unwrap_or(current.label); + let new_endpoint = endpoint.unwrap_or(current.endpoint); + let tunnel = service.update_active(&id, &new_label, &new_endpoint).await?; + if json { + println!( + "{}", + serde_json::json!({ + "type": "tunnel_updated", + "id": tunnel.id, + "label": tunnel.label, + "endpoint": tunnel.endpoint, + "hostnames": tunnel.hostnames + }) + ); + } else { + println!("Updated tunnel {}:", tunnel.id); + println!(" label: {}", tunnel.label); + println!(" endpoint: {}", tunnel.endpoint); + } + } + Commands::Delete { id } => { + let node = ListenNode::new(repo.clone()).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let outcome = service.delete_active(&id).await?; + if json { + let mut resources = Vec::new(); + if let Some(ref name) = outcome.http_proxy { + resources.push(serde_json::json!({"type": "HTTPProxy", "name": name})); + } + if let Some(ref name) = outcome.connector_ad { + resources.push(serde_json::json!({"type": "ConnectorAdvertisement", "name": name})); + } + if let Some(ref name) = outcome.traffic_protection_policy { + resources.push(serde_json::json!({"type": "TrafficProtectionPolicy", "name": name})); + } + if let Some(ref name) = outcome.connector { + resources.push(serde_json::json!({"type": "Connector", "name": name})); + } + println!( + "{}", + serde_json::json!({ + "type": "tunnel_deleted", + "id": id, + "deleted": true, + "resources": resources + }) + ); + } else { + println!("Deleted tunnel {}", id); + if let Some(name) = outcome.http_proxy { + println!(" HTTPProxy {}", name); + } + if let Some(name) = outcome.connector_ad { + println!(" ConnectorAdvertisement {}", name); + } + if let Some(name) = outcome.traffic_protection_policy { + println!(" TrafficProtectionPolicy {}", name); + } + if let Some(name) = outcome.connector { + println!(" Connector {}", name); + } + } + } + } + Ok(()) +} diff --git a/connect-lib/bin/src/progress.rs b/connect-lib/bin/src/progress.rs new file mode 100644 index 0000000..ecc074e --- /dev/null +++ b/connect-lib/bin/src/progress.rs @@ -0,0 +1,427 @@ +//! Binary-only tunnel progress rendering. The lib is println!-free; all +//! presentation logic lives here. +//! +//! Three responsibilities: +//! * `format_terminal_failure` — humanises a failed `ProgressStep` into an +//! actionable, multi-line error message. The canonical case is the iroh-DNS +//! owner-collision (`IrohDnsPublished: Pending` with `DeferredToOwner`). +//! * `render_progress_step` / `render_verify` — mode-aware callbacks that emit +//! text-mode log lines on stderr or JSON event objects on stdout. +//! * `await_tunnel_progress` / `verify_endpoints` — async drivers that own the +//! polling loop and HTTP probes, invoking the callbacks above on transitions. +//! +//! Mode-routing rule: +//! - `Mode::Text` writes to stderr (so stdout stays clean for shell composition) +//! - `Mode::Json` writes JSON event objects to stdout (so the Go supervisor's +//! line-oriented stdin reader sees one event per line) +//! +//! # JSON EVENT CONTRACT (emitted by this module) +//! +//! | Event type | When | Fields | +//! |----------------------|-------------------------------|-----------------------------------------------------------------| +//! | `tunnel_progress` | per step status transition | `step` (snake_case kind), `status`, `resource` (Option) | +//! | `tunnel_verifying` | start of HTTP probe per URL | `url` | +//! | `tunnel_verified` | HTTP probe success per URL | `url` | +//! +//! All events go to stdout (one JSON object per line) when `Mode::Json` is +//! selected. In `Mode::Text`, transitions are printed to stderr in human form. +//! The Go supervisor (`connect/tunnel/listen/main.go`) acknowledges all three +//! types via explicit case arms but currently no-ops them — only +//! `tunnel_ready` (emitted from main.rs) drives `gotReady`. + +use std::collections::HashMap; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// Set to true once the first progress step has been rendered, so the +/// test harness can detect that setup-phase output was emitted. +pub static PROGRESS_SEEN: AtomicBool = AtomicBool::new(false); + +use connect_lib::{ProgressStep, ProgressStepKind, StepStatus, TunnelProgress, TunnelService}; +use n0_error::Result; +use tokio::time::{sleep, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Text, + Json, +} + +// --- format_terminal_failure --- + +pub fn format_terminal_failure(step: &ProgressStep) -> String { + let mut out = String::new(); + out.push_str(&format!( + "Tunnel setup failed at step: {} ({})\n", + step.kind.label(), + step.kind.resource_kind() + )); + out.push_str(&format!( + " resource: {}\n", + step.resource.as_deref().unwrap_or("(none)") + )); + if let Some(r) = &step.reason { + out.push_str(&format!(" reason: {}\n", r)); + } + if let Some(m) = &step.message { + out.push_str(&format!(" message: {}\n", m)); + } + if matches!(step.kind, ProgressStepKind::IrohDnsPublished) + && step.status == StepStatus::Pending + && step.reason.as_deref() == Some("DeferredToOwner") + { + out.push_str( + "\nAnother connector with the same iroh key owns the DNS record \ + for this tunnel. Most likely this means you are running two \ + connectors against the same listen_key store. Stop the other \ + connector or use a different repo directory.\n", + ); + } + out +} + +// --- step-name + status-name helpers (used by JSON callback) --- + +pub(crate) fn step_kind_to_str(k: ProgressStepKind) -> &'static str { + match k { + ProgressStepKind::ProxyAccepted => "proxy_accepted", + ProgressStepKind::CertificatesReady => "certificates_ready", + ProgressStepKind::ConnectorReady => "connector_ready", + ProgressStepKind::IrohDnsPublished => "iroh_dns_published", + ProgressStepKind::ProxyProgrammed => "proxy_programmed", + ProgressStepKind::ConnectorMetadataProgrammed => "connector_metadata_programmed", + } +} + +pub(crate) fn status_to_str(s: StepStatus) -> &'static str { + match s { + StepStatus::Unknown => "unknown", + StepStatus::Pending => "pending", + StepStatus::Ready => "ready", + } +} + +// --- callbacks --- + +pub fn render_progress_step(mode: Mode, step: &ProgressStep, _prev: StepStatus, elapsed: Duration) { + if step.status == StepStatus::Ready { + let _ = writeln!( + std::io::stderr(), + " \u{2713} {} ({:.1}s) [{}]", + step.kind.label(), + elapsed.as_secs_f64(), + step.resource.as_deref().unwrap_or(""), + ); + let _ = std::io::stderr().flush(); + } + if mode == Mode::Json { + let v = serde_json::json!({ + "type": "tunnel_progress", + "step": step_kind_to_str(step.kind), + "status": status_to_str(step.status), + "resource": step.resource, + }); + println!("{}", v); + } +} + +pub fn render_verify(mode: Mode, label: &str, url: &str, elapsed: Duration, status: Option) { + let status_str = match status { + Some(s) => format!(": HTTP {}", s), + None => String::new(), + }; + let _ = writeln!( + std::io::stderr(), + " \u{2713} {} ({:.1}s) [{}]{}", + label, + elapsed.as_secs_f64(), + url, + status_str, + ); + let _ = std::io::stderr().flush(); + if mode == Mode::Json { + let json_type = match status { + Some(_) => "tunnel_verified", + None => "tunnel_verifying", + }; + println!( + "{}", + serde_json::json!({ "type": json_type, "url": url }) + ); + } +} + +// --- URL builder for verify_endpoints --- + +pub fn build_probe_urls(endpoint: &str, hostname: &str) -> (String, String) { + let origin = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + endpoint.to_string() + } else { + format!("http://{}", endpoint) + }; + let proxy = format!("https://{}", hostname); + (origin, proxy) +} + +// --- await_tunnel_progress --- + +/// Poll `service.get_active_progress(tunnel_id)` on a 250ms cadence; emit a +/// transition callback for every step whose status changed since the previous +/// poll. Returns the final `TunnelProgress` when all steps are Ready, returns +/// an error formatted via `format_terminal_failure` when a terminal-failure +/// step is observed, and returns an error if the tunnel disappears upstream +/// during setup. Prints a status line to stderr every 10s for any step that +/// has been Pending for at least 10s. +pub async fn await_tunnel_progress( + service: &TunnelService, + tunnel_id: &str, + progress_cb: F, +) -> Result +where + F: Fn(&ProgressStep, StepStatus), +{ + let mut last_seen: HashMap = HashMap::new(); + let mut pending_since: HashMap = HashMap::new(); + let mut last_status_print: HashMap = HashMap::new(); + + loop { + let progress_opt = service + .get_active_progress(tunnel_id) + .await + .map_err(|e| n0_error::anyerr!("polling tunnel {tunnel_id} progress: {e}"))?; + let Some(progress) = progress_opt else { + return Err(n0_error::anyerr!( + "Tunnel {tunnel_id} disappeared during setup" + )); + }; + + // Diff and emit transitions. + for step in &progress.steps { + let prev = last_seen + .get(&step.kind) + .copied() + .unwrap_or(StepStatus::Unknown); + if prev != step.status { + progress_cb(step, prev); + last_seen.insert(step.kind, step.status); + } + // Track Pending duration; print status every 10s. + if step.status == StepStatus::Pending { + pending_since.entry(step.kind).or_insert_with(Instant::now); + if let Some(start) = pending_since.get(&step.kind) { + let secs = start.elapsed().as_secs(); + let last_print = last_status_print.get(&step.kind).copied().unwrap_or(0); + if secs >= 10 && secs - last_print >= 10 { + let _ = writeln!( + std::io::stderr(), + " \u{25CB} waiting for {} ({:.0}s) [{}]", + step.kind.label(), + start.elapsed().as_secs_f64(), + step.resource.as_deref().unwrap_or("") + ); + let _ = std::io::stderr().flush(); + last_status_print.insert(step.kind, secs); + } + } + } else { + pending_since.remove(&step.kind); + last_status_print.remove(&step.kind); + } + } + + // Check terminal failure. + if let Some(failed) = progress.terminal_failure() { + return Err(n0_error::anyerr!("{}", format_terminal_failure(failed))); + } + + if progress.all_ready() { + return Ok(progress); + } + + sleep(Duration::from_millis(250)).await; + } +} + +// --- verify_endpoints --- + +/// Probe the origin endpoint (HTTP, best-effort) and proxy URL (HTTPS, +/// indefinite). Origin is bounded by `budget` and is non-fatal on failure. +/// Proxy retries indefinitely with exponential backoff and prints a status +/// message every 10s (e.g. `" waiting for proxy [url] (30s) ... HTTP 503"`) +/// so the user sees progress even during long settling times. +pub async fn verify_endpoints( + origin_endpoint: &str, + hostname: &str, + budget: Duration, + verify_cb: F, +) -> Result<()> +where + F: Fn(&str, &str, Duration, Option), +{ + let (origin_url, proxy_url) = build_probe_urls(origin_endpoint, hostname); + + let per_attempt_timeout = Duration::from_secs(5); + let client = reqwest::Client::builder() + .timeout(per_attempt_timeout) + .danger_accept_invalid_certs(false) + .build() + .map_err(|e| n0_error::anyerr!("building reqwest client for verify_endpoints: {e}"))?; + + // Origin probe — best-effort with budget, non-fatal on failure. + match probe_until_reachable(&client, &origin_url, budget / 2).await { + Ok((elapsed, status)) => { + verify_cb("origin reachable", &origin_url, elapsed, Some(status)); + } + Err(_e) => { + let _ = writeln!( + std::io::stderr(), + "warning: origin {} did not respond within budget — continuing", + origin_url + ); + let _ = std::io::stderr().flush(); + } + } + + // Proxy probe — indefinite retry with periodic status every 10s. + let start = Instant::now(); + let mut backoff = Duration::from_millis(250); + let mut last_status = Instant::now(); + loop { + match client.get(&proxy_url).send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + if status < 500 { + verify_cb("proxy responding", &proxy_url, start.elapsed(), Some(status)); + return Ok(()); + } + if last_status.elapsed() >= Duration::from_secs(10) { + let _ = writeln!( + std::io::stderr(), + " \u{25CB} waiting for proxy [{}] ({:.0}s) ... HTTP {}", + proxy_url, + start.elapsed().as_secs_f64(), + status, + ); + let _ = std::io::stderr().flush(); + last_status = Instant::now(); + } + } + Err(_e) => { + if last_status.elapsed() >= Duration::from_secs(10) { + let _ = writeln!( + std::io::stderr(), + " \u{25CB} waiting for proxy [{}] ({:.0}s) ... no response", + proxy_url, + start.elapsed().as_secs_f64(), + ); + let _ = std::io::stderr().flush(); + last_status = Instant::now(); + } + } + } + let sleep_dur = std::cmp::min(backoff, Duration::from_secs(2)); + sleep(sleep_dur).await; + backoff = std::cmp::min(backoff * 2, Duration::from_secs(2)); + } +} + +async fn probe_until_reachable( + client: &reqwest::Client, + url: &str, + budget: Duration, +) -> Result<(Duration, u16)> { + let start = Instant::now(); + let mut backoff = Duration::from_millis(250); + loop { + if start.elapsed() >= budget { + return Err(n0_error::anyerr!("probe budget exhausted")); + } + match client.get(url).send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + if status < 500 { + return Ok((start.elapsed(), status)); + } + } + Err(_e) => {} + } + let remaining = budget.saturating_sub(start.elapsed()); + if remaining.is_zero() { + return Err(n0_error::anyerr!("probe budget exhausted")); + } + sleep(std::cmp::min(backoff, remaining)).await; + backoff = std::cmp::min(backoff * 2, Duration::from_secs(2)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn step(kind: ProgressStepKind, status: StepStatus, reason: Option<&str>) -> ProgressStep { + ProgressStep { + kind, + status, + reason: reason.map(String::from), + message: None, + resource: Some(format!("{}/x", kind.resource_kind())), + } + } + + #[test] + fn terminal_failure_iroh_owner_collision_includes_actionable_message() { + let s = step( + ProgressStepKind::IrohDnsPublished, + StepStatus::Pending, + Some("DeferredToOwner"), + ); + let out = format_terminal_failure(&s); + assert!(out.contains("Tunnel setup failed at step")); + assert!(out.contains("Another connector with the same iroh key")); + } + + #[test] + fn terminal_failure_generic_still_has_header_and_resource() { + let s = step( + ProgressStepKind::ProxyAccepted, + StepStatus::Pending, + Some("Whatever"), + ); + let out = format_terminal_failure(&s); + assert!(out.contains("Tunnel setup failed at step")); + assert!(out.contains("resource: HTTPProxy/x")); + assert!(!out.contains("Another connector with the same iroh key")); + } + + #[test] + fn build_probe_urls_adds_http_prefix_to_bare_endpoint() { + let (origin, proxy) = build_probe_urls("localhost:8080", "x.example.com"); + assert_eq!(origin, "http://localhost:8080"); + assert_eq!(proxy, "https://x.example.com"); + } + + #[test] + fn build_probe_urls_keeps_scheme_when_present() { + let (origin, _) = build_probe_urls("https://api.example.com", "x.example.com"); + assert_eq!(origin, "https://api.example.com"); + } + + #[test] + fn json_progress_event_parses_back_to_expected_fields() { + // We can't directly capture println output in a unit test trivially; + // instead, reconstruct the same json! body and re-parse. + let s = step(ProgressStepKind::ProxyAccepted, StepStatus::Ready, None); + let v = serde_json::json!({ + "type": "tunnel_progress", + "step": step_kind_to_str(s.kind), + "status": status_to_str(s.status), + "resource": s.resource, + }); + let parsed: serde_json::Value = serde_json::from_str(&v.to_string()).unwrap(); + assert_eq!(parsed["type"], "tunnel_progress"); + assert_eq!(parsed["step"], "proxy_accepted"); + assert_eq!(parsed["status"], "ready"); + assert!(parsed["resource"].is_string()); + } +} diff --git a/connect-lib/lib/Cargo.toml b/connect-lib/lib/Cargo.toml new file mode 100644 index 0000000..d9005c1 --- /dev/null +++ b/connect-lib/lib/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "connect-lib" +version = "0.1.0" +edition = "2024" + +[dependencies] +arc-swap = { workspace = true, features = ["serde"] } +base64 = { workspace = true } +chrono = { workspace = true } +derive_more = { workspace = true } +gethostname = { workspace = true } +hex = { workspace = true } +http = { workspace = true } +k8s-openapi = { workspace = true } +kube = { workspace = true } +n0-error = { workspace = true } +n0-future = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yml = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +# iroh dependencies +iroh = { version = "0.95", default-features = false } +iroh-base = { version = "0.95" } +iroh-relay = { version = "0.95" } +iroh-tickets = "0.2" +iroh-n0des = { version = "0.8" } +iroh-proxy-utils = { git = "https://github.com/n0-computer/iroh-proxy-utils", rev = "38ef14f7bc215348d47987563bb1b5198cc91f40" } + +# postcard for ticket serialization +postcard = "1" + +[dev-dependencies] +uuid = { version = "1", features = ["v4"] } diff --git a/connect-lib/lib/src/config.rs b/connect-lib/lib/src/config.rs new file mode 100644 index 0000000..e35e886 --- /dev/null +++ b/connect-lib/lib/src/config.rs @@ -0,0 +1,74 @@ +use std::{ + fs, + net::{SocketAddr, SocketAddrV4, SocketAddrV6}, + path::PathBuf, +}; + +use n0_error::{Result, StackResultExt, StdResultExt}; +use serde::{Deserialize, Serialize}; + +use crate::SelectedContext; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum DiscoveryMode { + #[default] + /// Use the built-in n0des discovery defaults. + Default, + /// Use only DNS discovery (_iroh..). + Dns, + /// Use both n0des defaults and DNS discovery. + Hybrid, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct Config { + /// The IPv4 address that the endpoint will listen on. + /// + /// If None, defaults to a random free port, but it can be useful to specify a fixed + /// port, e.g. to configure a firewall rule. + pub ipv4_addr: Option, + + /// The IPv6 address that the endpoint will listen on. + /// + /// If None, defaults to a random free port, but it can be useful to specify a fixed + /// port, e.g. to configure a firewall rule. + pub ipv6_addr: Option, + + /// How the gateway resolves endpoint connection details. + #[serde(default)] + pub discovery_mode: DiscoveryMode, + + /// DNS origin domain used for _iroh.. lookups. + /// + /// Required when discovery_mode is `dns` or `hybrid`. + #[serde(default)] + pub dns_origin: Option, + + /// Optional DNS resolver address for discovery lookups. + /// + /// Useful for local development (e.g. 127.0.0.1:53535). + #[serde(default)] + pub dns_resolver: Option, + + /// The currently selected org/project context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_context: Option, +} + +impl Config { + pub async fn from_file(path: PathBuf) -> Result { + let config = tokio::fs::read_to_string(path) + .await + .context("reading config file")?; + let config = serde_yml::from_str(&config).std_context("parsing config file")?; + Ok(config) + } + + pub async fn write(&self, path: PathBuf) -> Result<()> { + let data = serde_yml::to_string(self).anyerr()?; + fs::write(path, data)?; + Ok(()) + } +} diff --git a/connect-lib/lib/src/datum_apis/connector.rs b/connect-lib/lib/src/datum_apis/connector.rs new file mode 100644 index 0000000..ed53494 --- /dev/null +++ b/connect-lib/lib/src/datum_apis/connector.rs @@ -0,0 +1,112 @@ +use k8s_openapi::{api::core::v1, apimachinery::pkg::apis::meta::v1 as metav1}; +use kube::CustomResource; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalConnectorReference { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ConnectorCapabilityType { + #[serde(rename = "ConnectTCP")] + ConnectTcp, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorCapabilityCommon { + pub disabled: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorCapabilityConnectTCP { + #[serde(flatten)] + pub common: ConnectorCapabilityCommon, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorCapability { + #[serde(rename = "type")] + pub capability_type: ConnectorCapabilityType, + pub connect_tcp: Option, +} + +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize)] +#[kube( + group = "networking.datumapis.com", + version = "v1alpha1", + kind = "Connector", + plural = "connectors", + namespaced, + status = "ConnectorStatus", + schema = "disabled" +)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorSpec { + pub connector_class_name: String, + pub capabilities: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PublicKeyDiscoveryMode { + #[serde(rename = "DNS")] + Dns, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublicKeyConnectorAddress { + pub address: String, + pub port: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorConnectionDetailsPublicKey { + pub id: String, + pub discovery_mode: Option, + pub home_relay: String, + pub addresses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ConnectorConnectionType { + #[serde(rename = "PublicKey")] + PublicKey, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorConnectionDetails { + #[serde(rename = "type")] + pub connection_type: ConnectorConnectionType, + pub public_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorCapabilityStatus { + #[serde(rename = "type")] + pub capability_type: ConnectorCapabilityType, + pub conditions: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorStatus { + pub capabilities: Option>, + pub conditions: Option>, + pub connection_details: Option, + pub lease_ref: Option, +} + +pub const CONNECTOR_CONDITION_READY: &str = "Ready"; +pub const CONNECTOR_CONDITION_IROH_DNS_PUBLISHED: &str = "IrohDNSPublished"; +/// The iroh DNS record is already owned by another Connector with the same +/// public key — typically a Connector in a different project. The losing +/// Connector cannot publish DNS and its tunnel data plane is silently +/// unreachable. See network-services-operator iroh_dns_controller.go. +pub const CONNECTOR_REASON_DEFERRED_TO_OWNER: &str = "DeferredToOwner"; diff --git a/connect-lib/lib/src/datum_apis/connector_advertisement.rs b/connect-lib/lib/src/datum_apis/connector_advertisement.rs new file mode 100644 index 0000000..ee6c787 --- /dev/null +++ b/connect-lib/lib/src/datum_apis/connector_advertisement.rs @@ -0,0 +1,65 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1 as metav1; +use kube::CustomResource; +use serde::{Deserialize, Serialize}; + +use crate::datum_apis::connector::LocalConnectorReference; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Layer4ServiceAddress(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Protocol { + #[serde(rename = "TCP")] + Tcp, + #[serde(rename = "UDP")] + Udp, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Layer4ServicePort { + pub name: String, + pub port: i32, + pub protocol: Protocol, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorAdvertisementLayer4Service { + pub address: Layer4ServiceAddress, + pub ports: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorAdvertisementLayer4 { + pub name: String, + pub services: Vec, +} + +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize)] +#[kube( + group = "networking.datumapis.com", + version = "v1alpha1", + kind = "ConnectorAdvertisement", + plural = "connectoradvertisements", + namespaced, + status = "ConnectorAdvertisementStatus", + schema = "disabled" +)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorAdvertisementSpec { + pub connector_ref: LocalConnectorReference, + pub layer4: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorAdvertisementStatus { + pub conditions: Option>, +} + +pub const CONNECTOR_ADVERTISEMENT_CONDITION_ACCEPTED: &str = "Accepted"; +pub const CONNECTOR_ADVERTISEMENT_REASON_ACCEPTED: &str = "Accepted"; +pub const CONNECTOR_ADVERTISEMENT_REASON_PENDING: &str = "Pending"; +pub const CONNECTOR_ADVERTISEMENT_REASON_CONNECTOR_NOT_FOUND: &str = "ConnectorNotFound"; diff --git a/connect-lib/lib/src/datum_apis/connector_class.rs b/connect-lib/lib/src/datum_apis/connector_class.rs new file mode 100644 index 0000000..dc42581 --- /dev/null +++ b/connect-lib/lib/src/datum_apis/connector_class.rs @@ -0,0 +1,13 @@ +use kube::CustomResource; +use serde::{Deserialize, Serialize}; + +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize)] +#[kube( + group = "networking.datumapis.com", + version = "v1alpha1", + kind = "ConnectorClass", + plural = "connectorclasses", + schema = "disabled" +)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorClassSpec {} diff --git a/connect-lib/lib/src/datum_apis/http_proxy.rs b/connect-lib/lib/src/datum_apis/http_proxy.rs new file mode 100644 index 0000000..7fd3264 --- /dev/null +++ b/connect-lib/lib/src/datum_apis/http_proxy.rs @@ -0,0 +1,167 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1 as metav1; +use serde::{Deserialize, Serialize}; + +pub type Hostname = String; +pub type SectionName = String; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayStatusAddress { + pub ip: Option, + pub hostname: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HTTPRouteRulesMatchesHeaders { + pub name: String, + #[serde(rename = "type")] + pub r#type: Option, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HTTPRouteRulesMatchesHeadersType { + Exact, + RegularExpression, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HTTPRouteRulesMatchesPath { + #[serde(rename = "type")] + pub r#type: Option, + pub value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HTTPRouteRulesMatchesPathType { + PathPrefix, + Exact, + RegularExpression, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HTTPRouteMatch { + pub path: Option, + pub headers: Option>, + #[serde(default)] + pub method: Option, + #[serde(default)] + pub query_params: Option>, + #[serde(default)] + pub time_of_day: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HTTPRouteRulesMatchesQueryParams { + pub name: String, + #[serde(rename = "type")] + pub r#type: Option, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HTTPRouteRulesMatchesQueryParamsType { + Exact, + RegularExpression, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HTTPRouteRulesMatchesTimeOfDay { + pub time: String, + pub modifier: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPRouteRulesFiltersRequestRedirect { + pub scheme: Option, + pub status_code: Option, + pub hostname: Option, + pub path: Option, + pub port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPRouteRulesFilters { + pub request_redirect: Option, + #[serde(rename = "type")] + pub r#type: HTTPRouteRulesFiltersType, + pub extension_ref: Option, + pub request_header_modifier: Option, + pub request_mirror: Option, + pub response_header_modifier: Option, + pub url_rewrite: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HTTPRouteRulesFiltersType { + RequestRedirect, + RequestHeaderModifier, + ResponseHeaderModifier, + URLRewrite, + RequestMirror, + ExtensionRef, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorReference { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPProxyRuleBackend { + pub endpoint: String, + pub connector: Option, + pub filters: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPProxyRule { + pub name: Option, + pub matches: Vec, + pub filters: Option>, + pub backends: Option>, +} + +#[derive(kube::CustomResource, Debug, Clone, Serialize, Deserialize)] +#[kube( + group = "networking.datumapis.com", + version = "v1alpha", + kind = "HTTPProxy", + plural = "httpproxies", + namespaced, + status = "HTTPProxyStatus", + schema = "disabled" +)] +#[serde(rename_all = "camelCase")] +pub struct HTTPProxySpec { + pub hostnames: Option>, + pub rules: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPProxyStatus { + pub addresses: Option>, + pub hostnames: Option>, + pub conditions: Option>, +} + +pub const HTTP_PROXY_CONDITION_ACCEPTED: &str = "Accepted"; +pub const HTTP_PROXY_CONDITION_PROGRAMMED: &str = "Programmed"; +pub const HTTP_PROXY_CONDITION_HOSTNAMES_VERIFIED: &str = "HostnamesVerified"; +pub const HTTP_PROXY_CONDITION_HOSTNAMES_IN_USE: &str = "HostnamesInUse"; +pub const HTTP_PROXY_CONDITION_CERTIFICATES_READY: &str = "CertificatesReady"; +pub const HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED: &str = "ConnectorMetadataProgrammed"; + +pub const HTTP_PROXY_REASON_ACCEPTED: &str = "Accepted"; +pub const HTTP_PROXY_REASON_PROGRAMMED: &str = "Programmed"; +pub const HTTP_PROXY_REASON_CONFLICT: &str = "Conflict"; +pub const HTTP_PROXY_REASON_PENDING: &str = "Pending"; +pub const HTTP_PROXY_REASON_HOSTNAMES_VERIFIED: &str = "HostnamesVerified"; +pub const HTTP_PROXY_REASON_UNVERIFIED_HOSTNAMES_PRESENT: &str = "UnverifiedHostnamesPresent"; +pub const HTTP_PROXY_REASON_HOSTNAME_IN_USE: &str = "HostnameInUse"; diff --git a/connect-lib/lib/src/datum_apis/lease.rs b/connect-lib/lib/src/datum_apis/lease.rs new file mode 100644 index 0000000..0d92929 --- /dev/null +++ b/connect-lib/lib/src/datum_apis/lease.rs @@ -0,0 +1 @@ +pub type Lease = k8s_openapi::api::coordination::v1::Lease; diff --git a/connect-lib/lib/src/datum_apis/mod.rs b/connect-lib/lib/src/datum_apis/mod.rs new file mode 100644 index 0000000..86d2f5a --- /dev/null +++ b/connect-lib/lib/src/datum_apis/mod.rs @@ -0,0 +1,6 @@ +pub mod connector; +pub mod connector_class; +pub mod connector_advertisement; +pub mod http_proxy; +pub mod lease; +pub mod traffic_protection_policy; diff --git a/connect-lib/lib/src/datum_apis/traffic_protection_policy.rs b/connect-lib/lib/src/datum_apis/traffic_protection_policy.rs new file mode 100644 index 0000000..6e27279 --- /dev/null +++ b/connect-lib/lib/src/datum_apis/traffic_protection_policy.rs @@ -0,0 +1,108 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1 as metav1; +use kube::CustomResource; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TrafficProtectionPolicyMode { + Observe, + Enforce, + Disabled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalPolicyTargetReferenceWithSectionName { + pub group: String, + pub kind: String, + pub name: String, + pub section_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TrafficProtectionPolicyRuleSetType { + OWASPCoreRuleSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParanoiaLevels { + pub blocking: Option, + pub detection: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OWASPScoreThresholds { + pub inbound: Option, + pub outbound: Option, +} + +pub type OWASPIDRange = String; +pub type OWASPTag = String; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OWASPRuleExclusions { + pub tags: Option>, + pub ids: Option>, + pub id_ranges: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OWASPCRS { + pub paranoia_levels: Option, + pub score_thresholds: Option, + pub rule_exclusions: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrafficProtectionPolicyRuleSet { + #[serde(rename = "type")] + pub rule_set_type: TrafficProtectionPolicyRuleSetType, + pub owasp_core_rule_set: Option, +} + +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize)] +#[kube( + group = "networking.datumapis.com", + version = "v1alpha", + kind = "TrafficProtectionPolicy", + plural = "trafficprotectionpolicies", + namespaced, + status = "TrafficProtectionPolicyStatus", + schema = "disabled" +)] +#[serde(rename_all = "camelCase")] +pub struct TrafficProtectionPolicySpec { + pub target_refs: Vec, + pub mode: Option, + pub sampling_percentage: Option, + pub rule_sets: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyAncestorRef { + pub name: String, + pub group: Option, + pub kind: Option, + pub namespace: Option, + pub port: Option, + pub section_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyAncestorStatus { + pub ancestor_ref: PolicyAncestorRef, + pub controller_name: String, + pub conditions: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrafficProtectionPolicyStatus { + pub ancestors: Option>, +} diff --git a/connect-lib/lib/src/datum_cloud/env.rs b/connect-lib/lib/src/datum_cloud/env.rs new file mode 100644 index 0000000..fbc5ae0 --- /dev/null +++ b/connect-lib/lib/src/datum_cloud/env.rs @@ -0,0 +1,152 @@ +use std::{borrow::Cow, env}; + +use serde::{Deserialize, Serialize}; + +const STAGING_API_URL: &str = "https://api.staging.env.datum.net"; +const PROD_API_URL: &str = "https://api.datum.net"; +const STAGING_WEB_URL: &str = "https://cloud.staging.env.datum.net"; +const PROD_WEB_URL: &str = "https://cloud.datum.net"; + +/// Environment for Datum API. Use [`ApiEnv::default()`] to respect `DATUM_API_HOST` first, +/// then `DATUM_API_ENV`. Use [`ApiEnv::from_env_with_host_override()`] for explicit host override. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ApiEnv { + Staging, + Production, + /// Custom API host (plugin mode override). + Custom { api_url: String }, +} + +impl ApiEnv { + /// Uses `DATUM_API_ENV`: `staging` → Staging, anything else (including unset) → Production. + fn from_env() -> Self { + match env::var("DATUM_API_ENV").as_deref() { + Ok("staging") => ApiEnv::Staging, + _ => ApiEnv::Production, + } + } + + /// Checks `DATUM_API_HOST` first, falls back to `from_env()`. + /// + /// In plugin mode, the Go plugin sets `DATUM_API_HOST` to point at a + /// specific API host. This method honors that override. + /// + /// If the host value does not have a scheme (`http://` or `https://`), + /// `https://` is prepended automatically so that downstream URL + /// construction always produces valid absolute URLs. + /// + /// An empty `DATUM_API_HOST` (set to `""`) is treated as unset — the + /// function falls through to `from_env()`. + pub fn from_env_with_host_override() -> Self { + if let Ok(host) = env::var("DATUM_API_HOST") { + if !host.is_empty() { + let api_url = if host.starts_with("http://") || host.starts_with("https://") { + host + } else { + format!("https://{}", host) + }; + return ApiEnv::Custom { api_url }; + } + } + Self::from_env() + } + + pub fn api_url(&self) -> Cow<'static, str> { + match self { + ApiEnv::Staging => Cow::Borrowed(STAGING_API_URL), + ApiEnv::Production => Cow::Borrowed(PROD_API_URL), + ApiEnv::Custom { api_url } => Cow::Owned(api_url.clone()), + } + } + + pub fn web_url(&self) -> Cow<'static, str> { + match self { + ApiEnv::Staging => Cow::Borrowed(STAGING_WEB_URL), + ApiEnv::Production => Cow::Borrowed(PROD_WEB_URL), + ApiEnv::Custom { api_url } => Cow::Owned( + api_url + .replace("api.", "app.") + .replace("//api.", "//app."), + ), + } + } +} + +impl Default for ApiEnv { + fn default() -> Self { + Self::from_env_with_host_override() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cleanup_env() { + unsafe { + std::env::remove_var("DATUM_API_ENV"); + std::env::remove_var("DATUM_API_HOST"); + } + } + + #[test] + fn default_respects_datum_api_env_when_no_host() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + cleanup_env(); + assert!(matches!(ApiEnv::default(), ApiEnv::Production)); + unsafe { std::env::set_var("DATUM_API_ENV", "staging"); } + assert!(matches!(ApiEnv::default(), ApiEnv::Staging)); + } + + #[test] + fn from_env_with_host_override_uses_datum_api_host() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + cleanup_env(); + unsafe { std::env::set_var("DATUM_API_HOST", "https://custom.example.com"); } + let env = ApiEnv::from_env_with_host_override(); + assert!(matches!(&env, ApiEnv::Custom { api_url } if api_url == "https://custom.example.com")); + } + + #[test] + fn api_url_custom_returns_host() { + let env = ApiEnv::Custom { api_url: "https://my.api.com".to_string() }; + assert_eq!(env.api_url(), "https://my.api.com"); + } + + #[test] + fn from_env_with_host_override_adds_https_scheme_when_missing() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + cleanup_env(); + unsafe { std::env::set_var("DATUM_API_HOST", "api.datum.net"); } + let env = ApiEnv::from_env_with_host_override(); + assert!(matches!(&env, ApiEnv::Custom { api_url } if api_url == "https://api.datum.net")); + } + + #[test] + fn from_env_with_host_override_preserves_existing_scheme() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + cleanup_env(); + unsafe { std::env::set_var("DATUM_API_HOST", "http://internal.api.datum.net"); } + let env = ApiEnv::from_env_with_host_override(); + assert!(matches!(&env, ApiEnv::Custom { api_url } if api_url == "http://internal.api.datum.net")); + } + + #[test] + fn from_env_with_host_override_empty_falls_back_to_production() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + cleanup_env(); + unsafe { std::env::set_var("DATUM_API_HOST", ""); } + let env = ApiEnv::from_env_with_host_override(); + assert!(matches!(env, ApiEnv::Production)); + } + + #[test] + fn api_url_staging_returns_staging_url() { + assert_eq!(ApiEnv::Staging.api_url(), STAGING_API_URL); + } + + #[test] + fn api_url_production_returns_prod_url() { + assert_eq!(ApiEnv::Production.api_url(), PROD_API_URL); + } +} diff --git a/connect-lib/lib/src/datum_cloud/external_token_source.rs b/connect-lib/lib/src/datum_cloud/external_token_source.rs new file mode 100644 index 0000000..9c0c7ca --- /dev/null +++ b/connect-lib/lib/src/datum_cloud/external_token_source.rs @@ -0,0 +1,488 @@ +use std::env; +use std::process::Command; + +use arc_swap::ArcSwap; +use base64::Engine; +use secrecy::{ExposeSecret, SecretString}; +use tokio::sync::watch; +use tracing::{debug, warn}; + +/// Errors that can occur when constructing an [`ExternalTokenSource`] from environment. +#[derive(Debug, thiserror::Error)] +pub enum ExternalTokenError { + #[error("DATUM_CREDENTIALS_HELPER environment variable not set")] + MissingHelper, + #[error("DATUM_SESSION not set and no session argument provided")] + MissingSession, + #[error("credentials helper exec failed: {0}")] + HelperExecError(String), + #[error("invalid JWT token: {0}")] + InvalidToken(String), + #[error("failed to parse JWT payload: {0}")] + JwtParse(#[source] serde_json::Error), +} + +/// Manages a bearer token provided from an external source (credentials helper + refresh loop). +/// +/// Used in plugin mode. The token is obtained at startup by executing the +/// credentials helper (`DATUM_CREDENTIALS_HELPER auth get-token --session `) +/// and refreshed periodically before JWT expiry or on demand via [`force_refresh()`](Self::force_refresh). +#[derive(Clone)] +pub struct ExternalTokenSource { + token: std::sync::Arc>, + token_tx: std::sync::Arc>, + refresh_trigger: std::sync::Arc>, +} + +impl std::fmt::Debug for ExternalTokenSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalTokenSource") + .finish_non_exhaustive() + } +} + +impl ExternalTokenSource { + /// Creates an `ExternalTokenSource` by executing the credentials helper + /// at startup to obtain the initial token. + /// + /// `session` is the session name to pass to `auth get-token --session `. + /// If `None`, falls back to `DATUM_SESSION` env var. + pub fn from_env(session: Option) -> Result { + let helper = + env::var("DATUM_CREDENTIALS_HELPER").map_err(|_| ExternalTokenError::MissingHelper)?; + + let session = match session { + Some(s) => s, + None => env::var("DATUM_SESSION").map_err(|_| ExternalTokenError::MissingSession)?, + }; + + let token = Self::exec_helper(&helper, &session)?; + + let exp = parse_jwt_expiry(&token).map_err(|e| { + ExternalTokenError::InvalidToken(format!("failed to extract expiry: {e}")) + })?; + + debug!( + token_len = token.len(), + exp = ?exp, + "ExternalTokenSource::from_env — token loaded from helper" + ); + + let (token_tx, _) = watch::channel(token.clone()); + let (refresh_tx, _) = watch::channel(0u64); + + Ok(Self { + token: std::sync::Arc::new(ArcSwap::from_pointee(SecretString::new( + token.clone().into(), + ))), + token_tx: std::sync::Arc::new(token_tx), + refresh_trigger: std::sync::Arc::new(refresh_tx), + }) + } + + /// Returns the current token as a plain `String`. + pub fn token(&self) -> String { + self.token.load_full().expose_secret().to_string() + } + + /// Returns a watch channel subscriber for token updates. + pub fn watch(&self) -> watch::Receiver { + self.token_tx.subscribe() + } + + /// Atomically swaps the token and notifies watch subscribers. + pub fn swap_token(&self, new_token: String) { + debug!( + new_token_len = new_token.len(), + "ExternalTokenSource::swap_token" + ); + self.token.store(std::sync::Arc::new(SecretString::new( + new_token.clone().into(), + ))); + let _ = self.token_tx.send(new_token); + } + + /// Start the background refresh loop. Must be called from within a tokio runtime. + /// + /// The loop periodically re-executes the credentials helper before the current + /// token expires, calls [`swap_token()`](Self::swap_token) with the result, + /// and responds to [`force_refresh()`](Self::force_refresh) signals. + pub fn start_refresh(&self, helper: String, session: String) { + let this = self.clone(); + let mut refresh_rx = self.refresh_trigger.subscribe(); + let initial_exp = match parse_jwt_expiry(&self.token()) { + Ok(exp) => exp, + Err(_) => None, + }; + tokio::spawn(async move { + this.run_refresh_loop(helper, session, &mut refresh_rx, initial_exp) + .await; + }); + } + + /// Triggers an immediate token refresh. + /// + /// Call this when a 401 response is observed from the API. + /// The refresh loop wakes up early, re-executes the credentials helper, + /// and calls [`swap_token()`](Self::swap_token) with the result. + pub fn force_refresh(&self) { + let current = *self.refresh_trigger.borrow(); + let _ = self.refresh_trigger.send(current.wrapping_add(1)); + } + + fn exec_helper(helper: &str, session: &str) -> Result { + let output = Command::new(helper) + .args(["auth", "get-token", "--session", session]) + .output() + .map_err(|e| ExternalTokenError::HelperExecError(format!("exec failed: {e}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ExternalTokenError::HelperExecError(format!( + "exit code {}: {}", + output.status, + stderr.trim() + ))); + } + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if token.is_empty() { + return Err(ExternalTokenError::HelperExecError( + "empty token returned".into(), + )); + } + Ok(token) + } + + async fn run_refresh_loop( + self, + helper: String, + session: String, + refresh_rx: &mut watch::Receiver, + initial_exp: Option, + ) { + // Compute the next refresh time: 60s before JWT expiry, or 1h from now if no expiry. + let mut next_refresh: std::time::SystemTime = initial_exp + .and_then(|exp| { + std::time::UNIX_EPOCH + .checked_add(std::time::Duration::from_secs(exp.saturating_sub(60))) + }) + .unwrap_or_else(|| { + std::time::SystemTime::now() + std::time::Duration::from_secs(3600) + }); + + let mut backoff = std::time::Duration::from_secs(5); + const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60); + + loop { + let now = std::time::SystemTime::now(); + let wait = if next_refresh > now { + next_refresh + .duration_since(now) + .unwrap_or(std::time::Duration::ZERO) + } else { + std::time::Duration::ZERO + }; + + // Wait either for the timer or a force_refresh signal + tokio::select! { + _ = tokio::time::sleep(wait) => {}, + _ = refresh_rx.changed() => { + debug!("ExternalTokenSource: forced refresh triggered"); + } + } + + // Execute helper to get a fresh token + match Self::exec_helper(&helper, &session) { + Ok(new_token) => { + self.swap_token(new_token.clone()); + backoff = std::time::Duration::from_secs(5); // Reset backoff + + // Parse new expiry for next refresh + next_refresh = match parse_jwt_expiry(&new_token) { + Ok(Some(exp)) => std::time::UNIX_EPOCH + + std::time::Duration::from_secs(exp.saturating_sub(60)), + _ => { + std::time::SystemTime::now() + + std::time::Duration::from_secs(3600) + } + }; + } + Err(e) => { + warn!("token refresh failed: {e}"); + // Retry with backoff + next_refresh = std::time::SystemTime::now() + backoff; + backoff = std::cmp::min(backoff * 2, MAX_BACKOFF); + } + } + } + } +} + +/// Parse the `exp` (expiry) claim from the middle segment of a JWT. +/// +/// Returns `None` if the claim is missing (caller may default to 1 h). +fn parse_jwt_expiry(token: &str) -> Result, JwtParseError> { + let parts: Vec<&str> = token.splitn(3, '.').collect(); + if parts.len() < 2 { + return Err(JwtParseError::InvalidToken( + "JWT must have at least 2 segments (header.payload[.signature])".into(), + )); + } + + let payload_b64 = parts[1]; + + // Base64url decode: replace URL-safe chars with standard base64 chars, then pad. + let mut standard_b64 = payload_b64.replace('-', "+").replace('_', "/"); + let pad = 4 - standard_b64.len() % 4; + if pad != 4 { + standard_b64.extend((0..pad).map(|_| '=')); + } + + let decoded = base64::engine::general_purpose::STANDARD + .decode(&standard_b64) + .map_err(|e| JwtParseError::InvalidBase64(e.to_string()))?; + + let payload_str = + String::from_utf8(decoded).map_err(|e| JwtParseError::InvalidUtf8(e.to_string()))?; + + let value: serde_json::Value = + serde_json::from_str(&payload_str).map_err(JwtParseError::Json)?; + + Ok(value.get("exp").and_then(|v| v.as_u64())) +} + +#[derive(Debug, thiserror::Error)] +enum JwtParseError { + #[error("invalid JWT format: {0}")] + InvalidToken(String), + #[error("invalid base64url encoding: {0}")] + InvalidBase64(String), + #[error("invalid UTF-8 in JWT payload: {0}")] + InvalidUtf8(String), + #[error("failed to parse JWT payload as JSON: {0}")] + Json(#[source] serde_json::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: create a JWT-like string with a given exp claim. + fn make_jwt_with_exp(exp: u64) -> String { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::json!({"alg":"HS256","typ":"JWT"}) + .to_string() + .as_bytes(), + ); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::json!({"exp": exp, "sub":"test-user"}) + .to_string() + .as_bytes(), + ); + format!("{header}.{payload}.fake_signature_here") + } + + /// A temporary directory that cleans up on drop. + struct TempDir { + path: std::path::PathBuf, + } + + impl TempDir { + fn new() -> Self { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("connect-ets-test-{ts}")); + std::fs::create_dir_all(&path).expect("should create temp dir"); + TempDir { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + /// Create a temporary helper script that outputs a fake JWT, set env vars, + /// and return a configured [`ExternalTokenSource`]. + /// + /// The returned `TempDir` keeps the script alive for the test scope. + fn setup_plugin_env() -> (TempDir, ExternalTokenSource) { + let _lock = crate::ENV_LOCK.lock().unwrap(); + let dir = TempDir::new(); + let helper_path = dir.path().join("fake-helper.sh"); + let jwt = make_jwt_with_exp(9999999999); + std::fs::write(&helper_path, format!("#!/bin/sh\necho '{}'\n", jwt)) + .expect("should write helper script"); + #[cfg(unix)] + std::fs::set_permissions( + &helper_path, + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .expect("should set executable permission"); + let helper_str = helper_path.to_string_lossy().to_string(); + + unsafe { + std::env::set_var("DATUM_CREDENTIALS_HELPER", &helper_str); + std::env::set_var("DATUM_SESSION", "test-session"); + } + + let source = + ExternalTokenSource::from_env(Some("test-session".to_string())).expect("should create token source"); + (dir, source) + } + + #[test] + fn parse_jwt_expiry_extracts_exp() { + let token = make_jwt_with_exp(1700000000); + let exp = parse_jwt_expiry(&token).unwrap().unwrap(); + assert_eq!(exp, 1700000000); + } + + #[test] + fn parse_jwt_expiry_returns_none_when_missing() { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{}"); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::json!({"sub":"test-user"}).to_string().as_bytes(), + ); + let token = format!("{header}.{payload}.sig"); + let exp = parse_jwt_expiry(&token).unwrap(); + assert!(exp.is_none()); + } + + #[test] + fn parse_jwt_expiry_rejects_too_short() { + let result = parse_jwt_expiry("not-a-jwt"); + assert!(result.is_err()); + } + + #[test] + fn parse_jwt_expiry_rejects_invalid_base64() { + let token = format!("header.!!!.sig"); + let result = parse_jwt_expiry(&token); + assert!(result.is_err()); + } + + #[test] + fn parse_jwt_expiry_rejects_invalid_json() { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{}"); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not-json"); + let token = format!("{header}.{payload}.sig"); + let result = parse_jwt_expiry(&token); + assert!(result.is_err()); + } + + #[test] + fn parse_jwt_expiry_handles_url_safe_chars() { + let payload_json = serde_json::json!({"exp": 9999999999u64, "sub": "test"}); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + payload_json.to_string().as_bytes(), + ); + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{}"); + let token = format!("{header}.{payload_b64}.sig"); + let exp = parse_jwt_expiry(&token).unwrap().unwrap(); + assert_eq!(exp, 9999999999); + } + + #[test] + fn from_env_requires_helper() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("DATUM_CREDENTIALS_HELPER"); + std::env::set_var("DATUM_SESSION", "test-session"); + } + let result = ExternalTokenSource::from_env(Some("test-session".to_string())); + assert!(matches!(result, Err(ExternalTokenError::MissingHelper))); + } + + #[test] + fn from_env_requires_session() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("DATUM_CREDENTIALS_HELPER", "/bin/echo"); + std::env::remove_var("DATUM_SESSION"); + } + let result = ExternalTokenSource::from_env(None); + assert!(matches!(result, Err(ExternalTokenError::MissingSession))); + } + + #[test] + fn from_env_succeeds_with_fake_helper() { + let (_dir, source) = setup_plugin_env(); + assert!(source.token().starts_with("eyJ")); + } + + #[test] + fn from_env_requires_datum_credentials_helper() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("DATUM_CREDENTIALS_HELPER"); + std::env::set_var("DATUM_SESSION", "test-session"); + } + let result = ExternalTokenSource::from_env(None); + assert!(matches!(result, Err(ExternalTokenError::MissingHelper))); + } + + #[test] + fn swap_token_updates_and_notifies_watch() { + let (_dir, source) = setup_plugin_env(); + + let rx = source.watch(); + let new_token = make_jwt_with_exp(8888888888); + source.swap_token(new_token.clone()); + + assert_eq!(source.token(), new_token); + assert_eq!(*rx.borrow(), new_token); + } + + #[test] + fn swap_token_multiple_times() { + let (_dir, source) = setup_plugin_env(); + + for i in 1..=5 { + let new_token = make_jwt_with_exp(7777777000 + i); + source.swap_token(new_token.clone()); + assert_eq!(source.token(), new_token); + } + } + + #[test] + fn watch_receiver_initial_value() { + let (_dir, source) = setup_plugin_env(); + let rx = source.watch(); + assert_eq!(*rx.borrow(), source.token()); + } + + #[test] + fn clone_preserves_state() { + let (_dir, source) = setup_plugin_env(); + let cloned = source.clone(); + + assert_eq!(source.token(), cloned.token()); + + let new_token = make_jwt_with_exp(6666666000); + source.swap_token(new_token.clone()); + assert_eq!(cloned.token(), new_token); + } + + #[test] + fn force_refresh_triggers_signal() { + let (_dir, source) = setup_plugin_env(); + let rx = source.refresh_trigger.subscribe(); + // Initial value is 0 + assert_eq!(*rx.borrow(), 0); + + source.force_refresh(); + // After force_refresh, the value should have incremented + // Since send happens synchronously, borrow() already shows the new value + assert_eq!(*rx.borrow(), 1); + + source.force_refresh(); + assert_eq!(*rx.borrow(), 2); + } +} diff --git a/connect-lib/lib/src/datum_cloud/mod.rs b/connect-lib/lib/src/datum_cloud/mod.rs new file mode 100644 index 0000000..c50d7fb --- /dev/null +++ b/connect-lib/lib/src/datum_cloud/mod.rs @@ -0,0 +1,598 @@ +use std::borrow::Cow; +use std::sync::Arc; +use std::time::Duration as StdDuration; + +use arc_swap::ArcSwap; +use chrono::Utc; +use n0_error::{Result, StdResultExt}; +use tokio::sync::watch; +use tracing::warn; + +use crate::http_user_agent::datum_http_user_agent; +use crate::{ProjectControlPlaneClient, Repo, SelectedContext}; + +pub mod env; +pub mod external_token_source; + +pub use self::{ + env::ApiEnv, +}; + +use self::external_token_source::ExternalTokenSource; + +/// Inline replacement for `openidconnect::AccessToken` — removed to avoid dependency. +#[derive(Debug, Clone)] +pub struct AccessToken(String); + +impl AccessToken { + pub fn new(token: String) -> Self { + Self(token) + } + + pub fn secret(&self) -> &str { + &self.0 + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +pub(crate) mod auth { + use chrono::Utc; + use std::sync::Arc; + use std::time::Duration as StdDuration; + + use arc_swap::ArcSwap; + + use super::AccessToken; + + #[derive(Debug, Clone)] + pub struct AuthTokens { + pub access_token: AccessToken, + pub refresh_token: Option, + pub issued_at: chrono::DateTime, + pub expires_in: StdDuration, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum LoginState { + Missing, + Valid, + Refreshing, + } + + impl Default for LoginState { + fn default() -> Self { + LoginState::Missing + } + } + + #[derive(Debug, Clone)] + pub struct UserProfile { + pub user_id: String, + pub email: String, + pub first_name: Option, + pub last_name: Option, + pub avatar_url: Option, + pub registration_approval: Option, + } + + #[derive(Debug, Clone)] + pub struct AuthState { + pub tokens: AuthTokens, + pub profile: UserProfile, + } + + #[derive(Debug)] + pub struct MaybeAuth(ArcSwap); + + impl Clone for MaybeAuth { + fn clone(&self) -> Self { + Self(ArcSwap::from(self.0.load_full())) + } + } + + impl MaybeAuth { + pub fn new(state: AuthState) -> Self { + Self(ArcSwap::from_pointee(state)) + } + + pub fn dummy(state: AuthState) -> Self { + Self(ArcSwap::from_pointee(state)) + } + + pub fn load(&self) -> Arc { + self.0.load_full() + } + + pub fn get(&self) -> Result, ()> { + Ok(self.0.load_full()) + } + } + + impl AuthState { + pub fn get(&self) -> Result<&AuthState, ()> { + Ok(self) + } + } + + /// Outcome classification for a token refresh attempt. + /// + /// The distinction matters for the heartbeat loop and the listener: a + /// `Transient` failure should keep auth state intact and let the next retry + /// recover, while a `Permanent` failure means the OAuth provider has + /// definitively rejected our credentials and only a fresh interactive login + /// can recover. Treating every refresh failure as permanent — which the + /// previous implementation did — meant a 30-second IdP wobble would log a + /// long-running tunnel out. + /// + /// Connect-lib note: this fork operates in plugin mode where token refresh + /// is performed by the parent process (datumctl) via the + /// DATUM_CREDENTIALS_HELPER subprocess. The OIDC machinery that produced + /// these errors upstream does not exist in connect-lib. The enum is + /// nevertheless exposed at `pub(crate)` so heartbeat/binary callers can + /// classify any future in-process refresh attempts (Phase 12 work) and so + /// the surface matches upstream for downstream merge compatibility. + #[derive(Debug)] + pub enum RefreshError { + /// The IdP definitively rejected the refresh (typically `invalid_grant`, + /// `invalid_client`, etc.). Auth state has been cleared; the operator + /// must log in again. + Permanent(n0_error::AnyError), + /// Transient failure (network, IdP 5xx, parse error, ID-token claim + /// verification). Auth state is preserved; the caller should retry with + /// backoff. + Transient(n0_error::AnyError), + } + + impl std::fmt::Display for RefreshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Permanent(e) => write!(f, "refresh permanently rejected by IdP: {e:#}"), + Self::Transient(e) => write!(f, "transient refresh failure: {e:#}"), + } + } + } + + impl std::error::Error for RefreshError {} +} + +pub(crate) use self::auth::RefreshError; + +pub use self::auth::{AuthState, AuthTokens, LoginState, MaybeAuth, UserProfile}; + +#[derive(derive_more::Debug, Clone)] +pub struct DatumCloudClient { + env: ApiEnv, + token_source: Arc, + http: reqwest::Client, + session: SessionStateWrapper, + login_state_tx: watch::Sender, +} + +impl DatumCloudClient { + /// Constructs a `DatumCloudClient` using an `ExternalTokenSource` (plugin mode). + pub fn with_external_token_source(env: ApiEnv, token_source: ExternalTokenSource) -> Self { + let http = reqwest::Client::builder() + .user_agent(datum_http_user_agent()) + .build() + .expect("reqwest client should build"); + let (login_state_tx, _) = watch::channel(LoginState::Valid); + Self { + env, + token_source: Arc::new(token_source), + http, + session: SessionStateWrapper::empty(), + login_state_tx, + } + } + + pub fn login_state(&self) -> LoginState { + LoginState::Valid + } + + pub fn is_plugin_mode(&self) -> bool { + true + } + + pub fn token(&self) -> String { + self.token_source.token() + } + + pub fn api_url(&self) -> Cow<'static, str> { + self.env.api_url() + } + + pub fn web_url(&self) -> Cow<'static, str> { + self.env.web_url() + } + + pub fn auth_update_watch(&self) -> watch::Receiver { + let (_, rx) = watch::channel(0u64); + rx + } + + /// Returns a watch receiver for login state changes. + pub fn login_state_watch(&self) -> watch::Receiver { + self.login_state_tx.subscribe() + } + + pub fn auth_state(&self) -> Arc { + Arc::new(MaybeAuth::dummy(AuthState { + tokens: AuthTokens { + access_token: AccessToken::new(self.token_source.token()), + refresh_token: None, + issued_at: Utc::now(), + expires_in: StdDuration::from_secs(3600), + }, + profile: UserProfile { + user_id: "external".to_string(), + email: "external@plugin".to_string(), + first_name: None, + last_name: None, + avatar_url: None, + registration_approval: None, + }, + })) + } + + pub async fn is_authenticated(&self) -> Result { + Ok(true) + } + + pub async fn login(&self) -> Result<()> { + Ok(()) + } + + pub async fn logout(&self) -> Result<()> { + Ok(()) + } + + pub fn selected_context(&self) -> Option { + self.session.selected_context() + } + + pub fn selected_context_watch(&self) -> watch::Receiver> { + self.session.selected_context_watch() + } + + pub async fn set_selected_context( + &self, + selected_context: Option, + ) -> Result<()> { + self.session.set_selected_context(selected_context).await + } + + fn project_control_plane_url(&self, project_id: &str) -> String { + format!( + "{}/apis/resourcemanager.miloapis.com/v1alpha1/projects/{project_id}/control-plane", + self.api_url() + ) + } + + pub async fn project_control_plane_client( + &self, + project_id: &str, + ) -> Result { + let token = self.token_source.token(); + self.project_control_plane_client_with_token(project_id, &token) + } + + pub async fn project_control_plane_client_active( + &self, + ) -> Result> { + let Some(selected) = self.selected_context() else { + return Ok(None); + }; + Ok(Some( + self.project_control_plane_client(&selected.project_id) + .await?, + )) + } + + pub fn orgs_projects_cache(&self) -> Vec { + self.session.orgs_projects() + } + + pub fn orgs_projects_watch(&self) -> watch::Receiver> { + self.session.orgs_projects_watch() + } + + #[allow(dead_code)] + async fn fetch_direct(&self, url: &str) -> Result { + tracing::debug!("GET {url}"); + + let token = self.token_source.token(); + + let res = self + .http + .get(url) + .header( + "Authorization", + format!("Bearer {token}"), + ) + .send() + .await + .inspect_err(|e| warn!(%url, "Failed to fetch: {e:#}")) + .with_std_context(|_| format!("Failed to fetch {url}"))?; + let status = res.status(); + if !status.is_success() { + let text = match res.text().await { + Ok(text) => text, + Err(err) => err.to_string(), + }; + warn!(%url, "Request failed: {status} {text}"); + n0_error::bail_any!("Request failed with status {status}"); + } + + let json: serde_json::Value = res + .json() + .await + .std_context("Failed to parse response text as JSON")?; + Ok(json) + } + + fn project_control_plane_client_with_token( + &self, + project_id: &str, + access_token: &str, + ) -> Result { + let server_url = self.project_control_plane_url(project_id); + ProjectControlPlaneClient::new( + project_id.to_string(), + server_url, + access_token.to_string(), + self.clone(), + ) + } +} + +#[derive(Debug, Clone, Default)] +struct SessionStateWrapper { + selected_context: Arc>>, + selected_context_tx: watch::Sender>, + orgs_projects: Arc>>, + orgs_projects_tx: watch::Sender>, + repo: Option, +} + +impl SessionStateWrapper { + fn empty() -> Self { + let (selected_context_tx, _) = watch::channel(None); + let (orgs_projects_tx, _) = watch::channel(Vec::new()); + Self { + selected_context: Arc::new(ArcSwap::from_pointee(None)), + selected_context_tx, + orgs_projects: Arc::new(ArcSwap::from_pointee(Vec::new())), + orgs_projects_tx, + repo: None, + } + } + + #[allow(dead_code)] + async fn from_repo(repo: Option) -> Result { + let selected = if let Some(repo) = repo.as_ref() { + repo.read_selected_context().await? + } else { + None + }; + let (selected_context_tx, _) = watch::channel(selected.clone()); + let (orgs_projects_tx, _) = watch::channel(Vec::new()); + Ok(Self { + selected_context: Arc::new(ArcSwap::from_pointee(selected)), + selected_context_tx, + orgs_projects: Arc::new(ArcSwap::from_pointee(Vec::new())), + orgs_projects_tx, + repo, + }) + } + + fn selected_context(&self) -> Option { + self.selected_context.load_full().as_ref().clone() + } + + fn selected_context_watch(&self) -> watch::Receiver> { + self.selected_context_tx.subscribe() + } + + async fn set_selected_context(&self, selected_context: Option) -> Result<()> { + let current = self.selected_context.load_full(); + if current.as_ref().as_ref() != selected_context.as_ref() { + if let Some(repo) = self.repo.as_ref() { + repo.write_selected_context(selected_context.as_ref()) + .await?; + } + self.selected_context + .store(Arc::new(selected_context.clone())); + } + let _ = self.selected_context_tx.send(selected_context); + Ok(()) + } + + fn orgs_projects(&self) -> Vec { + self.orgs_projects.load_full().as_ref().clone() + } + + fn orgs_projects_watch(&self) -> watch::Receiver> { + self.orgs_projects_tx.subscribe() + } + + #[allow(dead_code)] + fn set_orgs_projects(&self, orgs_projects: Vec) -> bool { + let current = self.orgs_projects.load_full(); + if current.as_ref().as_slice() == orgs_projects.as_slice() { + return false; + } + self.orgs_projects.store(Arc::new(orgs_projects.clone())); + let _ = self.orgs_projects_tx.send(orgs_projects); + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Organization { + pub resource_id: String, + pub display_name: String, + pub r#type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrganizationWithProjects { + pub org: Organization, + pub projects: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + pub resource_id: String, + pub display_name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + + fn make_jwt_with_exp(exp: u64) -> String { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::json!({"alg":"HS256","typ":"JWT"}).to_string().as_bytes(), + ); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::json!({"exp": exp, "sub":"test-user"}).to_string().as_bytes(), + ); + format!("{header}.{payload}.fake_sig") + } + + struct TempDir { + path: std::path::PathBuf, + } + + impl TempDir { + fn new() -> Self { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("connect-dc-test-{ts}")); + std::fs::create_dir_all(&path).expect("should create temp dir"); + TempDir { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn setup_plugin_env() -> (TempDir, ExternalTokenSource) { + let _lock = crate::ENV_LOCK.lock().unwrap(); + let dir = TempDir::new(); + let helper_path = dir.path().join("fake-helper.sh"); + let jwt = make_jwt_with_exp(9999999999); + std::fs::write(&helper_path, format!("#!/bin/sh\necho '{}'\n", jwt)) + .expect("should write helper script"); + #[cfg(unix)] + std::fs::set_permissions( + &helper_path, + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .expect("should set executable permission"); + let helper_str = helper_path.to_string_lossy().to_string(); + + unsafe { + std::env::set_var("DATUM_CREDENTIALS_HELPER", &helper_str); + std::env::set_var("DATUM_SESSION", "test-session"); + } + + let source = + ExternalTokenSource::from_env(Some("test-session".to_string())).expect("should create token source"); + (dir, source) + } + + #[test] + fn with_external_token_source_creates_plugin_mode_client() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + assert!(client.is_plugin_mode()); + } + + #[test] + fn login_state_valid_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + assert_eq!(client.login_state(), LoginState::Valid); + } + + #[test] + fn token_returns_external_token() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + // The fake helper returns a JWT with exp 9999999999 + assert!(client.token().starts_with("eyJ")); + } + + #[test] + fn auth_state_returns_dummy_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + let auth_state = client.auth_state(); + assert!(auth_state.get().is_ok()); + let auth = auth_state.get().unwrap(); + assert_eq!(auth.profile.user_id, "external"); + assert_eq!(auth.profile.email, "external@plugin"); + } + + #[test] + fn api_url_from_env_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + assert!(client.api_url().contains("datum.net")); + } + + #[test] + fn web_url_from_env_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + assert!(client.web_url().contains("datum.net")); + } + + #[test] + fn datum_cloud_client_clone_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + let cloned = client.clone(); + assert!(cloned.is_plugin_mode()); + assert_eq!(cloned.token(), client.token()); + } + + #[test] + fn auth_update_watch_returns_receiver_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + let rx = client.auth_update_watch(); + // Initial value should be 0 + assert_eq!(*rx.borrow(), 0); + } + + #[test] + fn selected_context_is_none_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + // In plugin mode, session state is empty (no OIDC repo) + assert!(client.selected_context().is_none()); + } + + #[test] + fn login_state_watch_returns_receiver_in_plugin_mode() { + let (_dir, token_source) = setup_plugin_env(); + let client = DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source); + let rx = client.login_state_watch(); + assert_eq!(*rx.borrow(), LoginState::Valid); + } +} diff --git a/connect-lib/lib/src/datum_cloud_client.rs b/connect-lib/lib/src/datum_cloud_client.rs new file mode 100644 index 0000000..56034ce --- /dev/null +++ b/connect-lib/lib/src/datum_cloud_client.rs @@ -0,0 +1,25 @@ +// Stub — full implementation in Wave 2 +// DatumCloudClient with External auth only + +use crate::datum_cloud::external_token_source::ExternalTokenSource; + +#[derive(Debug, Clone)] +pub struct DatumCloudClient; + +impl DatumCloudClient { + pub fn with_external_token_source(_source: ExternalTokenSource) -> Self { + Self + } + + pub fn is_plugin_mode(&self) -> bool { + true + } + + pub fn token(&self) -> Option { + None + } + + pub fn api_url(&self) -> String { + String::new() + } +} diff --git a/connect-lib/lib/src/heartbeat.rs b/connect-lib/lib/src/heartbeat.rs new file mode 100644 index 0000000..2cd1019 --- /dev/null +++ b/connect-lib/lib/src/heartbeat.rs @@ -0,0 +1,912 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; + +use chrono::Utc; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::MicroTime; +use kube::api::{ListParams, Patch, PatchParams}; +use kube::{Api, ResourceExt}; +use n0_error::{Result, StdResultExt}; +use n0_future::task::AbortOnDropHandle; +use rand::Rng; +use serde_json::json; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::ListenNode; +use crate::datum_apis::connector::{ + Connector, ConnectorConnectionDetails, ConnectorConnectionDetailsPublicKey, + ConnectorConnectionType, PublicKeyConnectorAddress, PublicKeyDiscoveryMode, +}; +use crate::datum_apis::lease::Lease; +use crate::datum_cloud::DatumCloudClient; + +type ProjectRunner = Arc< + dyn Fn( + String, + DatumCloudClient, + Arc, + CancellationToken, + ) -> tokio::task::JoinHandle<()> + + Send + + Sync, +>; + +const DEFAULT_PCP_NAMESPACE: &str = "default"; +const DEFAULT_LEASE_DURATION_SECS: i32 = 30; +const BACKOFF_INITIAL: Duration = Duration::from_secs(2); +const BACKOFF_MAX: Duration = Duration::from_secs(30); + +#[derive(derive_more::Debug, Clone)] +pub struct HeartbeatAgent { + #[debug(skip)] + inner: Arc, +} + +struct HeartbeatInner { + datum: DatumCloudClient, + provider: Arc, + runner: ProjectRunner, + projects: Mutex>, + known_projects: Mutex>, + login_task: Mutex>>, +} + +struct ProjectHeartbeat { + cancel: CancellationToken, + _task: AbortOnDropHandle<()>, +} + +impl HeartbeatAgent { + pub fn new(datum: DatumCloudClient, listen: ListenNode) -> Self { + let provider = Arc::new(ListenNodeDetailsProvider::new(listen)); + let runner: ProjectRunner = Arc::new(|project_id, datum, provider, cancel| { + tokio::spawn(run_project(project_id, datum, provider, cancel)) + }); + Self::new_with_runner(datum, provider, runner) + } + + fn new_with_runner( + datum: DatumCloudClient, + provider: Arc, + runner: ProjectRunner, + ) -> Self { + Self { + inner: Arc::new(HeartbeatInner { + datum, + provider, + runner, + projects: Mutex::new(HashMap::new()), + known_projects: Mutex::new(HashSet::new()), + login_task: Mutex::new(None), + }), + } + } + + /// Start in auto-enroll mode: watch login + projects state and keep + /// heartbeats running for every project the user has access to. + /// Intended for multi-project consumers like the UI. + /// + /// For the CLI tunnel use case where there is exactly one project of + /// interest, prefer [`Self::start_manual`] — auto-enroll silently + /// maintains presence in projects the user didn't ask about. + pub async fn start(&self) { + let mut guard = self.inner.login_task.lock().await; + if guard.is_some() { + return; + } + + // In plugin mode, login state never changes and projects are fixed. + // Skip the background watcher — just do an initial project refresh. + if self.inner.datum.is_plugin_mode() { + if let Err(err) = self.refresh_projects().await { + warn!("heartbeat: bootstrap failed: {err:#}"); + } + return; + } + + let this = self.clone(); + let mut login_rx = this.inner.datum.login_state_watch(); + let mut projects_rx = this.inner.datum.orgs_projects_watch(); + let task = tokio::spawn(async move { + if *login_rx.borrow() != crate::datum_cloud::LoginState::Missing + && let Err(err) = this.refresh_projects().await + { + warn!("heartbeat: bootstrap failed: {err:#}"); + } + loop { + tokio::select! { + res = login_rx.changed() => { + if res.is_err() { + return; + } + let login_state = login_rx.borrow().clone(); + match login_state { + crate::datum_cloud::LoginState::Missing => { + this.clear_projects().await; + this.clear_known_projects().await; + } + _ => { + if let Err(err) = this.refresh_projects().await { + warn!("heartbeat: bootstrap failed: {err:#}"); + } + } + } + } + res = projects_rx.changed() => { + if res.is_err() { + return; + } + if *login_rx.borrow() != crate::datum_cloud::LoginState::Missing + && let Err(err) = this.refresh_projects().await { + warn!("heartbeat: bootstrap failed: {err:#}"); + } + } + } + } + }); + *guard = Some(AbortOnDropHandle::new(task)); + } + + /// Start in manual mode: do not watch login state and do not auto-enroll + /// projects. Callers are responsible for [`Self::register_project`] / + /// [`Self::deregister_project`] for the projects they want heartbeats + /// for. Per-project loops still handle 401s via their own + /// force-refresh logic, so transient auth blips are tolerated; a + /// permanent logout is surfaced separately by the CLI's own login + /// watcher. + pub async fn start_manual(&self) { + let mut guard = self.inner.login_task.lock().await; + if guard.is_some() { + return; + } + // Park a completed task so future start() / start_manual() calls + // remain no-ops, matching start()'s "single-start" contract. + let task = tokio::spawn(async {}); + *guard = Some(AbortOnDropHandle::new(task)); + } + + pub async fn register_project(&self, project_id: impl Into) { + let project_id = project_id.into(); + let mut projects = self.inner.projects.lock().await; + if projects.contains_key(&project_id) { + return; + } + let cancel = CancellationToken::new(); + let task = (self.inner.runner)( + project_id.clone(), + self.inner.datum.clone(), + self.inner.provider.clone(), + cancel.clone(), + ); + projects.insert( + project_id, + ProjectHeartbeat { + cancel, + _task: AbortOnDropHandle::new(task), + }, + ); + } + + pub async fn deregister_project(&self, project_id: &str) { + let mut projects = self.inner.projects.lock().await; + if let Some(project) = projects.remove(project_id) { + project.cancel.cancel(); + } + } + + async fn clear_projects(&self) { + let mut projects = self.inner.projects.lock().await; + for (_, project) in projects.drain() { + project.cancel.cancel(); + } + } + + async fn clear_known_projects(&self) { + let mut known = self.inner.known_projects.lock().await; + known.clear(); + } + + pub async fn refresh_projects(&self) -> Result<()> { + let orgs = self.inner.datum.orgs_projects_cache(); + let mut next_projects: HashSet = HashSet::new(); + for org in orgs { + for project in org.projects { + next_projects.insert(project.resource_id); + } + } + + { + let mut known = self.inner.known_projects.lock().await; + if *known == next_projects { + return Ok(()); + } + *known = next_projects.clone(); + } + + let running: Vec = { + let projects = self.inner.projects.lock().await; + projects.keys().cloned().collect() + }; + for project_id in running { + if !next_projects.contains(&project_id) { + self.deregister_project(&project_id).await; + } + } + + for project_id in &next_projects { + let should_probe = { + let projects = self.inner.projects.lock().await; + !projects.contains_key(project_id.as_str()) + }; + if !should_probe { + continue; + } + match probe_connector( + &project_id, + self.inner.datum.clone(), + self.inner.provider.clone(), + ) + .await + { + Ok(true) => self.register_project(project_id.clone()).await, + Ok(false) => { + debug!(%project_id, "heartbeat: no connector yet"); + } + Err(err) => { + warn!(%project_id, "heartbeat: connector probe failed: {err:#}"); + } + } + } + + Ok(()) + } +} + +struct ConnectorCache { + name: String, + lease_name: Option, + lease_duration_seconds: Option, + last_details: Option, + last_home_relay: Option, +} + +/// Returns true if `err` is a kube API error with HTTP status 401. +/// Used to decide whether a heartbeat retry should force an OAuth token refresh +/// (the proactive refresh timer in `AuthClient` only fires when the access token +/// is within `REFRESH_AUTH_WHEN` of expiry, so a token rejected before that +/// would otherwise spin until the timer catches up). +fn is_unauthorized(err: &kube::Error) -> bool { + matches!(err, kube::Error::Api(e) if e.code == 401) +} + +fn is_not_found(err: &kube::Error) -> bool { + matches!(err, kube::Error::Api(e) if e.code == 404) +} + +/// What the heartbeat loop should do with its cache after a lease op fails. +#[derive(Debug, PartialEq, Eq)] +enum LeaseErrorAction { + /// Keep the cached connector/lease names; retry after backoff. + Retain, + /// Drop the cache so the next iteration re-resolves connector and lease + /// from scratch. Used when the lease no longer exists server-side. + Reset, + /// Force a token refresh, then retain the cache and retry. + RefreshAuth, +} + +fn classify_lease_error(err: &kube::Error) -> LeaseErrorAction { + if is_not_found(err) { + LeaseErrorAction::Reset + } else if is_unauthorized(err) { + LeaseErrorAction::RefreshAuth + } else { + LeaseErrorAction::Retain + } +} + +/// Force an OAuth token refresh after a 401. The proactive timer only refreshes +/// when the token is near expiry, so a server-side rejection that arrives early +/// (clock skew, revocation, etc.) would otherwise leave the heartbeat retrying +/// with the same dead token until the timer eventually fires. +/// +/// When auth is already in [`LoginState::Missing`] (e.g. after a previous +/// permanent refresh failure), this returns immediately without contacting the +/// IdP — the auth layer has already surfaced the loss to the operator and +/// there is nothing to refresh until they log in again. +/// +/// Plugin-mode adaptation (connect-lib fork): connect-lib does not own the +/// OAuth flow — token refresh is driven by the parent process (datumctl) +/// via the `DATUM_CREDENTIALS_HELPER` subprocess, which swaps the new token +/// into `ExternalTokenSource` out-of-band. From inside the heartbeat loop +/// all we can do is log the 401 trigger; the next pcp-client construction +/// will pick up whatever token the helper has provided. The +/// `LoginState::Missing` guard remains in place so that if a future +/// LoginState-driven mechanism marks the session as dead, we stop +/// hammering the kube path on every 401. +async fn force_refresh_auth(project_id: &str, datum: &DatumCloudClient) { + if matches!(datum.login_state(), crate::datum_cloud::LoginState::Missing) { + debug!( + %project_id, + "heartbeat: skipping forced refresh — auth state is missing, awaiting login" + ); + return; + } + debug!( + %project_id, + "heartbeat: 401 observed; token refresh is external in plugin mode (datumctl credentials helper)" + ); +} + +async fn run_project( + project_id: String, + datum: DatumCloudClient, + provider: Arc, + cancel: CancellationToken, +) { + let mut backoff = Backoff::new(); + let mut cache: Option = None; + + loop { + if cancel.is_cancelled() { + return; + } + + let pcp = match datum.project_control_plane_client(&project_id).await { + Ok(client) => client, + Err(err) => { + warn!(%project_id, "heartbeat: failed to get pcp client: {err:#}"); + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + }; + let client = pcp.client(); + let connectors: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let leases: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + + if cache.is_none() { + match find_connector(&connectors, provider.endpoint_id()).await { + Ok(Some(connector)) => { + let connector_name = connector.name_any(); + let lease_name = connector + .status + .as_ref() + .and_then(|status| status.lease_ref.as_ref()) + .map(|lease| lease.name.clone()); + let last_home_relay = connector + .status + .as_ref() + .and_then(|status| status.connection_details.as_ref()) + .and_then(|details| details.public_key.as_ref()) + .map(|details| details.home_relay.clone()); + info!( + %project_id, + connector = %connector_name, + lease = lease_name.as_deref().unwrap_or(""), + "heartbeat: registered connector, starting lease renewals" + ); + cache = Some(ConnectorCache { + name: connector_name, + lease_name, + lease_duration_seconds: None, + last_details: None, + last_home_relay, + }); + backoff.reset(); + } + Ok(None) => { + debug!(%project_id, "heartbeat: no connector yet"); + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + Err(err) => { + warn!(%project_id, "heartbeat: connector lookup failed: {err:#}"); + if is_unauthorized(&err) { + force_refresh_auth(&project_id, &datum).await; + } + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + } + } + + let Some(mut cached) = cache.take() else { + continue; + }; + + if cached.lease_name.is_none() { + match connectors.get(&cached.name).await { + Ok(connector) => { + cached.lease_name = connector + .status + .as_ref() + .and_then(|status| status.lease_ref.as_ref()) + .map(|lease| lease.name.clone()); + if cached.lease_name.is_none() { + sleep_with_cancel(backoff.next(), &cancel).await; + cache = Some(cached); + continue; + } + cached.last_home_relay = connector + .status + .as_ref() + .and_then(|status| status.connection_details.as_ref()) + .and_then(|details| details.public_key.as_ref()) + .map(|details| details.home_relay.clone()); + } + Err(err) => { + warn!( + %project_id, + connector = %cached.name, + "heartbeat: failed to fetch connector: {err:#}" + ); + if is_unauthorized(&err) { + force_refresh_auth(&project_id, &datum).await; + } + cache = None; + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + } + } + + let details = match provider.connection_details(cached.last_home_relay.as_deref()) { + Some(details) => details, + None => { + warn!(%project_id, connector = %cached.name, "heartbeat: missing home relay"); + cache = Some(cached); + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + }; + + let details_value = match serde_json::to_value(&details) { + Ok(value) => value, + Err(err) => { + warn!( + %project_id, + connector = %cached.name, + "heartbeat: failed to serialize details: {err:#}" + ); + cache = Some(cached); + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + }; + + if cached.last_details.as_ref() != Some(&details_value) { + let patch = json!({ "status": { "connectionDetails": details_value } }); + match connectors + .patch_status(&cached.name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + { + Ok(_) => { + cached.last_details = Some(patch["status"]["connectionDetails"].clone()); + } + Err(err) => { + warn!( + %project_id, + connector = %cached.name, + "heartbeat: failed to patch connection details: {err:#}" + ); + if is_unauthorized(&err) { + force_refresh_auth(&project_id, &datum).await; + cache = Some(cached); + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + } + } + } + + if cached.lease_duration_seconds.is_none() { + let Some(lease_name) = cached.lease_name.as_ref() else { + cache = Some(cached); + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + }; + match leases.get(lease_name).await { + Ok(lease) => { + cached.lease_duration_seconds = lease + .spec + .as_ref() + .and_then(|spec| spec.lease_duration_seconds); + } + Err(err) => { + warn!( + %project_id, + lease = %lease_name, + "heartbeat: failed to fetch lease: {err:#}" + ); + match classify_lease_error(&err) { + LeaseErrorAction::Reset => cache = None, + LeaseErrorAction::RefreshAuth => { + force_refresh_auth(&project_id, &datum).await; + cache = Some(cached); + } + LeaseErrorAction::Retain => cache = Some(cached), + } + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + } + } + + let Some(lease_name) = cached.lease_name.as_ref() else { + cache = Some(cached); + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + }; + + let renew_time = MicroTime(Utc::now()); + let patch = json!({ "spec": { "renewTime": renew_time } }); + if let Err(err) = leases + .patch(lease_name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + { + warn!(%project_id, lease = %lease_name, "heartbeat: lease renew failed: {err:#}"); + match classify_lease_error(&err) { + LeaseErrorAction::Reset => cache = None, + LeaseErrorAction::RefreshAuth => { + force_refresh_auth(&project_id, &datum).await; + cache = Some(cached); + } + LeaseErrorAction::Retain => cache = Some(cached), + } + sleep_with_cancel(backoff.next(), &cancel).await; + continue; + } + + let lease_duration = cached + .lease_duration_seconds + .unwrap_or(DEFAULT_LEASE_DURATION_SECS); + let interval = renewal_interval(lease_duration); + backoff.reset(); + cache = Some(cached); + sleep_with_cancel(interval, &cancel).await; + } +} + +async fn probe_connector( + project_id: &str, + datum: DatumCloudClient, + provider: Arc, +) -> Result { + let pcp = datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let connectors: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + let selector = provider.endpoint_id(); + Ok(find_connector(&connectors, selector) + .await + .std_context("connector lookup failed")? + .is_some()) +} + +async fn find_connector( + connectors: &Api, + endpoint_id: String, +) -> kube::Result> { + let selector = format!("status.connectionDetails.publicKey.id={endpoint_id}"); + let list = connectors + .list(&ListParams::default().fields(&selector)) + .await?; + if list.items.is_empty() { + return Ok(None); + } + if list.items.len() > 1 { + warn!( + %selector, + count = list.items.len(), + "heartbeat: multiple connectors found, using first" + ); + } + Ok(list.items.into_iter().next()) +} + +trait HeartbeatDetailsProvider: Send + Sync { + fn endpoint_id(&self) -> String; + fn connection_details( + &self, + fallback_home_relay: Option<&str>, + ) -> Option; +} + +struct ListenNodeDetailsProvider { + listen: ListenNode, +} + +impl ListenNodeDetailsProvider { + fn new(listen: ListenNode) -> Self { + Self { listen } + } +} + +impl HeartbeatDetailsProvider for ListenNodeDetailsProvider { + fn endpoint_id(&self) -> String { + self.listen.endpoint_id().to_string() + } + + fn connection_details( + &self, + fallback_home_relay: Option<&str>, + ) -> Option { + let endpoint = self.listen.endpoint(); + let endpoint_addr = endpoint.addr(); + let home_relay = endpoint_addr + .relay_urls() + .next() + .map(|url| url.to_string()) + .or_else(|| fallback_home_relay.map(|relay| relay.to_string()))?; + let addresses: Vec = endpoint_addr + .ip_addrs() + .map(|addr| PublicKeyConnectorAddress { + address: addr.ip().to_string(), + port: addr.port() as i32, + }) + .collect(); + + Some(ConnectorConnectionDetails { + connection_type: ConnectorConnectionType::PublicKey, + public_key: Some(ConnectorConnectionDetailsPublicKey { + id: endpoint.id().to_string(), + discovery_mode: Some(PublicKeyDiscoveryMode::Dns), + home_relay, + addresses, + }), + }) + } +} + +fn renewal_interval(lease_duration_seconds: i32) -> Duration { + let lease_duration_seconds = lease_duration_seconds.max(1) as u64; + let base = Duration::from_secs((lease_duration_seconds / 2).max(1)); + let jitter_max = (base.as_secs() / 5).max(1); + let mut rng = rand::rng(); + let jitter = rng.random_range(0..=jitter_max); + base + Duration::from_secs(jitter) +} + +async fn sleep_with_cancel(duration: Duration, cancel: &CancellationToken) { + tokio::select! { + _ = cancel.cancelled() => {} + _ = tokio::time::sleep(duration) => {} + } +} + +struct Backoff { + current: Duration, +} + +impl Backoff { + fn new() -> Self { + Self { + current: BACKOFF_INITIAL, + } + } + + fn next(&mut self) -> Duration { + let wait = self.current; + self.current = (self.current * 2).min(BACKOFF_MAX); + wait + } + + fn reset(&mut self) { + self.current = BACKOFF_INITIAL; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::datum_cloud::{ + ApiEnv, DatumCloudClient, RefreshError, external_token_source::ExternalTokenSource, + }; + use base64::Engine; + + struct TestProvider { + endpoint_id: String, + } + + impl HeartbeatDetailsProvider for TestProvider { + fn endpoint_id(&self) -> String { + self.endpoint_id.clone() + } + + fn connection_details( + &self, + _fallback_home_relay: Option<&str>, + ) -> Option { + None + } + } + + fn make_jwt_with_exp(exp: u64) -> String { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::json!({"alg":"HS256","typ":"JWT"}).to_string().as_bytes()); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::json!({"exp": exp, "sub":"test"}).to_string().as_bytes()); + format!("{header}.{payload}.fake_sig") + } + + struct TempDir { + path: std::path::PathBuf, + } + + impl TempDir { + fn new() -> Self { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("connect-hb-test-{ts}")); + std::fs::create_dir_all(&path).expect("should create temp dir"); + TempDir { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn setup_plugin_env() -> (TempDir, ExternalTokenSource) { + let _lock = crate::ENV_LOCK.lock().unwrap(); + let dir = TempDir::new(); + let helper_path = dir.path().join("fake-helper.sh"); + let jwt = make_jwt_with_exp(9999999999); + std::fs::write(&helper_path, format!("#!/bin/sh\necho '{}'\n", jwt)) + .expect("should write helper script"); + #[cfg(unix)] + std::fs::set_permissions( + &helper_path, + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .expect("should set executable permission"); + let helper_str = helper_path.to_string_lossy().to_string(); + + unsafe { + std::env::set_var("DATUM_CREDENTIALS_HELPER", &helper_str); + std::env::set_var("DATUM_SESSION", "test-session"); + } + + let source = + ExternalTokenSource::from_env(Some("test-session".to_string())).expect("should create token source"); + (dir, source) + } + + fn test_datum_client() -> DatumCloudClient { + let (_dir, token_source) = setup_plugin_env(); + DatumCloudClient::with_external_token_source(ApiEnv::Production, token_source) + } + + #[tokio::test] + async fn start_manual_does_not_auto_enroll() { + // Manual mode is the CLI tunnel-listen path: only the project the + // caller explicitly registers should get a heartbeat task. Auto- + // enroll would have probed `orgs_and_projects()` on bootstrap and + // registered every accessible project — we verify it didn't by + // checking the projects map stays empty until we register one. + // + // Adapted from upstream test (datum-cloud/app@b7e9d6b): the upstream + // version constructed the client via `DatumCloudClient::with_repo` + // (an OIDC path that does not exist on the connect-side fork — + // connect-lib uses ExternalTokenSource in plugin mode). The + // assertion semantics are identical. + let datum = test_datum_client(); + let provider = Arc::new(TestProvider { + endpoint_id: "test-endpoint".to_string(), + }); + let runner: ProjectRunner = Arc::new(|_project_id, _datum, _provider, cancel| { + tokio::spawn(async move { + cancel.cancelled().await; + }) + }); + let agent = HeartbeatAgent::new_with_runner(datum, provider, runner); + + agent.start_manual().await; + // Give any background bootstrap a chance to run; manual mode + // shouldn't have spawned one, but if it did this would expose it. + tokio::task::yield_now().await; + assert_eq!( + agent.inner.projects.lock().await.len(), + 0, + "manual mode must not auto-enroll any project" + ); + + agent.register_project("explicit-project").await; + assert_eq!( + agent.inner.projects.lock().await.len(), + 1, + "register_project still works in manual mode" + ); + + // start_manual is idempotent (matches start()'s contract): a + // second call is a no-op rather than tearing down and replacing. + agent.start_manual().await; + assert_eq!(agent.inner.projects.lock().await.len(), 1); + } + + #[test] + fn refresh_error_variants_classify_transient_vs_permanent() { + // Heartbeat-side classification consumer test: the auth layer + // (datum_cloud::auth::RefreshError) hands the heartbeat loop a + // typed error. A `Transient` variant means "keep credentials, + // retry with backoff"; a `Permanent` variant means "auth state + // is dead, stop hammering the IdP until re-login". Today the + // connect-side fork operates in plugin mode so neither variant + // is produced in-process (token refresh is external) — this + // test exists to assert the matchable surface for downstream + // callers (Phase 12 binary) and to satisfy the Wave 3 + // acceptance criterion that heartbeat.rs references + // RefreshError. + let transient = RefreshError::Transient(n0_error::anyerr!("IdP 5xx, retry")); + let permanent = RefreshError::Permanent(n0_error::anyerr!("refresh token revoked")); + match transient { + RefreshError::Transient(_) => {} + RefreshError::Permanent(_) => panic!("Transient must not match Permanent"), + } + match permanent { + RefreshError::Permanent(_) => {} + RefreshError::Transient(_) => panic!("Permanent must not match Transient"), + } + // Display impl must clearly differentiate the two so the heartbeat + // log line can be grepped (Transient → keep retrying; + // Permanent → surface to operator). + assert!( + format!("{}", RefreshError::Transient(n0_error::anyerr!("x"))) + .contains("transient") + ); + assert!( + format!("{}", RefreshError::Permanent(n0_error::anyerr!("x"))) + .contains("permanently") + ); + } + + fn api_error(code: u16, reason: &str) -> kube::Error { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".to_string(), + message: "test".to_string(), + reason: reason.to_string(), + code, + }) + } + + #[test] + fn classify_lease_error_resets_on_not_found() { + // Mirrors the production wedge: the Lease was deleted server-side and + // the renew loop kept patching the dead name. A 404 must clear the + // cache so the next iteration re-resolves the connector + lease. + assert_eq!( + classify_lease_error(&api_error(404, "NotFound")), + LeaseErrorAction::Reset + ); + } + + #[test] + fn classify_lease_error_refreshes_on_unauthorized() { + assert_eq!( + classify_lease_error(&api_error(401, "Unauthorized")), + LeaseErrorAction::RefreshAuth + ); + } + + #[test] + fn classify_lease_error_retains_on_transient() { + for code in [403, 409, 429, 500, 502, 503] { + assert_eq!( + classify_lease_error(&api_error(code, "Transient")), + LeaseErrorAction::Retain, + "code {code} should retain cache" + ); + } + } +} diff --git a/connect-lib/lib/src/http_user_agent.rs b/connect-lib/lib/src/http_user_agent.rs new file mode 100644 index 0000000..8420fc8 --- /dev/null +++ b/connect-lib/lib/src/http_user_agent.rs @@ -0,0 +1,15 @@ +//! Shared [`User-Agent`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) for +//! outbound HTTP from Datum Connect Plugin (reqwest and kube). Helps backend logs and support correlate +//! traffic with app builds. +//! +//! The version is [`env!("CARGO_PKG_VERSION")`] for this crate. + +/// Product token plus version, OS, and CPU arch for support and debugging. +pub fn datum_http_user_agent() -> String { + format!( + "Datum Connect Plugin/{} ({}; {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ) +} diff --git a/connect-lib/lib/src/lib.rs b/connect-lib/lib/src/lib.rs new file mode 100644 index 0000000..ffabaa6 --- /dev/null +++ b/connect-lib/lib/src/lib.rs @@ -0,0 +1,32 @@ +pub mod config; +pub mod datum_apis; +pub mod datum_cloud; +pub mod heartbeat; +pub mod http_user_agent; +pub mod node; +pub mod project_control_plane; +pub mod repo; +pub mod state; +pub mod tunnels; + +pub use config::{Config, DiscoveryMode}; +pub use datum_cloud::external_token_source::{ExternalTokenError, ExternalTokenSource}; +pub use datum_cloud::{ApiEnv, AuthState, AuthTokens, LoginState, MaybeAuth, UserProfile}; +pub use heartbeat::HeartbeatAgent; +pub use http_user_agent::datum_http_user_agent; +pub use node::{build_endpoint, ConnectNode, ListenNode}; +pub use project_control_plane::ProjectControlPlaneClient; +pub use repo::{MissingConnectDir, Repo}; +pub use state::{Advertisment, SelectedContext, State, StateWrapper, TcpProxyData}; +pub use tunnels::{ + ProgressStep, ProgressStepKind, StepStatus, TunnelDeleteOutcome, TunnelProgress, TunnelService, + TunnelSummary, +}; + +/// The root domain for datum connect URLs to subdomain from. A proxy URL will +/// be a three-word-codename subdomain off this URL. eg: "https://vast-gold-mine.iroh.datum.net" +pub const DATUM_CONNECT_GATEWAY_DOMAIN_NAME: &str = "iroh.datum.net"; + +/// Serializes env-dependent tests (std::env::set_var is not thread-safe). +#[cfg(test)] +pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/connect-lib/lib/src/node.rs b/connect-lib/lib/src/node.rs new file mode 100644 index 0000000..60cb1b2 --- /dev/null +++ b/connect-lib/lib/src/node.rs @@ -0,0 +1,750 @@ +use std::{fmt::Debug, net::SocketAddr, str::FromStr, sync::Arc, time::Duration}; + +use iroh::{ + Endpoint, EndpointId, SecretKey, discovery::dns::DnsDiscovery, endpoint::default_relay_mode, + protocol::Router, +}; +use iroh_base::RelayUrl; +use iroh_n0des::ApiSecret; +use iroh_proxy_utils::upstream::UpstreamMetrics; +use iroh_proxy_utils::{ + ALPN as IROH_HTTP_CONNECT_ALPN, Authority, HttpProxyRequest, HttpProxyRequestKind, +}; +use iroh_proxy_utils::{ + downstream::{DownstreamProxy, EndpointAuthority, ProxyMode}, + upstream::{AuthError, AuthHandler, UpstreamProxy}, +}; +use iroh_relay::dns::{DnsProtocol, DnsResolver}; +use iroh_relay::{RelayConfig, RelayMap}; +use n0_error::{Result, StackResultExt, StdResultExt}; +use tokio::{ + net::TcpListener, + sync::futures::Notified, + task::{JoinHandle, JoinSet}, +}; +use tracing::{Instrument, debug, error_span, info, instrument, warn}; + +use crate::{Repo, StateWrapper, TcpProxyData, config::Config, state::ProxyState}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct MetricsUpdate { + pub send: u64, + pub recv: u64, +} + +#[derive(Debug, Clone)] +pub struct ListenNode { + router: Router, + state: StateWrapper, + repo: Repo, + metrics: Arc, + _n0des: Option>, +} + +impl ListenNode { + pub async fn new(repo: Repo) -> Result { + let n0des_api_secret = n0des_api_secret_from_env()?; + Self::build(repo, n0des_api_secret, None).await + } + + /// Construct a listen node using a project-scoped iroh identity. The CLI + /// Tunnel command takes this path so each project's Connector has a + /// distinct iroh public key — see [`Repo::listen_key_for_project`] for + /// why that matters. + pub async fn new_for_project(repo: Repo, project_id: &str) -> Result { + let n0des_api_secret = n0des_api_secret_from_env()?; + Self::build(repo, n0des_api_secret, Some(project_id)).await + } + + /// Construct a listen node using a pre-generated iroh identity. + /// + /// The key is NOT read from disk — it is used directly. Useful when the + /// key is generated in memory (e.g., new tunnel creation) and needs to be + /// passed through without a round-trip to disk. + pub async fn new_with_key(repo: Repo, secret_key: SecretKey) -> Result { + let n0des_api_secret = n0des_api_secret_from_env()?; + Self::build_with_key(repo, n0des_api_secret, secret_key).await + } + + #[instrument("listen-node", skip_all)] + pub async fn with_n0des_api_secret( + repo: Repo, + n0des_api_secret: Option, + ) -> Result { + Self::build(repo, n0des_api_secret, None).await + } + + pub fn repo(&self) -> &Repo { + &self.repo + } + + #[instrument("listen-node", skip(repo, n0des_api_secret))] + async fn build( + repo: Repo, + n0des_api_secret: Option, + project_id: Option<&str>, + ) -> Result { + let config = repo.config().await?; + let secret_key = match project_id { + Some(pid) => repo.listen_key_for_project(pid).await?, + None => repo.listen_key().await?, + }; + let endpoint = build_endpoint(secret_key, &config).await?; + let n0des = build_n0des_client_opt(&endpoint, n0des_api_secret).await; + let state = repo.load_state().await?; + + let upstream_proxy = UpstreamProxy::new(state.clone())?; + let metrics = upstream_proxy.metrics(); + + let router = Router::builder(endpoint) + .accept(IROH_HTTP_CONNECT_ALPN, upstream_proxy) + .spawn(); + + let this = Self { + repo, + router, + state, + metrics, + _n0des: n0des, + }; + Ok(this) + } + + #[instrument("listen-node", skip(repo, n0des_api_secret, secret_key))] + async fn build_with_key( + repo: Repo, + n0des_api_secret: Option, + secret_key: SecretKey, + ) -> Result { + let config = repo.config().await?; + let endpoint = build_endpoint(secret_key, &config).await?; + let n0des = build_n0des_client_opt(&endpoint, n0des_api_secret).await; + let state = repo.load_state().await?; + + let upstream_proxy = UpstreamProxy::new(state.clone())?; + let metrics = upstream_proxy.metrics(); + + let router = Router::builder(endpoint) + .accept(IROH_HTTP_CONNECT_ALPN, upstream_proxy) + .spawn(); + + Ok(Self { + repo, + router, + state, + metrics, + _n0des: n0des, + }) + } + + pub fn state_updated(&self) -> Notified<'_> { + self.state.updated() + } + + pub fn state(&self) -> &StateWrapper { + &self.state + } + + pub fn metrics(&self) -> &Arc { + &self.metrics + } + + pub fn proxies(&self) -> Vec { + self.state.get().proxies.to_vec() + } + + pub fn proxy_by_id(&self, id: &str) -> Option { + self.state + .get() + .proxies + .iter() + .find(|p| p.id() == id) + .cloned() + } + + pub async fn set_proxy(&self, proxy: ProxyState) -> Result<()> { + self.state + .update(&self.repo, |state| state.set_proxy(proxy.clone())) + .await?; + Ok(()) + } + + pub async fn set_proxy_state(&self, proxy: ProxyState) -> Result<()> { + self.state + .update(&self.repo, |state| state.set_proxy(proxy)) + .await?; + Ok(()) + } + + pub async fn remove_proxy(&self, resource_id: &str) -> Result> { + debug!(%resource_id, "removing proxy {resource_id}"); + let res = self + .state + .update(&self.repo, move |state| state.remove_proxy(resource_id)) + .await; + debug!(%resource_id, "removed {res:?}"); + res + } + + pub async fn remove_proxy_state(&self, resource_id: &str) -> Result> { + debug!(%resource_id, "removing proxy state {resource_id}"); + let res = self + .state + .update(&self.repo, move |state| state.remove_proxy(resource_id)) + .await; + debug!(%resource_id, "removed {res:?}"); + res + } + + pub fn endpoint(&self) -> &Endpoint { + self.router.endpoint() + } + + pub fn endpoint_id(&self) -> EndpointId { + self.router.endpoint().id() + } +} + +impl StateWrapper { + fn tcp_proxy_exists(&self, host: &str, port: u16) -> bool { + let normalized_host = normalize_loopback(strip_host_scheme(host)); + let exists = self.get().proxies.iter().any(|a| { + a.enabled + && normalize_loopback(&a.info.service().host) == normalized_host + && a.info.service().port == port + }); + if !exists { + debug!( + requested_host = host, + normalized_host, port, "tcp_proxy_exists: no matching proxy found" + ); + } + exists + } +} + +fn strip_host_scheme(host: &str) -> &str { + host.strip_prefix("http://") + .or_else(|| host.strip_prefix("https://")) + .unwrap_or(host) +} + +fn normalize_loopback(host: &str) -> &str { + match host { + "localhost" | "::1" => "127.0.0.1", + _ => host, + } +} + +impl AuthHandler for StateWrapper { + async fn authorize<'a>( + &'a self, + _remote_id: EndpointId, + req: &'a HttpProxyRequest, + ) -> Result<(), AuthError> { + match &req.kind { + HttpProxyRequestKind::Tunnel { target } => { + if self.tcp_proxy_exists(&target.host, target.port) { + Ok(()) + } else { + Err(AuthError::Forbidden) + } + } + HttpProxyRequestKind::Absolute { target, .. } => { + if let Ok(authority) = Authority::from_absolute_uri(&target) { + if self.tcp_proxy_exists(&authority.host, authority.port) { + Ok(()) + } else { + Err(AuthError::Forbidden) + } + } else { + debug!(%target, "failed to parse host:port from absolute URL"); + Err(AuthError::Forbidden) + } + } + } + } +} + +#[derive(Debug, Clone)] +pub struct ConnectNode { + endpoint: Endpoint, + proxy: DownstreamProxy, + _n0des: Option>, +} + +impl ConnectNode { + pub async fn new(repo: Repo) -> Result { + let n0des_api_secret = n0des_api_secret_from_env()?; + Self::with_n0des_api_secret(repo, n0des_api_secret).await + } + + #[instrument("connect-node", skip_all)] + pub async fn with_n0des_api_secret( + repo: Repo, + n0des_api_secret: Option, + ) -> Result { + let config = repo.config().await?; + let secret_key = repo.connect_key().await?; + let endpoint = build_endpoint(secret_key, &config).await?; + let n0des = build_n0des_client_opt(&endpoint, n0des_api_secret).await; + let pool = DownstreamProxy::new(endpoint.clone(), Default::default()); + Ok(Self { + endpoint, + _n0des: n0des, + proxy: pool, + }) + } + + pub fn endpoint_id(&self) -> EndpointId { + self.endpoint.id() + } + + pub async fn connect_and_bind_local( + &self, + remote_id: EndpointId, + advertisment: &TcpProxyData, + bind_addr: SocketAddr, + ) -> Result { + let local_socket = TcpListener::bind(bind_addr).await?; + let bound_addr = local_socket.local_addr()?; + + let upstream = EndpointAuthority::new(remote_id, advertisment.clone().into()); + let mode = ProxyMode::Tcp(upstream); + + let proxy = self.proxy.clone(); + let task = tokio::spawn(async move { + info!("bound local socket on {bound_addr}"); + if let Err(err) = proxy.forward_tcp_listener(local_socket, mode).await { + warn!("Forwarding local socket failed: {err:#}"); + } + }.instrument(error_span!("forward-tcp", remote_id=%remote_id.fmt_short(), authority=%advertisment.address()))); + Ok(OutboundProxyHandle { + remote_id, + task, + bound_addr: bind_addr, + advertisment: advertisment.clone(), + }) + } +} + +pub struct OutboundProxyHandle { + task: JoinHandle<()>, + bound_addr: SocketAddr, + remote_id: EndpointId, + advertisment: TcpProxyData, +} + +impl OutboundProxyHandle { + pub fn abort(&self) { + self.task.abort(); + } + + pub fn remote_id(&self) -> EndpointId { + self.remote_id + } + + pub fn bound_addr(&self) -> SocketAddr { + self.bound_addr + } + + pub fn advertisment(&self) -> &TcpProxyData { + &self.advertisment + } +} + +pub async fn build_endpoint(secret_key: SecretKey, common: &Config) -> Result { + let relay_mode = relay_mode_from_env_or_build().await?; + let mut builder = match common.discovery_mode { + crate::config::DiscoveryMode::Dns => { + Endpoint::empty_builder(relay_mode).secret_key(secret_key) + } + crate::config::DiscoveryMode::Default | crate::config::DiscoveryMode::Hybrid => { + Endpoint::builder() + .relay_mode(relay_mode) + .secret_key(secret_key) + } + }; + if let Some(addr) = common.ipv4_addr { + builder = builder.bind_addr_v4(addr); + } + if let Some(addr) = common.ipv6_addr { + builder = builder.bind_addr_v6(addr); + } + match common.discovery_mode { + crate::config::DiscoveryMode::Default => {} + crate::config::DiscoveryMode::Dns | crate::config::DiscoveryMode::Hybrid => { + let origin = match &common.dns_origin { + Some(origin) => origin.clone(), + None => n0_error::bail_any!( + "dns_origin is required when discovery_mode is set to dns or hybrid" + ), + }; + if let Some(resolver_addr) = common.dns_resolver { + let resolver = DnsResolver::builder() + .with_nameserver(resolver_addr, DnsProtocol::Udp) + .build(); + builder = builder.dns_resolver(resolver); + } + builder = builder.discovery(DnsDiscovery::builder(origin)); + } + } + let endpoint = builder.bind().await?; + info!(id = %endpoint.id(), "iroh endpoint bound"); + Ok(endpoint) +} + +const DATUM_CONNECT_RELAY_URLS: &str = "DATUM_CONNECT_RELAY_URLS"; +const BUILD_DATUM_CONNECT_RELAY_URLS: &str = "BUILD_DATUM_CONNECT_RELAY_URLS"; +const STARTUP_RELAY_SELECTION_MAX: usize = 5; +const STARTUP_RELAY_PROBE_TIMEOUT: Duration = Duration::from_millis(800); + +/// Built-in Datum relay shortlist. Used when neither the runtime env +/// `DATUM_CONNECT_RELAY_URLS` nor the compile-time env +/// `BUILD_DATUM_CONNECT_RELAY_URLS` is set. Ensures stock `cargo build` / +/// `nix run` / IDE builds reach a Datum-routable relay network instead of +/// silently falling through to the n0 public relays (which the Datum +/// gateway cannot route through). +const DEFAULT_DATUM_RELAY_URLS: &str = + "iroh-relay.us-east-1.datumconnect.net,iroh-relay.us-west-1.datumconnect.net"; + +/// Resolve the iroh relay set with explicit precedence: +/// 1. runtime env `DATUM_CONNECT_RELAY_URLS` (operator override) +/// 2. compile-time env `BUILD_DATUM_CONNECT_RELAY_URLS` (CI-injected list) +/// 3. built-in `DEFAULT_DATUM_RELAY_URLS` shortlist +/// 4. iroh's `default_relay_mode()` — n0 public/canary relays. Reaching this +/// branch means the Datum gateway will not be able to dial this endpoint. +async fn relay_mode_from_env_or_build() -> Result { + if let Ok(raw_urls) = std::env::var(DATUM_CONNECT_RELAY_URLS) { + match parse_relay_urls(&raw_urls) { + Ok(relays) => { + let relays = + select_best_relays_for_startup(relays, STARTUP_RELAY_SELECTION_MAX).await; + info!( + source = %DATUM_CONNECT_RELAY_URLS, + count = relays.len(), + "using custom iroh relay list from environment" + ); + return Ok(iroh::endpoint::RelayMode::Custom(relays_to_map(relays))); + } + Err(err) => { + warn!("invalid relay urls in {DATUM_CONNECT_RELAY_URLS}: {err:#}"); + } + } + } + + if let Some(raw_urls) = option_env!("BUILD_DATUM_CONNECT_RELAY_URLS") { + match parse_relay_urls(raw_urls) { + Ok(relays) => { + let relays = + select_best_relays_for_startup(relays, STARTUP_RELAY_SELECTION_MAX).await; + info!( + source = %BUILD_DATUM_CONNECT_RELAY_URLS, + count = relays.len(), + "using custom iroh relay list from build environment" + ); + return Ok(iroh::endpoint::RelayMode::Custom(relays_to_map(relays))); + } + Err(err) => { + warn!("invalid relay urls in {BUILD_DATUM_CONNECT_RELAY_URLS}: {err:#}"); + } + } + } + + match parse_relay_urls(DEFAULT_DATUM_RELAY_URLS) { + Ok(relays) => { + let relays = select_best_relays_for_startup(relays, STARTUP_RELAY_SELECTION_MAX).await; + info!( + source = "built-in", + count = relays.len(), + "using built-in Datum relay shortlist" + ); + return Ok(iroh::endpoint::RelayMode::Custom(relays_to_map(relays))); + } + Err(err) => { + warn!("invalid built-in DEFAULT_DATUM_RELAY_URLS, this is a bug: {err:#}"); + } + } + + warn!( + "Falling back to iroh's default public relays (n0). The Datum gateway \ + cannot route through this relay network — inbound connections to this \ + endpoint will fail. Set DATUM_CONNECT_RELAY_URLS or fix \ + DEFAULT_DATUM_RELAY_URLS." + ); + Ok(default_relay_mode()) +} + +fn parse_relay_urls(raw: &str) -> Result> { + let relays: Vec = raw + .split(|c: char| c == ',' || c == ';' || c.is_ascii_whitespace()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(normalize_relay_url) + .map(|url| RelayUrl::from_str(&url)) + .collect::, _>>() + .std_context( + "Failed parsing relay URL list. Expected comma/space/newline separated URLs", + )?; + + if relays.is_empty() { + n0_error::bail_any!("Relay URL list was provided but empty after parsing"); + } + + let mut deduped = Vec::with_capacity(relays.len()); + for relay in relays { + if !deduped.iter().any(|seen: &RelayUrl| seen == &relay) { + deduped.push(relay); + } + } + Ok(deduped) +} + +fn normalize_relay_url(raw: &str) -> String { + if raw.contains("://") { + raw.to_string() + } else { + format!("https://{raw}") + } +} + +async fn select_best_relays_for_startup(relays: Vec, max_relays: usize) -> Vec { + let total_candidates = relays.len(); + if relays.len() <= max_relays { + return relays; + } + + let client = match reqwest::Client::builder() + .timeout(STARTUP_RELAY_PROBE_TIMEOUT) + .build() + { + Ok(client) => client, + Err(err) => { + warn!("relay probe setup failed, using first {max_relays} relays: {err:#}"); + return relays.into_iter().take(max_relays).collect(); + } + }; + + let mut joinset = JoinSet::new(); + for relay in relays.iter().cloned() { + let client = client.clone(); + joinset.spawn(async move { + let latency = probe_relay_latency(&client, &relay).await; + (relay, latency) + }); + } + + let mut successful = Vec::new(); + let mut failed = Vec::new(); + while let Some(joined) = joinset.join_next().await { + match joined { + Ok((relay, Ok(latency))) => successful.push((relay, latency)), + Ok((relay, Err(reason))) => failed.push((relay, reason)), + Err(err) => { + debug!("relay probe task join error: {err:#}"); + } + } + } + + successful.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.as_str().cmp(b.0.as_str()))); + let mut selected: Vec = successful + .iter() + .take(max_relays) + .map(|(relay, _)| relay.clone()) + .collect(); + + if selected.len() < max_relays { + failed.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); + for (relay, _) in &failed { + if selected.len() == max_relays { + break; + } + if !selected.iter().any(|r| r == relay) { + selected.push(relay.clone()); + } + } + } + + if selected.len() < max_relays { + for relay in relays { + if selected.len() == max_relays { + break; + } + if !selected.iter().any(|r| r == &relay) { + selected.push(relay); + } + } + } + + if !failed.is_empty() { + let failure_samples: Vec = failed + .iter() + .take(5) + .map(|(relay, reason)| format!("{relay} -> {reason}")) + .collect(); + warn!( + failed = failed.len(), + samples = ?failure_samples, + "relay ping probe failures observed" + ); + } + info!( + total = total_candidates, + successful = successful.len(), + selected = selected.len(), + selected_relays = ?selected, + "selected startup relay shortlist" + ); + selected +} + +async fn probe_relay_latency( + client: &reqwest::Client, + relay: &RelayUrl, +) -> std::result::Result { + let host = relay + .host_str() + .ok_or_else(|| "missing host in relay url".to_string())? + .trim_end_matches('.'); + let mut https_url = reqwest::Url::parse(&format!("https://{host}/ping")) + .map_err(|err| format!("url parse: {err}"))?; + https_url.set_query(None); + debug!( + relay = %relay, + url = %https_url, + timeout_ms = STARTUP_RELAY_PROBE_TIMEOUT.as_millis(), + "starting relay ping probe" + ); + let start = tokio::time::Instant::now(); + match client.get(https_url.clone()).send().await { + Ok(resp) if resp.status().is_success() => { + let elapsed = start.elapsed(); + debug!( + relay = %relay, + url = %https_url, + status = %resp.status(), + elapsed_ms = elapsed.as_millis(), + "relay ping probe succeeded" + ); + Ok(elapsed) + } + Ok(resp) => { + debug!( + relay = %relay, + url = %https_url, + status = %resp.status(), + elapsed_ms = start.elapsed().as_millis(), + "relay ping probe got non-success response" + ); + Err(format!("status {}", resp.status())) + } + Err(err) => { + debug!( + relay = %relay, + url = %https_url, + elapsed_ms = start.elapsed().as_millis(), + "relay ping probe request failed: {err:#}" + ); + Err(format!("{err:#}")) + } + } +} + +fn relays_to_map(relays: Vec) -> RelayMap { + RelayMap::from_iter(relays.into_iter().map(RelayConfig::from)) +} + +pub(crate) fn n0des_api_secret_from_env() -> Result> { + let api_secret_str = match std::env::var("N0DES_API_SECRET") { + Ok(s) => s, + Err(_) => match option_env!("BUILD_N0DES_API_SECRET") { + None => return Ok(None), + Some(s) => s.to_string(), + }, + }; + let api_secret = ApiSecret::from_str(&api_secret_str) + .context("Failed to parse n0des API secret from env variable N0DES_API_SECRET")?; + Ok(Some(api_secret)) +} + +pub(crate) async fn build_n0des_client_opt( + endpoint: &Endpoint, + api_secret: Option, +) -> Option> { + match api_secret { + None => { + info!("Disabling metrics collection: N0DES_API_SECRET is not set"); + None + } + Some(n0des_api_secret) => match build_n0des_client(endpoint, n0des_api_secret).await { + Ok(client) => Some(client), + Err(err) => { + warn!("Disabling metrics collection: Failed to connect to n0des: {err:#}"); + None + } + }, + } +} + +pub(crate) async fn build_n0des_client( + endpoint: &Endpoint, + api_secret: ApiSecret, +) -> Result> { + let remote_id = api_secret.remote.id; + debug!(remote=%remote_id.fmt_short(), "connecting to n0des endpoint"); + let client = iroh_n0des::Client::builder(endpoint) + .api_secret(api_secret)? + .build() + .await + .std_context("Failed to connect to n0des endpoint")?; + info!(remote=%remote_id.fmt_short(), "Connected to n0des endpoint for metrics collection"); + Ok(Arc::new(client)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_in_default_relay_list_parses() { + let parsed = parse_relay_urls(DEFAULT_DATUM_RELAY_URLS) + .expect("DEFAULT_DATUM_RELAY_URLS must parse — guards the runtime fallback path"); + assert!( + !parsed.is_empty(), + "DEFAULT_DATUM_RELAY_URLS must yield at least one relay" + ); + for relay in &parsed { + assert_eq!(relay.scheme(), "https"); + } + } + + #[tokio::test] + async fn new_with_key_uses_provided_key_without_disk_read() { + let tmp = std::env::temp_dir(); + let dir = tmp.join(format!("node-test-{}", uuid::Uuid::new_v4())); + let repo = Repo::open_or_create(&dir).await.unwrap(); + + // Generate a key in memory. + let key = SecretKey::generate(&mut rand::rng()); + // Derive expected EndpointId by creating a temporary endpoint. + let expected_id = { + let ep = iroh::Endpoint::builder() + .relay_mode(iroh::endpoint::RelayMode::Default) + .secret_key(key.clone()) + .bind() + .await + .unwrap(); + ep.id() + }; + + // new_with_key should use the key directly (no disk read needed). + let node = ListenNode::new_with_key(repo, key).await.unwrap(); + + // The endpoint ID must match the provided key's derived ID. + assert_eq!( + node.endpoint_id(), + expected_id, + "endpoint ID must match the provided key" + ); + } +} diff --git a/connect-lib/lib/src/project_control_plane.rs b/connect-lib/lib/src/project_control_plane.rs new file mode 100644 index 0000000..f7b1dfd --- /dev/null +++ b/connect-lib/lib/src/project_control_plane.rs @@ -0,0 +1,324 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; +use http::HeaderValue; +use http::header::USER_AGENT; +use kube::{Client, Config}; +use n0_error::{Result, StdResultExt}; +use n0_future::task::AbortOnDropHandle; +use secrecy::SecretString; +use tokio::sync::watch; +use tracing::warn; + +use crate::datum_cloud::DatumCloudClient; +use crate::datum_cloud::LoginState; +use crate::http_user_agent::datum_http_user_agent; + +#[derive(derive_more::Debug, Clone)] +pub struct ProjectControlPlaneClient { + project_id: String, + server_url: String, + access_token: Arc>, + #[debug("kube::Client")] + client: Arc>, + datum: DatumCloudClient, + _auth_task: Option>>, + token_rx: Option>, +} + +impl ProjectControlPlaneClient { + pub fn new( + project_id: String, + server_url: String, + access_token: String, + datum: DatumCloudClient, + ) -> Result { + let client = Self::build_kube_client(&server_url, &access_token)?; + let mut this = Self { + project_id, + server_url, + access_token: Arc::new(ArcSwap::from_pointee(access_token)), + client: Arc::new(ArcSwap::from_pointee(client)), + datum, + _auth_task: None, + token_rx: None, + }; + this.start_auth_watch(); + Ok(this) + } + + pub fn new_with_token_source( + project_id: String, + server_url: String, + token_source: crate::datum_cloud::external_token_source::ExternalTokenSource, + ) -> Result { + let initial_token = token_source.token(); + let client = Self::build_kube_client(&server_url, &initial_token)?; + let datum = DatumCloudClient::with_external_token_source( + crate::ApiEnv::from_env_with_host_override(), + token_source.clone(), + ); + let mut this = Self { + project_id, + server_url, + access_token: Arc::new(ArcSwap::from_pointee(initial_token)), + client: Arc::new(ArcSwap::from_pointee(client)), + datum, + _auth_task: None, + token_rx: Some(token_source.watch()), + }; + this.start_auth_watch(); + Ok(this) + } + + pub fn project_id(&self) -> &str { + &self.project_id + } + + pub fn server_url(&self) -> &str { + &self.server_url + } + + pub fn access_token(&self) -> String { + self.access_token.load_full().as_ref().clone() + } + + pub fn client(&self) -> Client { + self.client.load_full().as_ref().clone() + } + + pub async fn client_refreshed(&self) -> Result { + let access_token = self.datum.token(); + self.rebuild_if_changed(&access_token)?; + Ok(self.client()) + } + + fn build_kube_client(server_url: &str, access_token: &str) -> Result { + let uri = server_url + .parse() + .std_context("Invalid project control plane URL")?; + let mut config = Config::new(uri); + config.auth_info.token = Some(SecretString::new(access_token.to_string().into_boxed_str())); + let ua = HeaderValue::from_str(&datum_http_user_agent()) + .std_context("Invalid User-Agent for kube client")?; + config.headers.push((USER_AGENT, ua)); + Client::try_from(config).std_context("Failed to create project control plane client") + } + + fn rebuild_if_changed(&self, access_token: &str) -> Result<()> { + let current = self.access_token.load_full(); + if current.as_ref().as_str() == access_token { + return Ok(()); + } + + let client = Self::build_kube_client(&self.server_url, access_token)?; + self.client.store(Arc::new(client)); + self.access_token.store(Arc::new(access_token.to_string())); + Ok(()) + } + + async fn refresh_client_from_update(&self) -> Result<()> { + if self.datum.is_plugin_mode() { + let token = self.datum.token(); + return self.rebuild_if_changed(&token); + } + let auth_state = self.datum.auth_state(); + let auth = auth_state.load(); + self.rebuild_if_changed(&auth.tokens.access_token.secret().to_string()) + } + + fn start_auth_watch(&mut self) { + if self._auth_task.is_some() { + return; + } + let mut client = self.clone(); + let task = tokio::spawn(async move { + if let Some(token_rx) = client.token_rx.take() { + if let Err(err) = client.refresh_client_from_update().await { + warn!("failed to refresh project control plane client: {err:#}"); + } + let mut token_rx = token_rx; + loop { + if token_rx.changed().await.is_err() { + return; + } + let new_token = (*token_rx.borrow()).clone(); + if let Err(err) = client.rebuild_if_changed(&new_token) { + warn!("failed to refresh project control plane client: {err:#}"); + } + } + } else { + let mut login_rx = client.datum.login_state_watch(); + let mut auth_update_rx = client.datum.auth_update_watch(); + if *login_rx.borrow() != LoginState::Missing + && let Err(err) = client.refresh_client_from_update().await + { + warn!("failed to refresh project control plane client: {err:#}"); + } + loop { + tokio::select! { + res = login_rx.changed() => { + if res.is_err() { + return; + } + } + res = auth_update_rx.changed() => { + if res.is_err() { + return; + } + } + } + if *login_rx.borrow() != LoginState::Missing + && let Err(err) = client.refresh_client_from_update().await + { + warn!("failed to refresh project control plane client: {err:#}"); + } + } + } + }); + self._auth_task = Some(Arc::new(AbortOnDropHandle::new(task))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ExternalTokenSource; + use base64::Engine; + + fn make_jwt_with_exp(exp: u64) -> String { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::json!({"alg":"HS256","typ":"JWT"}).to_string().as_bytes(), + ); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::json!({"exp": exp, "sub":"test-user"}).to_string().as_bytes(), + ); + format!("{header}.{payload}.fake_sig") + } + + struct TempDir { + path: std::path::PathBuf, + } + + impl TempDir { + fn new() -> Self { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("connect-pcp-test-{ts}")); + std::fs::create_dir_all(&path).expect("should create temp dir"); + TempDir { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn setup_plugin_env() -> (TempDir, ExternalTokenSource) { + let _lock = crate::ENV_LOCK.lock().unwrap(); + let dir = TempDir::new(); + let helper_path = dir.path().join("fake-helper.sh"); + let jwt = make_jwt_with_exp(9999999999); + std::fs::write(&helper_path, format!("#!/bin/sh\necho '{}'\n", jwt)) + .expect("should write helper script"); + #[cfg(unix)] + std::fs::set_permissions( + &helper_path, + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .expect("should set executable permission"); + let helper_str = helper_path.to_string_lossy().to_string(); + + unsafe { + std::env::set_var("DATUM_CREDENTIALS_HELPER", &helper_str); + std::env::set_var("DATUM_SESSION", "test-session"); + } + + let source = + ExternalTokenSource::from_env(Some("test-session".to_string())).expect("should create token source"); + (dir, source) + } + + // These tests are integration-style — they require rustls CryptoProvider + // to be installed (requires 'ring' or 'aws-lc-rs' feature). Marked + // ignore so they don't fail in CI when those features are disabled. + // Run manually with: cargo test --lib -- --ignored + + #[test] + #[ignore] + fn new_with_token_source_accepts_external_token_source() { + let (_dir, token_source) = setup_plugin_env(); + let result = ProjectControlPlaneClient::new_with_token_source( + "test-project".to_string(), + "https://api.datum.net/apis/resourcemanager.miloapis.com/v1alpha1/projects/test-project/control-plane".to_string(), + token_source, + ); + let _ = result; + } + + #[test] + #[ignore] + fn new_with_token_source_sets_project_id() { + let (_dir, token_source) = setup_plugin_env(); + let pcp = ProjectControlPlaneClient::new_with_token_source( + "my-project-id".to_string(), + "https://api.datum.net/apis/resourcemanager.miloapis.com/v1alpha1/projects/my-project-id/control-plane".to_string(), + token_source, + ); + if let Ok(pcp) = pcp { + assert_eq!(pcp.project_id(), "my-project-id"); + } + } + + #[test] + #[ignore] + fn access_token_returns_token_from_source() { + let (_dir, token_source) = setup_plugin_env(); + let expected_token = token_source.token(); + let pcp = ProjectControlPlaneClient::new_with_token_source( + "test-project".to_string(), + "https://api.datum.net/apis/resourcemanager.miloapis.com/v1alpha1/projects/test-project/control-plane".to_string(), + token_source, + ); + if let Ok(pcp) = pcp { + assert_eq!(pcp.access_token(), expected_token); + } + } + + #[test] + #[ignore] + fn server_url_is_stored() { + let (_dir, token_source) = setup_plugin_env(); + let server_url = "https://custom.api.net/apis/resourcemanager.miloapis.com/v1alpha1/projects/test/control-plane".to_string(); + let pcp = ProjectControlPlaneClient::new_with_token_source( + "test-project".to_string(), + server_url.clone(), + token_source, + ); + if let Ok(pcp) = pcp { + assert_eq!(pcp.server_url(), server_url); + } + } + + #[test] + #[ignore] + fn datum_is_plugin_mode_after_new_with_token_source() { + let (_dir, token_source) = setup_plugin_env(); + let pcp = ProjectControlPlaneClient::new_with_token_source( + "test-project".to_string(), + "https://api.datum.net/apis/resourcemanager.miloapis.com/v1alpha1/projects/test-project/control-plane".to_string(), + token_source, + ); + if let Ok(pcp) = pcp { + assert!(pcp.datum.is_plugin_mode()); + } + } +} diff --git a/connect-lib/lib/src/repo.rs b/connect-lib/lib/src/repo.rs new file mode 100644 index 0000000..7acfc69 --- /dev/null +++ b/connect-lib/lib/src/repo.rs @@ -0,0 +1,473 @@ +use std::path::PathBuf; + +use iroh::SecretKey; +use tracing::{info, instrument, warn}; +use n0_error::{Result, StackResultExt, StdResultExt}; + +use crate::{ + config::Config, + state::State, +}; + +/// Error returned by [`Repo::default_location`] when the +/// `DATUM_CONNECT_DIR` environment variable is not set. +/// +/// Phase 11.5 D-09/D-10: the binary refuses to invent a default +/// location. The `Display` impl prints the multi-line directive +/// message that tells the user how to fix the situation. +#[derive(Debug, Clone)] +pub struct MissingConnectDir; + +impl std::fmt::Display for MissingConnectDir { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(MISSING_CONNECT_DIR_MSG) + } +} + +impl std::error::Error for MissingConnectDir {} + +const MISSING_CONNECT_DIR_MSG: &str = "error: DATUM_CONNECT_DIR is not set + +The datum-connect binary expects this variable to point to its state +directory (where it stores the iroh listen_key, config, and per-project +state). It is normally set by the datumctl plugin host. + +To run via datumctl (preferred): + datumctl connect tunnel ... + +To run datum-connect directly (development): + export DATUM_CONNECT_DIR=\"$HOME/.datumctl/connect\" + datum-connect ... + +(exit 64) +"; + +// Repo builds up a series of file path conventions from a root directory path. +#[derive(Debug, Clone)] +pub struct Repo(PathBuf); + +impl Repo { + /// Create a Repo from a path without opening/creating (for sync use cases like update install). + pub fn from_path(path: PathBuf) -> Self { + Self(path) + } + + const CONFIG_FILE: &str = "config.yml"; + const CONNECT_KEY_FILE: &str = "connect_key"; + pub const LISTEN_KEY_FILE: &str = "listen_key"; + const STATE_FILE: &str = "state.yml"; + pub fn default_location() -> Result { + match std::env::var("DATUM_CONNECT_DIR") { + Ok(path) if !path.is_empty() => Ok(PathBuf::from(path)), + Ok(_) | Err(_) => Err(MissingConnectDir), + } + } + + /// Opens or creates a repo at the given base directory. + pub async fn open_or_create(base_dir: impl Into) -> Result { + let base_dir = base_dir.into(); + tokio::fs::create_dir_all(&base_dir).await?; + info!("opening repo at {}", base_dir.display()); + + let this = Self(base_dir); + + Ok(this) + } + + pub async fn config(&self) -> Result { + let config_file_path = self.0.join(Self::CONFIG_FILE); + if !config_file_path.exists() { + warn!("config does not exist. creating new config"); + let cfg = Config::default(); + cfg.write(config_file_path).await?; + return Ok(cfg); + }; + + Config::from_file(config_file_path).await + } + + pub async fn load_state(&self) -> Result { + let state_file_path = self.0.join(Self::STATE_FILE); + let state = if !state_file_path.exists() { + let state = State::default(); + state.write_to_file(state_file_path).await?; + state + } else { + State::from_file(state_file_path).await? + }; + Ok(crate::StateWrapper::new(state)) + } + + pub async fn write_state(&self, state: &State) -> Result<()> { + state.write_to_file(self.0.join(Self::STATE_FILE)).await + } + + pub async fn write_selected_context( + &self, + selected: Option<&crate::SelectedContext>, + ) -> Result<()> { + let path = self.0.join(Self::CONFIG_FILE); + let mut config = if path.exists() { + let data = tokio::fs::read_to_string(&path) + .await + .context("reading config file")?; + serde_yml::from_str(&data).std_context("parsing config file")? + } else { + crate::config::Config::default() + }; + config.selected_context = selected.cloned(); + config.write(path).await + } + + pub async fn read_selected_context(&self) -> Result> { + let path = self.0.join(Self::CONFIG_FILE); + if path.exists() { + let data = tokio::fs::read_to_string(path) + .await + .context("reading config file")?; + let config: crate::config::Config = + serde_yml::from_str(&data).std_context("parsing config file")?; + return Ok(config.selected_context); + } + Ok(None) + } + + pub async fn connect_key(&self) -> Result { + let key_file_path = self.0.join(Self::CONNECT_KEY_FILE); + self.secret_key(key_file_path).await + } + + pub async fn listen_key(&self) -> Result { + let key_file_path = self.0.join(Self::LISTEN_KEY_FILE); + self.secret_key(key_file_path).await + } + + /// Project-scoped listen key. Each project gets its own iroh identity so + /// Connectors registered in different projects don't collide on the iroh + /// DNS record (the controller assigns ownership to one and leaves the + /// others with `IrohDNSPublished=False; DeferredToOwner`, which manifests + /// as a tunnel that reports ready but silently drops data). + /// + /// On first access for any project, if the legacy flat `listen_key` exists + /// it is moved into this project's directory so the user keeps continuity + /// with whatever Connector that key was registered as. Subsequent projects + /// (no legacy file left) get freshly generated keys. + pub async fn listen_key_for_project(&self, project_id: &str) -> Result { + let project_dir = self.0.join(project_id); + let key_file_path = project_dir.join(Self::LISTEN_KEY_FILE); + if !key_file_path.exists() { + let legacy = self.0.join(Self::LISTEN_KEY_FILE); + if legacy.exists() { + tokio::fs::create_dir_all(&project_dir).await?; + info!( + "migrating legacy listen_key {} -> {} for project {project_id}", + legacy.display(), + key_file_path.display(), + ); + tokio::fs::rename(&legacy, &key_file_path).await?; + } + } + self.secret_key(key_file_path).await + } + + /// Per-tunnel listen key. Each named tunnel gets its own iroh identity so + /// tunnels in the same project don't collide on the iroh DNS record. + /// + /// On first access, if a legacy flat `listen_key` exists at the repo root + /// for this project, it is moved into `//listen_key` + /// (preserving the key value for continuity with the registered Connector). + /// Subsequent tunnels in the same project (no legacy file left) get freshly + /// generated keys. + /// Legacy flat key location at the repo root (same as the old + /// `Repo::listen_key()` path). + const LEGACY_LISTEN_KEY: &'static str = "listen_key"; + + #[instrument("repo", skip_all)] + pub async fn listen_key_for_tunnel( + &self, + project_id: &str, + tunnel_name: &str, + ) -> Result { + let tunnel_dir = self.0.join(project_id).join(tunnel_name); + let key_file_path = tunnel_dir.join(Self::LISTEN_KEY_FILE); + + if !key_file_path.exists() { + // Check for legacy key at repo root (the old flat layout). + let legacy = self.0.join(Self::LEGACY_LISTEN_KEY); + if legacy.exists() { + tokio::fs::create_dir_all(&tunnel_dir).await?; + info!( + "migrating legacy listen_key {} -> {} for project {project_id} tunnel {tunnel_name}", + legacy.display(), + key_file_path.display(), + ); + tokio::fs::rename(&legacy, &key_file_path).await?; + } + } + + self.secret_key(key_file_path).await + } + + async fn secret_key(&self, key_file_path: PathBuf) -> Result { + if !key_file_path.exists() { + warn!("secret key does not exist. creating new key"); + if let Some(parent) = key_file_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + return self.create_key(&key_file_path).await; + }; + + let key = tokio::fs::read(key_file_path).await?; + let key = key.as_slice().try_into().anyerr()?; + Ok(SecretKey::from_bytes(key)) + } + + async fn create_key(&self, key_file_path: &PathBuf) -> Result { + let key = SecretKey::generate(&mut rand::rng()); + tokio::fs::write(key_file_path, key.to_bytes()).await?; + Ok(key) + } + + /// Get the base directory path of this repo + pub fn path(&self) -> &PathBuf { + &self.0 + } + + /// Delete the local state directory for a tunnel + pub async fn delete_tunnel_dir(&self, project_id: &str, tunnel_name: &str) -> Result<()> { + let tunnel_dir = self.0.join(project_id).join(tunnel_name); + if tunnel_dir.exists() { + tokio::fs::remove_dir_all(&tunnel_dir).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_repo_dir() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("datum-repo-test-{}", uuid::Uuid::new_v4())); + path + } + + #[tokio::test] + async fn listen_key_for_project_migrates_legacy_into_first_project() { + // The legacy `listen_key` lived at the repo root and was reused for + // every project the CLI talked to. The migration must move (not copy) + // it into the first project that requests it, so the second project + // gets a fresh identity instead of joining the cross-project DNS race. + let repo = Repo::open_or_create(temp_repo_dir()).await.unwrap(); + let legacy = repo.listen_key().await.unwrap(); + let legacy_bytes = legacy.to_bytes(); + let legacy_path = repo.0.join(Repo::LISTEN_KEY_FILE); + assert!(legacy_path.exists(), "precondition: legacy key exists"); + + let p1 = repo.listen_key_for_project("project-a").await.unwrap(); + assert_eq!( + p1.to_bytes(), + legacy_bytes, + "first project must adopt the legacy key" + ); + assert!(!legacy_path.exists(), "legacy file must be gone after migration"); + let p1_path = repo + .0 + .join("project-a") + .join(Repo::LISTEN_KEY_FILE); + assert!(p1_path.exists(), "key must now live under the project dir"); + + let p2 = repo.listen_key_for_project("project-b").await.unwrap(); + assert_ne!( + p2.to_bytes(), + legacy_bytes, + "second project must get a fresh key, not the legacy one" + ); + } + + #[tokio::test] + async fn listen_key_for_project_is_stable_across_calls() { + let repo = Repo::open_or_create(temp_repo_dir()).await.unwrap(); + let first = repo.listen_key_for_project("project-x").await.unwrap(); + let second = repo.listen_key_for_project("project-x").await.unwrap(); + assert_eq!( + first.to_bytes(), + second.to_bytes(), + "repeat calls must return the same persisted key" + ); + } + + #[tokio::test] + async fn listen_key_for_project_generates_fresh_without_legacy() { + let repo = Repo::open_or_create(temp_repo_dir()).await.unwrap(); + let key = repo.listen_key_for_project("only-project").await.unwrap(); + let legacy_path = repo.0.join(Repo::LISTEN_KEY_FILE); + assert!(!legacy_path.exists(), "no legacy must be created"); + let project_path = repo + .0 + .join("only-project") + .join(Repo::LISTEN_KEY_FILE); + assert!(project_path.exists()); + assert_eq!(tokio::fs::read(&project_path).await.unwrap(), key.to_bytes()); + } + + // ── Per-tunnel key tests ────────────────────────────────────────── + + #[tokio::test] + async fn listen_key_for_tunnel_fresh_project_generates_key_at_per_tunnel_path() { + let repo = Repo::open_or_create(temp_repo_dir()).await.unwrap(); + let key = repo + .listen_key_for_tunnel("my-project", "my-tunnel") + .await + .unwrap(); + let tunnel_dir = repo.0.join("my-project").join("my-tunnel"); + let key_path = tunnel_dir.join(Repo::LISTEN_KEY_FILE); + assert!(key_path.exists(), "key must exist at per-tunnel path"); + assert_eq!(tokio::fs::read(&key_path).await.unwrap(), key.to_bytes()); + } + + #[tokio::test] + async fn listen_key_for_tunnel_migrates_legacy_key_to_default_tunnel() { + let repo = Repo::open_or_create(temp_repo_dir()).await.unwrap(); + // Create a legacy key at the project root. + let legacy_key = repo.listen_key().await.unwrap(); + let legacy_bytes = legacy_key.to_bytes(); + let legacy_path = repo.0.join(Repo::LISTEN_KEY_FILE); + assert!(legacy_path.exists(), "precondition: legacy key exists"); + + // Access per-tunnel for "default" tunnel — should migrate. + let key = repo + .listen_key_for_tunnel("proj-migrate", "default") + .await + .unwrap(); + assert_eq!( + key.to_bytes(), + legacy_bytes, + "migrated key must match the legacy key value" + ); + assert!( + !legacy_path.exists(), + "legacy file must be removed after migration" + ); + let expected_path = repo + .0 + .join("proj-migrate") + .join("default") + .join(Repo::LISTEN_KEY_FILE); + assert!(expected_path.exists(), "key must now live at per-tunnel path"); + } + + #[tokio::test] + async fn listen_key_for_tunnel_is_stable_across_calls() { + let repo = Repo::open_or_create(temp_repo_dir()).await.unwrap(); + let first = repo + .listen_key_for_tunnel("stable-proj", "stable-tunnel") + .await + .unwrap(); + let second = repo + .listen_key_for_tunnel("stable-proj", "stable-tunnel") + .await + .unwrap(); + assert_eq!( + first.to_bytes(), + second.to_bytes(), + "repeat calls must return the same persisted key" + ); + } + + #[tokio::test] + async fn listen_key_for_tunnel_two_tunnels_get_distinct_keys() { + let repo = Repo::open_or_create(temp_repo_dir()).await.unwrap(); + let key_a = repo + .listen_key_for_tunnel("multi-proj", "tunnel-a") + .await + .unwrap(); + let key_b = repo + .listen_key_for_tunnel("multi-proj", "tunnel-b") + .await + .unwrap(); + assert_ne!( + key_a.to_bytes(), + key_b.to_bytes(), + "two tunnels in the same project must get distinct keys" + ); + } +} + +#[cfg(test)] +mod default_location_tests { + use super::*; + + // Both crates are Rust edition 2024 — std::env::set_var / + // remove_var require the `unsafe` block. The shared ENV_LOCK + // serializes against the other env-mutating tests in the crate + // (datum_cloud/external_token_source.rs, datum_cloud/mod.rs). + + #[test] + fn returns_ok_when_var_set() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + let saved = std::env::var("DATUM_CONNECT_DIR").ok(); + unsafe { std::env::set_var("DATUM_CONNECT_DIR", "/tmp/test-connect-dir"); } + + let got = Repo::default_location(); + + // Restore before asserting so a panic doesn't leak the mutation. + unsafe { + match saved { + Some(v) => std::env::set_var("DATUM_CONNECT_DIR", v), + None => std::env::remove_var("DATUM_CONNECT_DIR"), + } + } + + match got { + Ok(p) => assert_eq!(p, PathBuf::from("/tmp/test-connect-dir")), + Err(e) => panic!("expected Ok, got Err({e})"), + } + } + + #[test] + fn returns_err_when_var_empty() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + let saved = std::env::var("DATUM_CONNECT_DIR").ok(); + unsafe { std::env::set_var("DATUM_CONNECT_DIR", ""); } + + let got = Repo::default_location(); + + unsafe { + match saved { + Some(v) => std::env::set_var("DATUM_CONNECT_DIR", v), + None => std::env::remove_var("DATUM_CONNECT_DIR"), + } + } + + assert!(matches!(got, Err(MissingConnectDir))); + } + + #[test] + fn returns_err_when_var_unset() { + let _lock = crate::ENV_LOCK.lock().unwrap(); + let saved = std::env::var("DATUM_CONNECT_DIR").ok(); + unsafe { std::env::remove_var("DATUM_CONNECT_DIR"); } + + let got = Repo::default_location(); + + unsafe { + if let Some(v) = saved { + std::env::set_var("DATUM_CONNECT_DIR", v); + } + } + + assert!(matches!(got, Err(MissingConnectDir))); + } + + #[test] + fn missing_connect_dir_display_contains_directive() { + // Pure formatting check — no env mutation needed. + let msg = format!("{}", MissingConnectDir); + assert!(msg.contains("DATUM_CONNECT_DIR is not set"), "msg = {msg}"); + assert!(msg.contains("datumctl connect tunnel"), "msg = {msg}"); + assert!(msg.contains("export DATUM_CONNECT_DIR=\"$HOME/.datumctl/connect\""), "msg = {msg}"); + assert!(msg.contains("(exit 64)"), "msg = {msg}"); + } +} diff --git a/connect-lib/lib/src/state.rs b/connect-lib/lib/src/state.rs new file mode 100644 index 0000000..2e32dc8 --- /dev/null +++ b/connect-lib/lib/src/state.rs @@ -0,0 +1,311 @@ +use std::{path::PathBuf, str::FromStr, sync::Arc}; + +use arc_swap::{ArcSwap, Guard}; +use iroh::EndpointId; +use iroh_proxy_utils::Authority; +use iroh_tickets::{ParseError, Ticket}; +use n0_error::{Result, StackResultExt, StdResultExt}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Notify, futures::Notified}; + +use crate::{DATUM_CONNECT_GATEWAY_DOMAIN_NAME, Repo}; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct State { + pub proxies: Vec, +} + +impl State { + pub fn set_proxy(&mut self, proxy: ProxyState) { + if let Some(existing) = self + .proxies + .iter_mut() + .find(|p| p.info.resource_id == proxy.info.resource_id) + { + *existing = proxy; + } else { + self.proxies.push(proxy); + } + } + + pub fn remove_proxy(&mut self, resouce_id: &str) -> Option { + if let Some(idx) = self + .proxies + .iter() + .position(|p| p.info.resource_id == resouce_id) + { + Some(self.proxies.remove(idx)) + } else { + None + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub struct SelectedContext { + pub org_id: String, + pub org_name: String, + pub project_id: String, + pub project_name: String, + /// Organization type (e.g. "personal", "team"). Invitations are only allowed when not "personal". + #[serde(default)] + pub org_type: String, +} + +impl SelectedContext { + pub fn label(&self) -> String { + format!("{} / {}", self.org_name, self.project_name) + } + + /// True if this org is a personal org (invitations not allowed). + pub fn is_personal_org(&self) -> bool { + self.org_type.eq_ignore_ascii_case("personal") + } + + /// True if the user can send invitations (org is not personal and type is known). + pub fn can_send_invite(&self) -> bool { + !self.org_type.is_empty() && !self.is_personal_org() + } +} + +#[derive(Debug, Clone)] +pub struct StateWrapper { + inner: Arc>, + notify: Arc, +} + +impl StateWrapper { + pub fn new(state: State) -> Self { + Self { + inner: Arc::new(ArcSwap::new(Arc::new(state))), + notify: Default::default(), + } + } + + pub fn get(&self) -> Guard> { + self.inner.load() + } + + pub fn get_cloned(&self) -> Arc { + self.inner.load_full() + } + + pub fn updated(&self) -> Notified<'_> { + self.notify.notified() + } + + pub async fn update( + &self, + repo: &Repo, + f: impl FnOnce(&mut State) -> R, + ) -> n0_error::Result { + let mut inner = (*self.inner.load_full()).clone(); + let res = f(&mut inner); + let inner = Arc::new(inner); + self.inner.store(inner.clone()); + repo.write_state(&inner).await?; + self.notify.notify_waiters(); + Ok(res) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub struct ProxyState { + pub info: Advertisment, + pub enabled: bool, +} + +impl ProxyState { + pub fn new(info: Advertisment) -> Self { + Self { + info, + enabled: true, + } + } + + pub fn id(&self) -> &str { + &self.info.resource_id + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub struct Advertisment { + pub resource_id: String, + pub label: Option, + pub data: TcpProxyData, +} + +impl Advertisment { + pub fn new(data: TcpProxyData, label: Option) -> Self { + let resource_id = format!("proxy-{}", rand_str(12)); + Self { + resource_id, + data, + label, + } + } + + pub fn with_id(resource_id: String, data: TcpProxyData, label: Option) -> Self { + Self { + resource_id, + data, + label, + } + } + + pub fn id(&self) -> &str { + &self.resource_id + } + + pub fn label(&self) -> &str { + self.label.as_deref().unwrap_or_else(|| self.id()) + } + + pub fn codename(&self) -> String { + self.resource_id.clone() + } + + pub fn service(&self) -> &TcpProxyData { + &self.data + } + + pub fn domain(&self) -> String { + format!("{}.{}", self.id(), DATUM_CONNECT_GATEWAY_DOMAIN_NAME) + } + + // TODO: Change to HTTPS + pub fn datum_url(&self) -> String { + format!("http://{}.{}", self.id(), DATUM_CONNECT_GATEWAY_DOMAIN_NAME) + } + + // TODO: Not everything is HTTP + pub fn local_url(&self) -> String { + format!("http://{}", self.service().address()) + } + + pub fn datum_resource_url(&self) -> String { + format!("datum://{}", self.id()) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub struct TcpProxyData { + pub host: String, + pub port: u16, +} + +impl From for Authority { + fn from(value: TcpProxyData) -> Self { + Self { + host: value.host, + port: value.port, + } + } +} + +impl TcpProxyData { + pub fn from_host_port_str(s: &str) -> Result { + let (host, port) = Self::parse_host_port(s)?; + Ok(Self { host, port }) + } + + pub fn address(&self) -> String { + format!("{}:{}", self.host, self.port) + } + + fn parse_host_port(s: &str) -> Result<(String, u16)> { + let (host, port) = s.rsplit_once(":").context("missing port")?; + let port: u16 = port.parse().std_context("invalid port")?; + Ok((host.to_string(), port)) + } +} + +impl State { + pub(crate) async fn from_file(path: PathBuf) -> Result { + let data = tokio::fs::read(path).await?; + let state: State = serde_yml::from_slice(&data).anyerr()?; + Ok(state) + } + + pub(crate) async fn write_to_file(&self, path: PathBuf) -> Result<()> { + let data = serde_yml::to_string(&self).anyerr()?; + tokio::fs::write(&path, &data).await?; + Ok(()) + } +} + +impl Advertisment { + pub fn ticket(&self, endpoint: EndpointId) -> AdvertismentTicket { + AdvertismentTicket { + data: self.clone(), + endpoint, + } + } +} + +fn rand_str(len: usize) -> String { + rand::rng() + .sample_iter(&rand::distr::Alphanumeric) + .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + .take(len) + .map(char::from) + .collect() +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AdvertismentTicket { + pub data: Advertisment, + pub endpoint: EndpointId, +} + +impl AdvertismentTicket { + pub fn service(&self) -> &TcpProxyData { + &self.data.data + } +} + +impl FromStr for AdvertismentTicket { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + iroh_tickets::Ticket::deserialize(s) + } +} + +impl Ticket for AdvertismentTicket { + const KIND: &'static str = "datum"; + + fn to_bytes(&self) -> Vec { + postcard::to_allocvec(&self).expect("serialize should work") + } + + fn from_bytes(bytes: &[u8]) -> Result { + let ticket: Self = postcard::from_bytes(bytes)?; + Ok(ticket) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_tcp_proxy_data_from_host_port() { + let data = TcpProxyData::from_host_port_str("example.test:443").unwrap(); + assert_eq!(data.host, "example.test"); + assert_eq!(data.port, 443); + } + + #[test] + fn parse_tcp_proxy_data_rejects_missing_port() { + let err = TcpProxyData::from_host_port_str("example.test").unwrap_err(); + assert!(err.to_string().contains("missing port")); + } + + #[test] + fn parse_tcp_proxy_data_rejects_invalid_port() { + let err = TcpProxyData::from_host_port_str("example.test:abc").unwrap_err(); + assert!(err.to_string().contains("invalid port")); + } +} diff --git a/connect-lib/lib/src/tunnels.rs b/connect-lib/lib/src/tunnels.rs new file mode 100644 index 0000000..8e5bd58 --- /dev/null +++ b/connect-lib/lib/src/tunnels.rs @@ -0,0 +1,2126 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use iroh_proxy_utils::Authority; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{DeleteParams, ListParams, Patch, PatchParams, PostParams}; +use kube::{Api, ResourceExt}; +use n0_error::{Result, StdResultExt, StackResultExt}; +use serde_json::json; +use tracing::{debug, warn}; + +use crate::datum_apis::connector::{ + CONNECTOR_CONDITION_IROH_DNS_PUBLISHED, CONNECTOR_CONDITION_READY, + CONNECTOR_REASON_DEFERRED_TO_OWNER, Connector, ConnectorConnectionDetails, + ConnectorConnectionDetailsPublicKey, ConnectorConnectionType, ConnectorSpec, + PublicKeyConnectorAddress, PublicKeyDiscoveryMode, +}; +use crate::datum_apis::connector_class::ConnectorClass; +use crate::datum_apis::connector_advertisement::{ + ConnectorAdvertisement, ConnectorAdvertisementLayer4, ConnectorAdvertisementLayer4Service, + ConnectorAdvertisementSpec, Layer4ServiceAddress, Layer4ServicePort, Protocol, +}; +use crate::datum_apis::http_proxy::{ + ConnectorReference, HTTP_PROXY_CONDITION_ACCEPTED, HTTP_PROXY_CONDITION_CERTIFICATES_READY, + HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED, HTTP_PROXY_CONDITION_PROGRAMMED, HTTPProxy, + HTTPProxyRule, HTTPProxyRuleBackend, HTTPProxySpec, HTTPRouteMatch, + HTTPRouteRulesFiltersType, HTTPRouteRulesMatchesHeaders, HTTPRouteRulesMatchesHeadersType, + HTTPRouteRulesMatchesPath, HTTPRouteRulesMatchesPathType, +}; +use crate::datum_apis::traffic_protection_policy::{ + LocalPolicyTargetReferenceWithSectionName, OWASPCRS, ParanoiaLevels, TrafficProtectionPolicy, + TrafficProtectionPolicyMode, TrafficProtectionPolicyRuleSet, + TrafficProtectionPolicyRuleSetType, TrafficProtectionPolicySpec, +}; +use crate::datum_cloud::DatumCloudClient; +use crate::{Advertisment, ListenNode, TcpProxyData, state::ProxyState}; + +const DEFAULT_PCP_NAMESPACE: &str = "default"; +const DEFAULT_CONNECTOR_CLASS_NAME: &str = "datum-connect"; +const CONNECTOR_SELECTOR_FIELD: &str = "status.connectionDetails.publicKey.id"; +const ADVERTISEMENT_CONNECTOR_FIELD: &str = "spec.connectorRef.name"; +const DISPLAY_NAME_ANNOTATION: &str = "app.kubernetes.io/name"; + +/// Returns true if any rule in the HTTPProxy has a backend that references the given connector by name. +fn proxy_uses_connector(proxy: &HTTPProxy, connector_name: &str) -> bool { + proxy + .spec + .rules + .iter() + .flat_map(|rule| rule.backends.as_ref().and_then(|b| b.first())) + .any(|backend| { + backend + .connector + .as_ref() + .map(|c| c.name == connector_name) + .unwrap_or(false) + }) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TunnelSummary { + pub id: String, + pub label: String, + pub endpoint: String, + pub hostnames: Vec, + pub enabled: bool, + pub accepted: bool, + pub programmed: bool, + pub connector_metadata_programmed: bool, +} + +impl TunnelSummary { + pub fn origin_authority(&self) -> Option { + TcpProxyData::from_host_port_str(&strip_scheme(&self.endpoint)) + .ok() + .map(Authority::from) + } +} + +#[derive(Debug, Clone)] +pub struct TunnelDeleteOutcome { + pub project_id: String, + pub http_proxy: Option, + pub connector_ad: Option, + pub traffic_protection_policy: Option, + pub connector: Option, +} + +#[derive(Debug, Clone)] +pub struct TunnelService { + datum: DatumCloudClient, + listen: ListenNode, + publish_tickets: bool, + create_traffic_protection_policies: bool, +} + +fn proxy_state_from_summary( + tunnel_id: &str, + endpoint: &str, + label: &str, + enabled: bool, +) -> Result { + let data = TcpProxyData::from_host_port_str(&strip_scheme(endpoint))?; + let info = Advertisment::with_id(tunnel_id.to_string(), data, Some(label.to_string())); + Ok(ProxyState { info, enabled }) +} + +fn condition_is_true( + conditions: Option<&[k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition]>, + kind: &str, +) -> bool { + conditions + .unwrap_or_default() + .iter() + .find(|condition| condition.type_ == kind) + .map(|condition| condition.status == "True") + .unwrap_or(false) +} + +/// Returns true when the condition is True *or absent*. Used for +/// ConnectorMetadataProgrammed which is deliberately not set by the operator +/// in extension-server mode (EPP emission disabled). Absent means the +/// extension server is managing xDS injection directly — the tunnel is ready. +fn condition_is_true_or_absent( + conditions: Option<&[k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition]>, + kind: &str, +) -> bool { + match conditions + .unwrap_or_default() + .iter() + .find(|condition| condition.type_ == kind) + { + Some(c) => c.status == "True", + None => true, + } +} + +fn find_condition<'a>( + conditions: Option<&'a [k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition]>, + kind: &str, +) -> Option<&'a k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition> { + conditions.unwrap_or_default().iter().find(|c| c.type_ == kind) +} + +/// One checkpoint in the tunnel setup pipeline. Maps 1:1 to a controller +/// condition; the order roughly tracks how a healthy setup progresses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProgressStepKind { + /// HTTPProxy `Accepted` — control plane accepted the resource. + ProxyAccepted, + /// HTTPProxy `CertificatesReady` — TLS certs issued for the hostname. + CertificatesReady, + /// Connector `Ready` — agent is online and renewing its lease. + ConnectorReady, + /// Connector `IrohDNSPublished` — iroh DNS record published. The + /// failure-with-`DeferredToOwner` case is the silent-tunnel failure + /// that signals cross-project iroh-key collision. + IrohDnsPublished, + /// HTTPProxy `Programmed` — edge actually programmed the route. + ProxyProgrammed, + /// HTTPProxy `ConnectorMetadataProgrammed` — Envoy has the iroh metadata + /// it needs to dial the connector. + ConnectorMetadataProgrammed, +} + +impl ProgressStepKind { + pub fn label(&self) -> &'static str { + match self { + Self::ProxyAccepted => "tunnel accepted", + Self::CertificatesReady => "TLS certificate issued", + Self::ConnectorReady => "connector ready", + Self::IrohDnsPublished => "iroh DNS published", + Self::ProxyProgrammed => "route programmed", + Self::ConnectorMetadataProgrammed => "envoy metadata propagated", + } + } + + pub fn all() -> &'static [ProgressStepKind] { + &[ + Self::ProxyAccepted, + Self::CertificatesReady, + Self::ConnectorReady, + Self::IrohDnsPublished, + Self::ProxyProgrammed, + Self::ConnectorMetadataProgrammed, + ] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepStatus { + /// Controller hasn't reported on this condition yet. + Unknown, + /// Condition exists with status False — still waiting (or failing). + Pending, + /// Condition is True. + Ready, +} + +#[derive(Debug, Clone)] +pub struct ProgressStep { + pub kind: ProgressStepKind, + pub status: StepStatus, + pub reason: Option, + pub message: Option, + /// Pre-formatted "Kind/name" of the underlying Kubernetes resource + /// (`HTTPProxy/` or `Connector/`). The CLI + /// renders this alongside each step so the user can pivot to + /// `datumctl describe ...` on the exact resource that's stuck or + /// reporting a stale Ready. `None` only when the resource doesn't + /// exist server-side (e.g. probing for a tunnel id that's not there). + pub resource: Option, +} + +impl ProgressStepKind { + /// The Kubernetes resource kind whose conditions back this step. + pub fn resource_kind(&self) -> &'static str { + match self { + Self::ConnectorReady | Self::IrohDnsPublished => "Connector", + Self::ProxyAccepted + | Self::CertificatesReady + | Self::ProxyProgrammed + | Self::ConnectorMetadataProgrammed => "HTTPProxy", + } + } +} + +impl ProgressStep { + /// True if this step is in a terminal failure mode that won't self-heal + /// without user action. The canonical case is the iroh DNS owner + /// collision: another Connector with the same iroh key owns the record, + /// and waiting longer won't change that. + pub fn is_terminal_failure(&self) -> bool { + matches!(self.kind, ProgressStepKind::IrohDnsPublished) + && self.status == StepStatus::Pending + && self.reason.as_deref() == Some(CONNECTOR_REASON_DEFERRED_TO_OWNER) + } +} + +#[derive(Debug, Clone)] +pub struct TunnelProgress { + pub hostnames: Vec, + pub steps: Vec, +} + +impl TunnelProgress { + pub fn all_ready(&self) -> bool { + self.steps.iter().all(|s| { + // ConnectorMetadataProgrammed is absent in extension-server mode + // (EPP emission disabled). Treat Unknown as Ready for this step + // only — the extension server handles xDS injection directly and + // does not report back via a condition. + if s.kind == ProgressStepKind::ConnectorMetadataProgrammed + && s.status == StepStatus::Unknown + { + return true; + } + s.status == StepStatus::Ready + }) + } + + pub fn step(&self, kind: ProgressStepKind) -> Option<&ProgressStep> { + self.steps.iter().find(|s| s.kind == kind) + } + + pub fn terminal_failure(&self) -> Option<&ProgressStep> { + self.steps.iter().find(|s| s.is_terminal_failure()) + } + + fn from_resources(proxy: &HTTPProxy, connector: Option<&Connector>) -> Self { + let proxy_conds = proxy.status.as_ref().and_then(|s| s.conditions.as_deref()); + let proxy_gen = proxy.metadata.generation.unwrap_or(0); + let proxy_resource = proxy + .metadata + .name + .as_deref() + .map(|n| format!("HTTPProxy/{n}")); + let conn_conds = connector + .and_then(|c| c.status.as_ref()) + .and_then(|s| s.conditions.as_deref()); + let conn_gen = connector.and_then(|c| c.metadata.generation).unwrap_or(0); + let connector_resource = connector + .and_then(|c| c.metadata.name.as_deref()) + .map(|n| format!("Connector/{n}")); + + // A condition is Ready only if its observedGeneration has caught up + // with the resource's current generation. After we PATCH the spec + // (e.g. `tunnel listen --id` re-points the backend, bumping + // generation 1→2), the controller's prior True conditions still + // show observedGeneration=1 until it re-reconciles. Treating those + // as Ready makes the CLI claim "Tunnel ready" while the data plane + // is still serving 503s from stale Envoy config. + let make_step = |kind: ProgressStepKind, + conds: Option<&[k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition]>, + type_: &str, + current_gen: i64, + resource: Option| + -> ProgressStep { + let cond = find_condition(conds, type_); + let observed = cond.and_then(|c| c.observed_generation).unwrap_or(0); + let fresh = observed >= current_gen; + let status = match cond { + Some(c) if c.status == "True" && fresh => StepStatus::Ready, + Some(_) => StepStatus::Pending, + None => StepStatus::Unknown, + }; + ProgressStep { + kind, + status, + reason: cond.map(|c| c.reason.clone()), + message: cond.map(|c| c.message.clone()), + resource, + } + }; + + let steps = vec![ + make_step( + ProgressStepKind::ProxyAccepted, + proxy_conds, + HTTP_PROXY_CONDITION_ACCEPTED, + proxy_gen, + proxy_resource.clone(), + ), + make_step( + ProgressStepKind::CertificatesReady, + proxy_conds, + HTTP_PROXY_CONDITION_CERTIFICATES_READY, + proxy_gen, + proxy_resource.clone(), + ), + make_step( + ProgressStepKind::ConnectorReady, + conn_conds, + CONNECTOR_CONDITION_READY, + conn_gen, + connector_resource.clone(), + ), + make_step( + ProgressStepKind::IrohDnsPublished, + conn_conds, + CONNECTOR_CONDITION_IROH_DNS_PUBLISHED, + conn_gen, + connector_resource.clone(), + ), + make_step( + ProgressStepKind::ProxyProgrammed, + proxy_conds, + HTTP_PROXY_CONDITION_PROGRAMMED, + proxy_gen, + proxy_resource.clone(), + ), + make_step( + ProgressStepKind::ConnectorMetadataProgrammed, + proxy_conds, + HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED, + proxy_gen, + proxy_resource, + ), + ]; + + Self { + hostnames: proxy_hostnames(proxy), + steps, + } + } +} + +impl TunnelService { + pub fn new(datum: DatumCloudClient, listen: ListenNode) -> Self { + Self { + datum, + listen, + publish_tickets: publish_tickets_enabled(), + create_traffic_protection_policies: create_traffic_protection_policies_enabled(), + } + } + + pub async fn list_active(&self) -> Result> { + let Some(selected) = self.datum.selected_context() else { + return Ok(Vec::new()); + }; + self.list_project(&selected.project_id).await + } + + pub async fn get_active(&self, tunnel_id: &str) -> Result> { + let tunnels = self.list_active().await?; + Ok(tunnels.into_iter().find(|tunnel| tunnel.id == tunnel_id)) + } + + pub async fn get_active_by_endpoint(&self, endpoint: &str) -> Result> { + let tunnels = self.list_active().await?; + let normalized = normalize_endpoint(endpoint); + Ok(tunnels.into_iter().find(|tunnel| tunnel.endpoint == normalized)) + } + + /// Fetch the rich progress view for a tunnel: every checkpoint condition + /// from both the HTTPProxy and its referenced Connector. Returns `None` + /// if the proxy doesn't exist (matches `get_active`). + pub async fn get_active_progress( + &self, + tunnel_id: &str, + ) -> Result> { + let Some(selected) = self.datum.selected_context() else { + return Ok(None); + }; + let pcp = self + .datum + .project_control_plane_client(&selected.project_id) + .await?; + let client = pcp.client(); + let proxies: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let Some(proxy) = proxies + .get_opt(tunnel_id) + .await + .std_context("Failed to fetch HTTPProxy")? + else { + return Ok(None); + }; + + let connector = if let Some(name) = proxy_connector_name(&proxy) { + let connectors: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + connectors + .get_opt(&name) + .await + .std_context("Failed to fetch Connector")? + } else { + None + }; + + Ok(Some(TunnelProgress::from_resources(&proxy, connector.as_ref()))) + } + + /// Re-patch the connector's connectionDetails after it becomes Ready. + /// + /// The replicator mirrors the upstream-status annotation to the downstream + /// connector on every spec change. When the connector first becomes + /// Ready:True, the connector controller touches the downstream gateway + /// annotation to trigger an Envoy Gateway re-translation — but if the + /// annotation captured Ready:False before the lease renewed, that touch + /// never fires. Re-patching connectionDetails triggers a spec change on + /// the upstream connector, which causes the replicator to re-mirror the + /// annotation with the current (Ready:True) status, and EG re-translates. + pub async fn refresh_connection_details(&self) -> Result<()> { + let Some(selected) = self.datum.selected_context() else { + return Ok(()); + }; + let project_id = &selected.project_id; + let Some(connector) = self.find_connector(project_id).await? else { + return Ok(()); + }; + let pcp = self.datum.project_control_plane_client(project_id).await?; + let connectors: Api = + Api::namespaced(pcp.client(), DEFAULT_PCP_NAMESPACE); + let name = connector.name_any(); + if let Some(details) = build_connection_details(&self.listen) { + let details_value = serde_json::to_value(details) + .std_context("Failed to serialize connection details")?; + let patch = json!({ "status": { "connectionDetails": details_value } }); + if let Err(err) = connectors + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + { + warn!(%name, "Failed to refresh connector connectionDetails: {err:#}"); + } else { + debug!(%name, "refreshed connector connectionDetails to trigger replicator"); + } + } + Ok(()) + } + + pub async fn create_active(&self, label: &str, endpoint: &str) -> Result { + let Some(selected) = self.datum.selected_context() else { + n0_error::bail_any!("No project selected"); + }; + self.create_project(&selected.project_id, label, endpoint) + .await + } + + pub async fn update_active( + &self, + tunnel_id: &str, + label: &str, + endpoint: &str, + ) -> Result { + let Some(selected) = self.datum.selected_context() else { + n0_error::bail_any!("No project selected"); + }; + self.update_project(&selected.project_id, tunnel_id, label, endpoint) + .await + } + + pub async fn set_enabled_active( + &self, + tunnel_id: &str, + enabled: bool, + ) -> Result { + let Some(selected) = self.datum.selected_context() else { + n0_error::bail_any!("No project selected"); + }; + self.set_enabled_project(&selected.project_id, tunnel_id, enabled) + .await + } + + pub async fn delete_active(&self, tunnel_id: &str) -> Result { + let Some(selected) = self.datum.selected_context() else { + n0_error::bail_any!("No project selected"); + }; + self.delete_project(&selected.project_id, tunnel_id).await + } + + pub async fn list_project(&self, project_id: &str) -> Result> { + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let proxies: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let ads: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + + let proxy_list = proxies + .list(&ListParams::default()) + .await + .std_context("Failed to list HTTPProxy objects")?; + + let ad_list = ads + .list(&ListParams::default()) + .await + .std_context("Failed to list ConnectorAdvertisement objects")?; + let enabled_by_name: std::collections::HashMap = ad_list + .items + .into_iter() + .filter_map(|item| item.metadata.name.clone().map(|name| (name, item))) + .collect(); + + let mut tunnels = Vec::new(); + for proxy in proxy_list.items { + let Some(name) = proxy.metadata.name.clone() else { + continue; + }; + if !name.starts_with("tunnel-") { + continue; + } + let label = proxy + .metadata + .annotations + .as_ref() + .and_then(|labels| labels.get(DISPLAY_NAME_ANNOTATION)) + .cloned() + .unwrap_or_else(|| name.clone()); + let endpoint = normalize_endpoint(&proxy_backend_endpoint(&proxy).unwrap_or_default()); + let hostnames = proxy_hostnames(&proxy); + let accepted = condition_is_true( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_ACCEPTED, + ); + let programmed = condition_is_true( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_PROGRAMMED, + ); + let connector_metadata_programmed = condition_is_true_or_absent( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED, + ); + let enabled = enabled_by_name.contains_key(&name); + tunnels.push(TunnelSummary { + id: name, + label, + endpoint, + hostnames, + enabled, + accepted, + programmed, + connector_metadata_programmed, + }); + } + + Ok(tunnels) + } + + pub async fn create_project( + &self, + project_id: &str, + label: &str, + endpoint: &str, + ) -> Result { + let endpoint = normalize_endpoint(endpoint); + let target = parse_target(&endpoint)?; + let connector = self.ensure_connector(project_id).await?; + let connector_name = connector.name_any(); + + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let proxies: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let ads: Api = + Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + + debug!( + %project_id, + connector = %connector_name, + endpoint = %endpoint, + "creating HTTPProxy" + ); + let mut proxy = HTTPProxy { + metadata: ObjectMeta { + generate_name: Some("tunnel-".to_string()), + annotations: Some(BTreeMap::from([( + DISPLAY_NAME_ANNOTATION.to_string(), + label.to_string(), + )])), + ..Default::default() + }, + spec: HTTPProxySpec { + hostnames: None, + rules: vec![ + https_redirect_rule(), + proxy_rule(&endpoint, &connector_name), + ], + }, + status: None, + }; + let post_params = PostParams::default(); + proxy = with_quota_check_retry("HTTPProxy create", || { + proxies.create(&post_params, &proxy) + }) + .await + .map_err(|err| { + warn!( + %project_id, + connector = %connector_name, + endpoint = %endpoint, + "HTTPProxy create failed: {err:#}" + ); + format_quota_error(&err, "HTTPProxy") + .unwrap_or_else(|| format!("Failed to create HTTPProxy: {err}")) + }) + .map_err(|err| n0_error::anyerr!(err))?; + let proxy_name = proxy.name_any(); + debug!( + %project_id, + proxy = %proxy_name, + connector = %connector_name, + "created HTTPProxy" + ); + + let ad_spec = advertisement_spec(&connector_name, target); + debug!( + %project_id, + proxy = %proxy_name, + connector = %connector_name, + "creating ConnectorAdvertisement" + ); + let ad = ConnectorAdvertisement { + metadata: ObjectMeta { + name: Some(proxy_name.clone()), + ..Default::default() + }, + spec: ad_spec, + status: None, + }; + let ad_post = PostParams::default(); + with_quota_check_retry("ConnectorAdvertisement create", || { + ads.create(&ad_post, &ad) + }) + .await + .map_err(|err| { + warn!( + %project_id, + proxy = %proxy_name, + connector = %connector_name, + "ConnectorAdvertisement create failed: {err:#}" + ); + format_quota_error(&err, "ConnectorAdvertisement") + .unwrap_or_else(|| format!("Failed to create ConnectorAdvertisement: {err}")) + }) + .map_err(|err| n0_error::anyerr!(err))?; + debug!( + %project_id, + proxy = %proxy_name, + connector = %connector_name, + "created ConnectorAdvertisement" + ); + + if self.create_traffic_protection_policies { + let tpps: Api = + Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + debug!( + %project_id, + proxy = %proxy_name, + "creating TrafficProtectionPolicy" + ); + let tpp = TrafficProtectionPolicy { + metadata: ObjectMeta { + name: Some(proxy_name.clone()), + ..Default::default() + }, + spec: TrafficProtectionPolicySpec { + target_refs: vec![LocalPolicyTargetReferenceWithSectionName { + group: "gateway.networking.k8s.io".to_string(), + kind: "Gateway".to_string(), + name: proxy_name.clone(), + section_name: None, + }], + mode: Some(TrafficProtectionPolicyMode::Enforce), + sampling_percentage: None, + rule_sets: Some(vec![TrafficProtectionPolicyRuleSet { + rule_set_type: TrafficProtectionPolicyRuleSetType::OWASPCoreRuleSet, + owasp_core_rule_set: Some(OWASPCRS { + paranoia_levels: Some(ParanoiaLevels { + blocking: Some(1), + detection: Some(1), + }), + score_thresholds: None, + rule_exclusions: None, + }), + }]), + }, + status: None, + }; + let tpp_post = PostParams::default(); + with_quota_check_retry("TrafficProtectionPolicy create", || { + tpps.create(&tpp_post, &tpp) + }) + .await + .map_err(|err| { + warn!( + %project_id, + proxy = %proxy_name, + "TrafficProtectionPolicy create failed: {err:#}" + ); + format_quota_error(&err, "TrafficProtectionPolicy").unwrap_or_else(|| { + format!("Failed to create TrafficProtectionPolicy: {err}") + }) + }) + .map_err(|err| n0_error::anyerr!(err))?; + debug!( + %project_id, + proxy = %proxy_name, + "created TrafficProtectionPolicy" + ); + } else { + debug!( + %project_id, + proxy = %proxy_name, + "skipping TrafficProtectionPolicy creation (env disabled)" + ); + } + + let proxy_state = proxy_state_from_summary(&proxy_name, &endpoint, label, true)?; + if self.publish_tickets { + debug!(%proxy_name, "publishing ticket for tunnel"); + if let Err(err) = self.listen.set_proxy(proxy_state).await { + warn!(%proxy_name, "Failed to publish ticket: {err:#}"); + } + } else if let Err(err) = self.listen.set_proxy_state(proxy_state).await { + warn!(%proxy_name, "Failed to store proxy state: {err:#}"); + } + + Ok(TunnelSummary { + id: proxy_name, + label: label.to_string(), + endpoint, + hostnames: proxy_hostnames(&proxy), + enabled: true, + accepted: condition_is_true( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_ACCEPTED, + ), + programmed: condition_is_true( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_PROGRAMMED, + ), + connector_metadata_programmed: condition_is_true_or_absent( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED, + ), + }) + } + + pub async fn update_project( + &self, + project_id: &str, + tunnel_id: &str, + label: &str, + endpoint: &str, + ) -> Result { + let endpoint = normalize_endpoint(endpoint); + let target = parse_target(&endpoint)?; + let connector = self.ensure_connector(project_id).await?; + let connector_name = connector.name_any(); + + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let proxies: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let ads: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + + let existing = proxies + .get(tunnel_id) + .await + .std_context("Failed to fetch HTTPProxy")?; + let hostnames = existing.spec.hostnames.clone().unwrap_or_default(); + let desired_rules = vec![https_redirect_rule(), proxy_rule(&endpoint, &connector_name)]; + + // Skip the PATCH when the existing spec already matches what we'd + // write. A no-op patch still bumps metadata.generation on some API + // servers, which triggers a downstream Envoy re-reconcile and a + // window where the data plane returns 5xx — exactly the resume- + // induced churn the UI doesn't suffer because its enable path + // never touches HTTPProxy.spec. Making this verb idempotent at the + // lib boundary means every caller (CLI, UI Edit dialog, future + // datumctl plugin) gets the no-churn behavior for free. + if http_proxy_spec_matches(&existing, label, &desired_rules) { + debug!( + %project_id, + proxy = %tunnel_id, + "HTTPProxy spec already matches desired state; skipping patch" + ); + } else { + let patch = json!({ + "metadata": { + "annotations": { + DISPLAY_NAME_ANNOTATION: label, + } + }, + "spec": { + "hostnames": hostnames, + "rules": desired_rules, + } + }); + proxies + .patch(tunnel_id, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .std_context("Failed to update HTTPProxy")?; + } + + let existing_ad = ads + .get_opt(tunnel_id) + .await + .std_context("Failed to fetch ConnectorAdvertisement")?; + if let Some(existing_ad) = existing_ad.as_ref() { + let desired_ad_spec = advertisement_spec(&connector_name, target); + if advertisement_spec_matches(existing_ad, &desired_ad_spec) { + debug!( + %project_id, + advertisement = %tunnel_id, + "ConnectorAdvertisement spec already matches; skipping patch" + ); + } else { + let ad_patch = json!({ "spec": desired_ad_spec }); + ads.patch(tunnel_id, &PatchParams::default(), &Patch::Merge(&ad_patch)) + .await + .std_context("Failed to update ConnectorAdvertisement")?; + } + } + + let enabled = existing_ad.is_some(); + + let summary = TunnelSummary { + id: tunnel_id.to_string(), + label: label.to_string(), + endpoint, + hostnames: proxy_hostnames(&existing), + enabled, + accepted: condition_is_true( + existing + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_ACCEPTED, + ), + programmed: condition_is_true( + existing + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_PROGRAMMED, + ), + connector_metadata_programmed: condition_is_true_or_absent( + existing + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED, + ), + }; + + if !self.publish_tickets + && let Ok(proxy_state) = proxy_state_from_summary( + &summary.id, + &summary.endpoint, + &summary.label, + summary.enabled, + ) + && let Err(err) = self.listen.set_proxy_state(proxy_state).await + { + warn!(tunnel_id = %summary.id, "Failed to store proxy state: {err:#}"); + } + + Ok(summary) + } + + pub async fn set_enabled_project( + &self, + project_id: &str, + tunnel_id: &str, + enabled: bool, + ) -> Result { + let connector = self.ensure_connector(project_id).await?; + let connector_name = connector.name_any(); + + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let proxies: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let ads: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + + let proxy = proxies + .get(tunnel_id) + .await + .std_context("Failed to fetch HTTPProxy")?; + let endpoint = normalize_endpoint(&proxy_backend_endpoint(&proxy).unwrap_or_default()); + let label = proxy + .metadata + .annotations + .as_ref() + .and_then(|labels| labels.get(DISPLAY_NAME_ANNOTATION)) + .cloned() + .unwrap_or_else(|| tunnel_id.to_string()); + + // Always patch the proxy's connector backend to reference the fresh + // connector. The previous connector was deleted by ensure_connector; + // if we don't update the proxy here the operator watches a connector + // that no longer exists and the Ready/IrohDNSPublished conditions + // never become True. + { + let target = parse_target(&endpoint)?; + let desired_rules = vec![https_redirect_rule(), proxy_rule(&endpoint, &connector_name)]; + if !http_proxy_spec_matches(&proxy, &label, &desired_rules) { + let hostnames = proxy.spec.hostnames.clone().unwrap_or_default(); + let patch = json!({ + "metadata": { "annotations": { DISPLAY_NAME_ANNOTATION: &label } }, + "spec": { "hostnames": hostnames, "rules": desired_rules }, + }); + proxies + .patch(tunnel_id, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .std_context("Failed to patch HTTPProxy connector reference")?; + } + let _ = target; // used above + } + + if enabled { + let target = parse_target(&endpoint)?; + let ad_spec = advertisement_spec(&connector_name, target); + match ads + .get_opt(tunnel_id) + .await + .std_context("Failed to load ConnectorAdvertisement")? + { + Some(_) => { + let ad_patch = json!({ "spec": ad_spec }); + ads.patch(tunnel_id, &PatchParams::default(), &Patch::Merge(&ad_patch)) + .await + .std_context("Failed to update ConnectorAdvertisement")?; + } + None => { + let ad = ConnectorAdvertisement { + metadata: ObjectMeta { + name: Some(tunnel_id.to_string()), + ..Default::default() + }, + spec: ad_spec, + status: None, + }; + let ad_post = PostParams::default(); + with_quota_check_retry("ConnectorAdvertisement create", || { + ads.create(&ad_post, &ad) + }) + .await + .std_context("Failed to create ConnectorAdvertisement")?; + } + } + } else if ads + .get_opt(tunnel_id) + .await + .std_context("Failed to load ConnectorAdvertisement")? + .is_some() + { + ads.delete(tunnel_id, &DeleteParams::default()) + .await + .std_context("Failed to delete ConnectorAdvertisement")?; + } + + let summary = TunnelSummary { + id: tunnel_id.to_string(), + label, + endpoint, + hostnames: proxy_hostnames(&proxy), + enabled, + accepted: condition_is_true( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_ACCEPTED, + ), + programmed: condition_is_true( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_PROGRAMMED, + ), + connector_metadata_programmed: condition_is_true_or_absent( + proxy + .status + .as_ref() + .and_then(|status| status.conditions.as_deref()), + HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED, + ), + }; + + if !self.publish_tickets + && let Ok(proxy_state) = proxy_state_from_summary( + &summary.id, + &summary.endpoint, + &summary.label, + summary.enabled, + ) + && let Err(err) = self.listen.set_proxy_state(proxy_state).await + { + warn!(tunnel_id = %summary.id, "Failed to store proxy state: {err:#}"); + } + + Ok(summary) + } + + pub async fn delete_project( + &self, + project_id: &str, + tunnel_id: &str, + ) -> Result { + let connector = self.find_connector(project_id).await?; + let connector_name = connector.as_ref().map(|c| c.name_any()); + + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let proxies: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let ads: Api = + Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + let connectors: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + + let mut http_proxy_name: Option = None; + if proxies + .get_opt(tunnel_id) + .await + .std_context("Failed to load HTTPProxy")? + .is_some() + { + proxies + .delete(tunnel_id, &DeleteParams::default()) + .await + .std_context("Failed to delete HTTPProxy")?; + http_proxy_name = Some(tunnel_id.to_string()); + } + + let mut connector_ad_name: Option = None; + if ads + .get_opt(tunnel_id) + .await + .std_context("Failed to load ConnectorAdvertisement")? + .is_some() + { + ads.delete(tunnel_id, &DeleteParams::default()) + .await + .std_context("Failed to delete ConnectorAdvertisement")?; + connector_ad_name = Some(tunnel_id.to_string()); + } + + let mut tpp_name: Option = None; + let tpps: Api = + Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + if tpps + .get_opt(tunnel_id) + .await + .std_context("Failed to load TrafficProtectionPolicy")? + .is_some() + { + tpps.delete(tunnel_id, &DeleteParams::default()) + .await + .std_context("Failed to delete TrafficProtectionPolicy")?; + tpp_name = Some(tunnel_id.to_string()); + } + + if self.publish_tickets { + debug!(%tunnel_id, "unpublishing ticket for tunnel"); + if let Err(err) = self.listen.remove_proxy(tunnel_id).await { + warn!(%tunnel_id, "Failed to unpublish ticket: {err:#}"); + } + } else if let Err(err) = self.listen.remove_proxy_state(tunnel_id).await { + warn!(%tunnel_id, "Failed to remove proxy state: {err:#}"); + } + + let mut connector_name_out: Option = None; + if let Some(connector_name) = connector_name { + let remaining = proxies + .list(&ListParams::default()) + .await + .std_context("Failed to list remaining HTTPProxy objects")?; + let mut remaining_for_connector = remaining + .items + .into_iter() + .filter(|proxy| proxy_uses_connector(proxy, &connector_name)) + .peekable(); + if remaining_for_connector.peek().is_none() { + let ad_selector = format!("{ADVERTISEMENT_CONNECTOR_FIELD}={connector_name}"); + let ads_list = ads + .list(&ListParams::default().fields(&ad_selector)) + .await + .std_context("Failed to list remaining ConnectorAdvertisements")?; + for ad in ads_list.items { + if let Some(name) = ad.metadata.name.clone() + && let Err(err) = ads.delete(&name, &DeleteParams::default()).await + { + warn!(%name, "Failed to delete connector advertisement: {err:#}"); + } + } + + if connectors + .get_opt(&connector_name) + .await + .std_context("Failed to load Connector")? + .is_some() + { + connectors + .delete(&connector_name, &DeleteParams::default()) + .await + .std_context("Failed to delete Connector")?; + connector_name_out = Some(connector_name); + } + } + } + + if let Err(err) = self.listen.repo().delete_tunnel_dir(project_id, tunnel_id).await { + warn!(%tunnel_id, "Failed to delete tunnel local state: {err:#}"); + } + + Ok(TunnelDeleteOutcome { + project_id: project_id.to_string(), + http_proxy: http_proxy_name, + connector_ad: connector_ad_name, + traffic_protection_policy: tpp_name, + connector: connector_name_out, + }) + } + + async fn find_connector_readonly(&self, project_id: &str) -> Result> { + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let connectors: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + let endpoint_id = self.listen.endpoint_id().to_string(); + let selector = format!("{CONNECTOR_SELECTOR_FIELD}={endpoint_id}"); + let list = match connectors + .list(&ListParams::default().fields(&selector)) + .await + { + Ok(list) => list, + Err(kube::Error::Api(e)) if e.code == 403 => { + n0_error::bail_any!( + "Permission denied listing connectors in project {project_id}. \ + Switch your datumctl context to this project first: \ + 'datumctl ctx switch {project_id}'" + ); + } + Err(kube::Error::Api(e)) if e.code == 401 => { + n0_error::bail_any!( + "Authentication failed for project {project_id}. \ + Switch your datumctl context to this project first: \ + 'datumctl ctx switch {project_id}'" + ); + } + Err(err) => { + return Err(err).std_context("Failed to list connectors"); + } + }; + if list.items.is_empty() { + return Ok(None); + } + if list.items.len() > 1 { + debug!( + %selector, + count = list.items.len(), + "Multiple connectors found for endpoint, using first" + ); + } + Ok(Some(list.items.into_iter().next().unwrap())) + } + + async fn find_connector(&self, project_id: &str) -> Result> { + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let connectors: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + let endpoint_id = self.listen.endpoint_id().to_string(); + let selector = format!("{CONNECTOR_SELECTOR_FIELD}={endpoint_id}"); + let list = match connectors + .list(&ListParams::default().fields(&selector)) + .await + { + Ok(list) => list, + Err(kube::Error::Api(e)) if e.code == 403 => { + n0_error::bail_any!( + "Permission denied listing connectors in project {project_id}. \ + Switch your datumctl context to this project first: \ + 'datumctl ctx switch {project_id}'" + ); + } + Err(kube::Error::Api(e)) if e.code == 401 => { + n0_error::bail_any!( + "Authentication failed for project {project_id}. \ + Switch your datumctl context to this project first: \ + 'datumctl ctx switch {project_id}'" + ); + } + Err(err) => { + return Err(err).std_context("Failed to list connectors"); + } + }; + if list.items.is_empty() { + return Ok(None); + } + if list.items.len() > 1 { + debug!( + %selector, + count = list.items.len(), + "Multiple connectors found for endpoint, using first" + ); + } + let mut connector = list.items.into_iter().next().unwrap(); + patch_device_annotations(&connectors, &mut connector).await; + Ok(Some(connector)) + } + + async fn resolve_connector_class(client: kube::Client) -> Result { + let classes: Api = Api::all(client); + match classes.list(&ListParams::default()).await { + Ok(class_list) if !class_list.items.is_empty() => { + for c in &class_list.items { + if c.name_any() == DEFAULT_CONNECTOR_CLASS_NAME { + return Ok(DEFAULT_CONNECTOR_CLASS_NAME.to_string()); + } + } + let fallback = class_list + .items + .first() + .map(|c| c.name_any()) + .context("No ConnectorClass available")?; + warn!( + %fallback, + "ConnectorClass '{DEFAULT_CONNECTOR_CLASS_NAME}' not found, using '{fallback}'" + ); + Ok(fallback) + } + Ok(_) => { + warn!("No ConnectorClass found in cluster; using default '{DEFAULT_CONNECTOR_CLASS_NAME}'"); + Ok(DEFAULT_CONNECTOR_CLASS_NAME.to_string()) + } + Err(e) => { + warn!("Failed to list ConnectorClasses (using default '{DEFAULT_CONNECTOR_CLASS_NAME}'): {e:#}"); + Ok(DEFAULT_CONNECTOR_CLASS_NAME.to_string()) + } + } + } + + async fn ensure_connector(&self, project_id: &str) -> Result { + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let connectors: Api = Api::namespaced(client.clone(), DEFAULT_PCP_NAMESPACE); + + // Reuse an existing connector rather than deleting and recreating it. + // Delete-and-recreate causes a new generation-1 object; the replicator + // mirrors the status annotation once (at Ready:False while the lease + // hasn't renewed yet) and then never re-mirrors when Ready flips to + // True, so the extension server permanently sees the connector as + // offline and returns 503. Patching in-place keeps the same generation + // and the replicator re-mirrors on spec changes, avoiding the race. + if let Some(connector) = self.find_connector(project_id).await? { + let name = connector.name_any(); + debug!(%name, "reusing existing connector, patching connectionDetails"); + if let Some(details) = build_connection_details(&self.listen) { + let details_value = serde_json::to_value(details) + .std_context("Failed to serialize connection details")?; + let patch = json!({ "status": { "connectionDetails": details_value } }); + if let Err(err) = connectors + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + { + warn!(%name, "Failed to patch connector connectionDetails: {err:#}"); + } + } else { + warn!(%name, "Missing connection details for connector status patch"); + } + return Ok(connector); + } + + let class_name = Self::resolve_connector_class(client).await?; + + let mut connector = Connector { + metadata: ObjectMeta { + generate_name: Some("datum-connect-".to_string()), + annotations: Some(device_annotations()), + ..Default::default() + }, + spec: ConnectorSpec { + connector_class_name: class_name, + capabilities: None, + }, + status: None, + }; + let conn_post = PostParams::default(); + connector = with_quota_check_retry("Connector create", || { + connectors.create(&conn_post, &connector) + }) + .await + .std_context("Failed to create Connector")?; + + if let Some(details) = build_connection_details(&self.listen) { + let details_value = serde_json::to_value(details) + .std_context("Failed to serialize connection details")?; + let patch = json!({ "status": { "connectionDetails": details_value } }); + if let Err(err) = connectors + .patch_status( + &connector.name_any(), + &PatchParams::default(), + &Patch::Merge(&patch), + ) + .await + { + warn!(connector = %connector.name_any(), "Failed to patch connector status: {err:#}"); + } + } else { + warn!(connector = %connector.name_any(), "Missing connection details for connector status"); + } + + Ok(connector) + } +} + +#[derive(Debug, Clone)] +struct ParsedTarget { + address: String, + port: u16, +} + +fn parse_target(target: &str) -> Result { + let target = target.trim(); + if let Ok(url) = url::Url::parse(target) { + let host = url.host_str().context("missing host")?; + let port = url.port().context("missing port")?; + return Ok(ParsedTarget { + address: host.to_string(), + port, + }); + } + + let (host, port_str) = if target.starts_with('[') { + let end = target.find(']').context("invalid IPv6 address")?; + let host = &target[1..end]; + let port = target + .get(end + 1..) + .and_then(|rest| rest.strip_prefix(':')) + .context("missing port")?; + (host, port) + } else { + let (host, port) = target.rsplit_once(':').context("missing port")?; + (host, port) + }; + let port: u16 = port_str.parse().std_context("invalid port")?; + Ok(ParsedTarget { + address: host.to_string(), + port, + }) +} + +fn build_connection_details(listen: &ListenNode) -> Option { + let endpoint = listen.endpoint(); + let endpoint_addr = endpoint.addr(); + let home_relay = endpoint_addr.relay_urls().next()?.to_string(); + let addresses: Vec = endpoint_addr + .ip_addrs() + .map(|addr| PublicKeyConnectorAddress { + address: addr.ip().to_string(), + port: addr.port() as i32, + }) + .collect(); + + Some(ConnectorConnectionDetails { + connection_type: ConnectorConnectionType::PublicKey, + public_key: Some(ConnectorConnectionDetailsPublicKey { + id: endpoint.id().to_string(), + discovery_mode: Some(PublicKeyDiscoveryMode::Dns), + home_relay, + addresses, + }), + }) +} + +fn normalize_endpoint(endpoint: &str) -> String { + let endpoint = endpoint.trim(); + if endpoint.is_empty() { + return endpoint.to_string(); + } + if endpoint.contains("://") { + return endpoint.to_string(); + } + format!("http://{endpoint}") +} + +fn strip_scheme(endpoint: &str) -> String { + if let Ok(url) = url::Url::parse(endpoint) + && let Some(host) = url.host_str() + && let Some(port) = url.port() + { + return format!("{host}:{port}"); + } + endpoint.to_string() +} + +fn proxy_hostnames(proxy: &HTTPProxy) -> Vec { + proxy + .status + .as_ref() + .and_then(|status| status.hostnames.clone()) + .or_else(|| proxy.spec.hostnames.clone()) + .unwrap_or_default() +} + +/// True when the HTTPProxy's display label annotation and rules already +/// match what `update_project` would write. Used to short-circuit the +/// PATCH so a no-op update doesn't bump `metadata.generation` and trigger +/// a downstream Envoy re-reconcile (see the resume-induced 5xx window). +fn http_proxy_spec_matches( + existing: &HTTPProxy, + desired_label: &str, + desired_rules: &[HTTPProxyRule], +) -> bool { + let existing_label = existing + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(DISPLAY_NAME_ANNOTATION)) + .map(String::as_str); + if existing_label != Some(desired_label) { + return false; + } + // Compare via serde Value rather than structural equality on the Rust + // types so we get a stable representation that doesn't drift when + // Option<...> fields with serde defaults serialize differently. + let Ok(existing_rules_value) = serde_json::to_value(&existing.spec.rules) else { + return false; + }; + let Ok(desired_rules_value) = serde_json::to_value(desired_rules) else { + return false; + }; + existing_rules_value == desired_rules_value +} + +/// True when the ConnectorAdvertisement's spec already matches what +/// `update_project` would write. Same idempotency motivation as +/// `http_proxy_spec_matches`. +fn advertisement_spec_matches( + existing: &ConnectorAdvertisement, + desired: &ConnectorAdvertisementSpec, +) -> bool { + let Ok(existing_value) = serde_json::to_value(&existing.spec) else { + return false; + }; + let Ok(desired_value) = serde_json::to_value(desired) else { + return false; + }; + existing_value == desired_value +} + +/// Extract the connector name from the first backend that references one. +fn proxy_connector_name(proxy: &HTTPProxy) -> Option { + proxy + .spec + .rules + .iter() + .flat_map(|rule| rule.backends.iter().flatten()) + .find_map(|backend| backend.connector.as_ref().map(|c| c.name.clone())) +} + +/// Rule that matches requests with x-forwarded-proto: http and redirects to HTTPS (301). +/// Evaluated first so HTTP traffic is upgraded before hitting the backend rule. +fn https_redirect_rule() -> HTTPProxyRule { + HTTPProxyRule { + name: None, + matches: vec![HTTPRouteMatch { + path: Some(HTTPRouteRulesMatchesPath { + r#type: Some(HTTPRouteRulesMatchesPathType::PathPrefix), + value: Some("/".to_string()), + }), + headers: Some(vec![HTTPRouteRulesMatchesHeaders { + name: "x-forwarded-proto".to_string(), + r#type: Some(HTTPRouteRulesMatchesHeadersType::Exact), + value: "http".to_string(), + }]), + ..Default::default() + }], + filters: Some(vec![crate::datum_apis::http_proxy::HTTPRouteRulesFilters { + request_redirect: Some(crate::datum_apis::http_proxy::HTTPRouteRulesFiltersRequestRedirect { + scheme: Some("https".to_string()), + status_code: Some(301), + hostname: None, + path: None, + port: None, + }), + r#type: HTTPRouteRulesFiltersType::RequestRedirect, + extension_ref: None, + request_header_modifier: None, + request_mirror: None, + response_header_modifier: None, + url_rewrite: None, + }]), + backends: None, + } +} + +fn proxy_rule(endpoint: &str, connector_name: &str) -> HTTPProxyRule { + HTTPProxyRule { + name: None, + matches: vec![default_match()], + filters: None, + backends: Some(vec![HTTPProxyRuleBackend { + endpoint: endpoint.to_string(), + connector: Some(ConnectorReference { + name: connector_name.to_string(), + }), + filters: None, + }]), + } +} + +fn proxy_backend_endpoint(proxy: &HTTPProxy) -> Option { + proxy + .spec + .rules + .iter() + .find_map(|rule| rule.backends.as_ref().and_then(|b| b.first())) + .map(|backend| backend.endpoint.clone()) +} + +fn advertisement_spec(connector_name: &str, target: ParsedTarget) -> ConnectorAdvertisementSpec { + let port_name = format!("tcp-{}", target.port); + ConnectorAdvertisementSpec { + connector_ref: crate::datum_apis::connector::LocalConnectorReference { + name: connector_name.to_string(), + }, + layer4: Some(vec![ConnectorAdvertisementLayer4 { + name: "default".to_string(), + services: vec![ConnectorAdvertisementLayer4Service { + address: Layer4ServiceAddress(target.address), + ports: vec![Layer4ServicePort { + name: port_name, + port: target.port as i32, + protocol: Protocol::Tcp, + }], + }], + }]), + } +} + +fn default_match() -> HTTPRouteMatch { + HTTPRouteMatch { + path: Some(HTTPRouteRulesMatchesPath { + r#type: Some(HTTPRouteRulesMatchesPathType::PathPrefix), + value: Some("/".to_string()), + }), + ..Default::default() + } +} + +fn friendly_device_name() -> String { + #[cfg(target_os = "macos")] + { + if let Ok(output) = std::process::Command::new("scutil") + .arg("--get") + .arg("ComputerName") + .output() + { + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() { + return name; + } + } + } + } + let hostname = gethostname::gethostname().to_string_lossy().into_owned(); + hostname + .strip_suffix(".local") + .unwrap_or(&hostname) + .to_string() +} + +const DEVICE_NAME_ANNOTATION: &str = "datum.net/device-name"; +const DEVICE_OS_ANNOTATION: &str = "datum.net/device-os"; + +fn device_annotations() -> BTreeMap { + BTreeMap::from([ + (DEVICE_NAME_ANNOTATION.to_string(), friendly_device_name()), + ( + DEVICE_OS_ANNOTATION.to_string(), + std::env::consts::OS.to_string(), + ), + ]) +} + +async fn patch_device_annotations(api: &Api, connector: &mut Connector) { + let expected = device_annotations(); + let current = connector.metadata.annotations.as_ref(); + let needs_patch = expected.iter().any(|(k, v)| { + current + .and_then(|a| a.get(k)) + .map(|cv| cv != v) + .unwrap_or(true) + }); + if !needs_patch { + return; + } + let patch = json!({ "metadata": { "annotations": expected } }); + match api + .patch( + &connector.name_any(), + &PatchParams::default(), + &Patch::Merge(&patch), + ) + .await + { + Ok(patched) => *connector = patched, + Err(err) => { + warn!( + connector = %connector.name_any(), + "Failed to patch device annotations: {err:#}" + ); + } + } +} + +fn format_quota_error(err: &dyn std::error::Error, resource_type: &str) -> Option { + let err_msg = err.to_string(); + // Transient quota-check timeout — the error literally says "Please try + // again in a moment". Don't relabel it as "exceeded"; with the retry + // wrapper applied at creation sites we'll usually never get here, and + // when we do the original message is the most accurate signal. + if err_msg.contains("took too long to be checked against your quota") { + return None; + } + if err_msg.contains("quota") || err_msg.contains("Insufficient quota") { + return Some(format!( + "Quota limit exceeded for {resource_type} resources.\n\n\ + You've reached the limit for creating {resource_type} resources in this project.\n\n\ + To fix this, you can:\n \ + - Delete unused tunnels to free up capacity\n \ + - Contact support to request a higher quota limit\n\n\ + Run 'tunnel list' to see existing tunnels." + )); + } + None +} + +/// True if `err` is the operator's transient quota-check timeout (a 403 +/// whose message says "Please try again in a moment"). Distinct from +/// real quota exhaustion, which produces a different message and +/// shouldn't be retried. +fn is_quota_check_timeout(err: &kube::Error) -> bool { + matches!( + err, + kube::Error::Api(e) + if e.code == 403 + && e.message.contains("took too long to be checked against your quota") + ) +} + +/// Retry a kube API call up to ~15 seconds while it keeps tripping the +/// operator's quota-check timeout. Other errors return immediately so +/// real failures still surface fast. Prints a one-line stderr notice on +/// the first retry so the user knows we're waiting on the server. +async fn with_quota_check_retry(op_name: &str, mut f: F) -> kube::Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let delays = [ + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(2), + std::time::Duration::from_secs(4), + std::time::Duration::from_secs(8), + ]; + for (i, delay) in delays.iter().enumerate() { + match f().await { + Ok(v) => return Ok(v), + Err(err) if is_quota_check_timeout(&err) => { + if i == 0 { + eprintln!( + " … quota check timed out for {op_name}; retrying for up to 15s" + ); + } + warn!( + op = op_name, + attempt = i + 1, + next_delay_s = delay.as_secs(), + "quota check timed out; retrying" + ); + tokio::time::sleep(*delay).await; + } + Err(err) => return Err(err), + } + } + f().await +} + +fn publish_tickets_enabled() -> bool { + std::env::var("DATUM_CONNECT_PUBLISH_TICKETS") + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +fn create_traffic_protection_policies_enabled() -> bool { + std::env::var("DATUM_CONNECT_CREATE_TRAFFIC_PROTECTION_POLICIES") + .ok() + .or_else(|| { + option_env!("BUILD_DATUM_CONNECT_CREATE_TRAFFIC_PROTECTION_POLICIES") + .map(str::to_string) + }) + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::datum_apis::connector::{ConnectorSpec, ConnectorStatus}; + use crate::datum_apis::http_proxy::{HTTPProxySpec, HTTPProxyStatus}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time}; + use kube::api::ObjectMeta; + + fn cond(type_: &str, status: &str, reason: &str, message: &str) -> Condition { + Condition { + type_: type_.to_string(), + status: status.to_string(), + reason: reason.to_string(), + message: message.to_string(), + last_transition_time: Time(chrono::DateTime::UNIX_EPOCH), + observed_generation: None, + } + } + + fn proxy(conds: Vec) -> HTTPProxy { + let mut p = HTTPProxy::new( + "tunnel-test", + HTTPProxySpec { + hostnames: None, + rules: vec![], + }, + ); + p.metadata = ObjectMeta { + name: Some("tunnel-test".into()), + ..Default::default() + }; + p.status = Some(HTTPProxyStatus { + addresses: None, + hostnames: Some(vec!["ground-pearl.datumproxy.net".into()]), + conditions: Some(conds), + }); + p + } + + fn connector(conds: Vec) -> Connector { + let mut c = Connector::new( + "datum-connect-test", + ConnectorSpec { + connector_class_name: "datum-connect".into(), + capabilities: None, + }, + ); + c.status = Some(ConnectorStatus { + capabilities: None, + conditions: Some(conds), + connection_details: None, + lease_ref: None, + }); + c + } + + #[test] + fn progress_unknown_when_controllers_silent() { + let p = proxy(vec![]); + let progress = TunnelProgress::from_resources(&p, None); + assert_eq!(progress.steps.len(), 6); + assert!( + progress.steps.iter().all(|s| s.status == StepStatus::Unknown), + "no conditions yet → every step Unknown" + ); + assert!(!progress.all_ready()); + assert!(progress.terminal_failure().is_none()); + } + + #[test] + fn progress_all_ready_when_every_condition_true() { + let p = proxy(vec![ + cond(HTTP_PROXY_CONDITION_ACCEPTED, "True", "Accepted", ""), + cond(HTTP_PROXY_CONDITION_CERTIFICATES_READY, "True", "AllCertificatesReady", ""), + cond(HTTP_PROXY_CONDITION_PROGRAMMED, "True", "Programmed", ""), + cond( + HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED, + "True", + "ConnectorMetadataApplied", + "", + ), + ]); + let c = connector(vec![ + cond(CONNECTOR_CONDITION_READY, "True", "ConnectorReady", ""), + cond(CONNECTOR_CONDITION_IROH_DNS_PUBLISHED, "True", "Owner", ""), + ]); + let progress = TunnelProgress::from_resources(&p, Some(&c)); + assert!(progress.all_ready()); + assert!(progress.terminal_failure().is_none()); + } + + #[test] + fn progress_flags_deferred_to_owner_as_terminal() { + // This is the silent-tunnel failure: the iroh DNS record is owned by + // a different project's Connector. Waiting longer won't help — the + // CLI must bail and surface the owner so the user can act. + let p = proxy(vec![cond(HTTP_PROXY_CONDITION_ACCEPTED, "True", "Accepted", "")]); + let owner_msg = + "iroh DNS record is owned by Connector /other-project/default/datum-connect-xyz"; + let c = connector(vec![ + cond(CONNECTOR_CONDITION_READY, "True", "ConnectorReady", ""), + cond( + CONNECTOR_CONDITION_IROH_DNS_PUBLISHED, + "False", + CONNECTOR_REASON_DEFERRED_TO_OWNER, + owner_msg, + ), + ]); + let progress = TunnelProgress::from_resources(&p, Some(&c)); + let fail = progress.terminal_failure().expect("terminal failure detected"); + assert_eq!(fail.kind, ProgressStepKind::IrohDnsPublished); + assert_eq!(fail.message.as_deref(), Some(owner_msg)); + assert!(!progress.all_ready()); + } + + #[test] + fn progress_pending_for_false_but_non_terminal_reason() { + // CertificatesReady=False with reason "Issuing" should stay Pending + // (still progressing) — not Ready, not terminal. + let p = proxy(vec![cond( + HTTP_PROXY_CONDITION_CERTIFICATES_READY, + "False", + "Issuing", + "Certificate request submitted", + )]); + let progress = TunnelProgress::from_resources(&p, None); + let cert_step = progress + .step(ProgressStepKind::CertificatesReady) + .expect("step exists"); + assert_eq!(cert_step.status, StepStatus::Pending); + assert!(progress.terminal_failure().is_none()); + } + + #[test] + fn progress_step_carries_resource_label() { + // Every step should know which Kubernetes resource backs it so the + // CLI can render "[HTTPProxy/tunnel-test]" or + // "[Connector/datum-connect-test]" alongside the line — that's + // what the user copy-pastes into `datumctl describe`. + let p = proxy(vec![]); + let c = connector(vec![]); + let progress = TunnelProgress::from_resources(&p, Some(&c)); + + for step in &progress.steps { + let resource = step.resource.as_deref().expect("resource label set"); + let expected_kind = step.kind.resource_kind(); + assert!( + resource.starts_with(&format!("{expected_kind}/")), + "step {:?} should be backed by {expected_kind}, got {resource}", + step.kind, + ); + } + + // Connector-backed steps fall back to None when no connector exists. + let progress_no_conn = TunnelProgress::from_resources(&p, None); + let iroh = progress_no_conn + .step(ProgressStepKind::IrohDnsPublished) + .unwrap(); + assert!( + iroh.resource.is_none(), + "connector-backed step has no resource when connector is missing" + ); + let proxy_step = progress_no_conn + .step(ProgressStepKind::ProxyAccepted) + .unwrap(); + assert_eq!( + proxy_step.resource.as_deref(), + Some("HTTPProxy/tunnel-test") + ); + } + + fn api_error(code: u16, message: &str) -> kube::Error { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: message.into(), + reason: if code == 403 { "Forbidden".into() } else { "Unknown".into() }, + code, + }) + } + + #[test] + fn quota_check_timeout_classifier_matches_transient_403() { + // The exact phrase the operator emits when the quota check itself + // times out — distinct from real quota exhaustion. The error message + // literally says "Please try again in a moment". + let err = api_error( + 403, + "connectoradvertisements.networking.datumapis.com \"tunnel-x\" is forbidden: \ + Your request took too long to be checked against your quota. Please try again \ + in a moment — if this keeps happening, contact support.", + ); + assert!(is_quota_check_timeout(&err)); + + // Real exhaustion shouldn't trigger retry. + let exhausted = api_error(403, "Insufficient quota for ConnectorAdvertisement"); + assert!(!is_quota_check_timeout(&exhausted)); + + // 401 with similar text shouldn't match — different failure class. + let unauthorized = api_error(401, "took too long to be checked against your quota"); + assert!(!is_quota_check_timeout(&unauthorized)); + + // format_quota_error should NOT mangle the timeout message into a + // misleading "Quota limit exceeded" string. + assert!( + format_quota_error(&err, "ConnectorAdvertisement").is_none(), + "transient timeout must propagate verbatim, not become 'exceeded'" + ); + // It SHOULD format real exhaustion. + assert!(format_quota_error(&exhausted, "ConnectorAdvertisement").is_some()); + } + + #[test] + fn progress_pending_when_status_is_stale_for_current_generation() { + // `tunnel listen --id` PATCHes the HTTPProxy spec to re-point the + // backend at the current connector, bumping generation 1 → 2. The + // controller's prior True conditions still carry observedGeneration=1 + // until it re-reconciles. Treating those as Ready was the bug + // behind "Tunnel ready after 0 sec" while the edge served 503s + // for minutes — Envoy was still on the previous-generation config. + let mut stale = cond( + HTTP_PROXY_CONDITION_PROGRAMMED, + "True", + "Programmed", + "Stale from previous generation", + ); + stale.observed_generation = Some(1); + let mut p_stale = proxy(vec![stale]); + p_stale.metadata.generation = Some(2); + let progress_stale = TunnelProgress::from_resources(&p_stale, None); + let step = progress_stale + .step(ProgressStepKind::ProxyProgrammed) + .expect("step exists"); + assert_eq!( + step.status, + StepStatus::Pending, + "True condition with observedGeneration < generation must be Pending" + ); + assert!(!progress_stale.all_ready()); + + // Once the controller observes the new generation, status flips Ready. + let mut fresh = cond(HTTP_PROXY_CONDITION_PROGRAMMED, "True", "Programmed", ""); + fresh.observed_generation = Some(2); + let mut p_fresh = proxy(vec![fresh]); + p_fresh.metadata.generation = Some(2); + let progress_fresh = TunnelProgress::from_resources(&p_fresh, None); + assert_eq!( + progress_fresh + .step(ProgressStepKind::ProxyProgrammed) + .unwrap() + .status, + StepStatus::Ready, + "matched observedGeneration must be Ready" + ); + } + + fn proxy_with_backend(label: &str, endpoint: &str, connector_name: &str) -> HTTPProxy { + let mut p = HTTPProxy::new( + "tunnel-test", + HTTPProxySpec { + hostnames: Some(vec!["test.datumproxy.net".into()]), + rules: vec![https_redirect_rule(), proxy_rule(endpoint, connector_name)], + }, + ); + let mut ann = std::collections::BTreeMap::new(); + ann.insert(DISPLAY_NAME_ANNOTATION.to_string(), label.to_string()); + p.metadata = ObjectMeta { + name: Some("tunnel-test".into()), + annotations: Some(ann), + ..Default::default() + }; + p + } + + #[test] + fn http_proxy_spec_matches_skips_no_op_resume() { + // The CLI resume path now goes through update_active which calls + // update_project. When the existing tunnel already points at the + // current connector with the same endpoint and label, the lib must + // recognize that and skip the PATCH — sending one would bump + // metadata.generation and trigger a downstream Envoy re-reconcile. + let existing = + proxy_with_backend("my-label", "http://127.0.0.1:11434", "datum-connect-mhxj5"); + let desired_rules = vec![ + https_redirect_rule(), + proxy_rule("http://127.0.0.1:11434", "datum-connect-mhxj5"), + ]; + assert!(http_proxy_spec_matches( + &existing, + "my-label", + &desired_rules + )); + } + + #[test] + fn http_proxy_spec_matches_detects_each_drift_axis() { + let existing = + proxy_with_backend("my-label", "http://127.0.0.1:11434", "datum-connect-mhxj5"); + + // Different connector — adoption across identity change must patch. + let rules_new_connector = vec![ + https_redirect_rule(), + proxy_rule("http://127.0.0.1:11434", "datum-connect-NEW"), + ]; + assert!(!http_proxy_spec_matches( + &existing, + "my-label", + &rules_new_connector + )); + + // Different endpoint — backend retarget must patch. + let rules_new_endpoint = vec![ + https_redirect_rule(), + proxy_rule("http://127.0.0.1:9999", "datum-connect-mhxj5"), + ]; + assert!(!http_proxy_spec_matches( + &existing, + "my-label", + &rules_new_endpoint + )); + + // Different label — rename must patch. + let rules_same = vec![ + https_redirect_rule(), + proxy_rule("http://127.0.0.1:11434", "datum-connect-mhxj5"), + ]; + assert!(!http_proxy_spec_matches( + &existing, + "different-label", + &rules_same + )); + + // No annotation at all — must patch. + let mut bare = existing.clone(); + bare.metadata.annotations = None; + assert!(!http_proxy_spec_matches(&bare, "my-label", &rules_same)); + } + + fn target(host: &str, port: u16) -> ParsedTarget { + ParsedTarget { + address: host.to_string(), + port, + } + } + + fn advertisement_with_target(connector_name: &str, host: &str, port: u16) -> ConnectorAdvertisement { + ConnectorAdvertisement { + metadata: ObjectMeta { + name: Some("tunnel-test".into()), + ..Default::default() + }, + spec: advertisement_spec(connector_name, target(host, port)), + status: None, + } + } + + #[test] + fn advertisement_spec_matches_skips_no_op() { + let existing = advertisement_with_target("datum-connect-mhxj5", "127.0.0.1", 11434); + let desired = advertisement_spec("datum-connect-mhxj5", target("127.0.0.1", 11434)); + assert!(advertisement_spec_matches(&existing, &desired)); + } + + #[test] + fn advertisement_spec_matches_detects_drift() { + let existing = advertisement_with_target("datum-connect-mhxj5", "127.0.0.1", 11434); + let desired_new_port = + advertisement_spec("datum-connect-mhxj5", target("127.0.0.1", 9999)); + assert!(!advertisement_spec_matches(&existing, &desired_new_port)); + + let desired_new_conn = + advertisement_spec("datum-connect-NEW", target("127.0.0.1", 11434)); + assert!(!advertisement_spec_matches(&existing, &desired_new_conn)); + } +} diff --git a/connect-plugin/.goreleaser.yaml b/connect-plugin/.goreleaser.yaml new file mode 100644 index 0000000..e2719b5 --- /dev/null +++ b/connect-plugin/.goreleaser.yaml @@ -0,0 +1,65 @@ +# GoReleaser config for the datumctl-connect plugin. +# +# Release a version by pushing a semver tag (vX.Y.Z). The workflow runs +# goreleaser, which builds the plugin and publishes archives + checksums.txt +# to a GitHub release. Installable via `datumctl plugin install datum-cloud/connect`. +# +# Each archive bundles both the Go plugin binary (datumctl-connect) and the +# Rust tunnel agent binary (datum-connect). The Rust binary is cross-compiled +# in the workflow and placed at ../dist/rust/ before goreleaser runs. +# +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: datumctl-connect + +before: + hooks: + - go mod tidy + +builds: + - id: datumctl-connect + main: . + binary: datumctl-connect + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - "-X main.version=v{{.Version}}" + +archives: + - id: datumctl-connect + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: + - src: ../dist/rust/datum-connect-{{ .Os }}-{{ .Arch }}{{ if eq .Os "windows" }}.exe{{ end }} + dst: datum-connect{{ if eq .Os "windows" }}.exe{{ end }} + strip_parent: true + +checksum: + name_template: checksums.txt + +sboms: + - artifacts: archive + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/connect-plugin/e2e_interaction_test.go b/connect-plugin/e2e_interaction_test.go new file mode 100644 index 0000000..d89c259 --- /dev/null +++ b/connect-plugin/e2e_interaction_test.go @@ -0,0 +1,424 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "go.datum.net/datumctl-plugins/connect/internal/pidfile" + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" +) + +// TestDatumctlContextEnvVars verifies that the datumctl plugin SDK reads +// the correct environment variables that datumctl injects before exec-replacing +// a plugin. This tests the datumctl → plugin boundary. +func TestDatumctlContextEnvVars(t *testing.T) { + bin := buildPlugin(t) + + env := []string{ + "DATUM_ORG=my-org", + "DATUM_PROJECT=my-project", + "DATUM_API_HOST=api.datum.net", + "DATUM_PLUGIN_API_VERSION=1", + "DATUM_CREDENTIALS_HELPER=/fake/credentials-helper", + "DATUM_SESSION=dev", + "DATUM_CONNECT_DIR=" + t.TempDir(), + } + + cmd := exec.Command(bin, "--help") + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("--help exited non-zero: %v\n%s", err, out) + } + + available := string(out) + if !strings.Contains(available, "--org") { + t.Error("help should show --org flag (injected by datumctl)") + } + if !strings.Contains(available, "--project") { + t.Error("help should show --project flag (injected by datumctl)") + } +} + +// TestCredentialsHelperCalledWithSession verifies that the datumctl plugin +// SDK calls the credentials helper with the correct session argument. +func TestCredentialsHelperCalledWithSession(t *testing.T) { + helperDir := t.TempDir() + helperBin := filepath.Join(helperDir, "helper") + + helperSrc := `package main +import ( + "os" + "fmt" +) +func main() { + f, _ := os.Create("/tmp/helper-args.log") + if f != nil { + for i, arg := range os.Args { + fmt.Fprintf(f, "%d:%s\n", i, arg) + } + f.Close() + } + fmt.Println("session-token-from-helper") +} +` + helperPath := filepath.Join(helperDir, "helper.go") + if err := os.WriteFile(helperPath, []byte(helperSrc), 0644); err != nil { + t.Fatalf("write helper source: %v", err) + } + + buildCmd := exec.Command("go", "build", "-o", helperBin, helperPath) + if out, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("build helper: %v\n%s", err, out) + } + + cmd := exec.Command(helperBin, "auth", "get-token", "--session", "test-session") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helper failed: %v\n%s", err, out) + } + token := strings.TrimSpace(string(out)) + if token != "session-token-from-helper" { + t.Errorf("expected 'session-token-from-helper', got '%s'", token) + } +} + +// TestPluginManifestBeforeSubprocessSpawn verifies that --plugin-manifest +// is handled before any subprocess spawning, so the datumctl host can +// discover the plugin without triggering subprocess execution. +func TestPluginManifestBeforeSubprocessSpawn(t *testing.T) { + bin := buildPlugin(t) + + cmd := exec.Command(bin, "--plugin-manifest") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("--plugin-manifest should work without datumctl env: %v\n%s", err, out) + } + + var manifest map[string]interface{} + if err := json.Unmarshal(out, &manifest); err != nil { + t.Fatalf("manifest is not valid JSON: %v\n%s", err, out) + } + + if manifest["name"] != "connect" { + t.Error("manifest name should be 'connect'") + } + if manifest["api_version"] != float64(1) { + t.Errorf("expected api_version=1, got %v", manifest["api_version"]) + } +} + +// TestPluginPassesContextToSubcommand verifies that the Go plugin correctly +// passes datumctl context (org, project, output format) to subcommands. +func TestPluginPassesContextToSubcommand(t *testing.T) { + bin := buildPlugin(t) + fakeBin := buildFakeDatumConnect(t) + fakeHelper := buildFakeHelper(t, "testdata/fake-credentials-helper") + + connectDir, _ := os.Getwd() + cmd := exec.Command(bin, "--org", "custom-org", "--project", "custom-project", "--output", "yaml", "tunnel", "list") + cmd.Env = append(os.Environ(), + "FAKE_DATUM_CONNECT="+fakeBin, + "DATUM_CREDENTIALS_HELPER="+fakeHelper, + "DATUM_SESSION=dev", + "DATUM_CONNECT_DIR="+connectDir, + "PATH="+connectDir+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("list exited non-zero: %v\n%s", err, out) + } + + // Should contain tunnel data from fake binary + if !bytes.Contains(out, []byte("dev-server")) { + t.Errorf("expected tunnel data in output, got: %s", out) + } +} + +// TestCredentialsHelperTokenFlow verifies the token passing chain works: +// plugin reads DATUM_CREDENTIALS_HELPER → calls helper → gets token +func TestCredentialsHelperTokenFlow(t *testing.T) { + fakeHelper := buildFakeHelper(t, "testdata/fake-credentials-helper") + fakeBin := buildFakeDatumConnect(t) + + connectDir, _ := os.Getwd() + env := []string{ + "DATUM_CREDENTIALS_HELPER=" + fakeHelper, + "DATUM_SESSION=dev", + "FAKE_DATUM_CONNECT=" + fakeBin, + "DATUM_CONNECT_DIR=" + connectDir, + "PATH=" + connectDir + ":" + os.Getenv("PATH"), + } + + // Run the plugin with the fake helper and fake binary + cmd := exec.Command(buildPlugin(t), "tunnel", "list") + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("list exited non-zero: %v\n%s", err, out) + } + + // Should contain tunnel data from fake binary + if !bytes.Contains(out, []byte("dev-server")) { + t.Errorf("expected tunnel data in output, got: %s", out) + } +} + +// TestPluginBinaryIsExecutable verifies the built plugin binary is a valid executable. +func TestPluginBinaryIsExecutable(t *testing.T) { + bin := buildPlugin(t) + + info, err := os.Stat(bin) + if err != nil { + t.Fatalf("stat plugin binary: %v", err) + } + if info.Size() == 0 { + t.Error("plugin binary should not be empty") + } +} + +// TestFullChainEnvVarPropagation verifies the full env var chain: +// datumctl → plugin → Rust binary +// 1. datumctl sets DATUM_CREDENTIALS_HELPER +// 2. Plugin reads DATUM_CREDENTIALS_HELPER, calls helper, gets token +// 3. Plugin sets DATUM_ACCESS_TOKEN for Rust binary +// 4. Rust binary reads DATUM_ACCESS_TOKEN +func TestFullChainEnvVarPropagation(t *testing.T) { + // Step 1: Plugin manifest works standalone + pluginBin := buildPlugin(t) + + cmd := exec.Command(pluginBin, "--plugin-manifest") + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("manifest should work standalone: %v\n%s", err, out) + } + if !bytes.Contains(out, []byte(`"name": "connect"`)) { + t.Error("manifest should contain name='connect'") + } + + // Step 2: Verify plugin reads DATUM_CREDENTIALS_HELPER from env + fakeHelper := buildFakeHelper(t, "testdata/fake-credentials-helper") + cmd = exec.Command(pluginBin, "tunnel", "list") + cmd.Env = append(os.Environ(), "DATUM_CREDENTIALS_HELPER="+fakeHelper) + _ = cmd.Run() +} + +// --- Plan 05-03 process command e2e tests --- + +func TestPS_WithFakePIDFiles(t *testing.T) { + // Create temp state dir with a fake PID file + stateDir := t.TempDir() + + pidPath := filepath.Join(stateDir, "tunnels", "test-tun.pid") + os.MkdirAll(filepath.Dir(pidPath), 0755) + + startTime := time.Now().Add(-10 * time.Minute) + if err := pidfile.Write(pidPath, 99999, 10000, startTime, "/usr/bin/fake"); err != nil { + t.Fatalf("write pid file: %v", err) + } + + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "ps") + cmd.Env = []string{ + "DATUM_STATE_DIR=" + stateDir, + "DATUM_ACCESS_TOKEN=test-token", + "DATUM_CONNECT_DIR=" + t.TempDir(), + "HOME=" + os.Getenv("HOME"), + "PATH=" + os.Getenv("PATH"), + } + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ps exited non-zero: %v\n%s", err, out) + } + + if !strings.Contains(string(out), "test-tun") { + t.Errorf("ps output should contain tunnel name 'test-tun':\n%s", out) + } +} + +func TestPS_JSONOutput(t *testing.T) { + stateDir := t.TempDir() + + pidPath := filepath.Join(stateDir, "tunnels", "json-tun.pid") + os.MkdirAll(filepath.Dir(pidPath), 0755) + + _ = pidfile.Write(pidPath, 99999, 10000, time.Now(), "/usr/bin/fake") + + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "ps", "--json") + cmd.Env = []string{ + "DATUM_STATE_DIR=" + stateDir, + "DATUM_ACCESS_TOKEN=test-token", + "DATUM_CONNECT_DIR=" + t.TempDir(), + "HOME=" + os.Getenv("HOME"), + "PATH=" + os.Getenv("PATH"), + } + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ps --json exited non-zero: %v\n%s", err, out) + } + + var tunnels []map[string]interface{} + if err := json.Unmarshal(out, &tunnels); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out) + } + if len(tunnels) == 0 { + t.Error("expected at least 1 tunnel in JSON output") + } +} + +func TestStatus_StoppedTunnel(t *testing.T) { + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "status", "--name", "nonexistent") + cmd.Env = []string{ + "DATUM_ACCESS_TOKEN=test-token", + "DATUM_CONNECT_DIR=" + t.TempDir(), + "HOME=" + os.Getenv("HOME"), + "PATH=" + os.Getenv("PATH"), + } + out, _ := cmd.CombinedOutput() + + if !strings.Contains(string(out), "Stopped") { + t.Errorf("status for nonexistent tunnel should show Stopped:\n%s", out) + } +} + +// --- Plan 06-02 service install e2e tests --- + +func TestInstall_RequiresName(t *testing.T) { + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "install") + cmd.Env = append(os.Environ(), "DATUM_CONNECT_DIR="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err == nil { + t.Error("install with no flags should exit non-zero") + } + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() != 64 { + t.Errorf("expected exit code 64, got %d", exitErr.ExitCode()) + } + } + if !strings.Contains(string(out), "--name is required") { + t.Errorf("install with no flags should show '--name is required':\n%s", out) + } +} + +func TestInstall_RequiresEndpoint(t *testing.T) { + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "install", "--name", "test-tun") + cmd.Env = append(os.Environ(), "DATUM_CONNECT_DIR="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err == nil { + t.Error("install without --endpoint should exit non-zero") + } + if !strings.Contains(string(out), "--endpoint is required") { + t.Errorf("install without --endpoint should show '--endpoint is required':\n%s", out) + } +} + +func TestInstall_RequiresSession(t *testing.T) { + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "install", "--name", "test-tun", "--endpoint", "localhost:8080") + cmd.Env = append(os.Environ(), "DATUM_CONNECT_DIR="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err == nil { + t.Error("install without --session should exit non-zero") + } + if !strings.Contains(string(out), "--session is required") { + t.Errorf("install without --session should show '--session is required':\n%s", out) + } +} + +func TestInstallConfigPersistence(t *testing.T) { + // Verify config is written to the correct path and can be loaded back + t.Setenv("DATUM_ACCESS_TOKEN", "test-token") + + configDir := t.TempDir() + t.Setenv("HOME", configDir) // os.UserConfigDir uses HOME for XDG + + cfg := svcconfig.TunnelConfig{ + Name: "test-svc", + Label: "Test Service", + Endpoint: "localhost:8080", + Session: "my-session", + } + + configPath := svcconfig.ConfigFilePath("test-svc") + if err := svcconfig.Save(cfg, configPath); err != nil { + t.Fatalf("Save config: %v", err) + } + + loaded, err := svcconfig.Load(configPath) + if err != nil { + t.Fatalf("Load config: %v", err) + } + if loaded.Name != "test-svc" { + t.Errorf("Name = %q, want %q", loaded.Name, "test-svc") + } + if loaded.Session != "my-session" { + t.Errorf("Session = %q, want %q", loaded.Session, "my-session") + } +} + +func TestStatus_WithConfig(t *testing.T) { + // Verify status output includes installed info when config file exists + t.Setenv("DATUM_ACCESS_TOKEN", "test-token") + + // Build before overriding HOME — go build would otherwise place the + // module cache inside configDir (via GOPATH=$HOME/go default), leaving + // read-only cache files that cause t.TempDir cleanup to fail. + pluginBin := buildPlugin(t) + + // Create a config file for an installed-but-not-running tunnel + configDir := t.TempDir() + t.Setenv("HOME", configDir) + + cfg := svcconfig.TunnelConfig{ + Name: "installed-tun", + Endpoint: "localhost:9090", + Session: "svc-session", + } + if err := svcconfig.Save(cfg, svcconfig.ConfigFilePath("installed-tun")); err != nil { + t.Fatalf("Save config: %v", err) + } + + // Run status — should show Stopped and installed info + cmd := exec.Command(pluginBin, "tunnel", "status", "--name", "installed-tun") + cmd.Env = append(os.Environ(), "DATUM_CONNECT_DIR="+t.TempDir()) + out, _ := cmd.CombinedOutput() + + output := string(out) + if !strings.Contains(output, "Stopped") { + t.Errorf("status should show Stopped:\n%s", output) + } + if !strings.Contains(output, "Installed") { + t.Errorf("status should show installed info:\n%s", output) + } +} + +// Helper functions + +func buildPlugin(t *testing.T) string { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "connect-test") + cmd := exec.Command("go", "build", "-o", bin, ".") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build plugin: %v\n%s", err, out) + } + // Also build and copy the fake datum-connect binary next to the plugin + // so binary.Discover() can find it when FAKE_DATUM_CONNECT is set. + fakeBin := filepath.Join(dir, "fake-datum-connect") + fakeCmd := exec.Command("go", "build", "-o", fakeBin, "./testdata/fake-datum-connect") + fakeCmd.Dir = "." + if out, err := fakeCmd.CombinedOutput(); err != nil { + t.Fatalf("build fake-datum-connect: %v\n%s", err, out) + } + return bin +} diff --git a/connect-plugin/e2e_test.go b/connect-plugin/e2e_test.go new file mode 100644 index 0000000..733f7a2 --- /dev/null +++ b/connect-plugin/e2e_test.go @@ -0,0 +1,529 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestPluginManifestEmitsValidJSON(t *testing.T) { + // PLUG-01: --plugin-manifest emits valid JSON and exits 0 + cmd := exec.Command(buildPlugin(t), "--plugin-manifest") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("--plugin-manifest exited non-zero: %v\n%s", err, out) + } + + var manifest map[string]interface{} + if err := json.Unmarshal(out, &manifest); err != nil { + t.Fatalf("manifest is not valid JSON: %v\n%s", err, out) + } + + if manifest["name"] != "connect" { + t.Errorf("expected name='connect', got '%v'", manifest["name"]) + } + if manifest["version"] != "v0.1.0" { + t.Errorf("expected version='v0.1.0', got '%v'", manifest["version"]) + } + if manifest["description"] != "Manage Datum Connect tunnels" { + t.Errorf("expected description='Manage Datum Connect tunnels', got '%v'", manifest["description"]) + } +} + +func TestPluginManifestExitsBeforeCobraParses(t *testing.T) { + // --plugin-manifest must be handled before cobra parses args + // so even invalid cobra flags should not prevent manifest output + cmd := exec.Command(buildPlugin(t), "--plugin-manifest", "--invalid-cobra-flag") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("--plugin-manifest should exit 0 even with invalid flags: %v\n%s", err, out) + } + + var manifest map[string]interface{} + if err := json.Unmarshal(out, &manifest); err != nil { + t.Fatalf("manifest is not valid JSON: %v\n%s", err, out) + } + if manifest["name"] != "connect" { + t.Error("manifest name should be 'connect'") + } +} + +func TestAll12SubcommandsScaffolded(t *testing.T) { + // PLUG-06: All 12 subcommands available in help + cmd := exec.Command(buildPlugin(t), "tunnel", "--help") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("tunnel help exited non-zero: %v\n%s", err, out) + } + + expectedSubcommands := []string{ + "list", "listen", "update", "delete", + "ps", "stop", "logs", "status", + "install", "uninstall", "start", "run", + } + + available := string(out) + for _, sub := range expectedSubcommands { + if !strings.Contains(available, sub) { + t.Errorf("tunnel help should list subcommand '%s'", sub) + } + } +} + +func TestAllSubcommandsRunWithoutCrash(t *testing.T) { + // All 12 subcommands should run without crashing (may exit non-zero for + // missing required flags, but should not panic or produce stack traces) + subcommands := []string{ + "list", "listen", "update", "delete", + "ps", "stop", "logs", "status", + "install", "uninstall", "start", "run", + } + pluginBin := buildPlugin(t) + + for _, subcmd := range subcommands { + t.Run(subcmd, func(t *testing.T) { + cmd := exec.Command(pluginBin, "tunnel", subcmd) + cmd.Env = append(os.Environ(), "FAKE_DATUM_CONNECT=1") + out, err := cmd.CombinedOutput() + // May exit non-zero due to missing required flags — that's OK + // as long as there's no panic/stack trace + if err != nil { + if bytes.Contains(out, []byte("panic:")) { + t.Fatalf("%s panicked:\n%s", subcmd, out) + } + } + if !bytes.Contains(out, []byte(subcmd)) && !bytes.Contains(out, []byte("Error:")) { + // Some commands show usage on missing flags, others show "Error:" + // Just verify output isn't empty + if len(bytes.TrimSpace(out)) == 0 { + t.Errorf("%s produced no output", subcmd) + } + } + }) + } +} + +func TestFakeDatumConnectListJSON(t *testing.T) { + // Test fakes are functional and testable + fakeBin := buildFakeBinary(t, "testdata/fake-datum-connect") + cmd := exec.Command(fakeBin, "--json", "list") + cmd.Env = append(os.Environ(), "DATUM_ACCESS_TOKEN=test-token") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("fake-datum-connect list --json exited non-zero: %v\n%s", err, out) + } + + var tunnels []map[string]interface{} + if err := json.Unmarshal(out, &tunnels); err != nil { + t.Fatalf("fake output is not valid JSON: %v\n%s", err, out) + } + if len(tunnels) == 0 { + t.Error("expected at least one tunnel in list output") + } +} + +func TestFakeDatumConnectDeleteJSON(t *testing.T) { + fakeBin := buildFakeBinary(t, "testdata/fake-datum-connect") + cmd := exec.Command(fakeBin, "--json", "delete") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("fake-datum-connect delete --json exited non-zero: %v\n%s", err, out) + } + + var result map[string]interface{} + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("delete output is not valid JSON: %v\n%s", err, out) + } + if deleted, ok := result["deleted"].(bool); !ok || !deleted { + t.Error("delete should return {\"deleted\": true}") + } +} + +func TestFakeCredentialsHelperDefaultMode(t *testing.T) { + fakeBin := buildFakeHelper(t, "testdata/fake-credentials-helper") + cmd := exec.Command(fakeBin) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("fake-credentials-helper exited non-zero: %v\n%s", err, out) + } + + token := strings.TrimSpace(string(out)) + if token == "" { + t.Error("default mode should output a token") + } +} + +func TestFakeCredentialsHelperRefusesToken(t *testing.T) { + fakeBin := buildFakeHelper(t, "testdata/fake-credentials-helper") + cmd := exec.Command(fakeBin) + cmd.Env = append(os.Environ(), "FAKE_HELPER_MODE=refuses-token") + err := cmd.Run() + if err == nil { + t.Error("refuses-token mode should exit non-zero") + } +} + +func TestFakeCredentialsHelperSessionDependent(t *testing.T) { + fakeBin := buildFakeHelper(t, "testdata/fake-credentials-helper") + + // Should succeed with matching session + cmd := exec.Command(fakeBin, "--session", "test-session") + cmd.Env = append(os.Environ(), "FAKE_HELPER_MODE=session-dependent") + if err := cmd.Run(); err != nil { + t.Fatalf("session-dependent with correct session should succeed: %v", err) + } + + // Should fail with wrong session + cmd = exec.Command(fakeBin, "--session", "wrong-session") + cmd.Env = append(os.Environ(), "FAKE_HELPER_MODE=session-dependent") + if err := cmd.Run(); err == nil { + t.Error("session-dependent with wrong session should fail") + } +} + +func TestBuildScriptExists(t *testing.T) { + // Build script exists and is executable + info, err := os.Stat("scripts/build.sh") + if err != nil { + t.Fatalf("scripts/build.sh should exist: %v", err) + } + if info.Mode()&0111 == 0 { + t.Error("scripts/build.sh should be executable") + } +} + +func TestReleaseScriptExists(t *testing.T) { + // Release script exists and is executable + info, err := os.Stat("scripts/release.sh") + if err != nil { + t.Fatalf("scripts/release.sh should exist: %v", err) + } + if info.Mode()&0111 == 0 { + t.Error("scripts/release.sh should be executable") + } +} + +func buildFakeBinary(t *testing.T, src string) string { + t.Helper() + bin := src + "-test" + cmd := exec.Command("go", "build", "-o", bin, "./"+src) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build %s: %v\n%s", src, err, out) + } + t.Cleanup(func() { os.Remove(bin) }) + return bin +} + +// buildFakeDatumConnect builds the fake binary and returns its absolute path +// for use with FAKE_DATUM_CONNECT env var override in binary.Discover(). +func buildFakeDatumConnect(t *testing.T) string { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "fake-datum-connect") + cmd := exec.Command("go", "build", "-o", bin, "./testdata/fake-datum-connect") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build fake-datum-connect: %v\n%s", err, out) + } + return bin +} + +func buildFakeHelper(t *testing.T, src string) string { + t.Helper() + bin := src + "-test" + cmd := exec.Command("go", "build", "-o", bin, "./"+src) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build %s: %v\n%s", src, err, out) + } + t.Cleanup(func() { os.Remove(bin) }) + return bin +} + +// --- Plan 04-02 CRUD e2e tests --- + +func TestListCommandWithFakeBinary(t *testing.T) { + // CRUD-01: list delegates to Rust, renders table/json/yaml + fakeBin := buildFakeDatumConnect(t) + fakeHelper := buildFakeHelper(t, "testdata/fake-credentials-helper") + pluginBin := buildPlugin(t) + + connectDir, _ := os.Getwd() + cmd := exec.Command(pluginBin, "tunnel", "list") + cmd.Env = append(os.Environ(), + "FAKE_DATUM_CONNECT="+fakeBin, + "DATUM_CREDENTIALS_HELPER="+fakeHelper, + "DATUM_SESSION=dev", + "DATUM_CONNECT_DIR="+connectDir, + "PATH="+connectDir+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("list exited non-zero: %v\n%s", err, out) + } + output := string(out) + if !strings.Contains(output, "dev-server") { + t.Errorf("table output should contain tunnel label 'dev-server': %s", output) + } + if !strings.Contains(output, "localhost:8080") { + t.Errorf("table output should contain endpoint 'localhost:8080': %s", output) + } + + // Test JSON output + cmd = exec.Command(pluginBin, "tunnel", "list", "--output", "json") + cmd.Env = append(os.Environ(), + "FAKE_DATUM_CONNECT="+fakeBin, + "DATUM_CREDENTIALS_HELPER="+fakeHelper, + "DATUM_SESSION=dev", + "DATUM_CONNECT_DIR="+connectDir, + "PATH="+connectDir+":"+os.Getenv("PATH")) + out, err = cmd.CombinedOutput() + if err != nil { + t.Fatalf("list --output json exited non-zero: %v\n%s", err, out) + } + var tunnels []map[string]interface{} + if err := json.Unmarshal(out, &tunnels); err != nil { + t.Fatalf("json output is not valid JSON: %v\n%s", err, out) + } + if len(tunnels) != 2 { + t.Errorf("expected 2 tunnels, got %d", len(tunnels)) + } +} + +func TestDeleteCommandWithFakeBinary(t *testing.T) { + // CRUD-04: delete delegates to Rust with correct output + fakeBin := buildFakeDatumConnect(t) + fakeHelper := buildFakeHelper(t, "testdata/fake-credentials-helper") + pluginBin := buildPlugin(t) + + connectDir, _ := os.Getwd() + cmd := exec.Command(pluginBin, "tunnel", "delete", "--id", "tun-123", "--output", "json") + cmd.Env = append(os.Environ(), + "FAKE_DATUM_CONNECT="+fakeBin, + "DATUM_CREDENTIALS_HELPER="+fakeHelper, + "DATUM_SESSION=dev", + "DATUM_CONNECT_DIR="+connectDir, + "PATH="+connectDir+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("delete exited non-zero: %v\n%s", err, out) + } + + var result map[string]interface{} + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("delete output is not valid JSON: %v\n%s", err, out) + } + if deleted, ok := result["deleted"].(bool); !ok || !deleted { + t.Error("delete should return {\"deleted\": true}") + } +} + +func TestDeleteCommandMissingID(t *testing.T) { + // EXIT-02: missing required flag exits with POSIX code 64 + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "delete") + cmd.Env = append(os.Environ(), "DATUM_CONNECT_DIR="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err == nil { + t.Error("delete without --id should exit non-zero") + } + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() != 64 { + t.Errorf("expected exit code 64 (semantic rejection), got %d", exitErr.ExitCode()) + } + } + if !bytes.Contains(out, []byte("required")) { + t.Error("delete without --id should show 'required' error message") + } +} + +// --- Plan 04-03 listen e2e tests --- + +func TestListenCommandWithFakeBinary(t *testing.T) { + // CRUD-02, CRUD-06: listen creates tunnel, blocks, handles signals + fakeBin := buildFakeDatumConnect(t) + fakeHelper := buildFakeHelper(t, "testdata/fake-credentials-helper") + pluginBin := buildPlugin(t) + + connectDir, _ := os.Getwd() + // Start listen command + cmd := exec.Command(pluginBin, "tunnel", "listen", "--endpoint", "localhost:8080") + cmd.Env = append(os.Environ(), + "FAKE_DATUM_CONNECT="+fakeBin, + "DATUM_CREDENTIALS_HELPER="+fakeHelper, + "DATUM_SESSION=dev", + "DATUM_CONNECT_DIR="+connectDir, + "PATH="+connectDir+":"+os.Getenv("PATH")) + + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("failed to get stdout pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + t.Fatalf("listen failed to start: %v", err) + } + + // Read output until we see "Tunnel ready" or "Press Ctrl+C" + scanner := bufio.NewScanner(stdout) + var foundReady bool + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "ready") || strings.Contains(line, "Tunnel") { + foundReady = true + break + } + } + if !foundReady { + t.Error("listen should print tunnel ready message") + } + + // Send SIGINT to stop + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + t.Fatalf("failed to send SIGINT: %v", err) + } + + // Wait for child to exit + if err := cmd.Wait(); err != nil { + // Non-nil error is expected (signal termination) + if exitErr, ok := err.(*exec.ExitError); ok { + // SIGINT typically gives exit code 130 + if exitErr.ExitCode() != 130 && exitErr.ExitCode() != 0 { + t.Logf("listen exited with code %d (expected 0 or 130)", exitErr.ExitCode()) + } + } + } +} + +func TestListenJSONMode(t *testing.T) { + // CRUD-05: listen --json emits single JSON object on ready + fakeBin := buildFakeDatumConnect(t) + fakeHelper := buildFakeHelper(t, "testdata/fake-credentials-helper") + pluginBin := buildPlugin(t) + + connectDir, _ := os.Getwd() + cmd := exec.Command(pluginBin, "tunnel", "listen", "--endpoint", "localhost:8080", "--output", "json") + cmd.Env = append(os.Environ(), + "FAKE_DATUM_CONNECT="+fakeBin, + "DATUM_CREDENTIALS_HELPER="+fakeHelper, + "DATUM_SESSION=dev", + "DATUM_CONNECT_DIR="+connectDir, + "PATH="+connectDir+":"+os.Getenv("PATH")) + + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("failed to get stdout pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + t.Fatalf("listen --json failed to start: %v", err) + } + + // Read first line — should be the ready JSON + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("listen --json should output ready JSON") + } + firstLine := scanner.Bytes() + + var ready map[string]interface{} + if err := json.Unmarshal(firstLine, &ready); err != nil { + t.Fatalf("first line is not valid JSON: %s", firstLine) + } + if ready["status"] != "ready" { + t.Errorf("expected status='ready', got '%v'", ready["status"]) + } + + // Send SIGINT to stop + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + t.Fatalf("failed to send SIGINT: %v", err) + } + cmd.Wait() +} + +func TestPluginDefaultsConnectDir(t *testing.T) { + // When DATUM_CONNECT_DIR is unset, the plugin should compute the + // canonical default $HOME/.datumctl/connect and proceed (not exit 64). + // This allows the plugin to work without datumctl host injection. + pluginBin := buildPlugin(t) + + // Strip DATUM_CONNECT_DIR (and the legacy DATUM_CONNECT_REPO) from env. + env := []string{} + for _, e := range os.Environ() { + if strings.HasPrefix(e, "DATUM_CONNECT_DIR=") { + continue + } + if strings.HasPrefix(e, "DATUM_CONNECT_REPO=") { + continue + } + env = append(env, e) + } + + // Run tunnel list — should NOT exit 64. Will fail with "binary not + // found" because no fake binary is set up, but that's a different error. + cmd := exec.Command(pluginBin, "tunnel", "list") + cmd.Env = env + out, err := cmd.CombinedOutput() + + if err == nil { + t.Fatalf("expected non-zero exit (binary not found); got success with output:\n%s", out) + } + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("expected *exec.ExitError, got %T: %v", err, err) + } + if exitErr.ExitCode() == 64 { + t.Errorf("plugin exited 64 when DATUM_CONNECT_DIR was unset; should have computed default instead:\n%s", out) + } + if bytes.Contains(out, []byte("DATUM_CONNECT_DIR is not set")) { + t.Errorf("unexpected 'DATUM_CONNECT_DIR is not set' message — plugin should have auto-computed the default:\n%s", out) + } +} + +func TestPluginManifestProbeWorksWithoutConnectDir(t *testing.T) { + // The --plugin-manifest probe must work even when DATUM_CONNECT_DIR + // is unset (datumctl probes plugins before injecting env). + // plugin.ServeManifest self-exits 0 before our RequireConnectDir + // check runs; this test pins that ordering. + pluginBin := buildPlugin(t) + env := []string{} + for _, e := range os.Environ() { + if strings.HasPrefix(e, "DATUM_CONNECT_DIR=") { + continue + } + env = append(env, e) + } + cmd := exec.Command(pluginBin, "--plugin-manifest") + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("--plugin-manifest must exit 0 even without DATUM_CONNECT_DIR; err=%v, out=%s", err, out) + } + if !bytes.Contains(out, []byte("{")) { + t.Errorf("--plugin-manifest output should be JSON; got:\n%s", out) + } +} + +func TestListenMissingEndpointAndId(t *testing.T) { + // EXIT-02: missing both --endpoint and --id exits with code 64. + // 12-02 expanded the validation: either --endpoint or --id satisfies + // the requirement; neither still rejects. + pluginBin := buildPlugin(t) + cmd := exec.Command(pluginBin, "tunnel", "listen") + cmd.Env = append(os.Environ(), "DATUM_CONNECT_DIR="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err == nil { + t.Error("listen without --endpoint or --id should exit non-zero") + } + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() != 64 { + t.Errorf("expected exit code 64 (semantic rejection), got %d", exitErr.ExitCode()) + } + } + if !bytes.Contains(out, []byte("required")) { + t.Error("listen without --endpoint or --id should show 'required' error message") + } +} diff --git a/connect-plugin/fake-datum-connect-test b/connect-plugin/fake-datum-connect-test new file mode 100755 index 0000000..922e832 Binary files /dev/null and b/connect-plugin/fake-datum-connect-test differ diff --git a/connect-plugin/go.mod b/connect-plugin/go.mod new file mode 100644 index 0000000..bdb72f1 --- /dev/null +++ b/connect-plugin/go.mod @@ -0,0 +1,18 @@ +module go.datum.net/datumctl-plugins/connect + +go 1.25.8 + +replace go.datum.net/datumctl => ../../datumctl + +require ( + github.com/kardianos/service v1.2.4 + github.com/spf13/cobra v1.10.2 + go.datum.net/datumctl v0.15.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.45.0 // indirect +) diff --git a/connect-plugin/go.sum b/connect-plugin/go.sum new file mode 100644 index 0000000..9be25fb --- /dev/null +++ b/connect-plugin/go.sum @@ -0,0 +1,18 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= +github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/connect-plugin/internal/binary/discovery.go b/connect-plugin/internal/binary/discovery.go new file mode 100644 index 0000000..464ea3d --- /dev/null +++ b/connect-plugin/internal/binary/discovery.go @@ -0,0 +1,66 @@ +// Package binary provides functions to locate the datum-connect binary. +package binary + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// Discover locates the datum-connect binary. +// Search order: (1) FAKE_DATUM_CONNECT env var (test mode, absolute path), +// (2) same directory as the running plugin binary, (3) PATH lookup. +// Returns error if not found. +func Discover() (string, error) { + // (1) Test override: FAKE_DATUM_CONNECT set to absolute path + if path := os.Getenv("FAKE_DATUM_CONNECT"); path != "" { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + // (2) Same directory as the running binary + if path := findNextToSelf(); path != "" { + return path, nil + } + // (3) PATH lookup + if path := findInPath(); path != "" { + return path, nil + } + return "", fmt.Errorf("datum-connect binary not found: not next to plugin binary and not in PATH") +} + +// binaryName returns the platform-appropriate binary name. When +// FAKE_DATUM_CONNECT is set (test mode), returns the fake binary name. +// +// Phase 11.5 D-07: the legacy switch on DATUM_CONNECT_REPO was dead +// (both arms returned "datum-connect") and is removed. +func binaryName() string { + if os.Getenv("FAKE_DATUM_CONNECT") != "" { + return "fake-datum-connect" + } + return "datum-connect" +} + +// findNextToSelf returns the path to datum-connect in the same +// directory as the running binary, or "" if not found. +func findNextToSelf() string { + exe, err := os.Executable() + if err != nil { + return "" + } + path := filepath.Join(filepath.Dir(exe), binaryName()) + if _, err := os.Stat(path); err == nil { + return path + } + return "" +} + +// findInPath looks up datum-connect in PATH. +func findInPath() string { + path, err := exec.LookPath(binaryName()) + if err != nil { + return "" + } + return path +} diff --git a/connect-plugin/internal/daemon/fork.go b/connect-plugin/internal/daemon/fork.go new file mode 100644 index 0000000..4586155 --- /dev/null +++ b/connect-plugin/internal/daemon/fork.go @@ -0,0 +1,64 @@ +// Package daemon provides functions to fork processes into daemons. +package daemon + +import ( + "fmt" + "os" +) + +// Daemonize spawns a detached copy of the current Go binary as a background +// daemon. Uses os.StartProcess to create a new process with no terminal +// association. This is the cross-platform approach — fork() is not available +// on Windows. +// +// The child process runs tunnel run --name N which calls RunSupervisor. +// +// Returns the child PID. +func Daemonize(exePath string, args []string) (int, error) { + if len(args) == 0 { + return 0, fmt.Errorf("daemonize: no args provided") + } + + attr := &os.ProcAttr{ + Files: []*os.File{nil, nil, nil}, // Detach stdin/stdout/stderr + Env: os.Environ(), + } + + proc, err := os.StartProcess(exePath, args, attr) + if err != nil { + return 0, fmt.Errorf("daemonize: start process: %w", err) + } + + // Detach — don't wait for child + proc.Release() + + return proc.Pid, nil +} + +// ForegroundArgs builds the args to pass to Daemonize for a foreground listen +// subcommand detaching to background: tunnel run --name N [--log-file L]. +func ForegroundArgs(name, logFile, endpoint, label string, yes bool) []string { + args := []string{"tunnel", "run", "--name", name} + if logFile != "" { + args = append(args, "--log-file", logFile) + } + if endpoint != "" { + args = append(args, "--endpoint", endpoint) + } + if label != "" { + args = append(args, "--label", label) + } + if yes { + args = append(args, "--yes") + } + return args +} + +// SelfExe returns the path to the currently running executable. +func SelfExe() string { + exe, err := os.Executable() + if err != nil { + return "datumctl-connect" // fallback + } + return exe +} diff --git a/connect-plugin/internal/daemon/supervisor.go b/connect-plugin/internal/daemon/supervisor.go new file mode 100644 index 0000000..4e31c11 --- /dev/null +++ b/connect-plugin/internal/daemon/supervisor.go @@ -0,0 +1,118 @@ +// Package daemon provides tunnel supervisor and daemonization primitives. +// +// The supervisor manages the lifecycle of the Rust tunnel binary, forwards +// its typed JSON output to stdout, and writes/removes PID files. +// The daemonize functions provide cross-platform background process spawning. +package daemon + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "go.datum.net/datumctl-plugins/connect/internal/binary" + "go.datum.net/datumctl-plugins/connect/internal/env" + "go.datum.net/datumctl-plugins/connect/internal/pidfile" + rexec "go.datum.net/datumctl-plugins/connect/internal/exec" + "go.datum.net/datumctl-plugins/connect/internal/state" + "go.datum.net/datumctl/plugin" +) + +// Config holds the supervisor configuration. +type Config struct { + Name string + Label string + Endpoint string + LogFile string // optional Rust debug log file path + Yes bool // skip confirmation +} + +// RunSupervisor starts the Rust tunnel binary and supervises its lifecycle. +// It writes a PID file at start, removes it on exit, and forwards Rust output +// to stdout. The function blocks until the Rust binary exits. +func RunSupervisor(ctx context.Context, cfg Config) error { + // Discover binary + binaryPath, err := binary.Discover() + if err != nil { + return fmt.Errorf("binary discovery: %w", err) + } + + // Get plugin context + pluginCtx := plugin.Context() + + // Build environment (no DATUM_ACCESS_TOKEN — binary obtains token via credentials helper) + childEnv := env.Build(pluginCtx) + + // Build Rust args + rustArgs := []string{"--json", "listen", "--endpoint", cfg.Endpoint} + if cfg.Label != "" { + rustArgs = append(rustArgs, "--label", cfg.Label) + } + if cfg.Yes { + rustArgs = append(rustArgs, "--yes") + } + if cfg.LogFile != "" { + rustArgs = append(rustArgs, "--log-file", cfg.LogFile) + } + + // Start Rust binary + rustCmd := exec.CommandContext(ctx, binaryPath, rustArgs...) + rustCmd.Env = childEnv + + stdoutPipe, err := rustCmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + rustCmd.Stderr = os.Stderr + + if err := rustCmd.Start(); err != nil { + return fmt.Errorf("start datum-connect: %w", err) + } + + // Write PID file (Go PID known, Rust PID just started) + if cfg.Name != "" { + pidPath := pidFilePath(cfg.Name) + startTime := time.Now() + if err := pidfile.Write(pidPath, os.Getpid(), rustCmd.Process.Pid, startTime, binaryPath); err != nil { + // Non-fatal — supervisor continues + fmt.Fprintf(os.Stderr, "warning: failed to write pid file: %v\n", err) + } + defer func() { + _ = pidfile.Remove(pidPath) + }() + } + + // Read and forward typed JSON messages + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + msg, ok := rexec.ParseTypedMessage(line) + if !ok { + continue + } + // Forward all messages to stdout (the parent/supervisor) + fmt.Fprintln(os.Stdout, string(line)) + _ = msg // Use msg to suppress unused warning + } + + // Wait for Rust to exit + waitErr := rustCmd.Wait() + return waitErr +} + +// pidFilePath returns the PID file path for a named tunnel. +// Uses DATUM_CONNECT_TUNNEL_DIR env var if set (for testing isolation), +// otherwise falls back to the state package's tunnel directory. +func pidFilePath(name string) string { + if d := os.Getenv("DATUM_CONNECT_TUNNEL_DIR"); d != "" { + return filepath.Join(d, name+".pid") + } + return state.PidFilePath(name) +} diff --git a/connect-plugin/internal/daemon/supervisor_test.go b/connect-plugin/internal/daemon/supervisor_test.go new file mode 100644 index 0000000..5cb6469 --- /dev/null +++ b/connect-plugin/internal/daemon/supervisor_test.go @@ -0,0 +1,122 @@ +package daemon + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestRunSupervisor_StartsAndExits(t *testing.T) { + fakeBin := findFakeBinary(t) + setupFakeEnv(t, fakeBin) + + // Create temp PID directory + pidDir := t.TempDir() + t.Setenv("DATUM_CONNECT_TUNNEL_DIR", pidDir) + + cfg := Config{ + Name: "test-tun", + Endpoint: "localhost:8080", + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := RunSupervisor(ctx, cfg) + // The fake binary blocks on listen (waiting for signal), so it will timeout. + // We just verify it exited without panic and cleaned up. + t.Logf("RunSupervisor returned: %v", err) +} + +func TestRunSupervisor_WritesPIDFile(t *testing.T) { + fakeBin := findFakeBinary(t) + setupFakeEnv(t, fakeBin) + + pidDir := t.TempDir() + t.Setenv("DATUM_CONNECT_TUNNEL_DIR", pidDir) + + cfg := Config{ + Name: "pidtest", + Endpoint: "localhost:8080", + } + + // Run with timeout — supervisor will block on message loop + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _ = RunSupervisor(ctx, cfg) + + // After timeout, PID file should be cleaned up (defer ran) + pidPath := filepath.Join(pidDir, "pidtest.pid") + if _, err := os.Stat(pidPath); err == nil { + t.Error("PID file should be removed after supervisor exits") + } +} + +// findFakeBinary locates the pre-built fake-datum-connect binary. +func findFakeBinary(t *testing.T) string { + t.Helper() + candidates := []string{ + "../../testdata/fake-datum-connect/fake-datum-connect", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + abs, _ := filepath.Abs(c) + return abs + } + } + t.Skip("fake-datum-connect binary not found (run `go build` in testdata first)") + return "" +} + +// setupFakeEnv sets up environment so binary.Discover() finds the fake binary +// and plugin.Token() finds a working fake credentials helper. +func setupFakeEnv(t *testing.T, fakeBin string) { + t.Helper() + t.Setenv("FAKE_DATUM_CONNECT", fakeBin) + // Add fake binary dir to PATH + fakeDir := filepath.Dir(fakeBin) + t.Setenv("PATH", fakeDir+":"+os.Getenv("PATH")) + + // Build and use a fake credentials helper + helperBin := buildFakeHelper(t) + t.Setenv("DATUM_CREDENTIALS_HELPER", helperBin) + + // Set required datumctl env vars that plugin.Context() expects + t.Setenv("DATUM_ORG", "test-org") + t.Setenv("DATUM_PROJECT", "test-project") + t.Setenv("DATUM_API_HOST", "api.datum.net") + t.Setenv("DATUM_PLUGIN_API_VERSION", "1") + t.Setenv("DATUM_SESSION", "dev") +} + +// buildFakeHelper builds a simple credentials helper that returns a fixed token. +func buildFakeHelper(t *testing.T) string { + t.Helper() + helperDir := t.TempDir() + src := `package main +import "fmt" +func main() { fmt.Println("test-token-from-helper") } +` + srcPath := filepath.Join(helperDir, "main.go") + if err := os.WriteFile(srcPath, []byte(src), 0644); err != nil { + t.Fatalf("write helper source: %v", err) + } + binPath := filepath.Join(helperDir, "fake-helper") + cmd := exec.Command("go", "build", "-o", binPath, srcPath) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build helper: %v\n%s", err, out) + } + return binPath +} + +func TestMain(m *testing.M) { + // Build the fake binary before running tests + cmd := exec.Command("go", "build", "-o", "fake-datum-connect", "../../testdata/fake-datum-connect") + cmd.Dir = "." + _ = cmd.Run() + os.Exit(m.Run()) +} diff --git a/connect-plugin/internal/env/build.go b/connect-plugin/internal/env/build.go new file mode 100644 index 0000000..ae546b4 --- /dev/null +++ b/connect-plugin/internal/env/build.go @@ -0,0 +1,69 @@ +// Package env provides functions to build the child process environment +// and to gate the plugin on required env-var contracts. +package env + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "go.datum.net/datumctl/plugin" +) + +// Build returns the child process environment for the Rust binary. +// +// Phase 11.5: DATUM_CONNECT_DIR is OWNED by datumctl and arrives via +// os.Environ() pass-through; this function MUST NOT compute it, default +// it, or override it. The legacy DATUM_CONNECT_REPO=ctx.Project line is +// removed — it was the root cause of stray .// listen_key +// dirs (Phase 12 plan 12-02 scenario 6). +// +// Phase 13-06: DATUM_ACCESS_TOKEN line removed — the Rust binary now +// obtains its token from the credentials helper at startup. The helper +// receives DATUM_SESSION from the child env. +// +// Caller responsibility: check RequireConnectDir() before calling Build; +// if it returns an error, write FailConnectDirUnset() to stderr and +// os.Exit(64). +func Build(ctx plugin.PluginContext) []string { + result := os.Environ() + result = append(result, "DATUM_API_HOST="+ctx.APIHost) + result = append(result, "DATUM_CREDENTIALS_HELPER="+ctx.CredentialsHelper) + result = append(result, "DATUM_SESSION="+ctx.Session) + return result +} + +// RequireConnectDir ensures DATUM_CONNECT_DIR is set. When the env var +// is already present (e.g., injected by datumctl), it is left unchanged. +// Otherwise, the canonical default $HOME/.datumctl/connect is computed +// and exported into the current process environment so child processes +// inherit it. +// +// Returns an error only when the env var is missing AND the home +// directory cannot be determined — a truly broken environment. +func RequireConnectDir() error { + if os.Getenv("DATUM_CONNECT_DIR") != "" { + return nil + } + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("DATUM_CONNECT_DIR is not set and cannot determine home directory: %w", err) + } + def := filepath.Join(home, ".datumctl", "connect") + os.Setenv("DATUM_CONNECT_DIR", def) + return nil +} + +// FailConnectDirUnset writes a diagnostic error to w explaining that +// the home directory could not be determined. The caller is responsible +// for os.Exit(64). +func FailConnectDirUnset(w io.Writer, err error) { + fmt.Fprintf(w, `Error: %v + +The connect plugin normally stores its state at $HOME/.datumctl/connect/. +Could not determine your home directory to compute this path. + +(exit 64) +`, err) +} diff --git a/connect-plugin/internal/env/build_test.go b/connect-plugin/internal/env/build_test.go new file mode 100644 index 0000000..f812d4b --- /dev/null +++ b/connect-plugin/internal/env/build_test.go @@ -0,0 +1,137 @@ +package env + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "go.datum.net/datumctl/plugin" +) + +func TestBuild_PassesThroughOsEnviron(t *testing.T) { + t.Setenv("MY_CUSTOM_PASSTHROUGH_VAR_FOR_TEST", "hello-passthrough") + ctx := plugin.PluginContext{ + APIHost: "api.example", + Project: "proj", + CredentialsHelper: "helper", + Session: "sess", + } + got := Build(ctx) + found := false + for _, e := range got { + if e == "MY_CUSTOM_PASSTHROUGH_VAR_FOR_TEST=hello-passthrough" { + found = true + break + } + } + if !found { + t.Errorf("Build should pass os.Environ() through; missing custom var") + } +} + +func TestBuild_DoesNotInjectConnectDir(t *testing.T) { + t.Setenv("DATUM_CONNECT_DIR", "/tmp/should-be-inherited") + ctx := plugin.PluginContext{Project: "should-not-appear"} + got := Build(ctx) + // Count occurrences of DATUM_CONNECT_DIR= — must be 1 (the one we + // set via t.Setenv, passed through os.Environ()). If Build appends + // its own, we'd see 2. + count := 0 + for _, e := range got { + if strings.HasPrefix(e, "DATUM_CONNECT_DIR=") { + count++ + } + } + if count != 1 { + t.Errorf("Build should not inject DATUM_CONNECT_DIR; want 1 entry from os.Environ(), got %d", count) + } +} + +func TestBuild_DoesNotEmitLegacyConnectRepo(t *testing.T) { + // Legacy DATUM_CONNECT_REPO was the bug; assert it never appears in + // the produced slice unless the inherited env already had it. + os.Unsetenv("DATUM_CONNECT_REPO") + ctx := plugin.PluginContext{Project: "test-project-slug"} + got := Build(ctx) + for _, e := range got { + if strings.HasPrefix(e, "DATUM_CONNECT_REPO=") { + t.Errorf("Build must not emit DATUM_CONNECT_REPO; got %q", e) + } + } +} + +func TestBuild_AppendsExactlyThreePluginVars(t *testing.T) { + // Lock the contract: Build adds 3 vars (api-host, helper, session). + // DATUM_ACCESS_TOKEN was removed in Phase 13-06 (binary obtains token + // via credentials helper, not env). DATUM_CONNECT_DIR comes via + // os.Environ() pass-through. + os.Unsetenv("DATUM_API_HOST") + os.Unsetenv("DATUM_CREDENTIALS_HELPER") + os.Unsetenv("DATUM_SESSION") + ctx := plugin.PluginContext{ + APIHost: "h", + CredentialsHelper: "c", + Session: "s", + } + got := Build(ctx) + wantPrefixes := []string{ + "DATUM_API_HOST=h", + "DATUM_CREDENTIALS_HELPER=c", + "DATUM_SESSION=s", + } + for _, want := range wantPrefixes { + found := false + for _, e := range got { + if e == want { + found = true + break + } + } + if !found { + t.Errorf("Build missing entry %q", want) + } + } +} + +func TestRequireConnectDir_SetReturnsNil(t *testing.T) { + t.Setenv("DATUM_CONNECT_DIR", "/some/path") + if err := RequireConnectDir(); err != nil { + t.Errorf("RequireConnectDir() with var set = %v, want nil", err) + } +} + +func TestRequireConnectDir_UnsetSetsDefault(t *testing.T) { + os.Unsetenv("DATUM_CONNECT_DIR") + err := RequireConnectDir() + if err != nil { + t.Fatalf("RequireConnectDir() with var unset = %v, want nil (sets default)", err) + } + got := os.Getenv("DATUM_CONNECT_DIR") + if got == "" { + t.Fatal("RequireConnectDir() didn't set DATUM_CONNECT_DIR") + } + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".datumctl", "connect") + if got != want { + t.Errorf("RequireConnectDir() set DATUM_CONNECT_DIR=%q, want %q", got, want) + } +} + +func TestFailConnectDirUnset_WritesDirectiveMessage(t *testing.T) { + var buf bytes.Buffer + FailConnectDirUnset(&buf, fmt.Errorf("test error")) + out := buf.String() + required := []string{ + "Error: test error", + ".datumctl/connect", + "(exit 64)", + } + for _, want := range required { + if !strings.Contains(out, want) { + t.Errorf("directive message missing %q; got:\n%s", want, out) + } + } +} diff --git a/connect-plugin/internal/exec/run.go b/connect-plugin/internal/exec/run.go new file mode 100644 index 0000000..499cd47 --- /dev/null +++ b/connect-plugin/internal/exec/run.go @@ -0,0 +1,200 @@ +// Package exec provides shared subprocess orchestration for fire-and-forget CRUD commands. +package exec + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/output" + "go.datum.net/datumctl-plugins/connect/internal/signals" +) + +// OutputMode controls how subprocess output is handled. +type OutputMode int + +const ( + // OutputModeTable: Rust outputs JSON, Go converts to human-readable table. + OutputModeTable OutputMode = iota + // OutputModeJSON: Rust outputs JSON, Go passes through verbatim. + OutputModeJSON + // OutputModeYAML: Rust outputs JSON, Go converts to YAML. + OutputModeYAML +) + +// TypedMessage represents a typed JSON message from the Rust binary. +// +// Rust-side contract (enforced by Rust code): +// Every message emitted to stdout is a single-line JSON object with a "type" field. +// No message is emitted without a "type" field. No malformed JSON is emitted. +// +// {"type":"ready","id":"...","label":"...","endpoint":"...","hostnames":["..."],"status":"ready"} +// {"type":"error","message":"..."} +// {"type":"heartbeat"} +// {"type":"status","state":"..."} +// +// Go-side parse policy: +// - Valid JSON with "type" → dispatch on type +// - Valid JSON without "type" → fatal error (Rust contract requires "type" on every message) +// - Invalid JSON → fatal error (should never occur from Rust) +// - Empty line → skip silently +type TypedMessage struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + Fields map[string]interface{} `json:",inline"` +} + +// RunResult holds the captured output and exit status from a subprocess run. +type RunResult struct { + Stdout []byte + Stderr []byte + ExitCode int +} + +// Run executes the datum-connect binary with the given arguments and environment, +// captures its output, forwards signals, and returns the result. +// +// The function: +// 1. Creates the command with the given args and env +// 2. Captures stdout and stderr into buffers +// 3. Starts the command +// 4. Sets up signal forwarding (SIGINT/SIGTERM) with grace period +// 5. Waits for completion +// 6. Returns the captured result +// +// stderr handling: child stderr is captured into RunResult.Stderr. +// The caller (RunWithOutput) decides whether to surface it. +// This is the ONLY path data goes to stderr — no progress/status messages. +// +// Exit code mapping: +// - Child exits normally: RunResult.ExitCode = child's exit code, returned nil error +// - Child exits via signal: RunResult.ExitCode = 128 + signal number, returned nil error +// - Child not found: returned error (not a RunResult) +// - Go-side setup failure: returned error +// +// IMPORTANT: This function is for fire-and-forget commands only (list, update, delete). +// The listen command manages process lifecycle directly to handle streaming output. +func Run(ctx context.Context, binaryPath string, args []string, env []string, outputMode OutputMode) (*RunResult, error) { + cmd := exec.CommandContext(ctx, binaryPath, args...) + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start %s: %w", binaryPath, err) + } + + // childExited is closed after cmd.Wait() so signals.Forward never races + // with the cmd.Wait() call to reap the process exit status. + childExited := make(chan struct{}) + + // Start signal forwarding in a goroutine + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if err := signals.Forward(cmd.Process, childExited, 30*time.Second); err != nil { + // Signal forwarding failure is non-fatal; child may have already exited + } + }() + + // Wait for completion, then signal Forward that the child has exited + cmd.Wait() + close(childExited) + + // Wait for signal goroutine to finish + wg.Wait() + + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + + result := &RunResult{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + ExitCode: exitCode, + } + + // Format output based on mode + if outputMode == OutputModeYAML && len(result.Stdout) > 0 { + yaml, err := output.ConvertJSONToYAML(result.Stdout) + if err == nil { + result.Stdout = yaml + } + } + + return result, nil +} + +// RunWithOutput is a convenience wrapper that writes formatted output to a +// cmd.OutOrStdout() and exits with the child's exit code on failure. +// +// Exit code policy: +// - Child exits non-zero: os.Exit(child_exit_code) — EXIT-01: propagate verbatim +// - Go-side error: returns error (caller decides exit code) +// - Child not found: os.Exit(1) +func RunWithOutput(ctx context.Context, cmd *cobra.Command, binaryPath string, args []string, env []string, outputMode OutputMode) error { + result, err := Run(ctx, binaryPath, args, env, outputMode) + if err != nil { + return err + } + if result.ExitCode != 0 { + // Print stderr for debugging (child error output) + if len(result.Stderr) > 0 { + fmt.Fprintln(cmd.ErrOrStderr(), strings.TrimSpace(string(result.Stderr))) + } + // Exit with child's exit code (EXIT-01) + os.Exit(result.ExitCode) + } + // Write stdout to cmd output + if len(result.Stdout) > 0 { + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + } + return nil +} + +// ParseTypedMessage parses a JSON line from the Rust binary into a TypedMessage. +// Returns (TypedMessage, true) if the line is valid JSON with a "type" field. +// Returns (TypedMessage{}, false) for invalid JSON, missing "type", or empty lines. +// +// Parse/error policy: +// - Valid JSON with "type" field → parse and return (dispatch on type) +// - Valid JSON without "type" field → fatal error (Rust contract requires "type" on every message) +// - Invalid JSON → fatal error (should never occur from Rust) +// - Empty line → skip silently (trailing newline, whitespace) +// +// This function is safe to call on every line from child stdout. +func ParseTypedMessage(line []byte) (TypedMessage, bool) { + var msg map[string]interface{} + if err := json.Unmarshal(line, &msg); err != nil { + // Invalid JSON — caller treats as fatal error (should never occur from Rust) + return TypedMessage{}, false + } + + typeField, hasType := msg["type"] + if !hasType { + // Fatal: Rust contract requires "type" on every message + return TypedMessage{}, false + } + + typeStr, _ := typeField.(string) + var message string + if msgData, ok := msg["message"]; ok { + message, _ = msgData.(string) + } + return TypedMessage{ + Type: typeStr, + Message: message, + Fields: msg, + }, true +} diff --git a/connect-plugin/internal/exec/run_test.go b/connect-plugin/internal/exec/run_test.go new file mode 100644 index 0000000..2111395 --- /dev/null +++ b/connect-plugin/internal/exec/run_test.go @@ -0,0 +1,194 @@ +package exec + +import ( + "context" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func buildFakeBinary(t *testing.T, src string) string { + t.Helper() + _, thisFile, _, _ := runtime.Caller(0) + // connect-plugin module root is three levels up from internal/exec/run_test.go + moduleRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") + outDir := t.TempDir() + bin := filepath.Join(outDir, "fake-datum-connect-test") + cmd := exec.Command("go", "build", "-o", bin, "./"+src) + cmd.Dir = moduleRoot + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build %s: %v\n%s", src, err, out) + } + return bin +} + +func TestRunWithValidBinary(t *testing.T) { + // Test 1: Run() with valid binary, args, env — returns result with stdout, stderr, exit code 0 + fakeBin := buildFakeBinary(t, "testdata/fake-datum-connect") + env := []string{"DATUM_ACCESS_TOKEN=test-token"} + + result, err := Run(context.Background(), fakeBin, []string{"--json", "list"}, env, OutputModeJSON) + if err != nil { + t.Fatalf("Run() returned error: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + if len(result.Stdout) == 0 { + t.Error("expected stdout to be non-empty") + } + // Verify it's valid JSON from the fake binary + if !strings.Contains(string(result.Stdout), "tun-123") { + t.Errorf("expected stdout to contain 'tun-123', got: %s", string(result.Stdout)) + } +} + +func TestRunWithNonZeroExit(t *testing.T) { + // Test 2: Run() with binary that exits non-zero — returns captured output and exit code + fakeBin := buildFakeBinary(t, "testdata/fake-datum-connect") + env := []string{"FAKE_DUMMY_MODE=child-crash"} + + result, err := Run(context.Background(), fakeBin, []string{"--json", "list"}, env, OutputModeJSON) + if err != nil { + t.Fatalf("Run() returned error (expected nil for non-zero exit): %v", err) + } + if result.ExitCode == 0 { + t.Error("expected non-zero exit code from child crash") + } +} + +func TestRunWithNotFoundBinary(t *testing.T) { + // Test 3: Run() with binary not found — returns error (not wrapped in result) + _, err := Run(context.Background(), "/nonexistent/binary", []string{"list"}, nil, OutputModeJSON) + if err == nil { + t.Fatal("expected error for non-existent binary, got nil") + } + if !strings.Contains(err.Error(), "failed to start") { + t.Errorf("expected 'failed to start' in error, got: %v", err) + } +} + +func TestRunWithOutputModeYAML(t *testing.T) { + // Test 4: Run() with OutputModeYAML — stdout is YAML-converted from JSON + fakeBin := buildFakeBinary(t, "testdata/fake-datum-connect") + env := []string{"DATUM_ACCESS_TOKEN=test-token"} + + result, err := Run(context.Background(), fakeBin, []string{"--json", "list"}, env, OutputModeYAML) + if err != nil { + t.Fatalf("Run() returned error: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + // YAML output should not be raw JSON — it should contain YAML markers + yamlStr := string(result.Stdout) + if strings.Contains(yamlStr, "[{") { + t.Errorf("expected YAML output, got raw JSON: %s", yamlStr) + } + if !strings.Contains(yamlStr, "tun-123") { + t.Errorf("expected YAML output to contain 'tun-123', got: %s", yamlStr) + } +} + +func TestRunWithOutputModeJSON(t *testing.T) { + // Test 5: Run() with OutputModeJSON — stdout is passed through as-is + fakeBin := buildFakeBinary(t, "testdata/fake-datum-connect") + env := []string{"DATUM_ACCESS_TOKEN=test-token"} + + result, err := Run(context.Background(), fakeBin, []string{"--json", "list"}, env, OutputModeJSON) + if err != nil { + t.Fatalf("Run() returned error: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + // JSON output should be raw JSON + if !strings.Contains(string(result.Stdout), "tun-123") { + t.Errorf("expected JSON output to contain 'tun-123', got: %s", string(result.Stdout)) + } +} + +func TestRunWithOutputModeTable(t *testing.T) { + // Test 6: Run() with OutputModeTable — stdout is rendered as a table + fakeBin := buildFakeBinary(t, "testdata/fake-datum-connect") + env := []string{"DATUM_ACCESS_TOKEN=test-token"} + + result, err := Run(context.Background(), fakeBin, []string{"--json", "list"}, env, OutputModeTable) + if err != nil { + t.Fatalf("Run() returned error: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + // Table output should contain tab-separated values + tableStr := string(result.Stdout) + if !strings.Contains(tableStr, "dev-server") { + t.Errorf("expected table output to contain 'dev-server', got: %s", tableStr) + } + if !strings.Contains(tableStr, "localhost:8080") { + t.Errorf("expected table output to contain 'localhost:8080', got: %s", tableStr) + } +} + +func TestParseTypedMessage(t *testing.T) { + // Verify ParseTypedMessage handles typed messages correctly. + // Rust-side contract guarantees every message has a "type" field. + // Malformed JSON returns false; caller treats as fatal error. + tests := []struct { + name string + line []byte + expectType string + expectOk bool + }{ + { + name: "ready message", + line: []byte(`{"type":"ready","id":"tun-123","status":"ready"}`), + expectType: "ready", + expectOk: true, + }, + { + name: "error message", + line: []byte(`{"type":"error","message":"something failed"}`), + expectType: "error", + expectOk: true, + }, + { + name: "heartbeat message without message field", + line: []byte(`{"type":"heartbeat"}`), + expectType: "heartbeat", + expectOk: true, + }, + { + name: "malformed JSON", + line: []byte(`{invalid json}`), + expectType: "", + expectOk: false, + }, + { + name: "empty line", + line: []byte(``), + expectType: "", + expectOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, ok := ParseTypedMessage(tt.line) + if ok != tt.expectOk { + t.Errorf("ParseTypedMessage(%q) ok=%v, want %v", tt.line, ok, tt.expectOk) + } + if tt.expectOk && msg.Type != tt.expectType { + t.Errorf("ParseTypedMessage(%q) type=%q, want %q", tt.line, msg.Type, tt.expectType) + } + // Verify no panic on messages without "message" field + if tt.expectOk && tt.name == "heartbeat message without message field" { + if msg.Message != "" { + t.Errorf("expected empty message for heartbeat, got %q", msg.Message) + } + } + }) + } +} diff --git a/connect-plugin/internal/logfile/logfile.go b/connect-plugin/internal/logfile/logfile.go new file mode 100644 index 0000000..5b6d978 --- /dev/null +++ b/connect-plugin/internal/logfile/logfile.go @@ -0,0 +1,32 @@ +// Package logfile provides functions to resolve log file paths. +package logfile + +import ( + "os" + "os/user" + "path/filepath" + "runtime" +) + +// ResolveLogPath returns the log file path for a named tunnel. +// linux: $XDG_STATE_HOME/datumctl/connect/logs/.log +// darwin: ~/Library/Logs/datumctl/connect/.log +// windows: %LOCALAPPDATA%\datumctl\connect\logs\.log +func ResolveLogPath(name string) string { + switch runtime.GOOS { + case "windows": + return filepath.Join(os.Getenv("LOCALAPPDATA"), "datumctl", "connect", "logs", name+".log") + case "darwin": + u, err := user.Current() + if err != nil { + return filepath.Join(".", "logs", name+".log") + } + return filepath.Join(u.HomeDir, "Library", "Logs", "datumctl", "connect", name+".log") + default: + xdg := os.Getenv("XDG_STATE_HOME") + if xdg == "" { + xdg = filepath.Join(os.Getenv("HOME"), ".local", "state") + } + return filepath.Join(xdg, "datumctl", "connect", "logs", name+".log") + } +} diff --git a/connect-plugin/internal/output/convert.go b/connect-plugin/internal/output/convert.go new file mode 100644 index 0000000..e4f2e7f --- /dev/null +++ b/connect-plugin/internal/output/convert.go @@ -0,0 +1,78 @@ +// Package output provides functions to convert between JSON and YAML. +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + + "gopkg.in/yaml.v3" +) + +// ConvertJSONToYAML takes JSON bytes and returns YAML bytes. +func ConvertJSONToYAML(jsonData []byte) ([]byte, error) { + var data interface{} + if err := yaml.Unmarshal(jsonData, &data); err != nil { + return nil, err + } + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(data); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// ParseJSON takes JSON bytes and returns a map[string]interface{}. +func ParseJSON(data []byte) (map[string]interface{}, error) { + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +// RenderTable takes a JSON array of tunnel objects and renders them +// as a human-readable table to the given writer. +// Expected input: [{"id":"...","label":"...","endpoint":"...","status":"...","enabled":true,"hostnames":["..."]}] +func RenderTable(data []byte, w *tabwriter.Writer) error { + var tunnels []map[string]interface{} + if err := json.Unmarshal(data, &tunnels); err != nil { + return fmt.Errorf("failed to parse tunnel list: %w", err) + } + + // Header + fmt.Fprintln(w, "ID\tLABEL\tENDPOINT\tSTATUS\tENABLED\tHOSTNAMES") + fmt.Fprintln(w, "--\t-----\t--------\t------\t-------\t---------") + + for _, t := range tunnels { + id := fmt.Sprintf("%v", t["id"]) + label := fmt.Sprintf("%v", t["label"]) + endpoint := fmt.Sprintf("%v", t["endpoint"]) + status := fmt.Sprintf("%v", t["status"]) + enabled := "no" + if enabledVal, ok := t["enabled"].(bool); ok && enabledVal { + enabled = "yes" + } + hostnames := "\u2014" + if hnArr, ok := t["hostnames"].([]interface{}); ok && len(hnArr) > 0 { + hnStrs := make([]string, len(hnArr)) + for i, h := range hnArr { + hnStrs[i] = fmt.Sprintf("%v", h) + } + hostnames = strings.Join(hnStrs, ",") + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", id, label, endpoint, status, enabled, hostnames) + } + + return w.Flush() +} + +// RenderSingleJSON takes a single tunnel object and renders it as a +// human-readable table row (single-item table). +func RenderSingleJSON(data []byte, w *tabwriter.Writer) error { + return RenderTable(data, w) +} diff --git a/connect-plugin/internal/pidfile/pidfile.go b/connect-plugin/internal/pidfile/pidfile.go new file mode 100644 index 0000000..365510b --- /dev/null +++ b/connect-plugin/internal/pidfile/pidfile.go @@ -0,0 +1,99 @@ +// Package pidfile provides functions to manage PID files. +// +// PID file format (DAEMON-02): +// +// +// +// +// +package pidfile + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// PidFile represents the contents of a PID file. +// Format: go-pid, rust-pid, start-time-rfc3339, binary-path — one per line. +type PidFile struct { + GoPID int + RustPID int + StartTime time.Time + BinaryPath string +} + +// Write creates a PID file at path. Creates parent directories if needed. +func Write(path string, goPID, rustPID int, startTime time.Time, binaryPath string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create pid dir: %w", err) + } + content := fmt.Sprintf("%d\n%d\n%s\n%s\n", goPID, rustPID, startTime.Format(time.RFC3339), binaryPath) + return os.WriteFile(path, []byte(content), 0644) +} + +// Read parses a PID file and returns its contents. +// Returns an error if the file doesn't exist, is unreadable, or is malformed. +func Read(path string) (*PidFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read pid file: %w", err) + } + return Parse(data) +} + +// Parse parses PID file content without reading from disk. +func Parse(data []byte) (*PidFile, error) { + lines := splitLines(string(data)) + if len(lines) < 4 { + return nil, fmt.Errorf("malformed pid file: expected 4 lines, got %d", len(lines)) + } + + var p PidFile + if _, err := fmt.Sscanf(lines[0], "%d", &p.GoPID); err != nil { + return nil, fmt.Errorf("malformed go pid: %w", err) + } + if _, err := fmt.Sscanf(lines[1], "%d", &p.RustPID); err != nil { + return nil, fmt.Errorf("malformed rust pid: %w", err) + } + startTime, err := time.Parse(time.RFC3339, lines[2]) + if err != nil { + return nil, fmt.Errorf("malformed start time: %w", err) + } + p.StartTime = startTime + p.BinaryPath = lines[3] + return &p, nil +} + +// Exists checks if a PID file exists at path. +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Remove deletes the PID file at path. Returns nil if file doesn't exist. +func Remove(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func splitLines(s string) []string { + var lines []string + var current []byte + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, string(current)) + current = nil + } else { + current = append(current, s[i]) + } + } + if len(current) > 0 { + lines = append(lines, string(current)) + } + return lines +} diff --git a/connect-plugin/internal/pidfile/pidfile_test.go b/connect-plugin/internal/pidfile/pidfile_test.go new file mode 100644 index 0000000..1dbf4ed --- /dev/null +++ b/connect-plugin/internal/pidfile/pidfile_test.go @@ -0,0 +1,86 @@ +package pidfile + +import ( + "path/filepath" + "testing" + "time" +) + +func TestWriteAndRead(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.pid") + start := time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) + + err := Write(path, 1001, 1002, start, "/usr/bin/datum-connect") + if err != nil { + t.Fatalf("Write() failed: %v", err) + } + + p, err := Read(path) + if err != nil { + t.Fatalf("Read() failed: %v", err) + } + + if p.GoPID != 1001 { + t.Errorf("GoPID = %d, want 1001", p.GoPID) + } + if p.RustPID != 1002 { + t.Errorf("RustPID = %d, want 1002", p.RustPID) + } + if !p.StartTime.Equal(start) { + t.Errorf("StartTime = %v, want %v", p.StartTime, start) + } + if p.BinaryPath != "/usr/bin/datum-connect" { + t.Errorf("BinaryPath = %q, want %q", p.BinaryPath, "/usr/bin/datum-connect") + } +} + +func TestReadMissingFile(t *testing.T) { + _, err := Read("/nonexistent/pid") + if err == nil { + t.Fatal("Read() should fail for missing file") + } +} + +func TestExists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "exists.pid") + + if Exists(path) { + t.Error("Exists() should be false before file is created") + } + + Write(path, 1, 2, time.Now(), "/bin/fake") + if !Exists(path) { + t.Error("Exists() should be true after file is created") + } +} + +func TestParse(t *testing.T) { + data := []byte("1001\n1002\n2026-06-06T12:00:00Z\n/usr/bin/datum-connect\n") + p, err := Parse(data) + if err != nil { + t.Fatalf("Parse() failed: %v", err) + } + if p.GoPID != 1001 || p.RustPID != 1002 { + t.Errorf("unexpected pids: go=%d rust=%d", p.GoPID, p.RustPID) + } +} + +func TestRemove(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "remove.pid") + Write(path, 1, 2, time.Now(), "/bin/fake") + + if err := Remove(path); err != nil { + t.Fatalf("Remove() failed: %v", err) + } + if Exists(path) { + t.Error("file should not exist after Remove") + } + + // Remove again should not error + if err := Remove(path); err != nil { + t.Errorf("Remove() on missing file should not error: %v", err) + } +} diff --git a/connect-plugin/internal/pidfile/process.go b/connect-plugin/internal/pidfile/process.go new file mode 100644 index 0000000..b040a9a --- /dev/null +++ b/connect-plugin/internal/pidfile/process.go @@ -0,0 +1,111 @@ +package pidfile + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +// PIDAlive checks whether a process with the given PID is currently running. +// Uses OS-level checks: +// - Unix: signals PID 0 (doesn't actually send a signal, just checks existence) +// - Windows: uses tasklist /FI +// Returns false for invalid PIDs, errors, and non-existent processes. +func PIDAlive(pid int) bool { + if pid <= 0 { + return false + } + + switch runtime.GOOS { + case "windows": + return pidAliveWindows(pid) + default: + return pidAliveUnix(pid) + } +} + +func pidAliveUnix(pid int) bool { + // Signal 0 checks existence without sending a signal + return syscall.Kill(pid, 0) == nil +} + +func pidAliveWindows(pid int) bool { + out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/NH").Output() + if err != nil { + return false + } + return strings.Contains(string(out), strconv.Itoa(pid)) +} + +// RunningTunnel holds info about a discovered running tunnel process. +type RunningTunnel struct { + Name string + GoPID int + RustPID int + StartTime time.Time + BinaryPath string + Status string // "Running", "Starting", "Degraded", "Zombie" +} + +// ListRunningTunnels scans the tunnels directory and returns all tunnels +// with their current status based on PID file and process health. +func ListRunningTunnels(stateDir string) ([]RunningTunnel, error) { + tunnelsDir := filepath.Join(stateDir, "tunnels") + entries, err := os.ReadDir(tunnelsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var tunnels []RunningTunnel + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".pid" { + continue + } + name := strings.TrimSuffix(entry.Name(), ".pid") + path := filepath.Join(tunnelsDir, entry.Name()) + + pf, err := Read(path) + if err != nil { + continue + } + + t := RunningTunnel{ + Name: name, + GoPID: pf.GoPID, + RustPID: pf.RustPID, + StartTime: pf.StartTime, + BinaryPath: pf.BinaryPath, + Status: computeTunnelStatus(pf), + } + tunnels = append(tunnels, t) + } + return tunnels, nil +} + +// computeTunnelStatus determines the tunnel status from a PidFile. +func computeTunnelStatus(pf *PidFile) string { + goAlive := PIDAlive(pf.GoPID) + rustAlive := PIDAlive(pf.RustPID) + + switch { + case !goAlive && !rustAlive: + return "Zombie" + case goAlive && rustAlive: + return "Running" + case goAlive && !rustAlive: + return "Degraded" + case !goAlive && rustAlive: + return "Zombie" + default: + return "Unknown" + } +} diff --git a/connect-plugin/internal/pidfile/process_test.go b/connect-plugin/internal/pidfile/process_test.go new file mode 100644 index 0000000..a4efe1a --- /dev/null +++ b/connect-plugin/internal/pidfile/process_test.go @@ -0,0 +1,63 @@ +package pidfile + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestPIDAlive_CurrentProcess(t *testing.T) { + if !PIDAlive(os.Getpid()) { + t.Error("PIDAlive should return true for current process") + } +} + +func TestPIDAlive_Zero(t *testing.T) { + if PIDAlive(0) { + t.Error("PIDAlive(0) should return false") + } +} + +func TestPIDAlive_Negative(t *testing.T) { + if PIDAlive(-1) { + t.Error("PIDAlive(-1) should return false") + } +} + +func TestListRunningTunnels_EmptyDir(t *testing.T) { + dir := t.TempDir() + tunnels, err := ListRunningTunnels(dir) + if err != nil { + t.Fatalf("ListRunningTunnels() failed: %v", err) + } + if len(tunnels) != 0 { + t.Errorf("expected 0 tunnels, got %d", len(tunnels)) + } +} + +func TestListRunningTunnels_WithFiles(t *testing.T) { + dir := t.TempDir() + tunnelsDir := filepath.Join(dir, "tunnels") + os.MkdirAll(tunnelsDir, 0755) + + // Create a PID file for current process (should show as Running) + path := filepath.Join(tunnelsDir, "mytun.pid") + if err := Write(path, os.Getpid(), os.Getpid(), time.Now(), "/bin/fake"); err != nil { + t.Fatalf("Write() failed: %v", err) + } + + tunnels, err := ListRunningTunnels(dir) + if err != nil { + t.Fatalf("ListRunningTunnels() failed: %v", err) + } + if len(tunnels) != 1 { + t.Fatalf("expected 1 tunnel, got %d", len(tunnels)) + } + if tunnels[0].Name != "mytun" { + t.Errorf("expected name 'mytun', got %q", tunnels[0].Name) + } + if tunnels[0].Status != "Running" { + t.Errorf("expected status 'Running', got %q", tunnels[0].Status) + } +} diff --git a/connect-plugin/internal/rbaccheck/check.go b/connect-plugin/internal/rbaccheck/check.go new file mode 100644 index 0000000..f9c4285 --- /dev/null +++ b/connect-plugin/internal/rbaccheck/check.go @@ -0,0 +1,115 @@ +// Package rbaccheck provides RBAC permission checks via SelfSubjectAccessReview. +// +// Used at install time (Phase 13 D-05) to validate the service-account session +// has the necessary Kubernetes permissions before writing config and unit files. +package rbaccheck + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "strings" +) + +// SSARCheck represents a single SelfSubjectAccessReview check item. +type SSARCheck struct { + Resource string // K8s resource name (e.g., "httpproxies") + Verb string // K8s verb (e.g., "create", "update") + Group string // API group (e.g., "datum-cloud.github.io") +} + +// DefaultChecks returns the SSAR checks required for tunnel installation (D-05). +func DefaultChecks() []SSARCheck { + return []SSARCheck{ + {Resource: "httpproxies", Verb: "create", Group: "datum-cloud.github.io"}, + {Resource: "httpproxies", Verb: "update", Group: "datum-cloud.github.io"}, + {Resource: "connectors", Verb: "create", Group: "datum-cloud.github.io"}, + } +} + +// CheckAll runs all specified SSAR checks using the given K8s API server and token. +// Returns nil if all checks pass, or an error describing the first failure. +func CheckAll(apiServer, token string, checks []SSARCheck) error { + if apiServer == "" { + return fmt.Errorf("K8s API server URL is required (set DATUM_K8S_API)") + } + if token == "" { + return fmt.Errorf("bearer token is required") + } + + for _, c := range checks { + allowed, err := checkAccess(apiServer, token, c) + if err != nil { + return fmt.Errorf("SSAR check failed for %s %s: %w", c.Verb, c.Resource, err) + } + if !allowed { + return fmt.Errorf("service-account lacks permission to '%s' %s in group %s. Verify RBAC bindings and try again", + c.Verb, c.Resource, c.Group) + } + } + return nil +} + +// checkAccess performs a single SelfSubjectAccessReview request. +func checkAccess(apiServer, token string, check SSARCheck) (bool, error) { + ssar := map[string]interface{}{ + "apiVersion": "authorization.k8s.io/v1", + "kind": "SelfSubjectAccessReview", + "spec": map[string]interface{}{ + "resourceAttributes": map[string]interface{}{ + "verb": check.Verb, + "resource": check.Resource, + "group": check.Group, + }, + }, + } + + body, err := json.Marshal(ssar) + if err != nil { + return false, fmt.Errorf("marshal SSAR: %w", err) + } + + url := apiServer + "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return false, fmt.Errorf("create SSAR request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("SSAR request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("SSAR API returned status %d", resp.StatusCode) + } + + var result struct { + Status struct { + Allowed bool `json:"allowed"` + } `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, fmt.Errorf("parse SSAR response: %w", err) + } + + return result.Status.Allowed, nil +} + +// GetToken execs the credentials helper to obtain a bearer token for SSAR queries. +func GetToken(helper, session string) (string, error) { + out, err := exec.Command(helper, "auth", "get-token", "--session", session).Output() + if err != nil { + return "", fmt.Errorf("credentials helper exec: %w", err) + } + token := strings.TrimSpace(string(out)) + if token == "" { + return "", fmt.Errorf("empty token from credentials helper") + } + return token, nil +} diff --git a/connect-plugin/internal/signals/forward.go b/connect-plugin/internal/signals/forward.go new file mode 100644 index 0000000..1a66aac --- /dev/null +++ b/connect-plugin/internal/signals/forward.go @@ -0,0 +1,51 @@ +// Package signals provides signal forwarding from parent to child process. +package signals + +import ( + "os" + "os/signal" + "syscall" + "time" +) + +// Forward sets up signal forwarding from parent to child process. +// On SIGINT/SIGTERM (unix) or Ctrl+C/Ctrl+Break (windows), forwards +// the signal to child and waits up to gracePeriod for clean shutdown. +// +// childExited must be closed by the caller after cmd.Wait() returns — this +// avoids a double-Wait race where both Forward and the caller try to reap the +// same process, which can cause the caller's ProcessState to be nil. +// +// Platform behavior: +// - Unix: receives SIGINT/SIGTERM, forwards to child, waits gracePeriod, +// then sends SIGKILL if child hasn't exited +// - Windows: Go's signal.Notify with SIGINT handles Ctrl+C automatically. +// Force-kill uses os.Process.Kill(). +// +// Returns nil on success. The child's exit code is available via +// cmd.ProcessState.ExitCode() after Wait(). +func Forward(child *os.Process, childExited <-chan struct{}, gracePeriod time.Duration) error { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(ch) + + select { + case sig := <-ch: + // Received signal — forward to child + _ = child.Signal(sig) + + // Wait for child to exit within grace period + select { + case <-childExited: + return nil + case <-time.After(gracePeriod): + // Grace period expired — force kill + _ = child.Signal(syscall.SIGKILL) + <-childExited + return nil + } + case <-childExited: + // Child exited before receiving signal — nothing to forward + return nil + } +} diff --git a/connect-plugin/internal/state/state.go b/connect-plugin/internal/state/state.go new file mode 100644 index 0000000..346a0d4 --- /dev/null +++ b/connect-plugin/internal/state/state.go @@ -0,0 +1,70 @@ +// Package state provides cross-platform plugin state directory resolution. +// +// State directory contains tunnel PID files, logs, and other runtime data. +// Paths follow platform conventions: +// +// linux: $XDG_STATE_HOME/datumctl/connect (default ~/.local/share/datumctl/connect) +// darwin: ~/Library/Application Support/datumctl/connect +// windows: %LOCALAPPDATA%/datumctl/connect +package state + +import ( + "os" + "os/user" + "path/filepath" + "runtime" +) + +// Dir returns the plugin state base directory. +// DATUM_STATE_DIR overrides the platform default; used in tests. +func Dir() string { + if override := os.Getenv("DATUM_STATE_DIR"); override != "" { + return override + } + switch runtime.GOOS { + case "windows": + return filepath.Join(os.Getenv("LOCALAPPDATA"), "datumctl", "connect") + case "darwin": + u, err := user.Current() + if err != nil { + return filepath.Join(".", "datumctl", "connect") + } + return filepath.Join(u.HomeDir, "Library", "Application Support", "datumctl", "connect") + default: + xdg := os.Getenv("XDG_STATE_HOME") + if xdg == "" { + xdg = filepath.Join(os.Getenv("HOME"), ".local", "state") + } + return filepath.Join(xdg, "datumctl", "connect") + } +} + +// TunnelDir returns the tunnels subdirectory. +func TunnelDir() string { + return filepath.Join(Dir(), "tunnels") +} + +// PidFilePath returns the PID file path for a named tunnel. +func PidFilePath(name string) string { + return filepath.Join(TunnelDir(), name+".pid") +} + +// LogDir returns the log directory. +// On macOS uses ~/Library/Logs/ (conventional), others use /logs. +func LogDir() string { + switch runtime.GOOS { + case "darwin": + u, err := user.Current() + if err != nil { + return filepath.Join(Dir(), "logs") + } + return filepath.Join(u.HomeDir, "Library", "Logs", "datumctl", "connect") + default: + return filepath.Join(Dir(), "logs") + } +} + +// LogFilePath returns the log file path for a named tunnel. +func LogFilePath(name string) string { + return filepath.Join(LogDir(), name+".log") +} diff --git a/connect-plugin/internal/state/state_test.go b/connect-plugin/internal/state/state_test.go new file mode 100644 index 0000000..01c605c --- /dev/null +++ b/connect-plugin/internal/state/state_test.go @@ -0,0 +1,52 @@ +package state + +import ( + "runtime" + "strings" + "testing" +) + +func TestDir_NotEmpty(t *testing.T) { + d := Dir() + if d == "" { + t.Fatal("Dir() returned empty string") + } + if !strings.Contains(d, "datumctl") { + t.Errorf("Dir() should contain 'datumctl', got %q", d) + } +} + +func TestDir_IncludesConnect(t *testing.T) { + d := Dir() + if !strings.HasSuffix(d, "connect") && !strings.HasSuffix(d, "connect/") { + t.Errorf("Dir() should end with 'connect', got %q", d) + } +} + +func TestTunnelDir(t *testing.T) { + td := TunnelDir() + if !strings.HasSuffix(td, "tunnels") && !strings.HasSuffix(td, "tunnels/") { + t.Errorf("TunnelDir() should end with 'tunnels', got %q", td) + } +} + +func TestPidFilePath(t *testing.T) { + p := PidFilePath("mytun") + if !strings.HasSuffix(p, "mytun.pid") { + t.Errorf("PidFilePath('mytun') should end with 'mytun.pid', got %q", p) + } +} + +func TestLogDir(t *testing.T) { + ld := LogDir() + if runtime.GOOS == "darwin" && !strings.Contains(ld, "Library/Logs") && !strings.Contains(ld, "Library\\Logs") { + t.Errorf("LogDir() on darwin should contain Library/Logs, got %q", ld) + } +} + +func TestLogFilePath(t *testing.T) { + p := LogFilePath("mytun") + if !strings.HasSuffix(p, "mytun.log") { + t.Errorf("LogFilePath('mytun') should end with 'mytun.log', got %q", p) + } +} diff --git a/connect-plugin/internal/svcconfig/config.go b/connect-plugin/internal/svcconfig/config.go new file mode 100644 index 0000000..9cc953f --- /dev/null +++ b/connect-plugin/internal/svcconfig/config.go @@ -0,0 +1,105 @@ +// Package svcconfig provides tunnel configuration serialization. +// +// Config files are stored in the platform-appropriate config directory: +// +// linux: $XDG_CONFIG_HOME/datumctl/connect/config/ (default ~/.config/...) +// darwin: ~/Library/Application Support/datumctl/connect/config/ +// windows: %AppData%/datumctl/connect/config/ +// +// Schema (9 fields, Phase 13 D-04): +// - name, label, endpoint, project, session — required on install +// - org, api_host, created_at — optional metadata +// - credentials_helper_path — captured at install time, used at unit run time +package svcconfig + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// TunnelConfig represents a persisted tunnel configuration. +type TunnelConfig struct { + Name string `yaml:"name"` + Label string `yaml:"label"` + Endpoint string `yaml:"endpoint"` + Project string `yaml:"project"` + Session string `yaml:"session"` + Org string `yaml:"org,omitempty"` + APIHost string `yaml:"api_host,omitempty"` + CreatedAt string `yaml:"created_at,omitempty"` + CredentialsHelperPath string `yaml:"credentials_helper_path,omitempty"` +} + +// ConfigDir returns the plugin config directory path. +// Uses os.UserConfigDir() which follows XDG on Linux, platform conventions +// on macOS and Windows. +// Exposed as a variable for testability (tests can override it). +var ConfigDir = func() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = "." + } + return filepath.Join(dir, "datumctl", "connect", "config") +} + +// ConfigFilePath returns the config file path for a named tunnel. +func ConfigFilePath(name string) string { + return filepath.Join(ConfigDir(), name+".yaml") +} + +// Save writes a TunnelConfig to the given path as YAML. +// Automatically sets CreatedAt if empty. +func Save(cfg TunnelConfig, path string) error { + if cfg.CreatedAt == "" { + cfg.CreatedAt = time.Now().Format(time.RFC3339) + } + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + dir := path[:strings.LastIndexByte(path, '/')] + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + return os.WriteFile(path, data, 0644) +} + +// Load reads a TunnelConfig from the given path. +func Load(path string) (TunnelConfig, error) { + var cfg TunnelConfig + data, err := os.ReadFile(path) + if err != nil { + return cfg, fmt.Errorf("read config: %w", err) + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("parse config: %w", err) + } + return cfg, nil +} + +// Exists checks whether a config file exists for the given tunnel name. +func Exists(name string) (bool, error) { + path := ConfigFilePath(name) + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// Remove deletes the config file for the given tunnel name. +func Remove(name string) error { + path := ConfigFilePath(name) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/connect-plugin/internal/svcconfig/config_test.go b/connect-plugin/internal/svcconfig/config_test.go new file mode 100644 index 0000000..f66d44f --- /dev/null +++ b/connect-plugin/internal/svcconfig/config_test.go @@ -0,0 +1,103 @@ +package svcconfig + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestConfigDir_NotEmpty(t *testing.T) { + d := ConfigDir() + if d == "" { + t.Fatal("ConfigDir() returned empty string") + } + if !strings.Contains(d, "datumctl") { + t.Errorf("ConfigDir() should contain 'datumctl', got %q", d) + } +} + +func TestConfigFilePath(t *testing.T) { + p := ConfigFilePath("test-tun") + if !strings.HasSuffix(p, "test-tun.yaml") { + t.Errorf("ConfigFilePath should end with 'test-tun.yaml', got %q", p) + } +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + + cfg := TunnelConfig{ + Name: "test-tun", + Label: "test", + Endpoint: "localhost:8080", + Session: "my-session", + Project: "my-project", + Org: "my-org", + APIHost: "https://api.datum.net", + } + + if err := Save(cfg, path); err != nil { + t.Fatalf("Save() failed: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if loaded.Name != cfg.Name { + t.Errorf("Name = %q, want %q", loaded.Name, cfg.Name) + } + if loaded.Endpoint != cfg.Endpoint { + t.Errorf("Endpoint = %q, want %q", loaded.Endpoint, cfg.Endpoint) + } + if loaded.Session != cfg.Session { + t.Errorf("Session = %q, want %q", loaded.Session, cfg.Session) + } +} + +func TestExists(t *testing.T) { + dir := t.TempDir() + // Override ConfigDir for testing + orig := ConfigDir + ConfigDir = func() string { return filepath.Join(dir, "config") } + defer func() { ConfigDir = orig }() + + exists, err := Exists("noexist") + if err != nil { + t.Fatalf("Exists() failed: %v", err) + } + if exists { + t.Error("Exists() should be false for non-existent config") + } + + cfg := TunnelConfig{Name: "mytun", Endpoint: "localhost:8080", Session: "sess"} + if err := Save(cfg, ConfigFilePath("mytun")); err != nil { + t.Fatalf("Save() failed: %v", err) + } + + exists, err = Exists("mytun") + if err != nil { + t.Fatalf("Exists() failed: %v", err) + } + if !exists { + t.Error("Exists() should be true after Save") + } +} + +func TestRemove(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "remove.yaml") + cfg := TunnelConfig{Name: "remove-tun", Endpoint: "localhost:8080", Session: "sess"} + Save(cfg, path) + + if err := Remove("remove-tun"); err != nil { + t.Fatalf("Remove() failed: %v", err) + } + + exists, _ := Exists("remove-tun") + if exists { + t.Error("config should not exist after Remove") + } +} diff --git a/connect-plugin/internal/svcunit/unit.go b/connect-plugin/internal/svcunit/unit.go new file mode 100644 index 0000000..3b8568d --- /dev/null +++ b/connect-plugin/internal/svcunit/unit.go @@ -0,0 +1,154 @@ +// Package svcunit provides service unit management via kardianos/service. +// +// Manages user-scoped systemd units for Datum Connect tunnels. +// All operations use kardianos/service which delegates to systemctl --user. +package svcunit + +import ( + "fmt" + "os/exec" + + "github.com/kardianos/service" + + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" +) + +// ServiceName returns the kardianos/service name for a tunnel. +func ServiceName(tunnelName string) string { + return "datumctl-connect-" + tunnelName +} + +// ServiceArgs builds the CLI arguments for the tunnel run command. +// After Phase 13 (resolution table Item #7), tunnel run accepts only +// --name. All runtime config (project, session, endpoint, label, +// credentials_helper_path) comes from the YAML and server, not CLI flags. +func ServiceArgs(cfg svcconfig.TunnelConfig) []string { + return []string{"tunnel", "run", "--name", cfg.Name} +} + +// Install registers a user-scoped systemd unit via kardianos/service. +// Does NOT start the service. +func Install(cfg svcconfig.TunnelConfig, binaryPath string) error { + svc, err := newService(cfg, binaryPath) + if err != nil { + return fmt.Errorf("create service: %w", err) + } + if err := svc.Install(); err != nil { + return fmt.Errorf("install service: %w", err) + } + return nil +} + +// Uninstall removes the systemd unit and any running instance. +func Uninstall(tunnelName string, binaryPath string) error { + svc, err := newService(svcconfig.TunnelConfig{Name: tunnelName}, binaryPath) + if err != nil { + return fmt.Errorf("create service: %w", err) + } + // Stop first, then uninstall + _ = svc.Stop() + if err := svc.Uninstall(); err != nil { + return fmt.Errorf("uninstall service: %w", err) + } + return nil +} + +// Start starts the installed service via systemctl --user. +func Start(tunnelName string, binaryPath string) error { + svc, err := newService(svcconfig.TunnelConfig{Name: tunnelName}, binaryPath) + if err != nil { + return fmt.Errorf("create service: %w", err) + } + return svc.Start() +} + +// Stop stops the installed service via systemctl --user. +func Stop(tunnelName string, binaryPath string) error { + svc, err := newService(svcconfig.TunnelConfig{Name: tunnelName}, binaryPath) + if err != nil { + return fmt.Errorf("create service: %w", err) + } + return svc.Stop() +} + +// Status returns the service status. +func Status(tunnelName string, binaryPath string) (string, error) { + svc, err := newService(svcconfig.TunnelConfig{Name: tunnelName}, binaryPath) + if err != nil { + return "", fmt.Errorf("create service: %w", err) + } + st, err := svc.Status() + if err != nil { + return "", fmt.Errorf("get status: %w", err) + } + return statusString(st), nil +} + +// buildConfig assembles the kardianos/service.Config for a tunnel. +// The unit inherits DATUM_CONNECT_DIR from the plugin's pass-through +// environment (Phase 11.5); no per-service override is applied. +// Separated from newService so tests can inspect the Config without +// going through service.New. +func buildConfig(cfg svcconfig.TunnelConfig, binaryPath string) (*service.Config, error) { + // Build EnvVars for the unit file. + envVars := make(map[string]string) + if cfg.CredentialsHelperPath != "" { + envVars["DATUM_CREDENTIALS_HELPER"] = cfg.CredentialsHelperPath + } + // DATUM_CONNECT_DIR is NOT set here — it arrives via the plugin's + // os.Environ() pass-through (Phase 11.5). Per-service isolation was + // removed in Phase 13 (D-01). + // DATUM_SESSION is NOT set here — it arrives via the plugin's + // os.Environ() or is read by the Rust binary from the YAML config. + + return &service.Config{ + Name: ServiceName(cfg.Name), + DisplayName: fmt.Sprintf("Datum Connect Tunnel: %s", cfg.Name), + Description: fmt.Sprintf("Datum Connect tunnel %s", cfg.Name), + Executable: binaryPath, + Arguments: ServiceArgs(cfg), + Dependencies: []string{ + "After=network-online.target", + "Wants=network-online.target", + }, + Option: service.KeyValue{ + "UserService": true, + "Restart": "on-failure", + "RestartSec": "5", + }, + EnvVars: envVars, + }, nil +} + +// newService creates a kardianos/service instance for a tunnel. +func newService(cfg svcconfig.TunnelConfig, binaryPath string) (service.Service, error) { + svcConfig, err := buildConfig(cfg, binaryPath) + if err != nil { + return nil, err + } + svc, err := service.New(nil, svcConfig) + if err != nil { + return nil, fmt.Errorf("new service: %w", err) + } + return svc, nil +} + +func statusString(s service.Status) string { + switch s { + case service.StatusRunning: + return "Running" + case service.StatusStopped: + return "Stopped" + default: + return "Unknown" + } +} + +// binaryPath resolves the path to the current plugin binary for service use. +func binaryPath() string { + path, err := exec.LookPath("datumctl-connect") + if err == nil { + return path + } + return "datumctl-connect" +} diff --git a/connect-plugin/internal/svcunit/unit_test.go b/connect-plugin/internal/svcunit/unit_test.go new file mode 100644 index 0000000..b29315e --- /dev/null +++ b/connect-plugin/internal/svcunit/unit_test.go @@ -0,0 +1,103 @@ +package svcunit + +import ( + "strings" + "testing" + + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" +) + +func TestServiceName(t *testing.T) { + name := ServiceName("my-tunnel") + expected := "datumctl-connect-my-tunnel" + if name != expected { + t.Errorf("ServiceName = %q, want %q", name, expected) + } +} + +func TestServiceArgs(t *testing.T) { + // Phase 13: tunnel run accepts only --name; endpoint/session/credentials + // come from the YAML config, not CLI flags. + cfg := svcconfig.TunnelConfig{ + Name: "test-tun", + Label: "test", + Endpoint: "localhost:8080", + Session: "my-session", + } + args := ServiceArgs(cfg) + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--name test-tun") { + t.Errorf("args should contain --name, got: %s", joined) + } + if strings.Contains(joined, "--endpoint") { + t.Errorf("args should not contain --endpoint (comes from YAML), got: %s", joined) + } + if strings.Contains(joined, "--session") { + t.Errorf("args should not contain --session (comes from YAML), got: %s", joined) + } + if strings.Contains(joined, "--yes") { + t.Errorf("args should not contain --yes (removed in Phase 13), got: %s", joined) + } +} + +func TestServiceArgs_NoLabel(t *testing.T) { + cfg := svcconfig.TunnelConfig{ + Name: "minimal", + Endpoint: "localhost:8080", + Session: "sess", + } + args := ServiceArgs(cfg) + joined := strings.Join(args, " ") + if strings.Contains(joined, "--label") { + t.Errorf("args should not contain --label for empty label, got: %s", joined) + } +} + +func TestBuildConfig_NoDatumConnectDirEnvVar(t *testing.T) { + // Phase 13: DATUM_CONNECT_DIR is NOT injected by buildConfig — it arrives + // via the plugin's os.Environ() pass-through. Per-service isolation removed. + cfg := svcconfig.TunnelConfig{ + Name: "my-tunnel", + Endpoint: "localhost:8080", + } + sc, err := buildConfig(cfg, "/usr/local/bin/datumctl-connect") + if err != nil { + t.Fatalf("buildConfig() error = %v", err) + } + if _, ok := sc.EnvVars["DATUM_CONNECT_DIR"]; ok { + t.Errorf("EnvVars should not contain DATUM_CONNECT_DIR (set by environment, not unit file)") + } +} + +func TestBuildConfig_EmptyEnvVarsWithNoHelper(t *testing.T) { + // Without a credentials helper path, EnvVars should be empty. + sc, err := buildConfig(svcconfig.TunnelConfig{Name: "x"}, "bin") + if err != nil { + t.Fatalf("buildConfig() error = %v", err) + } + if len(sc.EnvVars) != 0 { + t.Errorf("EnvVars should be empty when no credentials helper; got %d: %v", + len(sc.EnvVars), sc.EnvVars) + } +} + +func TestBuildConfig_SetsCredentialsHelper(t *testing.T) { + cfg := svcconfig.TunnelConfig{ + Name: "x", + CredentialsHelperPath: "/usr/local/bin/my-helper", + } + sc, err := buildConfig(cfg, "bin") + if err != nil { + t.Fatalf("buildConfig() error = %v", err) + } + got, ok := sc.EnvVars["DATUM_CREDENTIALS_HELPER"] + if !ok { + t.Fatalf("EnvVars missing DATUM_CREDENTIALS_HELPER; got %v", sc.EnvVars) + } + if got != "/usr/local/bin/my-helper" { + t.Errorf("DATUM_CREDENTIALS_HELPER = %q, want %q", got, "/usr/local/bin/my-helper") + } + if len(sc.EnvVars) != 1 { + t.Errorf("EnvVars should have exactly 1 entry; got %d: %v", len(sc.EnvVars), sc.EnvVars) + } +} diff --git a/connect-plugin/main.go b/connect-plugin/main.go new file mode 100644 index 0000000..1d440d4 --- /dev/null +++ b/connect-plugin/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + + "go.datum.net/datumctl-plugins/connect/internal/env" + "go.datum.net/datumctl-plugins/connect/tunnel" + "go.datum.net/datumctl/plugin" +) + +// Overridden at release time via -ldflags "-X main.version=vX.Y.Z". +// See .goreleaser.yaml. +var version = "v0.1.0" + +func main() { + // Serve manifest before cobra parses anything + m := plugin.Manifest{ + Name: "connect", + Version: version, + Description: "Manage Datum Connect tunnels", + APIVersion: 1, + } + plugin.ServeManifest(m) + + // Phase 11.5 D-09/D-10/D-11: refuse to run any tunnel subcommand + // when DATUM_CONNECT_DIR is unset. ServeManifest above already + // self-exits for the --plugin-manifest probe, so by this point + // we are committed to running a real subcommand. + if err := env.RequireConnectDir(); err != nil { + env.FailConnectDirUnset(os.Stderr, err) + os.Exit(64) + } + + // Create root command with pre-wired flags + cmd := plugin.NewRootCmd("connect", "Manage Datum Connect tunnels") + cmd.AddCommand(tunnel.NewCmd()) + + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/connect-plugin/scripts/build.sh b/connect-plugin/scripts/build.sh new file mode 100755 index 0000000..8d2a61d --- /dev/null +++ b/connect-plugin/scripts/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Build script for the datum-connect plugin +# Usage: ./scripts/build.sh [--test] +# --test Run E2E tests after build (default: skip) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONNECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$CONNECT_DIR" + +echo "Building datum-connect plugin..." + +if go build -o datumctl-connect . ; then + echo "Build successful: $CONNECT_DIR/datumctl-connect" +else + echo "Build failed" >&2 + exit 1 +fi + +# Run E2E tests if --test flag is provided +if [[ "${1:-}" == "--test" ]]; then + echo "" + echo "Running E2E tests..." + if go test -count=1 ./e2e_test.go; then + echo "E2E tests passed" + else + echo "E2E tests failed" >&2 + exit 1 + fi +fi diff --git a/connect-plugin/scripts/release.sh b/connect-plugin/scripts/release.sh new file mode 100755 index 0000000..6e3b45f --- /dev/null +++ b/connect-plugin/scripts/release.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +# Release packaging script for the datum-connect plugin +# Implemented in Phase 7 + +# TODO: Phase 7 — implement release packaging +# +# Expected cross-platform build matrix: +# - linux/amd64 +# - linux/arm64 +# - darwin/amd64 +# - darwin/arm64 +# - windows/amd64 +# - windows/arm64 +# +# Expected output format: +# - tar.gz for linux/darwin +# - zip for windows +# +# The release should: +# 1. Build the plugin for each platform/architecture +# 2. Package with appropriate compression +# 3. Generate SHA256 checksums +# 4. Exclude testdata/ from release archives (T-02-01) + +echo "Release packaging — implemented in Phase 7" +exit 0 diff --git a/connect-plugin/testdata/fake-credentials-helper/main.go b/connect-plugin/testdata/fake-credentials-helper/main.go new file mode 100644 index 0000000..81f17a6 --- /dev/null +++ b/connect-plugin/testdata/fake-credentials-helper/main.go @@ -0,0 +1,76 @@ +// fake-credentials-helper emulates datumctl auth get-token for testing. +// +// Modes (controlled by FAKE_HELPER_MODE env var): +// (default): prints a static JWT with exp=1h in future +// expired-token: prints JWT with exp in the past +// refuses-token: exits 1 with error message +// slow-to-respond: sleeps 5s then prints JWT +// session-dependent: only succeeds if --session matches "test-session" +// +// Flags: --session +package main + +import ( + "fmt" + "os" + "strings" + "time" +) + +func main() { + args := os.Args[1:] + + mode := os.Getenv("FAKE_HELPER_MODE") + + // Parse --session flag + session := "" + for i, arg := range args { + if arg == "--session" && i+1 < len(args) { + session = args[i+1] + break + } + if strings.HasPrefix(arg, "--session=") { + session = arg[len("--session="):] + break + } + } + + // Handle refuses-token mode + if mode == "refuses-token" { + fmt.Fprintln(os.Stderr, "error: token retrieval refused") + os.Exit(1) + } + + // Handle slow-to-respond mode + if mode == "slow-to-respond" { + time.Sleep(5 * time.Second) + } + + // Handle session-dependent mode + if mode == "session-dependent" && session != "test-session" { + fmt.Fprintf(os.Stderr, "error: session %q not found\n", session) + os.Exit(1) + } + + // Default: print a static JWT + if mode == "expired-token" { + // Expired token (exp in the past) + fmt.Println("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJleHAiOjEwMDAwMDAwMDAsImlhdCI6MTAwMDAwMDAwMH0.expired-signature") + } else { + // Valid token (exp 1 hour from now) + exp := time.Now().Add(1 * time.Hour).Unix() + payload := fmt.Sprintf(`{"sub":"test-user","exp":%d,"iat":%d}`, exp, time.Now().Unix()) + // Simple base64-like encoding for demo (not real JWT) + fmt.Println("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + encodeToBase64(payload) + ".fake-signature") + } +} + +func encodeToBase64(s string) string { + // Simplified base64-like encoding for demo purposes + // In a real JWT this would be proper base64url encoding + result := "" + for _, r := range s { + result += string(r) + } + return result +} diff --git a/connect-plugin/testdata/fake-datum-connect/main.go b/connect-plugin/testdata/fake-datum-connect/main.go new file mode 100644 index 0000000..feae5d0 --- /dev/null +++ b/connect-plugin/testdata/fake-datum-connect/main.go @@ -0,0 +1,187 @@ +// fake-datum-connect emulates the datum-connect Rust binary for testing. +// +// Modes (controlled by FAKE_DUMMY_MODE env var): +// missing-token: exits 1 with error about missing token +// expired-token: prints ready JSON with "status": "expired" +// 401-then-recover: first call returns 401 JSON, second returns ready JSON +// child-crash: exits with code 1 +// +// Subcommands: list, listen, update, delete +// Global flags: --json, --project (and any other --flag pairs) +// Subcommand flags are accepted but ignored. +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "syscall" +) + +func main() { + args := os.Args[1:] + + // Scan args: collect --json, skip flags-with-values, find first positional as subcommand. + // Flags that consume the next arg as their value: + flagsWithValue := map[string]bool{ + "--project": true, "--endpoint": true, "--id": true, + "--label": true, "--name": true, "--session": true, "--log-file": true, + "--output": true, "-o": true, + } + jsonOut := false + var subcmd string + for i := 0; i < len(args); i++ { + switch { + case args[i] == "--json": + jsonOut = true + case flagsWithValue[args[i]]: + i++ // skip value + case !strings.HasPrefix(args[i], "-") && subcmd == "": + subcmd = args[i] + } + } + + mode := os.Getenv("FAKE_DUMMY_MODE") + + // Handle child-crash mode + if mode == "child-crash" { + os.Exit(1) + } + + // Handle 401-then-recover mode (check for a counter file) + if mode == "401-then-recover" { + counterPath := "/tmp/fake-datum-connect-401-counter" + count := 0 + if data, err := os.ReadFile(counterPath); err == nil { + count, _ = strconv.Atoi(string(data)) + } + count++ + os.WriteFile(counterPath, []byte(strconv.Itoa(count)), 0644) + if count == 1 { + if jsonOut { + fmt.Println(`{"status":"error","code":401,"message":"unauthorized"}`) + } else { + fmt.Fprintln(os.Stderr, "error: unauthorized (401)") + } + os.Exit(1) + } + // Fall through to normal handling on second call + } + + switch subcmd { + case "list": + handleList(jsonOut) + case "listen": + handleListen(jsonOut) + case "update": + handleUpdate(jsonOut) + case "delete": + handleDelete(jsonOut) + default: + fmt.Fprintln(os.Stderr, "Usage: fake-datum-connect [--json] [list|listen|update|delete]") + os.Exit(2) + } +} + +func handleList(jsonOut bool) { + token := os.Getenv("DATUM_ACCESS_TOKEN") + hasHelper := os.Getenv("DATUM_CREDENTIALS_HELPER") != "" + if token == "" && !hasHelper && os.Getenv("FAKE_DUMMY_MODE") != "expired-token" { + if os.Getenv("FAKE_DUMMY_MODE") != "missing-token" { + fmt.Fprintln(os.Stderr, "error: missing DATUM_ACCESS_TOKEN") + os.Exit(1) + } + } + + if os.Getenv("FAKE_DUMMY_MODE") == "expired-token" { + if jsonOut { + fmt.Println(`[{"id":"tun-123","label":"dev-server","endpoint":"localhost:8080","status":"expired"}]`) + } + return + } + + if jsonOut { + tunnels := []map[string]string{ + {"id": "tun-123", "label": "dev-server", "endpoint": "localhost:8080", "status": "ready"}, + {"id": "tun-456", "label": "staging-api", "endpoint": "localhost:3000", "status": "ready"}, + } + data, _ := json.Marshal(tunnels) + fmt.Println(string(data)) + } else { + fmt.Println("ID LABEL ENDPOINT STATUS") + fmt.Println("--- ---- ------- ------") + fmt.Println("tun-123 dev-server localhost:8080 ready") + fmt.Println("tun-456 staging-api localhost:3000 ready") + } +} + +func handleListen(jsonOut bool) { + token := os.Getenv("DATUM_ACCESS_TOKEN") + hasHelper := os.Getenv("DATUM_CREDENTIALS_HELPER") != "" + if token == "" && !hasHelper && os.Getenv("FAKE_DUMMY_MODE") != "expired-token" { + fmt.Fprintln(os.Stderr, "error: missing DATUM_ACCESS_TOKEN") + os.Exit(1) + } + + if os.Getenv("FAKE_DUMMY_MODE") == "expired-token" { + if jsonOut { + fmt.Println(`{"type":"error","message":"token expired"}`) + } else { + fmt.Fprintln(os.Stderr, "error: token expired") + } + os.Exit(1) + } + + // Daemon mode: emit a single ready message then exit immediately. + // This simulates the Rust binary when launched by the daemon supervisor + // in daemon-listen test mode. + if os.Getenv("FAKE_DUMMY_MODE") == "daemon-listen" { + fmt.Println(`{"type":"tunnel_ready","id":"tun-dmon","label":"daemon-test","endpoint":"localhost:8080","hostnames":["tun-dmon.datum.dev"],"status":"ready"}`) + return + } + + // Always emit typed JSON (listen command reads stdout regardless of --json flag) + fmt.Println(`{"type":"tunnel_ready","id":"tun-123","label":"dev-server","endpoint":"localhost:8080","hostnames":["tun-123.datum.dev"],"status":"ready"}`) + + // Block until SIGINT + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh +} + +func handleUpdate(jsonOut bool) { + if jsonOut { + fmt.Println(`{"id":"tun-123","label":"dev-server","endpoint":"localhost:9090","status":"ready","updated":true}`) + } else { + fmt.Println("Tunnel updated: endpoint -> localhost:9090") + } +} + +func handleDelete(jsonOut bool) { + if jsonOut { + fmt.Println(`{"id":"tun-123","deleted":true}`) + } else { + fmt.Println("Tunnel deleted: tun-123") + } +} + +func contains(args []string, s string) bool { + for _, a := range args { + if strings.Contains(a, s) { + return true + } + } + return false +} + +func getEnv(args []string, key string) string { + for _, a := range args { + if strings.HasPrefix(a, key+"=") { + return a[len(key)+1:] + } + } + return "" +} diff --git a/connect-plugin/tunnel/delete/main.go b/connect-plugin/tunnel/delete/main.go new file mode 100644 index 0000000..950f896 --- /dev/null +++ b/connect-plugin/tunnel/delete/main.go @@ -0,0 +1,98 @@ +package delete + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/binary" + "go.datum.net/datumctl-plugins/connect/internal/env" + "go.datum.net/datumctl-plugins/connect/internal/exec" + "go.datum.net/datumctl-plugins/connect/internal/output" + "go.datum.net/datumctl/plugin" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [flags]", + Short: "Delete a tunnel", + RunE: runDelete, + } + cmd.Flags().String("id", "", "Tunnel ID to delete (required)") + cmd.Flags().StringP("output", "o", "table", "Output format: table, json, yaml") + return cmd +} + +func runDelete(cmd *cobra.Command, args []string) error { + id, _ := cmd.Flags().GetString("id") + + if id == "" { + fmt.Fprintln(os.Stderr, "Error: --id is required") + os.Exit(64) // POSIX: semantic rejection (EXIT-02) + } + + // Discover binary + binaryPath, err := binary.Discover() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Get plugin context + pluginCtx := plugin.Context() + + // Build env (no DATUM_ACCESS_TOKEN — binary obtains token via credentials helper) + childEnv := env.Build(pluginCtx) + + // Run: --json delete --id X + result, err := exec.Run(context.Background(), binaryPath, []string{"--json", "delete", "--id", id}, childEnv, exec.OutputModeJSON) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if result.ExitCode != 0 { + if len(result.Stderr) > 0 { + fmt.Fprintln(os.Stderr, strings.TrimSpace(string(result.Stderr))) + } + os.Exit(result.ExitCode) + } + + // Output: JSON mode passes through, YAML converts, table renders + outputFlag, _ := cmd.Flags().GetString("output") + switch outputFlag { + case "json": + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + case "yaml": + yaml, err := output.ConvertJSONToYAML(result.Stdout) + if err != nil { + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + } else { + fmt.Fprint(cmd.OutOrStdout(), string(yaml)) + } + default: + // Table mode: render human-readable output for delete + var deleteResult map[string]interface{} + if err := json.Unmarshal(result.Stdout, &deleteResult); err == nil { + id, _ := deleteResult["id"].(string) + fmt.Fprintf(cmd.OutOrStdout(), "Deleted tunnel %s\n", id) + if resources, ok := deleteResult["resources"].([]interface{}); ok { + for _, r := range resources { + if res, ok := r.(map[string]interface{}); ok { + typ, _ := res["type"].(string) + name, _ := res["name"].(string) + fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", typ, name) + } + } + } + } else { + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + } + } + + return nil +} diff --git a/connect-plugin/tunnel/install/main.go b/connect-plugin/tunnel/install/main.go new file mode 100644 index 0000000..c7e86ea --- /dev/null +++ b/connect-plugin/tunnel/install/main.go @@ -0,0 +1,173 @@ +package install + +import ( + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/rbaccheck" + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" + "go.datum.net/datumctl-plugins/connect/internal/svcunit" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "install", + Short: "Install tunnel as a systemd user service", + Long: `Install a tunnel as a persistent systemd user service. + +The tunnel will be configured to start automatically on boot and +restart on failure. Use 'tunnel start' to start it immediately. + +Requires a service-account session (created via 'datumctl login +--credentials key.json --session ') and a project ID +(--project). Interactive sessions are rejected with exit code 78.`, + RunE: runInstall, + } + cmd.Flags().String("name", "", "Tunnel name (required)") + cmd.Flags().String("label", "", "Display name") + cmd.Flags().String("endpoint", "", "Local address to expose (host:port, required)") + cmd.Flags().String("project", "", "Project ID (required)") + cmd.Flags().String("session", "", "Service-account session name (required)") + cmd.Flags().Bool("yes", false, "Skip confirmation") + return cmd +} + +func runInstall(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + label, _ := cmd.Flags().GetString("label") + endpoint, _ := cmd.Flags().GetString("endpoint") + project, _ := cmd.Flags().GetString("project") + session, _ := cmd.Flags().GetString("session") + yes, _ := cmd.Flags().GetBool("yes") + + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + os.Exit(64) + } + if endpoint == "" { + fmt.Fprintln(os.Stderr, "Error: --endpoint is required") + os.Exit(64) + } + if session == "" { + fmt.Fprintln(os.Stderr, "Error: --session is required") + os.Exit(64) + } + if project == "" { + fmt.Fprintln(os.Stderr, "Error: --project is required") + os.Exit(64) + } + + // Validate: session exists and is service-account type + if err := validateSession(session); err != nil { + fmt.Fprintf(os.Stderr, "Error: session validation: %v\n", err) + os.Exit(78) // SVC-07: config error + } + + // Phase 13 D-05: SSAR — validate service-account has required RBAC permissions + if k8sAPI := os.Getenv("DATUM_K8S_API"); k8sAPI != "" { + if err := runSSARCheck(k8sAPI, session); err != nil { + fmt.Fprintf(os.Stderr, "Error: RBAC check failed: %v\n", err) + os.Exit(78) // Config error (SVC-07) + } + } else { + fmt.Fprintln(os.Stderr, "Warning: DATUM_K8S_API not set — skipping RBAC validation. Install will proceed without permission checks.") + } + + // Validate: no duplicate name + exists, err := svcconfig.Exists(name) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: check config: %v\n", err) + os.Exit(1) + } + if exists { + fmt.Fprintf(os.Stderr, "Error: tunnel '%s' is already installed\n", name) + os.Exit(64) + } + + // Capture credentials helper path at install time for unit env vars + credentialsHelperPath := os.Getenv("DATUM_CREDENTIALS_HELPER") + + // Build config + cfg := svcconfig.TunnelConfig{ + Name: name, + Label: label, + Endpoint: endpoint, + Project: project, + Session: session, + CredentialsHelperPath: credentialsHelperPath, + } + + // Write config + configPath := svcconfig.ConfigFilePath(name) + if err := svcconfig.Save(cfg, configPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: save config: %v\n", err) + os.Exit(1) + } + + // Install systemd unit + binPath, err := resolveBinaryPath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: resolve binary: %v\n", err) + os.Exit(1) + } + + if err := svcunit.Install(cfg, binPath); err != nil { + // Clean up config on failure + svcconfig.Remove(name) + fmt.Fprintf(os.Stderr, "Error: install service: %v\n", err) + os.Exit(1) + } + + // Silence the unused variable lint warning for `yes` + _ = yes + + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel '%s' installed (use 'tunnel start %s' to start)\n", name, name) + return nil +} + +// validateSession checks that the session exists and is service-account type. +func validateSession(session string) error { + helper := os.Getenv("DATUM_CREDENTIALS_HELPER") + if helper == "" { + return fmt.Errorf("DATUM_CREDENTIALS_HELPER not set") + } + // Check if credentials helper recognizes the session + out, err := exec.Command(helper, "auth", "get-token", "--session", session).Output() + if err != nil { + return fmt.Errorf("session '%s' not found or not accessible: %w", session, err) + } + if len(out) == 0 { + return fmt.Errorf("session '%s' returned empty token", session) + } + // TODO: check if session is service-account type via credentials helper metadata + // For now, if get-token succeeds, accept it + _ = out + return nil +} + +// runSSARCheck performs SelfSubjectAccessReview checks via the rbaccheck package. +func runSSARCheck(k8sAPI, session string) error { + helper := os.Getenv("DATUM_CREDENTIALS_HELPER") + if helper == "" { + return fmt.Errorf("DATUM_CREDENTIALS_HELPER not set") + } + + token, err := rbaccheck.GetToken(helper, session) + if err != nil { + return fmt.Errorf("get token: %w", err) + } + + return rbaccheck.CheckAll(k8sAPI, token, rbaccheck.DefaultChecks()) +} + +// resolveBinaryPath returns the path to the current plugin binary. +func resolveBinaryPath() (string, error) { + path, err := os.Executable() + if err != nil { + return exec.LookPath("datumctl-connect") + } + return path, nil +} diff --git a/connect-plugin/tunnel/list/main.go b/connect-plugin/tunnel/list/main.go new file mode 100644 index 0000000..34998fb --- /dev/null +++ b/connect-plugin/tunnel/list/main.go @@ -0,0 +1,85 @@ +package list + +import ( + "context" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/binary" + "go.datum.net/datumctl-plugins/connect/internal/env" + "go.datum.net/datumctl-plugins/connect/internal/exec" + "go.datum.net/datumctl-plugins/connect/internal/output" + "go.datum.net/datumctl/plugin" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List active tunnels", + RunE: runList, + } + cmd.Flags().StringP("output", "o", "table", "Output format: table, json, yaml") + return cmd +} + +func runList(cmd *cobra.Command, args []string) error { + // Discover the datum-connect binary + binaryPath, err := binary.Discover() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Get plugin context + pluginCtx := plugin.Context() + + // Build environment (no DATUM_ACCESS_TOKEN — binary obtains token via credentials helper) + childEnv := env.Build(pluginCtx) + + // Determine output mode + outputFlag, _ := cmd.Flags().GetString("output") + var outputMode exec.OutputMode + switch outputFlag { + case "json": + outputMode = exec.OutputModeJSON + case "yaml": + outputMode = exec.OutputModeYAML + default: + outputMode = exec.OutputModeTable + } + + // Run the Rust binary + result, err := exec.Run(context.Background(), binaryPath, []string{"--json", "list"}, childEnv, outputMode) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Exit code propagation (EXIT-01: propagate verbatim) + if result.ExitCode != 0 { + if len(result.Stderr) > 0 { + fmt.Fprintln(os.Stderr, strings.TrimSpace(string(result.Stderr))) + } + os.Exit(result.ExitCode) + } + + // Format and print output + switch outputMode { + case exec.OutputModeTable: + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + if err := output.RenderTable(result.Stdout, w); err != nil { + // Fallback to raw output if parsing fails + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + } + case exec.OutputModeJSON: + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + case exec.OutputModeYAML: + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + } + + return nil +} diff --git a/connect-plugin/tunnel/listen/main.go b/connect-plugin/tunnel/listen/main.go new file mode 100644 index 0000000..a5c10a9 --- /dev/null +++ b/connect-plugin/tunnel/listen/main.go @@ -0,0 +1,278 @@ +package listen + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/binary" + "go.datum.net/datumctl-plugins/connect/internal/daemon" + "go.datum.net/datumctl-plugins/connect/internal/env" + rexec "go.datum.net/datumctl-plugins/connect/internal/exec" + "go.datum.net/datumctl/plugin" +) + +const ( + // startupTimeout is the maximum time to wait for the first typed message + // (ready or error) from the Rust binary. + startupTimeout = 10 * time.Minute + // gracePeriod is the time to wait for clean shutdown after sending SIGINT. + gracePeriod = 30 * time.Second +) + +// TunnelReady represents the ready message from the Rust binary. +type TunnelReady struct { + ID string `json:"id"` + Label string `json:"label"` + Endpoint string `json:"endpoint"` + Hostnames []string `json:"hostnames"` + Status string `json:"status"` +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "listen [flags]", + Short: "Start a tunnel and block", + SilenceUsage: true, + RunE: runListen, + } + cmd.Flags().String("label", "", "Display name for the tunnel") + cmd.Flags().String("endpoint", "", "Local address to expose (host:port, required)") + cmd.Flags().String("id", "", "Existing tunnel resource name to resume (mutually inclusive with optional --endpoint)") + cmd.Flags().Bool("yes", false, "Skip confirmation prompt") + cmd.Flags().Bool("detach", false, "Run in background (daemon mode)") + cmd.Flags().String("name", "", "Tunnel name (required with --detach)") + cmd.Flags().String("log-file", "", "Path for Rust debug log output") + cmd.Flags().StringP("output", "o", "table", "Output format: table, json, yaml") + return cmd +} + +func runListen(cmd *cobra.Command, args []string) error { + label, _ := cmd.Flags().GetString("label") + endpoint, _ := cmd.Flags().GetString("endpoint") + id, _ := cmd.Flags().GetString("id") + yes, _ := cmd.Flags().GetBool("yes") + detach, _ := cmd.Flags().GetBool("detach") + name, _ := cmd.Flags().GetString("name") + logFile, _ := cmd.Flags().GetString("log-file") + + if endpoint == "" && id == "" { + // Neither flag given — semantic rejection (EXIT-02). + // The Rust binary requires at least one of --endpoint or --id; + // when neither is set and stdin is non-interactive the picker + // also can't run, so reject here for a faster, clearer error. + fmt.Fprintln(os.Stderr, "Error: --endpoint or --id is required") + os.Exit(64) // POSIX: semantic rejection (EXIT-02) + } + + // Detach mode: spawn background daemon and exit + if detach { + if id != "" { + fmt.Fprintln(os.Stderr, "Error: --id is not supported with --detach. Use 'tunnel run --name N' for detached named tunnels") + os.Exit(64) + } + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required with --detach") + os.Exit(64) + } + exe := daemon.SelfExe() + childArgs := daemon.ForegroundArgs(name, logFile, endpoint, label, yes) + _, err := daemon.Daemonize(exe, append([]string{exe}, childArgs...)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: daemonize: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel '%s' setting up in background; tunnel status will show progress\n", name) + return nil + } + + // Discover binary + binaryPath, err := binary.Discover() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Get plugin context + pluginCtx := plugin.Context() + + // Build environment (no DATUM_ACCESS_TOKEN — binary obtains token via credentials helper) + childEnv := env.Build(pluginCtx) + + // Pass tunnel name to the Rust binary so it can construct the + // per-tunnel key path. Only set when the name is known upfront + // (detach mode). For --endpoint-only and picker paths, the name + // comes from the server after tunnel creation (handled in Rust). + if name != "" { + childEnv = append(childEnv, "DATUM_CONNECT_TUNNEL_NAME="+name) + } + + // Build args + rustArgs := []string{"--json", "--project", pluginCtx.Project, "listen"} + if endpoint != "" { + rustArgs = append(rustArgs, "--endpoint", endpoint) + } + if id != "" { + rustArgs = append(rustArgs, "--id", id) + } + if label != "" { + rustArgs = append(rustArgs, "--label", label) + } + if yes { + rustArgs = append(rustArgs, "--yes") + } + + // Create and start the command + rustCmd := exec.CommandContext(context.Background(), binaryPath, rustArgs...) + rustCmd.Env = childEnv + + // Capture stdout for JSON parsing + stdoutReader, err := rustCmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + // stderr forwarded transparently to plugin stderr + rustCmd.Stderr = os.Stderr + + if err := rustCmd.Start(); err != nil { + return fmt.Errorf("failed to start datum-connect: %w", err) + } + + // Determine mode + isJSON := false + if outputFlag, _ := cmd.Flags().GetString("output"); outputFlag == "json" { + isJSON = true + } + + // Read and parse output line by line with startup timeout + scanner := bufio.NewScanner(stdoutReader) + var ready TunnelReady + var gotReady bool + + // Read lines — signals ready via readyCh + readDone := make(chan struct{}) + readyCh := make(chan struct{}) + go func() { + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + msg, ok := rexec.ParseTypedMessage(line) + if !ok { + // Invalid JSON or missing "type" — fatal error + rustCmd.Wait() + fmt.Fprintf(os.Stderr, "malformed message from child: %s\n", line) + return + } + + switch msg.Type { + case "tunnel_ready": + readyData, _ := json.Marshal(msg.Fields) + json.Unmarshal(readyData, &ready) + gotReady = true + + if isJSON { + // JSON mode: print ready JSON and stop reading. + // Add newline — the bufio.Scanner stripped it, and + // pipe-buffered stdout won't flush without one. + fmt.Fprintln(cmd.OutOrStdout(), string(line)) + close(readyCh) + return + } + // Interactive mode: print hostname + if len(ready.Hostnames) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel ready: https://%s\n", ready.Hostnames[0]) + } + fmt.Fprintln(cmd.OutOrStdout(), "Press Ctrl+C to stop...") + close(readyCh) + case "error": + if msg.Message != "" { + fmt.Fprintf(os.Stderr, "error: %s\n", msg.Message) + } + case "heartbeat", "status": + // Internal messages — no output + case "tunnel_progress", "tunnel_verifying", "tunnel_verified": + // Per-step setup-time status events from the Rust binary's + // await_tunnel_progress / verify_endpoints (Phase 12-03). + // Currently no-op at the supervisor layer — the human-friendly + // ready line is what we surface. Phase 13 may forward these + // to a future progress UI. + _ = msg + case "tunnel_terminal_failure", "tunnel_login_lost", "tunnel_deleted_upstream": + // Mid-session degradation signals from the Rust binary's runtime + // poll loop (Phase 12-04). Forward the message field to stderr so + // the user sees it; the child will exit on its own shortly. + if msg.Message != "" { + fmt.Fprintln(os.Stderr, msg.Message) + } + case "tunnel_disabled": + // Emitted by the Rust binary's cleanup block (Phase 12-04). + // No-op at supervisor layer; the child is about to exit. + _ = msg + case "tunnel_created", "tunnel_updated": + // Lifecycle events from create/update paths. No supervisor + // action needed in plugin/listen mode; tunnel_ready still + // drives gotReady. + _ = msg + case "tunnel_deleted": + // Emitted only by the `delete` subcommand. Not seen on the + // listen path. + _ = msg + default: + // Unknown type — skip + } + } + close(readDone) + }() + + // Wait for ready message or timeout + select { + case <-readyCh: + // Ready message received + case <-time.After(startupTimeout): + _ = rustCmd.Process.Signal(syscall.SIGKILL) + rustCmd.Wait() + return fmt.Errorf("timed out waiting for tunnel ready after %v", startupTimeout) + case <-readDone: + // Scanner ended — child exited without sending ready message + return fmt.Errorf("child exited before sending ready message") + } + + if !gotReady { + return fmt.Errorf("no ready message received from child") + } + + // Block until signal (Ctrl+C / SIGINT / SIGTERM) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + sig := <-sigCh + // Forward signal to child + _ = rustCmd.Process.Signal(sig) + + // Wait for child with grace period + done := make(chan error, 1) + go func() { + done <- rustCmd.Wait() + }() + + select { + case <-done: + // Child exited after signal — intentional shutdown, not an error. + return nil + case <-time.After(gracePeriod): + // Grace period expired — force kill + _ = rustCmd.Process.Signal(syscall.SIGKILL) + <-done + return nil + } +} diff --git a/connect-plugin/tunnel/logs/main.go b/connect-plugin/tunnel/logs/main.go new file mode 100644 index 0000000..bbfe352 --- /dev/null +++ b/connect-plugin/tunnel/logs/main.go @@ -0,0 +1,83 @@ +package logs + +import ( + "bufio" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/state" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs --name N [--follow]", + Short: "View tunnel logs", + RunE: runLogs, + } + cmd.Flags().String("name", "", "Tunnel name (required)") + cmd.Flags().BoolP("follow", "f", false, "Follow log output") + return cmd +} + +func runLogs(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + follow, _ := cmd.Flags().GetBool("follow") + + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + os.Exit(64) + } + + logPath := state.LogFilePath(name) + + // Check if log file exists + if _, err := os.Stat(logPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("no log file for tunnel '%s' (try running with --log-file)", name) + } + return fmt.Errorf("access log file: %w", err) + } + + if follow { + return followLogs(cmd, logPath) + } + return printLogs(cmd, logPath) +} + +func printLogs(cmd *cobra.Command, logPath string) error { + data, err := os.ReadFile(logPath) + if err != nil { + return fmt.Errorf("read log file: %w", err) + } + fmt.Fprint(cmd.OutOrStdout(), string(data)) + return nil +} + +func followLogs(cmd *cobra.Command, logPath string) error { + f, err := os.Open(logPath) + if err != nil { + return fmt.Errorf("open log file: %w", err) + } + defer f.Close() + + // Seek to end to start following from new content + _, _ = f.Seek(0, io.SeekEnd) + reader := bufio.NewReader(f) + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + // Wait for new content + time.Sleep(100 * time.Millisecond) + continue + } + return fmt.Errorf("read: %w", err) + } + fmt.Fprint(cmd.OutOrStdout(), line) + } +} diff --git a/connect-plugin/tunnel/ps/main.go b/connect-plugin/tunnel/ps/main.go new file mode 100644 index 0000000..5374d40 --- /dev/null +++ b/connect-plugin/tunnel/ps/main.go @@ -0,0 +1,99 @@ +package ps + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/pidfile" + "go.datum.net/datumctl-plugins/connect/internal/state" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ps [--prune] [--json]", + Short: "List running tunnels", + RunE: runPS, + } + cmd.Flags().Bool("prune", false, "Remove stale PID files") + cmd.Flags().Bool("json", false, "Output JSON format") + return cmd +} + +func runPS(cmd *cobra.Command, args []string) error { + prune, _ := cmd.Flags().GetBool("prune") + jsonOut, _ := cmd.Flags().GetBool("json") + + tunnels, err := pidfile.ListRunningTunnels(state.Dir()) + if err != nil { + return fmt.Errorf("list tunnels: %w", err) + } + + // Prune stale entries if requested + if prune { + var remaining []pidfile.RunningTunnel + for _, t := range tunnels { + if t.Status == "Zombie" { + path := state.PidFilePath(t.Name) + _ = pidfile.Remove(path) + } else { + remaining = append(remaining, t) + } + } + tunnels = remaining + } + + if jsonOut { + return outputJSON(cmd, tunnels) + } + return outputTable(cmd, tunnels) +} + +func outputTable(cmd *cobra.Command, tunnels []pidfile.RunningTunnel) error { + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tPID\tRUST\tSTATUS\tUPTIME\tENDPOINT") + fmt.Fprintln(w, "----\t---\t----\t------\t------\t--------") + + if len(tunnels) == 0 { + fmt.Fprintln(w, "(no running tunnels)") + w.Flush() + return nil + } + + for _, t := range tunnels { + uptime := formatUptime(t.StartTime) + endpoint := t.BinaryPath + if endpoint == "" { + endpoint = "\u2014" + } + fmt.Fprintf(w, "%s\t%d\t%d\t%s\t%s\t%s\n", + t.Name, t.GoPID, t.RustPID, t.Status, uptime, endpoint) + } + return w.Flush() +} + +func outputJSON(cmd *cobra.Command, tunnels []pidfile.RunningTunnel) error { + data, err := json.MarshalIndent(tunnels, "", " ") + if err != nil { + return fmt.Errorf("json marshal: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} + +func formatUptime(startTime time.Time) string { + if startTime.IsZero() { + return "\u2014" + } + d := time.Since(startTime).Round(time.Second) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) +} diff --git a/connect-plugin/tunnel/root.go b/connect-plugin/tunnel/root.go new file mode 100644 index 0000000..fa669c1 --- /dev/null +++ b/connect-plugin/tunnel/root.go @@ -0,0 +1,42 @@ +package tunnel + +import ( + "github.com/spf13/cobra" + + deletecmd "go.datum.net/datumctl-plugins/connect/tunnel/delete" + "go.datum.net/datumctl-plugins/connect/tunnel/install" + "go.datum.net/datumctl-plugins/connect/tunnel/list" + "go.datum.net/datumctl-plugins/connect/tunnel/listen" + "go.datum.net/datumctl-plugins/connect/tunnel/logs" + "go.datum.net/datumctl-plugins/connect/tunnel/ps" + "go.datum.net/datumctl-plugins/connect/tunnel/run" + "go.datum.net/datumctl-plugins/connect/tunnel/start" + "go.datum.net/datumctl-plugins/connect/tunnel/status" + "go.datum.net/datumctl-plugins/connect/tunnel/stop" + "go.datum.net/datumctl-plugins/connect/tunnel/uninstall" + "go.datum.net/datumctl-plugins/connect/tunnel/update" +) + +// NewCmd returns the tunnel root command with all subcommands. +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tunnel", + Short: "Manage tunnels", + Long: "Manage tunnels to local services via Datum Connect", + } + + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(listen.NewCmd()) + cmd.AddCommand(update.NewCmd()) + cmd.AddCommand(deletecmd.NewCmd()) + cmd.AddCommand(ps.NewCmd()) + cmd.AddCommand(stop.NewCmd()) + cmd.AddCommand(logs.NewCmd()) + cmd.AddCommand(status.NewCmd()) + cmd.AddCommand(install.NewCmd()) + cmd.AddCommand(uninstall.NewCmd()) + cmd.AddCommand(start.NewCmd()) + cmd.AddCommand(run.NewCmd()) + + return cmd +} diff --git a/connect-plugin/tunnel/run/main.go b/connect-plugin/tunnel/run/main.go new file mode 100644 index 0000000..2287fff --- /dev/null +++ b/connect-plugin/tunnel/run/main.go @@ -0,0 +1,80 @@ +package run + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/daemon" + "go.datum.net/datumctl-plugins/connect/internal/state" + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "(internal) Run tunnel supervisor", + Long: `Start the tunnel supervisor process. This is the internal entry point +used by the daemon background process (--detach). It is also called by +systemd/launchd service units in Phase 6.`, + RunE: runRun, + } + cmd.Flags().String("name", "", "Tunnel name (required)") + cmd.Flags().String("project", "", "Project ID (checked against persisted config)") + return cmd +} + +func runRun(cmd *cobra.Command, args []string) error { + // Server-of-truth (Phase 13 D-04, resolution table Item #11): + // The Rust binary resolves the tunnel's label and endpoint from the + // server (HTTPProxy resource) via get_active_by_endpoint. The values + // passed through from the YAML snapshot are startup hints only — the + // binary overrides them with the server's live state. + + name, _ := cmd.Flags().GetString("name") + projectFlag, _ := cmd.Flags().GetString("project") + + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + os.Exit(64) + } + + // Load persisted config + cfgPath := svcconfig.ConfigFilePath(name) + svcCfg, err := svcconfig.Load(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: load config for '%s': %v\n", name, err) + os.Exit(1) + } + + // Check --project mismatch (install-time project is authoritative for services) + if projectFlag != "" && projectFlag != svcCfg.Project { + fmt.Fprintf(os.Stderr, "Error: --project '%s' does not match installed project '%s'. Reinstall to change project.\n", projectFlag, svcCfg.Project) + os.Exit(64) + } + + // Set session in env for the child Rust binary + if svcCfg.Session != "" { + os.Setenv("DATUM_SESSION", svcCfg.Session) + } + + logFile := state.LogFilePath(name) + + cfg := daemon.Config{ + Name: name, + Label: svcCfg.Label, + Endpoint: svcCfg.Endpoint, + LogFile: logFile, + } + + ctx := context.Background() + if err := daemon.RunSupervisor(ctx, cfg); err != nil { + fmt.Fprintf(os.Stderr, "supervisor: %v\n", err) + os.Exit(1) + } + return nil +} + + diff --git a/connect-plugin/tunnel/start/main.go b/connect-plugin/tunnel/start/main.go new file mode 100644 index 0000000..634768c --- /dev/null +++ b/connect-plugin/tunnel/start/main.go @@ -0,0 +1,78 @@ +package start + +import ( + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" + "go.datum.net/datumctl-plugins/connect/internal/svcunit" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start --name N [--project P]", + Short: "Start a tunnel service", + RunE: runStart, + } + cmd.Flags().String("name", "", "Tunnel name (required)") + cmd.Flags().String("project", "", "Project ID (checked against persisted config)") + return cmd +} + +func runStart(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + os.Exit(64) + } + + // Check --project mismatch (install-time project is authoritative for services) + if projectFlag, _ := cmd.Flags().GetString("project"); projectFlag != "" { + cfgPath := svcconfig.ConfigFilePath(name) + cfg, err := svcconfig.Load(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: load config for '%s': %v\n", name, err) + os.Exit(1) + } + if projectFlag != cfg.Project { + fmt.Fprintf(os.Stderr, "Error: --project '%s' does not match installed project '%s'. Reinstall to change project.\n", projectFlag, cfg.Project) + os.Exit(64) + } + } + + // Check installed + exists, err := svcconfig.Exists(name) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: check config: %v\n", err) + os.Exit(1) + } + if !exists { + fmt.Fprintf(os.Stderr, "Error: tunnel '%s' is not installed\n", name) + os.Exit(64) + } + + binPath, err := resolveBinaryPath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: resolve binary: %v\n", err) + os.Exit(1) + } + + if err := svcunit.Start(name, binPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: start service: %v\n", err) + os.Exit(1) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel '%s' started\n", name) + return nil +} + +func resolveBinaryPath() (string, error) { + path, err := os.Executable() + if err != nil { + return exec.LookPath("datumctl-connect") + } + return path, nil +} diff --git a/connect-plugin/tunnel/status/main.go b/connect-plugin/tunnel/status/main.go new file mode 100644 index 0000000..d2b63c6 --- /dev/null +++ b/connect-plugin/tunnel/status/main.go @@ -0,0 +1,92 @@ +package status + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/pidfile" + "go.datum.net/datumctl-plugins/connect/internal/state" + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status --name N", + Short: "Show tunnel status", + RunE: runStatus, + } + cmd.Flags().String("name", "", "Tunnel name (required)") + return cmd +} + +func runStatus(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + os.Exit(64) + } + + pidPath := state.PidFilePath(name) + pf, err := pidfile.Read(pidPath) + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel '%s': Stopped\n", name) + } else { + goAlive := pidfile.PIDAlive(pf.GoPID) + rustAlive := pidfile.PIDAlive(pf.RustPID) + + status := computeStatus(goAlive, rustAlive) + uptime := formatDuration(time.Since(pf.StartTime)) + + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel: %s\n", name) + fmt.Fprintf(cmd.OutOrStdout(), "Status: %s\n", status) + fmt.Fprintf(cmd.OutOrStdout(), "Go PID: %d (alive: %v)\n", pf.GoPID, goAlive) + fmt.Fprintf(cmd.OutOrStdout(), "Rust PID: %d (alive: %v)\n", pf.RustPID, rustAlive) + fmt.Fprintf(cmd.OutOrStdout(), "Started: %s\n", pf.StartTime.Format(time.RFC3339)) + fmt.Fprintf(cmd.OutOrStdout(), "Uptime: %s\n", uptime) + fmt.Fprintf(cmd.OutOrStdout(), "Binary: %s\n", pf.BinaryPath) + } + + // Phase 6: Also check if installed as a service + installed, _ := svcconfig.Exists(name) + if installed { + cfg, _ := svcconfig.Load(svcconfig.ConfigFilePath(name)) + fmt.Fprintf(cmd.OutOrStdout(), "Installed: yes (session: %s)\n", cfg.Session) + fmt.Fprintf(cmd.OutOrStdout(), "Endpoint: %s\n", cfg.Endpoint) + if cfg.Label != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Label: %s\n", cfg.Label) + } + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Installed: no\n") + } + + return nil +} + +func computeStatus(goAlive, rustAlive bool) string { + switch { + case !goAlive && !rustAlive: + return "Stopped" + case goAlive && rustAlive: + return "Running" + case goAlive && !rustAlive: + return "Degraded" + case !goAlive && rustAlive: + return "Zombie" + default: + return "Unknown" + } +} + +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) +} diff --git a/connect-plugin/tunnel/stop/main.go b/connect-plugin/tunnel/stop/main.go new file mode 100644 index 0000000..0405808 --- /dev/null +++ b/connect-plugin/tunnel/stop/main.go @@ -0,0 +1,101 @@ +package stop + +import ( + "fmt" + "os" + "os/exec" + "syscall" + "time" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/pidfile" + "go.datum.net/datumctl-plugins/connect/internal/state" + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" + "go.datum.net/datumctl-plugins/connect/internal/svcunit" +) + +const ( + gracePeriod = 30 * time.Second +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop --name N", + Short: "Stop a tunnel", + RunE: runStop, + } + cmd.Flags().String("name", "", "Tunnel name (required)") + return cmd +} + +func runStop(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + os.Exit(64) + } + + // Phase 6: Check if this is an installed service (no running daemon) + pidPath := state.PidFilePath(name) + if !pidfile.Exists(pidPath) { + // No running daemon — try service stop + installed, _ := svcconfig.Exists(name) + if installed { + binPath, err := resolveBinaryPath() + if err != nil { + return fmt.Errorf("resolve binary: %w", err) + } + if err := svcunit.Stop(name, binPath); err != nil { + return fmt.Errorf("stop service: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel '%s' service stopped\n", name) + return nil + } + return fmt.Errorf("tunnel '%s' not running and not installed", name) + } + + pf, err := pidfile.Read(pidPath) + if err != nil { + return fmt.Errorf("tunnel '%s' not running: %w", name, err) + } + + // Kill Rust child first (per CONTEXT.md stop flow) + rustProc, err := os.FindProcess(pf.RustPID) + if err == nil { + // Send SIGTERM to Rust + _ = rustProc.Signal(syscall.SIGTERM) + } + + // Wait up to grace period for Rust to exit + done := make(chan struct{}) + go func() { + for i := 0; i < int(gracePeriod/time.Second); i++ { + if !pidfile.PIDAlive(pf.RustPID) { + close(done) + return + } + time.Sleep(time.Second) + } + // Timeout — force kill Rust + if rustProc != nil { + _ = rustProc.Signal(syscall.SIGKILL) + } + close(done) + }() + <-done + + // Clean up PID file + _ = pidfile.Remove(pidPath) + + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel '%s' stopped\n", name) + return nil +} + +func resolveBinaryPath() (string, error) { + path, err := os.Executable() + if err != nil { + return exec.LookPath("datumctl-connect") + } + return path, nil +} diff --git a/connect-plugin/tunnel/uninstall/main.go b/connect-plugin/tunnel/uninstall/main.go new file mode 100644 index 0000000..dd5e4d5 --- /dev/null +++ b/connect-plugin/tunnel/uninstall/main.go @@ -0,0 +1,87 @@ +package uninstall + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/svcconfig" + "go.datum.net/datumctl-plugins/connect/internal/svcunit" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "uninstall --name N", + Short: "Uninstall a tunnel service", + RunE: runUninstall, + } + cmd.Flags().String("name", "", "Tunnel name (required)") + return cmd +} + +func runUninstall(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + if name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + os.Exit(64) + } + + // Check config exists + exists, err := svcconfig.Exists(name) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: check config: %v\n", err) + os.Exit(1) + } + if !exists { + fmt.Fprintf(os.Stderr, "Error: tunnel '%s' is not installed\n", name) + os.Exit(64) + } + + // Find binary path + binPath, err := resolveBinaryPath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: resolve binary: %v\n", err) + os.Exit(1) + } + + // Stop and uninstall systemd unit + if err := svcunit.Uninstall(name, binPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: uninstall service: %v\n", err) + os.Exit(1) + } + + // Delete config + if err := svcconfig.Remove(name); err != nil { + fmt.Fprintf(os.Stderr, "Error: remove config: %v\n", err) + os.Exit(1) + } + + // Phase 11.5 D-13: remove the per-service state subdirectory + // (matches the path baked into the unit file by svcunit.buildConfig). + // Best-effort: report errors but do not fail uninstall — the systemd + // unit and svcconfig entry are already gone, refusing to consider the + // tunnel uninstalled because of a directory-permission issue would + // be surprising. + if home, err := os.UserHomeDir(); err == nil { + stateDir := filepath.Join(home, ".datumctl", "connect", "services", name) + if err := os.RemoveAll(stateDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not remove %s: %v\n", stateDir, err) + } + } else { + fmt.Fprintf(os.Stderr, "Warning: could not compute service state dir for cleanup: %v\n", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Tunnel '%s' uninstalled\n", name) + return nil +} + +func resolveBinaryPath() (string, error) { + path, err := os.Executable() + if err != nil { + return exec.LookPath("datumctl-connect") + } + return path, nil +} diff --git a/connect-plugin/tunnel/update/main.go b/connect-plugin/tunnel/update/main.go new file mode 100644 index 0000000..27b92f2 --- /dev/null +++ b/connect-plugin/tunnel/update/main.go @@ -0,0 +1,105 @@ +package update + +import ( + "context" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl-plugins/connect/internal/binary" + "go.datum.net/datumctl-plugins/connect/internal/env" + "go.datum.net/datumctl-plugins/connect/internal/exec" + "go.datum.net/datumctl-plugins/connect/internal/output" + "go.datum.net/datumctl/plugin" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update [flags]", + Short: "Update a tunnel", + RunE: runUpdate, + } + cmd.Flags().String("id", "", "Tunnel ID to update (required)") + cmd.Flags().String("label", "", "New display name") + cmd.Flags().String("endpoint", "", "New local address (host:port)") + cmd.Flags().StringP("output", "o", "table", "Output format: table, json, yaml") + return cmd +} + +func runUpdate(cmd *cobra.Command, args []string) error { + // Server-of-truth (Phase 13 D-04, resolution table Item #11): + // This function delegates to the Rust binary which mutates the server-side + // HTTPProxy resource. It does NOT rewrite the local YAML config — the YAML + // is an install-time snapshot only. Runtime values (label, endpoint) come + // from the server. + + id, _ := cmd.Flags().GetString("id") + label, _ := cmd.Flags().GetString("label") + endpoint, _ := cmd.Flags().GetString("endpoint") + + if id == "" { + fmt.Fprintln(os.Stderr, "Error: --id is required") + os.Exit(64) // POSIX: semantic rejection (EXIT-02) + } + + // Discover binary + binaryPath, err := binary.Discover() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Get plugin context + pluginCtx := plugin.Context() + + // Build env (no DATUM_ACCESS_TOKEN — binary obtains token via credentials helper) + childEnv := env.Build(pluginCtx) + + // Build args: --json update --id X [--label Y] [--endpoint Z] + rustArgs := []string{"--json", "update", "--id", id} + if label != "" { + rustArgs = append(rustArgs, "--label", label) + } + if endpoint != "" { + rustArgs = append(rustArgs, "--endpoint", endpoint) + } + + // Run + result, err := exec.Run(context.Background(), binaryPath, rustArgs, childEnv, exec.OutputModeJSON) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if result.ExitCode != 0 { + if len(result.Stderr) > 0 { + fmt.Fprintln(os.Stderr, strings.TrimSpace(string(result.Stderr))) + } + os.Exit(result.ExitCode) + } + + // Output: JSON mode passes through, YAML converts, table renders + outputFlag, _ := cmd.Flags().GetString("output") + switch outputFlag { + case "json": + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + case "yaml": + yaml, err := output.ConvertJSONToYAML(result.Stdout) + if err != nil { + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + } else { + fmt.Fprint(cmd.OutOrStdout(), string(yaml)) + } + default: + // Table mode for single object — render as single-row table + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + if err := output.RenderTable(result.Stdout, w); err != nil { + fmt.Fprint(cmd.OutOrStdout(), string(result.Stdout)) + } + } + + return nil +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3235f14 --- /dev/null +++ b/flake.lock @@ -0,0 +1,96 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780749050, + "narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a799d3e3886da994fa307f817a6bc705ae538eeb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1780802404, + "narHash": "sha256-bGtIUeLb0yChX4h6hB40OOCwcYhcpQZHXSDvZGdWgeM=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "8e596a8430f2ce54d55c742198187d6945a5501e", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6168a5e --- /dev/null +++ b/flake.nix @@ -0,0 +1,117 @@ +{ + description = "Datum Connect plugin — Rust binary (datum-connect) + Go plugin (datumctl-connect)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + + # Pinned to match the rust-toolchain.toml in connect-lib/ (if any) or + # the latest stable on nixpkgs unstable. The plugin Rust binary only + # builds for native host targets — no WASM, no cross-compile from this + # shell (release builds happen via scripts/release.sh which sets its + # own targets). + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; + }; + + # Native build inputs (tools needed at build time). + nativeBuildInputs = with pkgs; [ + pkg-config + ]; + + # Build inputs (libraries linked against). + # openssl is needed by openssl-sys (transitive via reqwest in + # connect-lib). libiconv is required on darwin. + buildInputs = with pkgs; [ + openssl + ] ++ lib.optionals stdenv.isDarwin [ + libiconv + ]; + + in + { + # ── Packaged Rust binary ────────────────────────────────────────── + # `nix build` produces the datum-connect Rust binary used by the + # Go plugin as a subprocess in plugin mode. + packages.default = pkgs.rustPlatform.buildRustPackage { + pname = "datum-connect"; + version = "0.1.0"; + src = ./connect-lib; + + cargoLock = { + lockFile = ./connect-lib/Cargo.lock; + # iroh-proxy-utils is a git dependency; its hash is required for + # reproducible builds. Update via `nix build` failure → copy the + # expected hash into this map. + outputHashes = { + "iroh-proxy-utils-0.1.0" = "sha256-ZV71q22zCWBqFdrc0jzkwyQdVc/H0r0BBB6dKrNARr8="; + }; + }; + + inherit nativeBuildInputs buildInputs; + + cargoBuildFlags = [ "-p" "datum-connect" ]; + # Workspace tests require network (iroh STUN/relay); run locally + # via `task test:rust` in the dev shell. + doCheck = false; + + meta = with pkgs.lib; { + description = "Datum Connect tunnel agent (plugin-mode Rust binary)"; + homepage = "https://github.com/datum-cloud/datumctl-plugins"; + license = licenses.agpl3Only; + mainProgram = "datum-connect"; + }; + }; + + # ── Development shell ───────────────────────────────────────────── + # Use via `nix develop` from this directory (or `nix develop + # path:./connect` from the workspace root). Provides Rust + Go + + # task + pkg-config + openssl, with PKG_CONFIG_PATH set so + # openssl-sys finds its lib via pkg-config out of the box. + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + # Rust toolchain (stable, with clippy + rustfmt + rust-analyzer) + rustToolchain + + # Go toolchain for the datumctl-connect plugin shell. + # Pinned to match the directive in go.mod (currently 1.25.x); + # nixpkgs unstable's `go` is the active stable Go release. + go + + # Task runner — Taskfile.yaml at connect root is the canonical + # entry point for build / test / install workflows. + go-task + + # Common build tools + pkg-config + openssl + + # Useful dev utilities + git + ] ++ lib.optionals stdenv.isDarwin [ libiconv ]; + + # openssl-sys reads OpenSSL paths from pkg-config. nixpkgs splits + # openssl into `out` (libs) and `dev` (headers + .pc files); the + # latter is what pkg-config needs to be pointed at. + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + + # rust-analyzer wants this to navigate to std/core sources. + RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; + + shellHook = ""; + }; + + # nix fmt + formatter = pkgs.nixpkgs-fmt; + }); +}