From f6a7f0922d14107a5dbeec9f3eaf605dde041155 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 01:36:10 -0700 Subject: [PATCH 01/11] Add governance and identity registry scaffolding --- .forgejo/workflows/lint-governance.yml | 27 ++++ .github/workflows/lint-governance.yml | 23 +++ AGENTS.md | 14 ++ Makefile | 6 + README.md | 3 + Scripts/bep | 133 ++++++++++++++++++ Scripts/check-bep-metadata.py | 94 +++++++++++++ contributors.nix | 47 +++++++ evolution/README.md | 14 ++ .../BEP-0005-daemon-ipc-and-apple-boundary.md | 78 ++++++++++ ...6-tailnet-authority-first-control-plane.md | 71 ++++++++++ ...dentity-registry-and-operator-bootstrap.md | 73 ++++++++++ nixos/hosts/burrow-forge/default.nix | 50 ++++--- 13 files changed, 612 insertions(+), 21 deletions(-) create mode 100644 .forgejo/workflows/lint-governance.yml create mode 100644 .github/workflows/lint-governance.yml create mode 100644 AGENTS.md create mode 100755 Scripts/bep create mode 100755 Scripts/check-bep-metadata.py create mode 100644 contributors.nix create mode 100644 evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md create mode 100644 evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md create mode 100644 evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md diff --git a/.forgejo/workflows/lint-governance.yml b/.forgejo/workflows/lint-governance.yml new file mode 100644 index 0000000..490702e --- /dev/null +++ b/.forgejo/workflows/lint-governance.yml @@ -0,0 +1,27 @@ +name: Lint Governance + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + workflow_dispatch: + +jobs: + governance: + name: BEP Metadata + runs-on: [self-hosted, linux, x86_64, burrow-forge] + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Validate BEP metadata + shell: bash + run: | + set -euo pipefail + python3 Scripts/check-bep-metadata.py diff --git a/.github/workflows/lint-governance.yml b/.github/workflows/lint-governance.yml new file mode 100644 index 0000000..08b665c --- /dev/null +++ b/.github/workflows/lint-governance.yml @@ -0,0 +1,23 @@ +name: Governance Lint + +on: + pull_request: + branches: + - "*" + +jobs: + governance: + name: BEP Metadata + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Validate BEP metadata + shell: bash + run: | + set -euo pipefail + python3 Scripts/check-bep-metadata.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0ca7ced --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# instructions for agents + +1. Spell the project name as `Burrow` in user-facing copy and `burrow` in code, package, and protocol identifiers unless an existing integration requires a different literal. +2. Read [CONSTITUTION.md](CONSTITUTION.md) before changing Apple clients, the daemon, the control plane, forge infrastructure, identity, or security-sensitive code. +3. Anchor non-trivial changes in a Burrow Evolution Proposal (BEP) under [evolution/](evolution/README.md) so future contributors can inherit the rationale, safeguards, and rollout shape. +4. Before touching the Apple app, daemon IPC, or Tailnet flows, review: + - [evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md](evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md) + - [evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md](evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md) + - [evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md](evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md) + - [evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md](evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md) +5. Apple clients must talk only to the daemon over gRPC. Do not add direct HTTP, control-plane, or helper-process calls from Swift UI code. +6. Treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a separate user-facing protocol surface. +7. Maintain canonical identity and operator metadata in [contributors.nix](contributors.nix). If Burrow forge, Authentik, Headscale, or admin/group mappings need to change, edit that registry first and derive runtime configuration from it. +8. When process or architecture is unclear, stop and draft or update a BEP instead of improvising durable behavior in code. diff --git a/Makefile b/Makefile index f927f5f..1a0488c 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,12 @@ check: build: @cargo build +bep-check: + @python3 Scripts/check-bep-metadata.py + +bep-list: + @Scripts/bep list + daemon-console: @$(sudo_cargo_console) daemon diff --git a/README.md b/README.md index b8684c3..ba4f50c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Routine verification now runs unprivileged with `cargo test --workspace --all-fe The repository now carries its own design and deployment record: - [Constitution](./CONSTITUTION.md) +- [Agent Instructions](./AGENTS.md) - [Burrow Evolution](./evolution/README.md) - [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.md) - [Protocol Roadmap](./docs/PROTOCOL_ROADMAP.md) @@ -19,6 +20,8 @@ The repository now carries its own design and deployment record: Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). +Agent and governance-sensitive work should start with [AGENTS.md](./AGENTS.md), [CONSTITUTION.md](./CONSTITUTION.md), and the relevant BEPs under [`evolution/proposals/`](./evolution/proposals/). Identity and bootstrap metadata now live in [`contributors.nix`](./contributors.nix). + The project structure is divided in the following folders: ``` diff --git a/Scripts/bep b/Scripts/bep new file mode 100755 index 0000000..1c6bd64 --- /dev/null +++ b/Scripts/bep @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +proposals_dir="$repo_root/evolution/proposals" + +auto_browse() { + if command -v wisu >/dev/null 2>&1; then + exec wisu -i -g --icons "$repo_root/evolution" + fi + exec ls -la "$repo_root/evolution" +} + +usage() { + cat <<'USAGE' +Usage: bep [command] + +Commands: + list [--status ] List BEPs, optionally filtered by status. + open Open a BEP in $EDITOR. + help Show this help. + +If no command is provided, bep launches a simple browser for evolution/. +USAGE +} + +normalize_id() { + local raw="$1" + if [[ "$raw" =~ ^BEP-[0-9]+$ ]]; then + printf '%s' "$raw" + return 0 + fi + if [[ "$raw" =~ ^[0-9]+$ ]]; then + printf 'BEP-%04d' "$raw" + return 0 + fi + return 1 +} + +read_status() { + local file="$1" + awk -F ': ' '/^Status:/ {print $2; exit}' "$file" +} + +read_title() { + local file="$1" + local line + line=$(head -n 1 "$file" || true) + printf '%s' "$line" | sed -E 's/^# `[^`]+`[[:space:]]+//; s/^[^A-Za-z0-9]+//' +} + +list_bep() { + local filter="${1:-}" + local filter_lower="" + if [[ -n "$filter" ]]; then + filter_lower=$(printf '%s' "$filter" | tr '[:upper:]' '[:lower:]') + fi + + printf '%-10s %-18s %s\n' "BEP" "Status" "Title" + local file + local entries=() + for file in "$proposals_dir"/BEP-*.md; do + [[ -e "$file" ]] || continue + local base + base=$(basename "$file") + local id + id=$(printf '%s' "$base" | cut -d- -f1-2) + local status + status=$(read_status "$file") + local status_lower + status_lower=$(printf '%s' "$status" | tr '[:upper:]' '[:lower:]') + if [[ -n "$filter_lower" && "$status_lower" != "$filter_lower" ]]; then + continue + fi + local title + title=$(read_title "$file") + entries+=("$(printf '%-10s %-18s %s' "$id" "$status" "$title")") + done + if [[ ${#entries[@]} -gt 0 ]]; then + printf '%s\n' "${entries[@]}" | sort + fi +} + +open_bep() { + local raw="$1" + local id + if ! id=$(normalize_id "$raw"); then + echo "Unknown BEP id: $raw" >&2 + exit 1 + fi + local matches + matches=("$proposals_dir"/"$id"-*.md) + if [[ ${#matches[@]} -eq 0 || ! -e "${matches[0]}" ]]; then + echo "No proposal found for $id" >&2 + exit 1 + fi + if [[ ${#matches[@]} -gt 1 ]]; then + echo "Multiple proposals match $id:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + local editor="${EDITOR:-vi}" + exec "$editor" "${matches[0]}" +} + +command=${1:-} +case "$command" in + "") + auto_browse + ;; + list) + if [[ ${2:-} == "--status" && -n ${3:-} ]]; then + list_bep "$3" + else + list_bep + fi + ;; + open) + if [[ -z ${2:-} ]]; then + echo "bep open requires an id" >&2 + exit 1 + fi + open_bep "$2" + ;; + help|-h|--help) + usage + ;; + *) + echo "Unknown command: $command" >&2 + usage + exit 1 + ;; +esac diff --git a/Scripts/check-bep-metadata.py b/Scripts/check-bep-metadata.py new file mode 100755 index 0000000..d054934 --- /dev/null +++ b/Scripts/check-bep-metadata.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import pathlib +import re +import sys + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +PROPOSALS_DIR = REPO_ROOT / "evolution" / "proposals" +ALLOWED_STATUSES = { + "Pitch", + "Draft", + "In Review", + "Accepted", + "Implemented", + "Rejected", + "Returned for Revision", + "Superseded", + "Archived", +} +REQUIRED_FIELDS = [ + "Status", + "Proposal", + "Authors", + "Coordinator", + "Reviewers", + "Constitution Sections", + "Implementation PRs", + "Decision Date", +] + + +def text_block_lines(path: pathlib.Path) -> list[str]: + content = path.read_text(encoding="utf-8") + match = re.search(r"```text\n(.*?)\n```", content, re.DOTALL) + if not match: + raise ValueError("missing leading ```text metadata block") + return [line.rstrip() for line in match.group(1).splitlines() if line.strip()] + + +def validate(path: pathlib.Path) -> list[str]: + errors: list[str] = [] + proposal_id = path.name.split("-", 2)[:2] + expected_id = "-".join(proposal_id).removesuffix(".md") + + try: + lines = text_block_lines(path) + except ValueError as exc: + return [f"{path}: {exc}"] + + field_names = [line.split(":", 1)[0] for line in lines] + if field_names != REQUIRED_FIELDS: + errors.append( + f"{path}: metadata fields must appear in order {', '.join(REQUIRED_FIELDS)}" + ) + return errors + + fields = dict(line.split(":", 1) for line in lines) + fields = {key.strip(): value.strip() for key, value in fields.items()} + + if fields["Status"] not in ALLOWED_STATUSES: + errors.append(f"{path}: invalid Status {fields['Status']!r}") + + if fields["Proposal"] != expected_id: + errors.append( + f"{path}: Proposal field {fields['Proposal']!r} does not match filename id {expected_id!r}" + ) + + if fields["Status"] in {"Accepted", "Implemented", "Superseded", "Rejected", "Archived"} and fields["Decision Date"] == "Pending": + errors.append( + f"{path}: Decision Date must not be Pending once status is {fields['Status']}" + ) + + return errors + + +def main() -> int: + errors: list[str] = [] + for path in sorted(PROPOSALS_DIR.glob("BEP-*.md")): + errors.extend(validate(path)) + + if errors: + for error in errors: + print(error, file=sys.stderr) + return 1 + + print(f"checked {len(list(PROPOSALS_DIR.glob('BEP-*.md')))} BEPs") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/contributors.nix b/contributors.nix new file mode 100644 index 0000000..f6cc014 --- /dev/null +++ b/contributors.nix @@ -0,0 +1,47 @@ +{ + groups = { + users = "burrow-users"; + admins = "burrow-admins"; + }; + + identities = { + contact = { + displayName = "Burrow"; + canonicalEmail = "contact@burrow.net"; + sourceEmail = "net.burrow@gmail.com"; + isAdmin = true; + forgeAuthorized = true; + bootstrapAuthentik = true; + sshPublicKeyPath = ./nixos/keys/contact_at_burrow_net.pub; + roles = [ + "operator" + "forge-admin" + ]; + }; + + conrad = { + displayName = "Conrad Kramer"; + canonicalEmail = "conrad@burrow.net"; + sourceEmail = "ckrames1234@gmail.com"; + isAdmin = true; + forgeAuthorized = false; + bootstrapAuthentik = true; + roles = [ + "operator" + "founder" + ]; + }; + + agent = { + displayName = "Burrow Agent"; + canonicalEmail = "agent@burrow.net"; + isAdmin = false; + forgeAuthorized = true; + bootstrapAuthentik = false; + sshPublicKeyPath = ./nixos/keys/agent_at_burrow_net.pub; + roles = [ + "automation" + ]; + }; + }; +} diff --git a/evolution/README.md b/evolution/README.md index e55a347..794b1fe 100644 --- a/evolution/README.md +++ b/evolution/README.md @@ -58,3 +58,17 @@ evolution/ ``` Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly. + +## BEP Helper + +Use the `bep` helper under `Scripts/` to browse or list proposals: + +- `Scripts/bep` opens a quick browser for `evolution/`. +- `Scripts/bep list --status Draft` lists proposals by status. +- `Scripts/bep open BEP-0005` opens a proposal in `$EDITOR`. + +Validate proposal metadata with: + +```bash +python3 Scripts/check-bep-metadata.py +``` diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md new file mode 100644 index 0000000..1227444 --- /dev/null +++ b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md @@ -0,0 +1,78 @@ +# `BEP-0005` - Daemon IPC and Apple Boundary + +```text +Status: Draft +Proposal: BEP-0005 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, IV, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should formalize one Apple/runtime boundary: Apple clients speak only to the daemon over gRPC on the app-group Unix socket, and the daemon owns all external control-plane, helper-process, and runtime coordination work. This prevents UI code from accreting side HTTP paths or ad hoc control-plane integrations that bypass the system Burrow is supposed to own. + +## Motivation + +- The current Tailnet work already showed the failure mode: Swift UI code started reaching around the daemon boundary to talk to helper HTTP endpoints directly. +- Apple-specific process ownership is easy to blur between the app, the network extension, and helper daemons unless the contract is explicit. +- If Burrow wants a durable multi-runtime architecture, the daemon must remain the only orchestration boundary between clients and control/data-plane behavior. + +## Detailed Design + +- Apple UI and Apple support libraries may call only daemon gRPC methods over the declared Burrow Unix socket. +- Direct Swift calls to external control-plane HTTP APIs, localhost helper HTTP servers, or runtime-specific subprocesses are forbidden. +- The daemon is responsible for: + - discovery of Tailnet authorities and related metadata + - control-plane session setup and tracking + - login/session lifecycle brokering + - runtime start/stop/reconcile + - translating helper or bridge processes into stable daemon RPCs +- `burrow/src/control/` owns transport-neutral control-plane semantics such as discovery, authority normalization, and request/response shaping. +- Apple UI owns presentation only: + - forms + - local state + - presenting returned auth URLs or statuses + - surfacing daemon availability and errors +- Any new Apple-facing runtime capability requires a daemon RPC first. + +## Security and Operational Considerations + +- Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code. +- The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized. +- Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly. + +## Contributor Playbook + +- Before adding a new Apple-side workflow, identify the daemon RPC that should own it. +- If the RPC does not exist, add the protocol shape in `proto/burrow.proto`, implement it in the daemon, and only then wire Swift UI. +- Verify that no Swift UI or support code calls external control-plane HTTP endpoints directly. +- For Tailnet and similar flows, test: + - daemon unavailable behavior + - successful RPC path + - error propagation through the UI + +## Alternatives Considered + +- Let Apple UI call control-plane endpoints directly for convenience. Rejected because it creates parallel orchestration paths and breaks the daemon contract. +- Allow one-off exceptions for login helpers. Rejected because those exceptions become the architecture. + +## Impact on Other Work + +- Governs the Tailnet refactor and future Apple runtime work. +- Interacts with BEP-0002 control-plane bootstrap and BEP-0003 transport refactoring. + +## Decision + +Pending. + +## References + +- `Apple/UI/` +- `Apple/Core/` +- `Apple/NetworkExtension/` +- `burrow/src/daemon/` +- `burrow/src/control/` diff --git a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md new file mode 100644 index 0000000..fea4aba --- /dev/null +++ b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md @@ -0,0 +1,71 @@ +# `BEP-0006` - Tailnet Authority-First Control Plane + +```text +Status: Draft +Proposal: BEP-0006 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: I, II, IV, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a distinct user-facing protocol. Burrow’s config and UI should therefore be authority-first rather than provider-first. + +## Motivation + +- Splitting Tailscale and Headscale into separate user-facing providers causes fake architectural divergence. +- Discovery already naturally returns an authority and optional issuer; that is the stable contract users actually need. +- Future managed or enterprise deployments should fit the same model without requiring another protocol picker. + +## Detailed Design + +- Tailnet configuration is centered on: + - account + - identity + - authority/login server URL + - optional tailnet name + - optional hostname + - auth method/material +- User-facing surfaces should not force a protocol choice between Tailscale and Headscale. +- Provider inference may remain internal metadata for compatibility and diagnostics: + - default managed Tailscale authority + - custom self-hosted authority + - Burrow-owned authority when explicitly applicable +- Discovery returns authority and related metadata; editing the authority is the mechanism that moves a configuration from managed default to custom control server. +- The daemon and control layer own provider inference; the UI should primarily present “Tailnet” plus the selected authority. + +## Security and Operational Considerations + +- Authority-first config reduces UI complexity and makes misconfiguration easier to reason about. +- Provider-specific assumptions must not leak into packet or control-plane semantics unless the authority actually requires them. +- Auth material must remain authority-scoped and identity-scoped in daemon storage. + +## Contributor Playbook + +- Remove provider pickers from Tailnet UI unless a concrete protocol difference requires one. +- Store the authority explicitly in payloads and infer provider internally only when needed. +- Prefer tests that validate authority normalization and discovery behavior over UI-provider branching. + +## Alternatives Considered + +- Keep separate user-facing providers for Tailscale and Headscale. Rejected because it models deployment shape as protocol shape. +- Collapse all control planes into one opaque Burrow provider. Rejected because the authority still matters operationally and diagnostically. + +## Impact on Other Work + +- Refines BEP-0002’s Tailscale-shaped control-plane work. +- Constrains the Tailnet Apple refactor and future daemon control-plane storage. + +## Decision + +Pending. + +## References + +- `burrow/src/control/` +- `Apple/UI/Networks/` +- `proto/burrow.proto` diff --git a/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md b/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md new file mode 100644 index 0000000..1fde0fb --- /dev/null +++ b/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md @@ -0,0 +1,73 @@ +# `BEP-0007` - Identity Registry and Operator Bootstrap + +```text +Status: Draft +Proposal: BEP-0007 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, IV, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should maintain one canonical registry for project identities, aliases, bootstrap users, SSH keys, and admin-group mappings. Forgejo, Authentik, and related bootstrap configuration should derive from that registry instead of hardcoding overlapping identity facts in multiple modules. + +## Motivation + +- Burrow currently hardcodes operator and admin/bootstrap user facts directly in host configuration. +- Multi-account and self-hosted identity are becoming core architecture, not incidental infra details. +- A single registry reduces drift across Forgejo, Authentik, Headscale, SSH authorization, and future control-plane bootstrap. + +## Detailed Design + +- Add a root-level identity registry (`contributors.nix`) as the canonical source of truth for: + - usernames + - display names + - canonical emails + - external source emails or aliases + - admin scope + - bootstrap eligibility + - forge authorized SSH keys + - named roles +- Consume that registry from host configuration for: + - Forgejo authorized keys + - Forgejo bootstrap admin defaults + - Authentik bootstrap users + - Burrow user/admin group names +- Future work may derive contributor docs, OIDC bootstrap, and additional runtime configuration from the same registry. + +## Security and Operational Considerations + +- Identity drift is a security bug when it affects admin groups, bootstrap accounts, or SSH authorization. +- The registry stores metadata only; secrets remain in agenix or other declared secret paths. +- Changes to the registry should receive explicit review because they affect access and governance. + +## Contributor Playbook + +- Edit `contributors.nix` first when changing operator, admin, alias, or bootstrap identity state. +- Derive runtime configuration from the registry instead of duplicating the same facts elsewhere. +- Keep secret references separate from identity metadata. + +## Alternatives Considered + +- Continue hardcoding users in module options. Rejected because drift is inevitable once Forgejo, Authentik, and Headscale all depend on the same identities. +- Create separate per-service user lists. Rejected because it duplicates governance facts and weakens review. + +## Impact on Other Work + +- Supports forge auth, Authentik group sync, and future multi-account Burrow control-plane work. +- Creates the basis for stronger contributor and operator provenance later. + +## Decision + +Pending. + +## References + +- `contributors.nix` +- `nixos/hosts/burrow-forge/default.nix` +- `nixos/modules/burrow-authentik.nix` +- `nixos/modules/burrow-forge.nix` diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index d612ea8..fb5b8ae 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -1,4 +1,23 @@ -{ config, self, ... }: +{ config, lib, self, ... }: + +let + contributors = import ../../../contributors.nix; + identities = contributors.identities; + bootstrapUsers = lib.mapAttrsToList + ( + username: identity: { + inherit username; + name = identity.displayName; + email = identity.canonicalEmail; + sourceEmail = identity.sourceEmail or null; + isAdmin = identity.isAdmin or false; + } + ) + (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); + forgeAuthorizedKeys = map + (username: builtins.readFile identities.${username}.sshPublicKeyPath) + (builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeAuthorized or false) identities)); +in { imports = [ @@ -59,12 +78,14 @@ services.burrow.forge = { enable = true; + contactEmail = identities.contact.canonicalEmail; + adminUsername = "contact"; + adminEmail = identities.contact.canonicalEmail; adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; + oidcAdminGroup = contributors.groups.admins; + oidcRestrictedGroup = contributors.groups.users; oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; - authorizedKeys = [ - (builtins.readFile ../../keys/contact_at_burrow_net.pub) - (builtins.readFile ../../keys/agent_at_burrow_net.pub) - ]; + authorizedKeys = forgeAuthorizedKeys; }; services.burrow.forgeRunner = { @@ -92,22 +113,9 @@ googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleLoginMode = "redirect"; - bootstrapUsers = [ - { - username = "contact"; - name = "Burrow"; - email = "contact@burrow.net"; - sourceEmail = "net.burrow@gmail.com"; - isAdmin = true; - } - { - username = "conrad"; - name = "Conrad Kramer"; - email = "conrad@burrow.net"; - sourceEmail = "ckrames1234@gmail.com"; - isAdmin = true; - } - ]; + userGroupName = contributors.groups.users; + adminGroupName = contributors.groups.admins; + bootstrapUsers = bootstrapUsers; }; services.burrow.headscale = { From d1e28b881775967fa696294bc4d3c18ebebde757 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 01:36:55 -0700 Subject: [PATCH 02/11] Route Tailnet Apple flows through daemon gRPC --- Apple/Core/Client.swift | 198 ++++++++++++++++ Apple/UI/BurrowView.swift | 406 ++++++-------------------------- Apple/UI/Networks/Network.swift | 254 ++++++-------------- burrow/src/control/discovery.rs | 136 ++++++++++- burrow/src/daemon/instance.rs | 48 +++- burrow/src/daemon/mod.rs | 7 +- burrow/src/daemon/rpc/client.rs | 8 +- proto/burrow.proto | 28 +++ 8 files changed, 565 insertions(+), 520 deletions(-) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index 8874e3b..c426fe7 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -1,5 +1,7 @@ +import Foundation import GRPC import NIOTransportServices +import SwiftProtobuf public typealias TunnelClient = Burrow_TunnelAsyncClient public typealias NetworksClient = Burrow_NetworksAsyncClient @@ -30,3 +32,199 @@ extension NetworksClient: Client { self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none) } } + +public struct Burrow_TailnetDiscoverRequest: Sendable { + public var email: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetDiscoverResponse: Sendable { + public var domain: String = "" + public var authority: String = "" + public var oidcIssuer: String = "" + public var managed: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetProbeRequest: Sendable { + public var authority: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetProbeResponse: Sendable { + public var authority: String = "" + public var statusCode: Int32 = 0 + public var summary: String = "" + public var detail: String = "" + public var reachable: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "email") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.email) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.email.isEmpty { + try visitor.visitSingularStringField(value: self.email, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetDiscoverResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetDiscoverResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "domain"), + 2: .same(proto: "authority"), + 3: .same(proto: "oidc_issuer"), + 4: .same(proto: "managed"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.domain) + case 2: try decoder.decodeSingularStringField(value: &self.authority) + case 3: try decoder.decodeSingularStringField(value: &self.oidcIssuer) + case 4: try decoder.decodeSingularBoolField(value: &self.managed) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.domain.isEmpty { + try visitor.visitSingularStringField(value: self.domain, fieldNumber: 1) + } + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 2) + } + if !self.oidcIssuer.isEmpty { + try visitor.visitSingularStringField(value: self.oidcIssuer, fieldNumber: 3) + } + if self.managed { + try visitor.visitSingularBoolField(value: self.managed, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetProbeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetProbeRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "authority") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.authority) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetProbeResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "authority"), + 2: .same(proto: "status_code"), + 3: .same(proto: "summary"), + 4: .same(proto: "detail"), + 5: .same(proto: "reachable"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.authority) + case 2: try decoder.decodeSingularInt32Field(value: &self.statusCode) + case 3: try decoder.decodeSingularStringField(value: &self.summary) + case 4: try decoder.decodeSingularStringField(value: &self.detail) + case 5: try decoder.decodeSingularBoolField(value: &self.reachable) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1) + } + if self.statusCode != 0 { + try visitor.visitSingularInt32Field(value: self.statusCode, fieldNumber: 2) + } + if !self.summary.isEmpty { + try visitor.visitSingularStringField(value: self.summary, fieldNumber: 3) + } + if !self.detail.isEmpty { + try visitor.visitSingularStringField(value: self.detail, fieldNumber: 4) + } + if self.reachable { + try visitor.visitSingularBoolField(value: self.reachable, fieldNumber: 5) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +public struct TailnetClient: Client, GRPCClient { + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions + + public init(channel: any GRPCChannel) { + self.channel = channel + self.defaultCallOptions = .init() + } + + public func discover( + _ request: Burrow_TailnetDiscoverRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetDiscoverResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/Discover", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func probe( + _ request: Burrow_TailnetProbeRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetProbeResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/Probe", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } +} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index b4fa7d8..9938eef 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,4 +1,3 @@ -import AuthenticationServices import BurrowConfiguration import Foundation import SwiftUI @@ -204,7 +203,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: .wireGuard case .tor: .tor - case .tailnet: .headscale + case .tailnet: .tailnet } } @@ -285,13 +284,12 @@ private struct AccountDraft { var wireGuardConfig = "" var discoveryEmail = "" - var tailnetProvider: TailnetProvider = .tailscale var authority = "" var tailnet = "" var hostname = ProcessInfo.processInfo.hostName var username = "" var secret = "" - var authMode: AccountAuthMode = .web + var authMode: AccountAuthMode = .none var torAddresses = "100.64.0.2/32" var torDNS = "1.1.1.1, 1.0.0.1" @@ -317,7 +315,6 @@ private struct AccountDraft { private struct ConfigurationSheetView: View { @Environment(\.dismiss) private var dismiss - @Environment(\.webAuthenticationSession) private var webAuthenticationSession let sheet: ConfigurationSheet let networkViewModel: NetworkViewModel @@ -326,17 +323,13 @@ private struct ConfigurationSheetView: View { @State private var draft: AccountDraft @State private var isSubmitting = false @State private var errorMessage: String? - @State private var loginSessionID: String? - @State private var loginStatus: TailnetLoginStatus? @State private var discoveryStatus: TailnetDiscoveryResponse? @State private var discoveryError: String? @State private var isDiscoveringTailnet = false @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeError: String? @State private var isProbingAuthority = false - @State private var pollingTask: Task? @State private var didRunAutomation = false - @State private var webAuthenticationTask: Task? init( sheet: ConfigurationSheet, @@ -447,20 +440,12 @@ private struct ConfigurationSheetView: View { .onAppear { runAutomationIfNeeded() } - .onChange(of: draft.tailnetProvider) { _, _ in - resetAuthorityProbe() - } .onChange(of: draft.authority) { _, _ in resetAuthorityProbe() } .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() } - .onDisappear { - pollingTask?.cancel() - webAuthenticationTask?.cancel() - webAuthenticationTask = nil - } } @ViewBuilder @@ -490,48 +475,30 @@ private struct ConfigurationSheetView: View { tailnetDiscoveryCard(status: nil, failure: discoveryError) } - Picker( - "Provider", - selection: Binding( - get: { draft.tailnetProvider }, - set: { applyTailnetProvider($0) } - ) - ) { - ForEach(TailnetProvider.allCases) { provider in - Text(provider.title).tag(provider) + TextField("Authority URL", text: $draft.authority) + .burrowLoginField() + .autocorrectionDisabled() + + Text("Use the managed Tailnet authority or enter a custom Tailnet control server.") + .font(.footnote) + .foregroundStyle(.secondary) + + Button { + probeTailnetAuthority() + } label: { + Label { + Text(isProbingAuthority ? "Checking Connection" : "Check Connection") + } icon: { + Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") } } - .pickerStyle(.menu) + .buttonStyle(.borderless) + .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) - tailnetProviderCard - - if draft.tailnetProvider.requiresControlURL { - TextField("Server URL", text: $draft.authority) - .burrowLoginField() - .autocorrectionDisabled() - - Button { - probeTailnetAuthority() - } label: { - Label { - Text(isProbingAuthority ? "Checking Connection" : "Check Connection") - } icon: { - Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") - } - } - .buttonStyle(.borderless) - .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) - - if let authorityProbeStatus { - tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) - } else if let authorityProbeError { - tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) - } - } else { - LabeledContent("Server") { - Text("Tailscale managed") - .foregroundStyle(.secondary) - } + if let authorityProbeStatus { + tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) + } else if let authorityProbeError { + tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) } TextField("Tailnet", text: $draft.tailnet) @@ -540,28 +507,24 @@ private struct ConfigurationSheetView: View { } Section("Authentication") { - if tailnetUsesWebLogin { - tailnetWebLoginCard - } else { - TextField("Username", text: $draft.username) - .burrowLoginField() - .autocorrectionDisabled() - Picker("Authentication", selection: $draft.authMode) { - ForEach(availableTailnetAuthModes) { mode in - Text(mode.title).tag(mode) - } + TextField("Username", text: $draft.username) + .burrowLoginField() + .autocorrectionDisabled() + Picker("Authentication", selection: $draft.authMode) { + ForEach(availableTailnetAuthModes) { mode in + Text(mode.title).tag(mode) } - .pickerStyle(.menu) - if draft.authMode != .none { - SecureField( - draft.authMode == .password ? "Password" : "Preauth Key", - text: $draft.secret - ) - } - Text("Credentials stay on-device. Burrow uses them when it needs to register or refresh this identity.") - .font(.footnote) - .foregroundStyle(.secondary) } + .pickerStyle(.menu) + if draft.authMode != .none { + SecureField( + draft.authMode == .password ? "Password" : "Preauth Key", + text: $draft.secret + ) + } + Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.") + .font(.footnote) + .foregroundStyle(.secondary) } } @@ -618,10 +581,8 @@ private struct ConfigurationSheetView: View { if sheet == .tailnet { HStack(spacing: 8) { - summaryBadge(draft.tailnetProvider.title) - summaryBadge( - tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title - ) + summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") + summaryBadge(draft.authMode.title) } } } @@ -632,79 +593,6 @@ private struct ConfigurationSheetView: View { ) } - private var tailnetProviderCard: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 10) { - Image(systemName: tailnetProviderIconName) - .font(.headline) - .foregroundStyle(sheetAccentColor) - .frame(width: 28, height: 28) - .background( - Circle() - .fill(sheetAccentColor.opacity(0.14)) - ) - - VStack(alignment: .leading, spacing: 2) { - Text(draft.tailnetProvider.title) - .font(.headline) - Text(draft.tailnetProvider.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer() - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - } - - @ViewBuilder - private var tailnetWebLoginCard: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Sign in with the shared browser session.") - .font(.subheadline.weight(.medium)) - - if let loginStatus { - labeledValue("State", loginStatus.backendState) - if let tailnetName = loginStatus.tailnetName { - labeledValue("Tailnet", tailnetName) - } - if let dnsName = loginStatus.selfDNSName { - labeledValue("Device", dnsName) - } - if !loginStatus.tailscaleIPs.isEmpty { - labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", ")) - } - if let authURL = loginStatus.authURL { - Button("Resume Sign-In") { - if let url = URL(string: authURL) { - openLoginURL(url) - } - } - .buttonStyle(.borderless) - } - if !loginStatus.health.isEmpty { - Text(loginStatus.health.joined(separator: " • ")) - .font(.footnote) - .foregroundStyle(.secondary) - } - } else { - Text("Burrow launches the local bridge, then opens the real provider sign-in page in-app.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - } - private func tailnetAuthorityProbeCard( status: TailnetAuthorityProbeStatus?, failure: String? @@ -739,12 +627,15 @@ private struct ConfigurationSheetView: View { ) -> some View { VStack(alignment: .leading, spacing: 6) { if let status { - Text("Discovered \(status.provider.title)") + Text("Discovered Tailnet Server") .font(.subheadline.weight(.medium)) Text(status.authority) .font(.footnote.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) + Text(status.provider == .tailscale ? "Managed authority" : "Custom authority") + .font(.footnote) + .foregroundStyle(.secondary) if let oidcIssuer = status.oidcIssuer { Text("OIDC: \(oidcIssuer)") .font(.footnote) @@ -826,12 +717,8 @@ private struct ConfigurationSheetView: View { } case .tailnet: - Menu("Provider") { - ForEach(TailnetProvider.allCases) { provider in - Button(provider.title) { - applyTailnetProvider(provider) - } - } + Button("Use Tailscale Managed Server") { + applyTailnetDefaults(for: .tailscale) } if availableTailnetAuthModes.count > 1 { @@ -839,7 +726,7 @@ private struct ConfigurationSheetView: View { ForEach(availableTailnetAuthModes) { mode in Button(mode.title) { draft.authMode = mode - if mode == .none || mode == .web { + if mode == .none { draft.secret = "" } } @@ -847,8 +734,8 @@ private struct ConfigurationSheetView: View { } } - Button("Restore Provider Defaults") { - applyTailnetDefaults(for: draft.tailnetProvider) + Button("Clear Discovery Result") { + resetTailnetDiscoveryFeedback() } } } @@ -886,17 +773,6 @@ private struct ConfigurationSheetView: View { } } - private var tailnetProviderIconName: String { - switch draft.tailnetProvider { - case .tailscale: - "globe.badge.chevron.backward" - case .headscale: - "server.rack" - case .burrow: - "shield" - } - } - private var showsBottomActionButton: Bool { #if os(iOS) true @@ -920,9 +796,6 @@ private struct ConfigurationSheetView: View { case .tor: return "Save Account" case .tailnet: - if tailnetUsesWebLogin { - return loginStatus?.running == true ? "Save Account" : "Start Sign-In" - } return "Save Account" } } @@ -937,12 +810,9 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { return true } - if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { + if normalizedOptional(draft.authority) == nil { return true } - if tailnetUsesWebLogin { - return false - } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true } @@ -1027,41 +897,12 @@ private struct ConfigurationSheetView: View { } private func submitTailnet() async throws { - if tailnetUsesWebLogin { - if loginStatus?.running == true { - webAuthenticationTask?.cancel() - webAuthenticationTask = nil - try await saveTailnetAccount(secret: nil, username: nil) - dismiss() - } else { - try await startTailnetLogin() - } - return - } - let secret = draft.authMode == .none ? nil : draft.secret let username = normalizedOptional(draft.username) try await saveTailnetAccount(secret: secret, username: username) dismiss() } - private func startTailnetLogin() async throws { - let response = try await TailnetBridgeClient.startLogin( - TailnetLoginStartRequest( - accountName: normalized(draft.accountName, fallback: "default"), - identityName: normalized(draft.identityName, fallback: "apple"), - hostname: normalizedOptional(draft.hostname), - controlURL: normalizedOptional(draft.authority) ?? draft.tailnetProvider.defaultAuthority - ) - ) - loginSessionID = response.sessionID - loginStatus = response.status - if let authURL = response.status.authURL, let url = URL(string: authURL) { - openLoginURL(url) - } - startPollingTailscaleLogin() - } - private func runAutomationIfNeeded() { guard !didRunAutomation, sheet == .tailnet, @@ -1080,79 +921,19 @@ private struct ConfigurationSheetView: View { Task { @MainActor in switch automation.action { case .tailnetLogin: - draft.tailnetProvider = .tailscale - do { - try await startTailnetLogin() - } catch { - errorMessage = error.localizedDescription - } + applyTailnetDefaults(for: .tailscale) + probeTailnetAuthority() case .headscaleProbe: - applyTailnetProvider(.headscale) draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority probeTailnetAuthority() } } } - private func startPollingTailscaleLogin() { - pollingTask?.cancel() - guard let loginSessionID else { return } - pollingTask = Task { @MainActor in - while !Task.isCancelled { - do { - let status = try await TailnetBridgeClient.status(sessionID: loginSessionID) - let previousAuthURL = loginStatus?.authURL - loginStatus = status - if previousAuthURL == nil, - let authURL = status.authURL, - let url = URL(string: authURL) - { - openLoginURL(url) - } - if status.running { - webAuthenticationTask?.cancel() - webAuthenticationTask = nil - return - } - } catch { - errorMessage = error.localizedDescription - return - } - try? await Task.sleep(for: .seconds(2)) - } - } - } - - private func openLoginURL(_ url: URL) { - webAuthenticationTask?.cancel() - webAuthenticationTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(300)) - do { - _ = try await webAuthenticationSession.authenticate( - using: url, - callbackURLScheme: "burrow", - preferredBrowserSession: .shared - ) - } catch is CancellationError { - return - } catch let error as ASWebAuthenticationSessionError - where error.code == .canceledLogin - { - return - } catch { - errorMessage = error.localizedDescription - } - webAuthenticationTask = nil - } - } - private func saveTailnetAccount(secret: String?, username: String?) async throws { - let provider = draft.tailnetProvider + let provider = inferredTailnetProvider let title = titleOrFallback( - hostnameFallback( - from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, - fallback: provider.title - ) + hostnameFallback(from: draft.authority, fallback: "Tailnet") ) let payload = TailnetNetworkPayload( @@ -1160,22 +941,14 @@ private struct ConfigurationSheetView: View { authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""), account: normalized(draft.accountName, fallback: "default"), identity: normalized(draft.identityName, fallback: "apple"), - tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), + tailnet: normalizedOptional(draft.tailnet), hostname: normalizedOptional(draft.hostname) ) var noteParts: [String] = [ - provider.title, - tailnetUsesWebLogin - ? "State: \(loginStatus?.backendState ?? "NeedsLogin")" - : "Auth: \(draft.authMode.title)", + isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet", + "Auth: \(draft.authMode.title)", ] - if let dnsName = loginStatus?.selfDNSName { - noteParts.append("Device: \(dnsName)") - } - if let magicDNSSuffix = loginStatus?.magicDNSSuffix { - noteParts.append("MagicDNS: \(magicDNSSuffix)") - } do { let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) @@ -1186,7 +959,7 @@ private struct ConfigurationSheetView: View { let record = NetworkAccountRecord( id: UUID(), - kind: .headscale, + kind: .tailnet, title: title, authority: payload.authority, provider: provider, @@ -1195,7 +968,7 @@ private struct ConfigurationSheetView: View { hostname: payload.hostname, username: username, tailnet: payload.tailnet, - authMode: tailnetUsesWebLogin ? .web : draft.authMode, + authMode: draft.authMode, note: noteParts.joined(separator: " • "), createdAt: .now, updatedAt: .now @@ -1226,33 +999,15 @@ private struct ConfigurationSheetView: View { draft.torListen = defaults.torListen } - private func applyTailnetProvider(_ provider: TailnetProvider) { - resetTailnetDiscoveryFeedback() - draft.tailnetProvider = provider - applyTailnetDefaults(for: provider) - } - private func applyTailnetDefaults(for provider: TailnetProvider) { + resetTailnetDiscoveryFeedback() draft.authority = provider.defaultAuthority ?? "" - loginStatus = nil - loginSessionID = nil - pollingTask?.cancel() - if provider == .tailscale { - draft.authMode = .web - draft.username = "" - draft.secret = "" - } else { - if !availableTailnetAuthModes.contains(draft.authMode) { - draft.authMode = provider.supportsWebLogin ? .web : .none - } - if draft.authMode == .web && !provider.supportsWebLogin { - draft.authMode = .none - } + if !availableTailnetAuthModes.contains(draft.authMode) { + draft.authMode = .none } } private func probeTailnetAuthority() { - guard draft.tailnetProvider.requiresControlURL else { return } guard let authority = normalizedOptional(draft.authority) else { authorityProbeStatus = nil authorityProbeError = "Enter a server URL first." @@ -1266,10 +1021,7 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isProbingAuthority = false } do { - authorityProbeStatus = try await TailnetAuthorityProbeClient.probe( - provider: draft.tailnetProvider, - authority: authority - ) + authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority) } catch { authorityProbeError = error.localizedDescription } @@ -1300,15 +1052,9 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isDiscoveringTailnet = false } do { - let discovery = try await TailnetDiscoveryClient.discover(email: email) + let discovery = try await networkViewModel.discoverTailnet(email: email) discoveryStatus = discovery - draft.tailnetProvider = discovery.provider draft.authority = discovery.authority - if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil { - draft.authMode = .web - draft.username = "" - draft.secret = "" - } probeTailnetAuthority() } catch { discoveryError = error.localizedDescription @@ -1361,19 +1107,19 @@ private struct ConfigurationSheetView: View { return host } - private var tailnetUsesWebLogin: Bool { - draft.authMode == .web && draft.tailnetProvider.supportsWebLogin + private var availableTailnetAuthModes: [AccountAuthMode] { + [.none, .password, .preauthKey] } - private var availableTailnetAuthModes: [AccountAuthMode] { - switch draft.tailnetProvider { - case .tailscale: - [.web] - case .headscale: - [.web, .none, .password, .preauthKey] - case .burrow: - [.none, .password, .preauthKey] - } + private var inferredTailnetProvider: TailnetProvider { + TailnetProvider.inferred( + authority: normalizedOptional(draft.authority), + explicit: discoveryStatus?.provider + ) + } + + private var isManagedTailnetAuthority: Bool { + TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority)) } @ViewBuilder diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 9a534ce..b048add 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -26,13 +26,6 @@ struct TailnetNetworkPayload: Codable, Sendable { } } -struct TailnetLoginStartRequest: Codable, Sendable { - var accountName: String - var identityName: String - var hostname: String? - var controlURL: String? -} - struct TailnetDiscoveryResponse: Codable, Sendable { var domain: String var provider: TailnetProvider @@ -40,23 +33,6 @@ struct TailnetDiscoveryResponse: Codable, Sendable { var oidcIssuer: String? } -struct TailnetLoginStatus: Codable, Sendable { - var backendState: String - var authURL: String? - var running: Bool - var needsLogin: Bool - var tailnetName: String? - var magicDNSSuffix: String? - var selfDNSName: String? - var tailscaleIPs: [String] - var health: [String] -} - -struct TailnetLoginStartResponse: Codable, Sendable { - var sessionID: String - var status: TailnetLoginStatus -} - struct TailnetAuthorityProbeStatus: Sendable { var authority: String var statusCode: Int @@ -64,148 +40,38 @@ struct TailnetAuthorityProbeStatus: Sendable { var detail: String? } -enum TailnetBridgeClient { - private static let baseURL = URL(string: "http://127.0.0.1:8080")! - - static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse { - var urlRequest = URLRequest( - url: baseURL.appendingPathComponent("v1/tailscale/login/start") - ) - urlRequest.httpMethod = "POST" - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - urlRequest.httpBody = try encoder.encode(request) - - let (data, response) = try await URLSession.shared.data(for: urlRequest) - try validate(response: response, data: data) - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(TailnetLoginStartResponse.self, from: data) - } - - static func status(sessionID: String) async throws -> TailnetLoginStatus { - let url = baseURL - .appendingPathComponent("v1/tailscale/login") - .appendingPathComponent(sessionID) - let (data, response) = try await URLSession.shared.data(from: url) - try validate(response: response, data: data) - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(TailnetLoginStatus.self, from: data) - } - - fileprivate static func validate(response: URLResponse, data: Data) throws { - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let message = String(data: data, encoding: .utf8)?.trimmingCharacters( - in: .whitespacesAndNewlines - ) - throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)") - } - } -} - enum TailnetDiscoveryClient { - private static let baseURL = URL(string: "http://127.0.0.1:8080")! + static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { + var request = Burrow_TailnetDiscoverRequest() + request.email = email - static func discover(email: String) async throws -> TailnetDiscoveryResponse { - guard var components = URLComponents( - url: baseURL.appendingPathComponent("v1/tailnet/discover"), - resolvingAgainstBaseURL: false - ) else { - throw URLError(.badURL) - } - components.queryItems = [ - URLQueryItem(name: "email", value: email) - ] - guard let url = components.url else { - throw URLError(.badURL) - } - - let (data, response) = try await URLSession.shared.data(from: url) - try TailnetBridgeClient.validate(response: response, data: data) - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(TailnetDiscoveryResponse.self, from: data) + let response = try await TailnetClient.unix(socketURL: socketURL).discover(request) + return TailnetDiscoveryResponse( + domain: response.domain, + provider: response.managed ? .tailscale : .headscale, + authority: response.authority, + oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.oidcIssuer + ) } } enum TailnetAuthorityProbeClient { - static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus { - let normalizedAuthority = normalizeAuthority(authority) - let baseURL = try validatedBaseURL(normalizedAuthority) - let probeURL = probeURL(for: provider, baseURL: baseURL) - - var request = URLRequest(url: probeURL) - request.timeoutInterval = 10 - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let message = String(data: data, encoding: .utf8)?.trimmingCharacters( - in: .whitespacesAndNewlines - ) - throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)") - } - - let body = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let detail = body.flatMap { $0.isEmpty ? nil : $0 } + static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus { + var request = Burrow_TailnetProbeRequest() + request.authority = authority + let response = try await TailnetClient.unix(socketURL: socketURL).probe(request) return TailnetAuthorityProbeStatus( - authority: normalizedAuthority, - statusCode: http.statusCode, - summary: "\(provider.title) reachable", - detail: detail + authority: response.authority, + statusCode: Int(response.statusCode), + summary: response.summary, + detail: response.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.detail ) } - - private static func normalizeAuthority(_ authority: String) -> String { - let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.contains("://") { - return trimmed - } - return "https://\(trimmed)" - } - - private static func validatedBaseURL(_ authority: String) throws -> URL { - guard let url = URL(string: authority), url.host != nil else { - throw TailnetBridgeError.server("Invalid server URL") - } - return url - } - - private static func probeURL(for provider: TailnetProvider, baseURL: URL) -> URL { - switch provider { - case .headscale: - baseURL.appendingPathComponent("health") - case .burrow: - baseURL.appendingPathComponent("healthz") - case .tailscale: - baseURL - } - } -} - -enum TailnetBridgeError: LocalizedError { - case server(String) - - var errorDescription: String? { - switch self { - case .server(let message): - message - } - } } @Observable @@ -215,7 +81,7 @@ final class NetworkViewModel: Sendable { private(set) var connectionError: String? private let socketURLResult: Result - nonisolated(unsafe) private var task: Task? + @ObservationIgnored private var task: Task? init(socketURLResult: Result) { self.socketURLResult = socketURLResult @@ -242,6 +108,16 @@ final class NetworkViewModel: Sendable { try await addNetwork(type: .tailnet, payload: payload.encoded()) } + func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse { + let socketURL = try socketURLResult.get() + return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL) + } + + func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus { + let socketURL = try socketURLResult.get() + return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) + } + private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { let socketURL = try socketURLResult.get() let networkID = nextNetworkID @@ -341,19 +217,6 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { } } - var supportsWebLogin: Bool { - switch self { - case .tailscale, .headscale: - true - case .burrow: - false - } - } - - var requiresControlURL: Bool { - self != .tailscale - } - var defaultAuthority: String? { switch self { case .tailscale: @@ -368,19 +231,44 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { var subtitle: String { switch self { case .tailscale: - "Use Tailscale's real browser login flow." + "Managed Tailnet authority." case .headscale: - "Use your Headscale control plane with browser or key-based sign-in." + "Custom Tailnet control server." case .burrow: - "Store Burrow control-plane credentials." + "Burrow-native Tailnet authority." } } + + static func inferred(authority: String?, explicit: TailnetProvider?) -> TailnetProvider { + if explicit == .burrow { + return .burrow + } + if isManagedTailscaleAuthority(authority) { + return .tailscale + } + return .headscale + } + + static func isManagedTailscaleAuthority(_ authority: String?) -> Bool { + guard let normalized = authority? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "/")), + !normalized.isEmpty + else { + return false + } + + return normalized == "https://controlplane.tailscale.com" + || normalized == "http://controlplane.tailscale.com" + || normalized == "controlplane.tailscale.com" + } } enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { case wireGuard case tor - case headscale + case tailnet var id: String { rawValue } @@ -388,7 +276,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "WireGuard" case .tor: "Tor" - case .headscale: "Tailnet" + case .tailnet: "Tailnet" } } @@ -396,7 +284,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Import a tunnel and optional account metadata." case .tor: "Store Arti account and identity preferences." - case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities." + case .tailnet: "Save Tailnet authority, identity, and login material." } } @@ -404,7 +292,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: .init("WireGuard") case .tor: .orange - case .headscale: .mint + case .tailnet: .mint } } @@ -412,7 +300,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Add Network" case .tor: "Save Account" - case .headscale: "Save Account" + case .tailnet: "Save Account" } } @@ -422,7 +310,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { nil case .tor: "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." - case .headscale: + case .tailnet: "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon." } } @@ -430,7 +318,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { case none - case web case password case preauthKey @@ -439,7 +326,6 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { case .none: "None" - case .web: "Web Login" case .password: "Password" case .preauthKey: "Preauth Key" } @@ -465,17 +351,15 @@ struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable { struct TailnetCard { var id: Int32 - var provider: String var title: String var detail: String init(network: Burrow_Network) { let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) id = network.id - provider = payload?.provider.title ?? "Tailnet" title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" detail = [ - payload?.provider.title, + payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority, payload?.authority, payload.map { "Account: \($0.account)" }, ] @@ -492,7 +376,7 @@ struct TailnetCard { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { - Text(provider) + Text("Tailnet") .font(.headline) .foregroundStyle(.white.opacity(0.85)) Text(title) diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index 28b48bb..5fc7add 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -7,6 +7,7 @@ use super::TailnetProvider; pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server"; const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet"; const WEBFINGER_PATH: &str = "/.well-known/webfinger"; +const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com"; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TailnetDiscovery { @@ -17,6 +18,15 @@ pub struct TailnetDiscovery { pub oidc_issuer: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TailnetAuthorityProbe { + pub authority: String, + pub status_code: i32, + pub summary: String, + pub detail: String, + pub reachable: bool, +} + #[derive(Clone, Debug, Default, Deserialize)] struct WebFingerDocument { #[serde(default)] @@ -43,6 +53,63 @@ pub async fn discover_tailnet(email: &str) -> Result { discover_tailnet_at(&client, email, &base_url).await } +pub fn normalize_authority(authority: &str) -> String { + let trimmed = authority.trim(); + if trimmed.contains("://") { + trimmed.to_owned() + } else { + format!("https://{trimmed}") + } +} + +pub fn is_managed_tailscale_authority(authority: &str) -> bool { + let normalized = normalize_authority(authority) + .trim_end_matches('/') + .to_ascii_lowercase(); + normalized == format!("https://{MANAGED_TAILSCALE_AUTHORITY}") + || normalized == format!("http://{MANAGED_TAILSCALE_AUTHORITY}") +} + +pub async fn probe_tailnet_authority(authority: &str) -> Result { + let authority = normalize_authority(authority); + if is_managed_tailscale_authority(&authority) { + return Ok(TailnetAuthorityProbe { + authority, + status_code: 200, + summary: "Tailscale-managed control plane".to_owned(), + detail: "Using Tailscale's default login server.".to_owned(), + reachable: true, + }); + } + + let base_url = + Url::parse(&authority).with_context(|| format!("invalid tailnet authority {authority}"))?; + let client = Client::builder() + .user_agent("burrow-tailnet-probe") + .timeout(std::time::Duration::from_secs(10)) + .build() + .context("failed to build tailnet authority probe client")?; + + if let Some(status) = + probe_url(&client, base_url.join("/health")?, &authority, "Tailnet server reachable").await? + { + return Ok(status); + } + + if let Some(status) = probe_url( + &client, + base_url.clone(), + &authority, + "Tailnet server reachable", + ) + .await? + { + return Ok(status); + } + + Err(anyhow!("could not connect to the server")) +} + pub async fn discover_tailnet_at( client: &Client, email: &str, @@ -57,7 +124,7 @@ pub async fn discover_tailnet_at( if let Some(authority) = discover_webfinger(client, email, base_url).await? { return Ok(TailnetDiscovery { domain, - provider: TailnetProvider::Headscale, + provider: inferred_provider(Some(&authority), None), authority, oidc_issuer: None, }); @@ -78,6 +145,19 @@ pub fn email_domain(email: &str) -> Result { Ok(domain) } +pub fn inferred_provider( + authority: Option<&str>, + explicit: Option<&TailnetProvider>, +) -> TailnetProvider { + if matches!(explicit, Some(TailnetProvider::Burrow)) { + return TailnetProvider::Burrow; + } + if authority.is_some_and(is_managed_tailscale_authority) { + return TailnetProvider::Tailscale; + } + TailnetProvider::Headscale +} + async fn discover_well_known(client: &Client, base_url: &Url) -> Result> { let url = base_url .join(TAILNET_DISCOVERY_PATH) @@ -133,6 +213,37 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res } } +async fn probe_url( + client: &Client, + url: Url, + authority: &str, + summary: &str, +) -> Result> { + let response = match client + .get(url) + .header("accept", "application/json") + .send() + .await + { + Ok(response) => response, + Err(_) => return Ok(None), + }; + + let status = response.status(); + if !status.is_success() { + return Ok(None); + } + + let detail = response.text().await.unwrap_or_default().trim().to_owned(); + Ok(Some(TailnetAuthorityProbe { + authority: authority.to_owned(), + status_code: i32::from(status.as_u16()), + summary: summary.to_owned(), + detail, + reachable: true, + })) +} + #[cfg(test)] mod tests { use axum::{routing::get, Router}; @@ -147,6 +258,13 @@ mod tests { assert!(email_domain("contact").is_err()); } + #[test] + fn detects_managed_tailscale_authority() { + assert!(is_managed_tailscale_authority("controlplane.tailscale.com")); + assert!(is_managed_tailscale_authority("https://controlplane.tailscale.com/")); + assert!(!is_managed_tailscale_authority("https://ts.burrow.net")); + } + #[tokio::test] async fn discovers_from_well_known_document() -> Result<()> { let router = Router::new().route( @@ -209,4 +327,20 @@ mod tests { server.abort(); Ok(()) } + + #[tokio::test] + async fn probes_custom_authority() -> Result<()> { + let router = Router::new().route("/health", get(|| async { "ok" })); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let authority = format!("http://{}", listener.local_addr()?); + let server = tokio::spawn(async move { axum::serve(listener, router).await }); + + let status = probe_tailnet_authority(&authority).await?; + assert_eq!(status.authority, authority); + assert_eq!(status.status_code, 200); + assert!(status.reachable); + + server.abort(); + Ok(()) + } } diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 1eb0629..e4e6d96 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -13,13 +13,16 @@ use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, - NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, + networks_server::Networks, tailnet_control_server::TailnetControl, + tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, + NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, + TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelStatusResponse, }, runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ + control::discovery, daemon::rpc::ServerConfig, database::{add_network, delete_network, get_connection, list_networks, reorder_network}, }; @@ -266,6 +269,47 @@ impl Networks for DaemonRPCServer { } } +#[tonic::async_trait] +impl TailnetControl for DaemonRPCServer { + async fn discover( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let discovery = discovery::discover_tailnet(&request.email) + .await + .map_err(proc_err)?; + + Ok(Response::new(TailnetDiscoverResponse { + domain: discovery.domain, + authority: discovery.authority.clone(), + oidc_issuer: discovery.oidc_issuer.unwrap_or_default(), + managed: matches!( + discovery::inferred_provider(Some(&discovery.authority), Some(&discovery.provider)), + crate::control::TailnetProvider::Tailscale + ), + })) + } + + async fn probe( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let status = discovery::probe_tailnet_authority(&request.authority) + .await + .map_err(proc_err)?; + + Ok(Response::new(TailnetProbeResponse { + authority: status.authority, + status_code: status.status_code, + summary: status.summary, + detail: status.detail, + reachable: status.reachable, + })) + } +} + fn proc_err(err: impl ToString) -> RspStatus { RspStatus::internal(err.to_string()) } diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index a016788..724e3bb 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -16,7 +16,10 @@ use tonic::transport::Server; use tracing::info; use crate::{ - daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer}, + daemon::rpc::grpc_defs::{ + networks_server::NetworksServer, tailnet_control_server::TailnetControlServer, + tunnel_server::TunnelServer, + }, database::get_connection, }; @@ -36,9 +39,11 @@ pub async fn daemon_main( let uds = UnixListener::bind(sock_path)?; let serve_job = tokio::spawn(async move { let uds_stream = UnixListenerStream::new(uds); + let tailnet_server = burrow_server.clone(); let _srv = Server::builder() .add_service(TunnelServer::new(burrow_server.clone())) .add_service(NetworksServer::new(burrow_server)) + .add_service(TailnetControlServer::new(tailnet_server)) .serve_with_incoming(uds_stream) .await?; Ok::<(), AhError>(()) diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index 06a9b45..aa84c64 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -5,11 +5,15 @@ use tokio::net::UnixStream; use tonic::transport::{Endpoint, Uri}; use tower::service_fn; -use super::grpc_defs::{networks_client::NetworksClient, tunnel_client::TunnelClient}; +use super::grpc_defs::{ + networks_client::NetworksClient, tailnet_control_client::TailnetControlClient, + tunnel_client::TunnelClient, +}; use crate::daemon::get_socket_path; pub struct BurrowClient { pub networks_client: NetworksClient, + pub tailnet_client: TailnetControlClient, pub tunnel_client: TunnelClient, } @@ -31,9 +35,11 @@ impl BurrowClient { })) .await?; let nw_client = NetworksClient::new(channel.clone()); + let tailnet_client = TailnetControlClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone()); Ok(BurrowClient { networks_client: nw_client, + tailnet_client, tunnel_client: tun_client, }) } diff --git a/proto/burrow.proto b/proto/burrow.proto index 5b5a30b..79e8976 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -17,6 +17,11 @@ service Networks { rpc NetworkDelete (NetworkDeleteRequest) returns (Empty); } +service TailnetControl { + rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); + rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); +} + message NetworkReorderRequest { int32 id = 1; int32 index = 2; @@ -56,6 +61,29 @@ message Empty { } +message TailnetDiscoverRequest { + string email = 1; +} + +message TailnetDiscoverResponse { + string domain = 1; + string authority = 2; + string oidc_issuer = 3; + bool managed = 4; +} + +message TailnetProbeRequest { + string authority = 1; +} + +message TailnetProbeResponse { + string authority = 1; + int32 status_code = 2; + string summary = 3; + string detail = 4; + bool reachable = 5; +} + enum State { Stopped = 0; Running = 1; From 0c660acd1e0b61dde4a3ea80643b5df9ae381623 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 02:09:58 -0700 Subject: [PATCH 03/11] Add daemon-owned Tailnet login flow --- Apple/Core/Client.swift | 228 ++++++++++++++++++++ Apple/UI/BurrowView.swift | 318 ++++++++++++++++++++++++++-- Apple/UI/Networks/Network.swift | 93 ++++++++ burrow/src/auth/server/tailscale.rs | 77 ++++++- burrow/src/daemon/instance.rs | 93 +++++++- proto/burrow.proto | 31 +++ 6 files changed, 812 insertions(+), 28 deletions(-) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index c426fe7..e44ebcd 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -68,6 +68,46 @@ public struct Burrow_TailnetProbeResponse: Sendable { public init() {} } +public struct Burrow_TailnetLoginStartRequest: Sendable { + public var accountName: String = "" + public var identityName: String = "" + public var hostname: String = "" + public var authority: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetLoginStatusRequest: Sendable { + public var sessionID: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetLoginCancelRequest: Sendable { + public var sessionID: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetLoginStatusResponse: Sendable { + public var sessionID: String = "" + public var backendState: String = "" + public var authURL: String = "" + public var running: Bool = false + public var needsLogin: Bool = false + public var tailnetName: String = "" + public var magicDNSSuffix: String = "" + public var selfDNSName: String = "" + public var tailnetIPs: [String] = [] + public var health: [String] = [] + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ @@ -195,6 +235,158 @@ extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._Mes } } +extension Burrow_TailnetLoginStartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginStartRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "account_name"), + 2: .standard(proto: "identity_name"), + 3: .same(proto: "hostname"), + 4: .same(proto: "authority"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.accountName) + case 2: try decoder.decodeSingularStringField(value: &self.identityName) + case 3: try decoder.decodeSingularStringField(value: &self.hostname) + case 4: try decoder.decodeSingularStringField(value: &self.authority) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.accountName.isEmpty { + try visitor.visitSingularStringField(value: self.accountName, fieldNumber: 1) + } + if !self.identityName.isEmpty { + try visitor.visitSingularStringField(value: self.identityName, fieldNumber: 2) + } + if !self.hostname.isEmpty { + try visitor.visitSingularStringField(value: self.hostname, fieldNumber: 3) + } + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetLoginStatusRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginStatusRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "session_id") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.sessionID) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.sessionID.isEmpty { + try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetLoginCancelRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginCancelRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "session_id") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.sessionID) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.sessionID.isEmpty { + try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginStatusResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "session_id"), + 2: .standard(proto: "backend_state"), + 3: .standard(proto: "auth_url"), + 4: .same(proto: "running"), + 5: .standard(proto: "needs_login"), + 6: .standard(proto: "tailnet_name"), + 7: .standard(proto: "magic_dns_suffix"), + 8: .standard(proto: "self_dns_name"), + 9: .standard(proto: "tailnet_ips"), + 10: .same(proto: "health"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.sessionID) + case 2: try decoder.decodeSingularStringField(value: &self.backendState) + case 3: try decoder.decodeSingularStringField(value: &self.authURL) + case 4: try decoder.decodeSingularBoolField(value: &self.running) + case 5: try decoder.decodeSingularBoolField(value: &self.needsLogin) + case 6: try decoder.decodeSingularStringField(value: &self.tailnetName) + case 7: try decoder.decodeSingularStringField(value: &self.magicDNSSuffix) + case 8: try decoder.decodeSingularStringField(value: &self.selfDNSName) + case 9: try decoder.decodeRepeatedStringField(value: &self.tailnetIPs) + case 10: try decoder.decodeRepeatedStringField(value: &self.health) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.sessionID.isEmpty { + try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) + } + if !self.backendState.isEmpty { + try visitor.visitSingularStringField(value: self.backendState, fieldNumber: 2) + } + if !self.authURL.isEmpty { + try visitor.visitSingularStringField(value: self.authURL, fieldNumber: 3) + } + if self.running { + try visitor.visitSingularBoolField(value: self.running, fieldNumber: 4) + } + if self.needsLogin { + try visitor.visitSingularBoolField(value: self.needsLogin, fieldNumber: 5) + } + if !self.tailnetName.isEmpty { + try visitor.visitSingularStringField(value: self.tailnetName, fieldNumber: 6) + } + if !self.magicDNSSuffix.isEmpty { + try visitor.visitSingularStringField(value: self.magicDNSSuffix, fieldNumber: 7) + } + if !self.selfDNSName.isEmpty { + try visitor.visitSingularStringField(value: self.selfDNSName, fieldNumber: 8) + } + if !self.tailnetIPs.isEmpty { + try visitor.visitRepeatedStringField(value: self.tailnetIPs, fieldNumber: 9) + } + if !self.health.isEmpty { + try visitor.visitRepeatedStringField(value: self.health, fieldNumber: 10) + } + try unknownFields.traverse(visitor: &visitor) + } +} + public struct TailnetClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions @@ -227,4 +419,40 @@ public struct TailnetClient: Client, GRPCClient { interceptors: [] ) } + + public func loginStart( + _ request: Burrow_TailnetLoginStartRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetLoginStatusResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/LoginStart", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func loginStatus( + _ request: Burrow_TailnetLoginStatusRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetLoginStatusResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/LoginStatus", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func loginCancel( + _ request: Burrow_TailnetLoginCancelRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_Empty { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/LoginCancel", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } } diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 9938eef..b95e904 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,6 +1,9 @@ import BurrowConfiguration import Foundation import SwiftUI +#if canImport(AuthenticationServices) +import AuthenticationServices +#endif #if canImport(UIKit) import UIKit #elseif canImport(AppKit) @@ -309,6 +312,7 @@ private struct AccountDraft { accountName = "default" identityName = "apple" authority = TailnetProvider.tailscale.defaultAuthority ?? "" + authMode = .web } } } @@ -329,6 +333,14 @@ private struct ConfigurationSheetView: View { @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeError: String? @State private var isProbingAuthority = false + @State private var tailnetLoginStatus: TailnetLoginStatus? + @State private var tailnetLoginError: String? + @State private var tailnetLoginSessionID: String? + @State private var isStartingTailnetLogin = false + @State private var tailnetPresentedAuthURL: URL? + @State private var preserveTailnetLoginSession = false + @State private var browserAuthenticator = TailnetBrowserAuthenticator() + @State private var tailnetLoginPollTask: Task? @State private var didRunAutomation = false init( @@ -397,7 +409,10 @@ private struct ConfigurationSheetView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - dismiss() + Task { @MainActor in + await cancelTailnetLoginIfNeeded() + dismiss() + } } } #if os(iOS) @@ -446,14 +461,28 @@ private struct ConfigurationSheetView: View { .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() } + .onChange(of: draft.authMode) { _, newMode in + guard newMode != .web else { return } + Task { @MainActor in + await cancelTailnetLoginIfNeeded() + } + } + .onDisappear { + tailnetLoginPollTask?.cancel() + browserAuthenticator.cancel() + if !preserveTailnetLoginSession { + Task { @MainActor in + await cancelTailnetLoginIfNeeded() + } + } + } } @ViewBuilder private var tailnetSections: some View { Section("Connection") { TextField("Email address", text: $draft.discoveryEmail) - .textInputAutocapitalization(.never) - .keyboardType(.emailAddress) + .burrowEmailField() .burrowLoginField() .autocorrectionDisabled() @@ -507,22 +536,44 @@ private struct ConfigurationSheetView: View { } Section("Authentication") { - TextField("Username", text: $draft.username) - .burrowLoginField() - .autocorrectionDisabled() Picker("Authentication", selection: $draft.authMode) { ForEach(availableTailnetAuthModes) { mode in Text(mode.title).tag(mode) } } .pickerStyle(.menu) - if draft.authMode != .none { - SecureField( - draft.authMode == .password ? "Password" : "Preauth Key", - text: $draft.secret - ) + + if draft.authMode == .web { + Button { + startTailnetLogin() + } label: { + Label { + Text(isStartingTailnetLogin ? "Starting Sign-In" : tailnetSignInActionTitle) + } icon: { + Image(systemName: isStartingTailnetLogin ? "hourglass" : "person.badge.key") + } + } + .buttonStyle(.borderless) + .disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil) + + if let tailnetLoginStatus { + tailnetLoginCard(status: tailnetLoginStatus, failure: nil) + } else if let tailnetLoginError { + tailnetLoginCard(status: nil, failure: tailnetLoginError) + } + } else { + TextField("Username", text: $draft.username) + .burrowLoginField() + .autocorrectionDisabled() + if draft.authMode != .none { + SecureField( + draft.authMode == .password ? "Password" : "Preauth Key", + text: $draft.secret + ) + } } - Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.") + + Text(tailnetAuthenticationFootnote) .font(.footnote) .foregroundStyle(.secondary) } @@ -583,6 +634,9 @@ private struct ConfigurationSheetView: View { HStack(spacing: 8) { summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") summaryBadge(draft.authMode.title) + if tailnetLoginStatus?.running == true { + summaryBadge("Signed In") + } } } } @@ -659,6 +713,52 @@ private struct ConfigurationSheetView: View { ) } + private func tailnetLoginCard( + status: TailnetLoginStatus?, + failure: String? + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + if let status { + Text(status.running ? "Signed In" : status.needsLogin ? "Browser Sign-In Required" : "Checking Sign-In") + .font(.subheadline.weight(.medium)) + if let tailnetName = status.tailnetName, !tailnetName.isEmpty { + Text("Tailnet: \(tailnetName)") + .font(.footnote) + .foregroundStyle(.secondary) + } + if let selfDNSName = status.selfDNSName, !selfDNSName.isEmpty { + Text(selfDNSName) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !status.tailnetIPs.isEmpty { + Text(status.tailnetIPs.joined(separator: ", ")) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !status.health.isEmpty { + Text(status.health.joined(separator: " • ")) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else if let failure { + Text("Sign-In failed") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.red) + Text(failure) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + private func summaryBadge(_ label: String) -> some View { Text(label) .font(.caption.weight(.medium)) @@ -813,6 +913,9 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.authority) == nil { return true } + if draft.authMode == .web { + return tailnetLoginStatus?.running != true + } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true } @@ -897,8 +1000,9 @@ private struct ConfigurationSheetView: View { } private func submitTailnet() async throws { - let secret = draft.authMode == .none ? nil : draft.secret + let secret = (draft.authMode == .none || draft.authMode == .web) ? nil : draft.secret let username = normalizedOptional(draft.username) + preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true try await saveTailnetAccount(secret: secret, username: username) dismiss() } @@ -922,7 +1026,7 @@ private struct ConfigurationSheetView: View { switch automation.action { case .tailnetLogin: applyTailnetDefaults(for: .tailscale) - probeTailnetAuthority() + startTailnetLogin() case .headscaleProbe: draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority probeTailnetAuthority() @@ -950,6 +1054,10 @@ private struct ConfigurationSheetView: View { "Auth: \(draft.authMode.title)", ] + if draft.authMode == .web, tailnetLoginStatus?.running == true { + noteParts.append("Browser sign-in complete") + } + do { let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) noteParts.append("Linked to daemon network #\(networkID)") @@ -1003,7 +1111,36 @@ private struct ConfigurationSheetView: View { resetTailnetDiscoveryFeedback() draft.authority = provider.defaultAuthority ?? "" if !availableTailnetAuthModes.contains(draft.authMode) { - draft.authMode = .none + draft.authMode = .web + } + } + + private func startTailnetLogin() { + guard let authority = normalizedOptional(draft.authority) else { + tailnetLoginStatus = nil + tailnetLoginError = "Enter a server URL first." + return + } + + isStartingTailnetLogin = true + tailnetLoginError = nil + preserveTailnetLoginSession = false + + Task { @MainActor in + defer { isStartingTailnetLogin = false } + do { + let status = try await networkViewModel.startTailnetLogin( + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "apple"), + hostname: normalizedOptional(draft.hostname), + authority: authority + ) + tailnetLoginSessionID = status.sessionID + updateTailnetLoginStatus(status) + beginTailnetLoginPolling(sessionID: status.sessionID) + } catch { + tailnetLoginError = error.localizedDescription + } } } @@ -1031,6 +1168,7 @@ private struct ConfigurationSheetView: View { private func resetAuthorityProbe() { authorityProbeStatus = nil authorityProbeError = nil + tailnetLoginError = nil } private func resetTailnetDiscoveryFeedback() { @@ -1062,6 +1200,76 @@ private struct ConfigurationSheetView: View { } } + private func beginTailnetLoginPolling(sessionID: String) { + tailnetLoginPollTask?.cancel() + tailnetLoginPollTask = Task { @MainActor in + while !Task.isCancelled { + do { + let status = try await networkViewModel.tailnetLoginStatus(sessionID: sessionID) + updateTailnetLoginStatus(status) + if status.running { + tailnetLoginPollTask = nil + return + } + } catch { + tailnetLoginError = error.localizedDescription + tailnetLoginPollTask = nil + return + } + try? await Task.sleep(for: .seconds(1)) + } + } + } + + private func updateTailnetLoginStatus(_ status: TailnetLoginStatus) { + tailnetLoginStatus = status + tailnetLoginError = nil + tailnetLoginSessionID = status.sessionID + + if status.running { + browserAuthenticator.cancel() + tailnetPresentedAuthURL = nil + return + } + + guard let authURL = status.authURL else { + return + } + + if tailnetPresentedAuthURL != authURL { + tailnetPresentedAuthURL = authURL + browserAuthenticator.start(url: authURL) { [sessionID = status.sessionID] in + Task { @MainActor in + if tailnetLoginStatus?.running != true { + tailnetLoginSessionID = sessionID + } + } + } + } + } + + private func cancelTailnetLoginIfNeeded() async { + tailnetLoginPollTask?.cancel() + tailnetLoginPollTask = nil + browserAuthenticator.cancel() + tailnetPresentedAuthURL = nil + + guard tailnetLoginStatus?.running != true, + let sessionID = tailnetLoginSessionID + else { + return + } + + do { + try await networkViewModel.cancelTailnetLogin(sessionID: sessionID) + } catch { + tailnetLoginError = error.localizedDescription + } + + tailnetLoginStatus = nil + tailnetLoginSessionID = nil + } + private func pasteWireGuardConfiguration() { guard let clipboardString else { return } draft.wireGuardConfig = clipboardString @@ -1108,7 +1316,28 @@ private struct ConfigurationSheetView: View { } private var availableTailnetAuthModes: [AccountAuthMode] { - [.none, .password, .preauthKey] + [.web, .none, .password, .preauthKey] + } + + private var tailnetSignInActionTitle: String { + if tailnetLoginStatus?.running == true { + return "Signed In" + } + if tailnetLoginSessionID != nil { + return "Resume Sign-In" + } + return "Start Sign-In" + } + + private var tailnetAuthenticationFootnote: String { + switch draft.authMode { + case .web: + return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running." + case .none: + return "Save the authority only. Useful when the control plane handles authentication elsewhere." + case .password, .preauthKey: + return "Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh." + } } private var inferredTailnetProvider: TailnetProvider { @@ -1215,8 +1444,65 @@ private extension View { self #endif } + + @ViewBuilder + func burrowEmailField() -> some View { + #if os(iOS) + textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + #else + self + #endif + } } +#if canImport(AuthenticationServices) +@MainActor +private final class TailnetBrowserAuthenticator: NSObject { + private var session: ASWebAuthenticationSession? + + func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { + cancel() + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { _, _ in + onDismiss() + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + _ = session.start() + } + + func cancel() { + session?.cancel() + session = nil + } +} + +extension TailnetBrowserAuthenticator: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + #if canImport(AppKit) + return NSApplication.shared.keyWindow + ?? NSApplication.shared.windows.first + ?? ASPresentationAnchor() + #elseif canImport(UIKit) + return ASPresentationAnchor() + #else + return ASPresentationAnchor() + #endif + } +} +#else +@MainActor +private final class TailnetBrowserAuthenticator { + func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { + _ = url + onDismiss() + } + + func cancel() {} +} +#endif + private struct BurrowAutomationConfig { enum Action: String { case tailnetLogin = "tailnet-login" diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index b048add..32f0b8c 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -40,6 +40,19 @@ struct TailnetAuthorityProbeStatus: Sendable { var detail: String? } +struct TailnetLoginStatus: Sendable { + var sessionID: String + var backendState: String + var authURL: URL? + var running: Bool + var needsLogin: Bool + var tailnetName: String? + var magicDNSSuffix: String? + var selfDNSName: String? + var tailnetIPs: [String] + var health: [String] +} + enum TailnetDiscoveryClient { static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { var request = Burrow_TailnetDiscoverRequest() @@ -74,6 +87,58 @@ enum TailnetAuthorityProbeClient { } } +enum TailnetLoginClient { + static func start( + accountName: String, + identityName: String, + hostname: String?, + authority: String, + socketURL: URL + ) async throws -> TailnetLoginStatus { + var request = Burrow_TailnetLoginStartRequest() + request.accountName = accountName + request.identityName = identityName + request.hostname = hostname ?? "" + request.authority = authority + let response = try await TailnetClient.unix(socketURL: socketURL).loginStart(request) + return decode(response) + } + + static func status(sessionID: String, socketURL: URL) async throws -> TailnetLoginStatus { + var request = Burrow_TailnetLoginStatusRequest() + request.sessionID = sessionID + let response = try await TailnetClient.unix(socketURL: socketURL).loginStatus(request) + return decode(response) + } + + static func cancel(sessionID: String, socketURL: URL) async throws { + var request = Burrow_TailnetLoginCancelRequest() + request.sessionID = sessionID + _ = try await TailnetClient.unix(socketURL: socketURL).loginCancel(request) + } + + private static func decode(_ response: Burrow_TailnetLoginStatusResponse) -> TailnetLoginStatus { + TailnetLoginStatus( + sessionID: response.sessionID, + backendState: response.backendState, + authURL: URL(string: response.authURL.trimmingCharacters(in: .whitespacesAndNewlines)), + running: response.running, + needsLogin: response.needsLogin, + tailnetName: response.tailnetName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.tailnetName, + magicDNSSuffix: response.magicDNSSuffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.magicDNSSuffix, + selfDNSName: response.selfDNSName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.selfDNSName, + tailnetIPs: response.tailnetIPs, + health: response.health + ) + } +} + @Observable @MainActor final class NetworkViewModel: Sendable { @@ -118,6 +183,32 @@ final class NetworkViewModel: Sendable { return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) } + func startTailnetLogin( + accountName: String, + identityName: String, + hostname: String?, + authority: String + ) async throws -> TailnetLoginStatus { + let socketURL = try socketURLResult.get() + return try await TailnetLoginClient.start( + accountName: accountName, + identityName: identityName, + hostname: hostname, + authority: authority, + socketURL: socketURL + ) + } + + func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus { + let socketURL = try socketURLResult.get() + return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL) + } + + func cancelTailnetLogin(sessionID: String) async throws { + let socketURL = try socketURLResult.get() + try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL) + } + private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { let socketURL = try socketURLResult.get() let networkID = nextNetworkID @@ -317,6 +408,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { } enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { + case web case none case password case preauthKey @@ -325,6 +417,7 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { + case .web: "Browser Sign-In" case .none: "None" case .password: "Password" case .preauthKey: "Preauth Key" diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index fbe1980..55516e1 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -82,11 +82,22 @@ impl TailscaleBridgeManager { let key = session_key(&request.account_name, &request.identity_name); if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { - let status = self.fetch_status(existing.as_ref()).await?; - return Ok(TailscaleLoginStartResponse { - session_id: existing.session_id.clone(), - status, - }); + match self.fetch_status(existing.as_ref()).await { + Ok(status) => { + return Ok(TailscaleLoginStartResponse { + session_id: existing.session_id.clone(), + status, + }); + } + Err(err) => { + log::warn!( + "tailscale login session {} is stale, restarting: {err}", + existing.session_id + ); + self.sessions.lock().await.remove(&key); + let _ = self.shutdown_session(existing.as_ref()).await; + } + } } let state_dir = state_root().join(session_dir_name(&request)); @@ -155,11 +166,28 @@ impl TailscaleBridgeManager { }; match session { - Some(session) => self.fetch_status(session.as_ref()).await.map(Some), + Some(session) => match self.fetch_status(session.as_ref()).await { + Ok(status) => Ok(Some(status)), + Err(err) => { + self.remove_session_by_id(session_id).await; + Err(err) + } + }, None => Ok(None), } } + pub async fn cancel(&self, session_id: &str) -> Result { + let session = self.remove_session_by_id(session_id).await; + match session { + Some(session) => { + self.shutdown_session(session.as_ref()).await?; + Ok(true) + } + None => Ok(false), + } + } + async fn wait_for_status(&self, session: &ManagedSession) -> Result { let mut last_error = None; let mut last_status = None; @@ -201,6 +229,38 @@ impl TailscaleBridgeManager { .await .context("invalid tailscale helper status response") } + + async fn remove_session_by_id(&self, session_id: &str) -> Option> { + let mut sessions = self.sessions.lock().await; + let key = sessions + .iter() + .find_map(|(key, session)| (session.session_id == session_id).then(|| key.clone()))?; + sessions.remove(&key) + } + + async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> { + let _ = self + .client + .post(format!("{}/shutdown", session.listen_url)) + .send() + .await; + + for _ in 0..10 { + let mut child = session.child.lock().await; + if child.try_wait()?.is_some() { + return Ok(()); + } + drop(child); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let mut child = session.child.lock().await; + child + .start_kill() + .context("failed to kill tailscale helper")?; + let _ = child.wait().await; + Ok(()) + } } fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result { @@ -249,7 +309,10 @@ fn state_root() -> PathBuf { .join("Burrow") .join("tailscale"); } - home.join(".local").join("share").join("burrow").join("tailscale") + home.join(".local") + .join("share") + .join("burrow") + .join("tailscale") } fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index e4e6d96..0a23ddc 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -13,15 +13,19 @@ use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tailnet_control_server::TailnetControl, - tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, - NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, - TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, - TunnelConfigurationResponse, TunnelStatusResponse, + networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, + Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, + State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, + TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, + TunnelStatusResponse, }, runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ + auth::server::tailscale::{ + TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest, + TailscaleLoginStatus, + }, control::discovery, daemon::rpc::ServerConfig, database::{add_network, delete_network, get_connection, list_networks, reorder_network}, @@ -49,6 +53,7 @@ pub struct DaemonRPCServer { wg_state_chan: (watch::Sender, watch::Receiver), network_update_chan: (watch::Sender<()>, watch::Receiver<()>), active_tunnel: Arc>>, + tailnet_login: TailscaleBridgeManager, } impl DaemonRPCServer { @@ -59,6 +64,7 @@ impl DaemonRPCServer { wg_state_chan: watch::channel(RunState::Idle), network_update_chan: watch::channel(()), active_tunnel: Arc::new(RwLock::new(None)), + tailnet_login: TailscaleBridgeManager::default(), }) } @@ -130,6 +136,11 @@ impl DaemonRPCServer { Ok(()) } + + fn tailnet_control_url(authority: &str) -> Option { + let authority = discovery::normalize_authority(authority); + (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) + } } #[tonic::async_trait] @@ -308,6 +319,60 @@ impl TailnetControl for DaemonRPCServer { reachable: status.reachable, })) } + + async fn login_start( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let response = self + .tailnet_login + .start_login(BridgeLoginStartRequest { + account_name: request.account_name, + identity_name: request.identity_name, + hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname), + control_url: Self::tailnet_control_url(&request.authority), + }) + .await + .map_err(proc_err)?; + + Ok(Response::new(tailnet_login_rsp( + response.session_id, + response.status, + ))) + } + + async fn login_status( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let status = self + .tailnet_login + .status(&request.session_id) + .await + .map_err(proc_err)?; + let Some(status) = status else { + return Err(RspStatus::not_found("tailnet login session not found")); + }; + Ok(Response::new(tailnet_login_rsp(request.session_id, status))) + } + + async fn login_cancel( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let canceled = self + .tailnet_login + .cancel(&request.session_id) + .await + .map_err(proc_err)?; + if !canceled { + return Err(RspStatus::not_found("tailnet login session not found")); + } + Ok(Response::new(Empty {})) + } } fn proc_err(err: impl ToString) -> RspStatus { @@ -327,3 +392,21 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse { start: None, // TODO: Add timestamp } } + +fn tailnet_login_rsp( + session_id: String, + status: TailscaleLoginStatus, +) -> super::rpc::grpc_defs::TailnetLoginStatusResponse { + super::rpc::grpc_defs::TailnetLoginStatusResponse { + session_id, + backend_state: status.backend_state, + auth_url: status.auth_url.unwrap_or_default(), + running: status.running, + needs_login: status.needs_login, + tailnet_name: status.tailnet_name.unwrap_or_default(), + magic_dns_suffix: status.magic_dns_suffix.unwrap_or_default(), + self_dns_name: status.self_dns_name.unwrap_or_default(), + tailnet_ips: status.tailscale_ips, + health: status.health, + } +} diff --git a/proto/burrow.proto b/proto/burrow.proto index 79e8976..a590cb1 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -20,6 +20,9 @@ service Networks { service TailnetControl { rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); + rpc LoginStart (TailnetLoginStartRequest) returns (TailnetLoginStatusResponse); + rpc LoginStatus (TailnetLoginStatusRequest) returns (TailnetLoginStatusResponse); + rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty); } message NetworkReorderRequest { @@ -84,6 +87,34 @@ message TailnetProbeResponse { bool reachable = 5; } +message TailnetLoginStartRequest { + string account_name = 1; + string identity_name = 2; + string hostname = 3; + string authority = 4; +} + +message TailnetLoginStatusRequest { + string session_id = 1; +} + +message TailnetLoginCancelRequest { + string session_id = 1; +} + +message TailnetLoginStatusResponse { + string session_id = 1; + string backend_state = 2; + string auth_url = 3; + bool running = 4; + bool needs_login = 5; + string tailnet_name = 6; + string magic_dns_suffix = 7; + string self_dns_name = 8; + repeated string tailnet_ips = 9; + repeated string health = 10; +} + enum State { Stopped = 0; Running = 1; From 75bcfaf6559bec939a07480c14dbab07438e0e14 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 03:03:17 -0700 Subject: [PATCH 04/11] Add Tailnet UI auth test flow --- Apple/AppUITests/BurrowUITests.swift | 232 ++++++++++++++ Apple/Burrow.xcodeproj/project.pbxproj | 95 ++++++ .../xcshareddata/xcschemes/App.xcscheme | 15 +- Apple/Configuration/UITests.xcconfig | 14 + Apple/UI/BurrowView.swift | 10 + Scripts/authentik-sync-burrow-directory.sh | 16 +- Scripts/authentik-sync-tailnet-auth-flow.sh | 294 ++++++++++++++++++ Scripts/run-ios-tailnet-ui-tests.sh | 73 +++++ contributors.nix | 13 + nixos/hosts/burrow-forge/default.nix | 11 + nixos/modules/burrow-authentik.nix | 93 +++++- secrets.nix | 1 + secrets/infra/authentik-ui-test-password.age | 9 + 13 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 Apple/AppUITests/BurrowUITests.swift create mode 100644 Apple/Configuration/UITests.xcconfig create mode 100755 Scripts/authentik-sync-tailnet-auth-flow.sh create mode 100755 Scripts/run-ios-tailnet-ui-tests.sh create mode 100644 secrets/infra/authentik-ui-test-password.age diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift new file mode 100644 index 0000000..f9dbeae --- /dev/null +++ b/Apple/AppUITests/BurrowUITests.swift @@ -0,0 +1,232 @@ +import XCTest + +@MainActor +final class BurrowTailnetLoginUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testTailnetLoginThroughAuthentikWebSession() throws { + let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL") + let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email + let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD") + + let app = XCUIApplication() + app.launch() + + let tailnetButton = app.buttons["quick-add-tailnet"] + XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear") + tailnetButton.tap() + + let discoveryField = app.textFields["tailnet-discovery-email"] + XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear") + replaceText(in: discoveryField, with: email) + + let findServerButton = app.buttons["tailnet-find-server"] + XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear") + findServerButton.tap() + + let discoveryCard = app.otherElements["tailnet-discovery-card"] + XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear") + + let authorityField = app.textFields["tailnet-authority"] + XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") + XCTAssertTrue( + waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20), + "Tailnet authority was not populated from discovery" + ) + + let probeButton = app.buttons["tailnet-check-connection"] + XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear") + probeButton.tap() + + let probeCard = app.otherElements["tailnet-authority-probe-card"] + XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete") + + let signInButton = app.buttons["tailnet-start-sign-in"] + XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear") + signInButton.tap() + + acceptAuthenticationPromptIfNeeded(in: app) + + let webSession = webAuthenticationSession() + XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear") + + signIntoAuthentik(in: webSession, username: username, password: password) + + app.activate() + XCTAssertTrue( + waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60), + "Tailnet sign-in never reached the running state" + ) + } + + private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let promptCandidates = [ + springboard.buttons["Continue"], + springboard.buttons["Allow"], + app.buttons["Continue"], + app.buttons["Allow"], + ] + + for button in promptCandidates where button.waitForExistence(timeout: 3) { + button.tap() + return + } + } + + private func webAuthenticationSession() -> XCUIApplication { + let safariViewService = XCUIApplication(bundleIdentifier: "com.apple.SafariViewService") + if safariViewService.waitForExistence(timeout: 5) { + return safariViewService + } + + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + _ = safari.waitForExistence(timeout: 5) + return safari + } + + private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) { + let usernameField = firstExistingElement( + in: webSession, + queries: [ + { $0.textFields["Username"] }, + { $0.textFields["Email or Username"] }, + { $0.textFields["Email address"] }, + { $0.textFields["Email"] }, + { $0.webViews.textFields["Username"] }, + { $0.webViews.textFields["Email or Username"] }, + { $0.descendants(matching: .textField).firstMatch }, + ], + timeout: 25 + ) + XCTAssertTrue(usernameField.exists, "Authentik username field did not appear") + replaceText(in: usernameField, with: username) + + let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) + if immediatePasswordField.exists { + replaceSecureText(in: immediatePasswordField, with: password) + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + return + } + + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Next", "Sign In", "Log in", "Login"], + timeout: 5 + ) + + let passwordField = firstExistingSecureField(in: webSession, timeout: 20) + XCTAssertTrue(passwordField.exists, "Authentik password field did not appear") + replaceSecureText(in: passwordField, with: password) + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + } + + private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement { + let candidates = [ + app.secureTextFields["Password"], + app.secureTextFields["Password or Token"], + app.webViews.secureTextFields["Password"], + app.webViews.secureTextFields["Password or Token"], + app.descendants(matching: .secureTextField).firstMatch, + ] + + return firstExistingElement(from: candidates, timeout: timeout) + } + + private func tapFirstExistingButton( + in app: XCUIApplication, + titles: [String], + timeout: TimeInterval + ) { + let candidates = titles.flatMap { title in + [ + app.buttons[title], + app.webViews.buttons[title], + ] + } + [app.descendants(matching: .button).firstMatch] + + let button = firstExistingElement(from: candidates, timeout: timeout) + XCTAssertTrue(button.exists, "Expected one of \(titles.joined(separator: ", ")) to appear") + button.tap() + } + + private func requiredEnvironment(_ key: String) throws -> String { + guard let value = ProcessInfo.processInfo.environment[key], + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw XCTSkip("Missing required UI test environment variable \(key)") + } + return value + } + + private func waitForFieldValue( + _ field: XCUIElement, + containing substring: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate(format: "value CONTAINS %@", substring) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: field) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + private func waitForButtonLabel( + _ button: XCUIElement, + equals expected: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate(format: "label == %@", expected) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + private func firstExistingElement( + in app: XCUIApplication, + queries: [(XCUIApplication) -> XCUIElement], + timeout: TimeInterval + ) -> XCUIElement { + firstExistingElement(from: queries.map { $0(app) }, timeout: timeout) + } + + private func firstExistingElement(from candidates: [XCUIElement], timeout: TimeInterval) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + repeat { + for candidate in candidates where candidate.exists { + return candidate + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } while Date() < deadline + + return candidates[0] + } + + private func replaceText(in element: XCUIElement, with value: String) { + element.tap() + clearText(in: element) + element.typeText(value) + } + + private func replaceSecureText(in element: XCUIElement, with value: String) { + element.tap() + clearText(in: element) + element.typeText(value) + } + + private func clearText(in element: XCUIElement) { + guard let currentValue = element.value as? String, !currentValue.isEmpty else { + return + } + + let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) + element.typeText(deleteSequence) + } +} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 9897f79..83d32e0 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; + D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.swift */; }; D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; }; @@ -49,6 +50,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + D11000022F70000100112233 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; + remoteInfo = App; + }; D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; @@ -130,6 +138,9 @@ /* Begin PBXFileReference section */ D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + D11000032F70000100112233 /* BurrowUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BurrowUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D11000042F70000100112233 /* BurrowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowUITests.swift; sourceTree = ""; }; + D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -182,6 +193,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + D11000062F70000100112233 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D020F65029E4A697002790F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -243,6 +261,7 @@ D0D4E4F72C8D941D007F820A /* Framework.xcconfig */, D020F64029E4A1FF002790F6 /* Compiler.xcconfig */, D0D4E4F62C8D932D007F820A /* Debug.xcconfig */, + D11000052F70000100112233 /* UITests.xcconfig */, D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */, D020F64229E4A1FF002790F6 /* Info.plist */, D0D4E5912C8D9D0A007F820A /* Constants */, @@ -268,6 +287,7 @@ isa = PBXGroup; children = ( D05B9F7429E39EEC008CB1F9 /* App */, + D11000072F70000100112233 /* AppUITests */, D020F65629E4A697002790F6 /* NetworkExtension */, D0D4E49C2C8D921A007F820A /* Core */, D0D4E4AD2C8D921A007F820A /* UI */, @@ -281,6 +301,7 @@ isa = PBXGroup; children = ( D05B9F7229E39EEC008CB1F9 /* Burrow.app */, + D11000032F70000100112233 /* BurrowUITests.xctest */, D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */, D0BCC6032A09535900AD070D /* libburrow.a */, D0D4E5312C8D996F007F820A /* BurrowCore.framework */, @@ -303,6 +324,14 @@ path = App; sourceTree = ""; }; + D11000072F70000100112233 /* AppUITests */ = { + isa = PBXGroup; + children = ( + D11000042F70000100112233 /* BurrowUITests.swift */, + ); + path = AppUITests; + sourceTree = ""; + }; D0B98FD729FDDB57004E7149 /* libburrow */ = { isa = PBXGroup; children = ( @@ -375,6 +404,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + D11000082F70000100112233 /* BurrowUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */; + buildPhases = ( + D110000A2F70000100112233 /* Sources */, + D11000062F70000100112233 /* Frameworks */, + D11000092F70000100112233 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D110000B2F70000100112233 /* PBXTargetDependency */, + ); + name = BurrowUITests; + productName = BurrowUITests; + productReference = D11000032F70000100112233 /* BurrowUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; D020F65229E4A697002790F6 /* NetworkExtension */ = { isa = PBXNativeTarget; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; @@ -490,6 +537,10 @@ LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { + D11000082F70000100112233 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = D05B9F7129E39EEC008CB1F9; + }; D020F65229E4A697002790F6 = { CreatedOnToolsVersion = 14.3; }; @@ -522,6 +573,7 @@ projectRoot = ""; targets = ( D05B9F7129E39EEC008CB1F9 /* App */, + D11000082F70000100112233 /* BurrowUITests */, D020F65229E4A697002790F6 /* NetworkExtension */, D0D4E5502C8D9BF2007F820A /* UI */, D0D4E5302C8D996F007F820A /* Core */, @@ -531,6 +583,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + D11000092F70000100112233 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D05B9F7029E39EEC008CB1F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -594,6 +653,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + D110000A2F70000100112233 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D11000012F70000100112233 /* BurrowUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D020F64F29E4A697002790F6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -652,6 +719,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + D110000B2F70000100112233 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D05B9F7129E39EEC008CB1F9 /* App */; + targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; + }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; @@ -694,6 +766,20 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + D110000C2F70000100112233 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + D110000D2F70000100112233 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Release; + }; D020F65F29E4A697002790F6 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; @@ -781,6 +867,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D110000C2F70000100112233 /* Debug */, + D110000D2F70000100112233 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme index a524e87..f580ea7 100644 --- a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -28,7 +28,20 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldAutocreateTestPlan = "NO"> + + + + + + some View { diff --git a/Scripts/authentik-sync-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh index 656b738..277c5f4 100644 --- a/Scripts/authentik-sync-burrow-directory.sh +++ b/Scripts/authentik-sync-burrow-directory.sh @@ -116,7 +116,7 @@ lookup_user_pk() { ensure_user() { local user_spec="$1" - local username name email is_admin groups_json effective_groups_json group_name + local username name email is_admin groups_json password_file effective_groups_json group_name local group_pks_json payload user_pk username="$(printf '%s\n' "$user_spec" | jq -r '.username')" @@ -124,6 +124,7 @@ ensure_user() { email="$(printf '%s\n' "$user_spec" | jq -r '.email')" is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')" groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')" + password_file="$(printf '%s\n' "$user_spec" | jq -r '.passwordFile // empty')" if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then echo "error: each Burrow Authentik user requires username and email" >&2 @@ -178,6 +179,19 @@ ensure_user() { echo "error: could not create Authentik user ${username}" >&2 exit 1 fi + + if [[ -n "$password_file" ]]; then + if [[ ! -s "$password_file" ]]; then + echo "error: password file for Authentik user ${username} is missing: ${password_file}" >&2 + exit 1 + fi + + api POST "/api/v3/core/users/${user_pk}/set_password/" "$( + jq -cn \ + --arg password "$(tr -d '\r\n' < "$password_file")" \ + '{password: $password}' + )" >/dev/null + fi } lookup_application_pk() { diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh new file mode 100755 index 0000000..bfb00ef --- /dev/null +++ b/Scripts/authentik-sync-tailnet-auth-flow.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +provider_slug="${AUTHENTIK_TAILNET_PROVIDER_SLUG:-ts}" +authentication_flow_name="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME:-Burrow Tailnet Authentication}" +authentication_flow_slug="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG:-burrow-tailnet-authentication}" +identification_stage_name="${AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME:-burrow-tailnet-identification-stage}" +password_stage_name="${AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME:-burrow-tailnet-password-stage}" +user_login_stage_name="${AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME:-burrow-tailnet-user-login-stage}" +google_source_slug="${AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG:-google}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-tailnet-auth-flow.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_TAILNET_PROVIDER_SLUG + AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME + AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG + AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME + AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME + AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME + AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_stage_by_name() { + local path="$1" + local name="$2" + + api GET "${path}?page_size=200" \ + | jq -c --arg name "$name" '.results[]? | select(.name == $name)' \ + | head -n1 +} + +lookup_flow_pk() { + local slug="$1" + + api GET "/api/v3/flows/instances/?slug=${slug}" \ + | jq -r '.results[]? | select(.slug != null) | .pk // empty' \ + | head -n1 +} + +lookup_source_pk() { + local slug="$1" + + api GET "/api/v3/sources/oauth/?page_size=200&slug=${slug}" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_password_stage() { + local existing payload stage_pk + + existing="$(lookup_stage_by_name "/api/v3/stages/password/" "$password_stage_name")" + payload="$( + jq -cn \ + --arg name "$password_stage_name" \ + '{ + name: $name, + backends: [ + "authentik.core.auth.InbuiltBackend", + "authentik.core.auth.TokenBackend" + ], + allow_show_password: false, + failed_attempts_before_cancel: 5 + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/password/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/password/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_identification_stage() { + local password_stage_pk="$1" + local google_source_pk="$2" + local existing payload stage_pk sources_json + + existing="$(lookup_stage_by_name "/api/v3/stages/identification/" "$identification_stage_name")" + if [[ -n "$google_source_pk" ]]; then + sources_json="$(jq -cn --arg source "$google_source_pk" '[$source]')" + else + sources_json='[]' + fi + + payload="$( + jq -cn \ + --arg name "$identification_stage_name" \ + --arg password_stage "$password_stage_pk" \ + --argjson sources "$sources_json" \ + '{ + name: $name, + user_fields: ["username", "email"], + password_stage: $password_stage, + case_insensitive_matching: true, + show_matched_user: true, + sources: $sources, + show_source_labels: true, + pretend_user_exists: false, + enable_remember_me: false + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/identification/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/identification/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_user_login_stage() { + local existing payload stage_pk + + existing="$(lookup_stage_by_name "/api/v3/stages/user_login/" "$user_login_stage_name")" + payload="$( + jq -cn \ + --arg name "$user_login_stage_name" \ + '{ + name: $name, + session_duration: "hours=12", + terminate_other_sessions: false, + remember_me_offset: "seconds=0", + network_binding: "no_binding", + geoip_binding: "no_binding" + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/user_login/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/user_login/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_authentication_flow() { + local existing_pk payload + + existing_pk="$(lookup_flow_pk "$authentication_flow_slug")" + payload="$( + jq -cn \ + --arg name "$authentication_flow_name" \ + --arg slug "$authentication_flow_slug" \ + '{ + name: $name, + title: $name, + slug: $slug, + designation: "authentication", + policy_engine_mode: "any", + layout: "stacked" + }' + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/flows/instances/${authentication_flow_slug}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/flows/instances/" "$payload" \ + | jq -r '.pk // empty' + fi +} + +ensure_flow_binding() { + local flow_pk="$1" + local stage_pk="$2" + local order="$3" + local existing payload binding_pk + + existing="$( + api GET "/api/v3/flows/bindings/?target=${flow_pk}&stage=${stage_pk}&page_size=200" \ + | jq -c '.results[]?' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$flow_pk" \ + --arg stage "$stage_pk" \ + --argjson order "$order" \ + '{ + target: $target, + stage: $stage, + order: $order, + policy_engine_mode: "any" + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/flows/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/flows/bindings/" "$payload" >/dev/null + fi +} + +wait_for_authentik + +provider_pk="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -r --arg provider_slug "$provider_slug" ' + .results[]? + | select(.assigned_application_slug == $provider_slug or .slug == $provider_slug) + | .pk // empty + ' \ + | head -n1 +)" + +if [[ -z "$provider_pk" ]]; then + echo "error: could not resolve Authentik Tailnet OAuth provider ${provider_slug}" >&2 + exit 1 +fi + +google_source_pk="$(lookup_source_pk "$google_source_slug" || true)" +password_stage_pk="$(ensure_password_stage)" +identification_stage_pk="$(ensure_identification_stage "$password_stage_pk" "$google_source_pk")" +user_login_stage_pk="$(ensure_user_login_stage)" +authentication_flow_pk="$(ensure_authentication_flow)" + +ensure_flow_binding "$authentication_flow_pk" "$identification_stage_pk" 10 +ensure_flow_binding "$authentication_flow_pk" "$user_login_stage_pk" 30 + +api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( + jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' +)" >/dev/null + +echo "Synced Burrow Tailnet authentication flow for provider ${provider_slug}." diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh new file mode 100755 index 0000000..5086bd1 --- /dev/null +++ b/Scripts/run-ios-tailnet-ui-tests.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" +simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}" +simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}" +derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}" +source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}" +fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback" +socket_path="${fallback_dir}/burrow.sock" +daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}" +ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}" +ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}" +password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age" +age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}" + +ui_test_password="${BURROW_UI_TEST_PASSWORD:-}" +if [[ -z "$ui_test_password" ]]; then + if [[ -f "$password_secret" && -f "$age_identity" ]]; then + ui_test_password="$(age -d -i "$age_identity" "$password_secret" | tr -d '\r\n')" + else + echo "error: BURROW_UI_TEST_PASSWORD is unset and ${password_secret} could not be decrypted" >&2 + exit 1 + fi +fi + +mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path" +rm -f "$socket_path" + +cleanup() { + if [[ -n "${daemon_pid:-}" ]]; then + kill "$daemon_pid" >/dev/null 2>&1 || true + wait "$daemon_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +cargo build -p burrow --bin burrow + +( + cd "$fallback_dir" + BURROW_SOCKET_PATH="burrow.sock" \ + "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 +) & +daemon_pid=$! + +for _ in $(seq 1 50); do + [[ -S "$socket_path" ]] && break + sleep 0.2 +done + +if [[ ! -S "$socket_path" ]]; then + echo "error: Burrow daemon did not create ${socket_path}" >&2 + [[ -f "$daemon_log" ]] && cat "$daemon_log" >&2 + exit 1 +fi + +BURROW_UI_TEST_EMAIL="$ui_test_email" \ +BURROW_UI_TEST_USERNAME="$ui_test_username" \ +BURROW_UI_TEST_PASSWORD="$ui_test_password" \ +xcodebuild \ + -quiet \ + -skipPackagePluginValidation \ + -project "${repo_root}/Apple/Burrow.xcodeproj" \ + -scheme App \ + -configuration Debug \ + -destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \ + -derivedDataPath "$derived_data_path" \ + -clonedSourcePackagesDirPath "$source_packages_path" \ + -only-testing:BurrowUITests \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/contributors.nix b/contributors.nix index f6cc014..22c28b6 100644 --- a/contributors.nix +++ b/contributors.nix @@ -43,5 +43,18 @@ "automation" ]; }; + + ui-test = { + displayName = "Burrow UI Test"; + canonicalEmail = "ui-test@burrow.net"; + isAdmin = false; + forgeAuthorized = false; + bootstrapAuthentik = true; + authentikPasswordSecret = "burrowAuthentikUiTestPassword"; + roles = [ + "testing" + "apple-ui" + ]; + }; }; } diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index fb5b8ae..6c106f4 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -3,6 +3,10 @@ let contributors = import ../../../contributors.nix; identities = contributors.identities; + authentikPasswordSecretPath = identity: + if identity ? authentikPasswordSecret + then config.age.secrets.${identity.authentikPasswordSecret}.path + else null; bootstrapUsers = lib.mapAttrsToList ( username: identity: { @@ -11,6 +15,7 @@ let email = identity.canonicalEmail; sourceEmail = identity.sourceEmail or null; isAdmin = identity.isAdmin or false; + passwordFile = authentikPasswordSecretPath identity; } ) (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); @@ -70,6 +75,12 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowAuthentikUiTestPassword = { + file = ../../../secrets/infra/authentik-ui-test-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; networking.extraHosts = '' 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 4e31d43..478d0d9 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -11,6 +11,7 @@ let directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; + tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 metadata: @@ -175,6 +176,36 @@ in description = "Identification-stage behavior for the Google Authentik source."; }; + headscaleAuthenticationFlowSlug = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-authentication"; + description = "Authentik authentication flow slug used for Burrow Tailnet sign-in."; + }; + + headscaleAuthenticationFlowName = lib.mkOption { + type = lib.types.str; + default = "Burrow Tailnet Authentication"; + description = "Authentik authentication flow name used for Burrow Tailnet sign-in."; + }; + + headscaleIdentificationStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-identification-stage"; + description = "Authentik identification stage used for Burrow Tailnet sign-in."; + }; + + headscalePasswordStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-password-stage"; + description = "Authentik password stage used for Burrow Tailnet sign-in."; + }; + + headscaleUserLoginStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-user-login-stage"; + description = "Authentik user-login stage used for Burrow Tailnet sign-in."; + }; + userGroupName = lib.mkOption { type = lib.types.str; default = "burrow-users"; @@ -217,6 +248,11 @@ in default = false; description = "Whether this user should be in the Burrow admin group."; }; + passwordFile = lib.mkOption { + type = nullOr str; + default = null; + description = "Optional host-local file containing a bootstrap password for this user."; + }; }; }); default = [ ]; @@ -468,7 +504,7 @@ EOF restartTriggers = [ directorySyncScript cfg.envFile - ]; + ] ++ lib.concatMap (user: lib.optional (user.passwordFile != null) user.passwordFile) cfg.bootstrapUsers; path = [ pkgs.bash pkgs.coreutils @@ -491,7 +527,7 @@ EOF export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName} export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: { - inherit (user) username name email isAdmin; + inherit (user) username name email isAdmin passwordFile; groups = user.groups; }) cfg.bootstrapUsers)}' @@ -499,6 +535,59 @@ EOF ''; }; + systemd.services.burrow-authentik-tailnet-auth-flow = { + description = "Reconcile the Burrow Tailnet authentication flow"; + after = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) [ "burrow-authentik-google-source.service" ]; + wants = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) [ "burrow-authentik-google-source.service" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + tailnetAuthFlowSyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_TAILNET_PROVIDER_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME=${lib.escapeShellArg cfg.headscaleAuthenticationFlowName} + export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG=${lib.escapeShellArg cfg.headscaleAuthenticationFlowSlug} + export AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME=${lib.escapeShellArg cfg.headscaleIdentificationStageName} + export AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME=${lib.escapeShellArg cfg.headscalePasswordStageName} + export AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME=${lib.escapeShellArg cfg.headscaleUserLoginStageName} + export AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug} + + ${pkgs.bash}/bin/bash ${tailnetAuthFlowSyncScript} + ''; + }; + systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { description = "Reconcile the Burrow Authentik Forgejo OIDC application"; after = [ diff --git a/secrets.nix b/secrets.nix index 909b929..cc23605 100644 --- a/secrets.nix +++ b/secrets.nix @@ -12,6 +12,7 @@ in "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-ui-test-password.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-ui-test-password.age b/secrets/infra/authentik-ui-test-password.age new file mode 100644 index 0000000..f39c21a --- /dev/null +++ b/secrets/infra/authentik-ui-test-password.age @@ -0,0 +1,9 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q 4+zOIEyQTCHqKdZKV/H4D7e4y+UTrc9rYzvCgGUPVEg +S+tAlc4wvzVUe9r9+mBAnUj5C31bQqo4PK3muBCzs2Y +-> ssh-ed25519 IrZmAg 1KasjHiY1MQVLIzoDdGshhDhaDimOtZ5EyE4GyZngHg +ov711Sp+Q/zQw0NUpB2rnKEF8bFxoVafdVQ/8gSbSZA +-> X25519 3EWdCP5UkWd1g6bDaQm/kNCNlhSONrz8RB7OZgT9nXE +6+HoM9mg6P/CtU39P8SCyutLkmYw27MikoZZ5L9nI54 +--- Rw0o+MvtvHQrrYPNtCPxHGR67K67nyJUQRd4DN3nOCY +fn Date: Fri, 3 Apr 2026 03:08:06 -0700 Subject: [PATCH 05/11] Allow local UI test secret decryption --- secrets.nix | 4 +++- secrets/infra/authentik-ui-test-password.age | 23 ++++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/secrets.nix b/secrets.nix index cc23605..5a3ac8c 100644 --- a/secrets.nix +++ b/secrets.nix @@ -1,4 +1,5 @@ let + conradev = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBueQxNbP2246pxr/m7au4zNVm+ShC96xuOcfEcpIjWZ"; contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa"; agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net"; burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l"; @@ -7,12 +8,13 @@ let agent burrowForgeHost ]; + uiTestRecipients = burrowForgeRecipients ++ [ conradev ]; in { "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; - "secrets/infra/authentik-ui-test-password.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-ui-test-password.age b/secrets/infra/authentik-ui-test-password.age index f39c21a..e84a7be 100644 --- a/secrets/infra/authentik-ui-test-password.age +++ b/secrets/infra/authentik-ui-test-password.age @@ -1,9 +1,14 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q 4+zOIEyQTCHqKdZKV/H4D7e4y+UTrc9rYzvCgGUPVEg -S+tAlc4wvzVUe9r9+mBAnUj5C31bQqo4PK3muBCzs2Y --> ssh-ed25519 IrZmAg 1KasjHiY1MQVLIzoDdGshhDhaDimOtZ5EyE4GyZngHg -ov711Sp+Q/zQw0NUpB2rnKEF8bFxoVafdVQ/8gSbSZA --> X25519 3EWdCP5UkWd1g6bDaQm/kNCNlhSONrz8RB7OZgT9nXE -6+HoM9mg6P/CtU39P8SCyutLkmYw27MikoZZ5L9nI54 ---- Rw0o+MvtvHQrrYPNtCPxHGR67K67nyJUQRd4DN3nOCY -fn Date: Fri, 3 Apr 2026 17:49:11 -0700 Subject: [PATCH 06/11] Add tailnet connectivity smoke path --- Scripts/run-tailnet-connectivity-smoke.sh | 186 ++++++++++ Tools/tailscale-login-bridge/main.go | 410 +++++++++++++++++++- burrow/src/main.rs | 433 ++++++++++++++++++++++ 3 files changed, 1019 insertions(+), 10 deletions(-) create mode 100755 Scripts/run-tailnet-connectivity-smoke.sh diff --git a/Scripts/run-tailnet-connectivity-smoke.sh b/Scripts/run-tailnet-connectivity-smoke.sh new file mode 100755 index 0000000..f3053d3 --- /dev/null +++ b/Scripts/run-tailnet-connectivity-smoke.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" +smoke_root="${BURROW_TAILNET_SMOKE_ROOT:-/tmp/burrow-tailnet-connectivity}" +socket_path="${smoke_root}/burrow.sock" +db_path="${smoke_root}/burrow.db" +daemon_log="${BURROW_TAILNET_SMOKE_DAEMON_LOG:-${smoke_root}/daemon.log}" +payload_path="${smoke_root}/tailnet.json" +authority="${BURROW_TAILNET_SMOKE_AUTHORITY:-https://ts.burrow.net}" +account_name="${BURROW_TAILNET_SMOKE_ACCOUNT:-ui-test}" +identity_name="${BURROW_TAILNET_SMOKE_IDENTITY:-apple}" +hostname="${BURROW_TAILNET_SMOKE_HOSTNAME:-burrow-apple}" +message="${BURROW_TAILNET_SMOKE_MESSAGE:-burrow-tailnet-smoke}" +timeout_ms="${BURROW_TAILNET_SMOKE_TIMEOUT_MS:-8000}" +remote_ip="${BURROW_TAILNET_SMOKE_REMOTE_IP:-}" +remote_port="${BURROW_TAILNET_SMOKE_REMOTE_PORT:-18081}" +remote_hostname="${BURROW_TAILNET_SMOKE_REMOTE_HOSTNAME:-burrow-echo}" +remote_authkey="${BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY:-}" +helper_bin="${BURROW_TAILNET_SMOKE_HELPER_BIN:-${smoke_root}/tailscale-login-bridge}" +remote_state_root="${BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT:-${smoke_root}/remote-state}" +remote_stdout="${smoke_root}/remote-helper.stdout" +remote_stderr="${BURROW_TAILNET_SMOKE_REMOTE_LOG:-${smoke_root}/remote-helper.log}" + +if [[ -n "${TS_AUTHKEY:-}" ]]; then + default_tailnet_state_root="${smoke_root}/local-state" +else + default_tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState" +fi +tailnet_state_root="${BURROW_TAILNET_STATE_ROOT:-${default_tailnet_state_root}}" + +need_login=0 +if [[ -z "${TS_AUTHKEY:-}" ]] && { [[ ! -d "$tailnet_state_root" ]] || [[ -z "$(find "$tailnet_state_root" -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null)" ]]; }; then + need_login=1 +fi + +if [[ "$need_login" -eq 1 ]]; then + echo "Tailnet state root is empty; running iOS login bootstrap first..." + "${repo_root}/Scripts/run-ios-tailnet-ui-tests.sh" +fi + +rm -rf "$smoke_root" +mkdir -p "$smoke_root" + +cleanup() { + rm -f "$payload_path" + if [[ -n "${daemon_pid:-}" ]]; then + kill "$daemon_pid" >/dev/null 2>&1 || true + wait "$daemon_pid" >/dev/null 2>&1 || true + fi + if [[ -n "${remote_pid:-}" ]]; then + kill "$remote_pid" >/dev/null 2>&1 || true + wait "$remote_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +wait_for_helper_listen() { + python3 - <<'PY' "$1" +import json +import pathlib +import sys +import time + +path = pathlib.Path(sys.argv[1]) +deadline = time.time() + 20 +while time.time() < deadline: + if path.exists(): + with path.open("r", encoding="utf-8") as handle: + line = handle.readline().strip() + if line: + hello = json.loads(line) + print(hello["listen_addr"]) + raise SystemExit(0) + time.sleep(0.1) +raise SystemExit("timed out waiting for helper startup line") +PY +} + +wait_for_helper_ip() { + python3 - <<'PY' "$1" +import json +import sys +import time +import urllib.request + +url = sys.argv[1] +deadline = time.time() + 30 +while time.time() < deadline: + with urllib.request.urlopen(url, timeout=5) as response: + status = json.load(response) + if status.get("running") and status.get("tailscale_ips"): + print(status["tailscale_ips"][0]) + raise SystemExit(0) + time.sleep(0.25) +raise SystemExit("timed out waiting for helper to become ready") +PY +} + +python3 - <<'PY' "$payload_path" "$authority" "$account_name" "$identity_name" "$hostname" +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +payload = { + "authority": sys.argv[2], + "account": sys.argv[3], + "identity": sys.argv[4], + "hostname": sys.argv[5], +} +path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +PY + +cargo build -p burrow --bin burrow +( + cd "${repo_root}/Tools/tailscale-login-bridge" + GOWORK=off go build -o "$helper_bin" . +) + +if [[ -z "$remote_ip" ]]; then + if [[ -z "$remote_authkey" ]] && { [[ ! -d "$remote_state_root" ]] || [[ -z "$(find "$remote_state_root" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; }; then + echo "error: set BURROW_TAILNET_SMOKE_REMOTE_IP, BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY, or BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT to an existing logged-in helper state" >&2 + exit 1 + fi + + if [[ -n "$remote_authkey" ]]; then + rm -rf "$remote_state_root" + mkdir -p "$remote_state_root" + fi + + ( + cd "$repo_root" + if [[ -n "$remote_authkey" ]]; then + export TS_AUTHKEY="$remote_authkey" + fi + "$helper_bin" \ + --listen 127.0.0.1:0 \ + --state-dir "$remote_state_root" \ + --hostname "$remote_hostname" \ + --control-url "$authority" \ + --udp-echo-port "$remote_port" \ + >"$remote_stdout" 2>"$remote_stderr" + ) & + remote_pid=$! + + remote_listen_addr="$(wait_for_helper_listen "$remote_stdout")" + remote_ip="$(wait_for_helper_ip "http://${remote_listen_addr}/status")" +fi + +( + cd "$smoke_root" + RUST_LOG="${BURROW_TAILNET_SMOKE_RUST_LOG:-info,burrow=debug}" \ + BURROW_SOCKET_PATH="$socket_path" \ + BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ + "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 +) & +daemon_pid=$! + +for _ in $(seq 1 50); do + [[ -S "$socket_path" ]] && break + sleep 0.2 +done + +if [[ ! -S "$socket_path" ]]; then + echo "error: Burrow daemon did not create ${socket_path}" >&2 + [[ -f "$daemon_log" ]] && cat "$daemon_log" >&2 + exit 1 +fi + +run_burrow() { + BURROW_SOCKET_PATH="$socket_path" \ + BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ + "${repo_root}/target/debug/burrow" "$@" +} + +run_burrow network-add 1 1 "$payload_path" +run_burrow start +run_burrow tunnel-config +run_burrow tailnet-udp-echo "${remote_ip}:${remote_port}" --message "$message" --timeout-ms "$timeout_ms" + +echo +echo "Tailnet connectivity smoke passed." +echo "State root: $tailnet_state_root" +echo "Remote: ${remote_ip}:${remote_port}" diff --git a/Tools/tailscale-login-bridge/main.go b/Tools/tailscale-login-bridge/main.go index 82ca9b0..877d0e4 100644 --- a/Tools/tailscale-login-bridge/main.go +++ b/Tools/tailscale-login-bridge/main.go @@ -2,17 +2,26 @@ package main import ( "context" + "encoding/binary" "encoding/json" + "errors" "flag" "fmt" + "io" "log" "net" + "net/netip" "net/http" "os" + "strconv" + "sync" "time" + "github.com/tailscale/wireguard-go/tun" "tailscale.com/client/local" "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" "tailscale.com/tsnet" ) @@ -26,13 +35,123 @@ type statusResponse struct { SelfDNSName string `json:"self_dns_name,omitempty"` TailscaleIPs []string `json:"tailscale_ips,omitempty"` Health []string `json:"health,omitempty"` + Peers []peerSummary `json:"peers,omitempty"` } +type peerSummary struct { + Name string `json:"name,omitempty"` + DNSName string `json:"dns_name,omitempty"` + TailscaleIPs []string `json:"tailscale_ips,omitempty"` + Online bool `json:"online"` + Active bool `json:"active"` + Relay string `json:"relay,omitempty"` + CurAddr string `json:"cur_addr,omitempty"` + LastSeenUnix int64 `json:"last_seen_unix,omitempty"` +} + +type pingResponse struct { + Result *ipnstate.PingResult `json:"result,omitempty"` +} + +type helperHello struct { + ListenAddr string `json:"listen_addr"` + PacketSocket string `json:"packet_socket,omitempty"` +} + +type helperState struct { + mu sync.RWMutex + authURL string +} + +func (s *helperState) authURLSnapshot() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.authURL +} + +func (s *helperState) setAuthURL(url string) { + s.mu.Lock() + defer s.mu.Unlock() + s.authURL = url +} + +func (s *helperState) clearAuthURL() { + s.setAuthURL("") +} + +// chanTUN is a tun.Device backed by channels so another process can feed and +// consume raw IP packets while tsnet handles the Tailnet control/data plane. +type chanTUN struct { + Inbound chan []byte + Outbound chan []byte + closed chan struct{} + events chan tun.Event +} + +func newChanTUN() *chanTUN { + t := &chanTUN{ + Inbound: make(chan []byte, 1024), + Outbound: make(chan []byte, 1024), + closed: make(chan struct{}), + events: make(chan tun.Event, 1), + } + t.events <- tun.EventUp + return t +} + +func (t *chanTUN) File() *os.File { return nil } + +func (t *chanTUN) Close() error { + select { + case <-t.closed: + default: + close(t.closed) + close(t.Inbound) + } + return nil +} + +func (t *chanTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) { + select { + case <-t.closed: + return 0, io.EOF + case pkt, ok := <-t.Outbound: + if !ok { + return 0, io.EOF + } + sizes[0] = copy(bufs[0][offset:], pkt) + return 1, nil + } +} + +func (t *chanTUN) Write(bufs [][]byte, offset int) (int, error) { + for _, buf := range bufs { + pkt := buf[offset:] + if len(pkt) == 0 { + continue + } + select { + case <-t.closed: + return 0, errors.New("closed") + case t.Inbound <- append([]byte(nil), pkt...): + default: + } + } + return len(bufs), nil +} + +func (t *chanTUN) MTU() (int, error) { return 1280, nil } +func (t *chanTUN) Name() (string, error) { return "burrow-tailnet", nil } +func (t *chanTUN) Events() <-chan tun.Event { return t.events } +func (t *chanTUN) BatchSize() int { return 1 } + func main() { listen := flag.String("listen", "127.0.0.1:0", "local listen address") stateDir := flag.String("state-dir", "", "persistent state directory") hostname := flag.String("hostname", "burrow-apple", "tailnet hostname") controlURL := flag.String("control-url", "", "optional control URL") + packetSocket := flag.String("packet-socket", "", "optional unix socket path for raw packet bridging") + udpEchoPort := flag.Int("udp-echo-port", 0, "optional tailnet UDP echo port") flag.Parse() if *stateDir == "" { @@ -48,6 +167,24 @@ func main() { Hostname: *hostname, UserLogf: log.Printf, } + + var tunDevice *chanTUN + var packetListener net.Listener + if *packetSocket != "" { + _ = os.Remove(*packetSocket) + ln, err := net.Listen("unix", *packetSocket) + if err != nil { + log.Fatalf("packet listen: %v", err) + } + packetListener = ln + defer func() { + packetListener.Close() + _ = os.Remove(*packetSocket) + }() + + tunDevice = newChanTUN() + server.Tun = tunDevice + } if *controlURL != "" { server.ControlURL = *controlURL } @@ -61,6 +198,7 @@ func main() { if err != nil { log.Fatalf("local client: %v", err) } + state := &helperState{} ln, err := net.Listen("tcp", *listen) if err != nil { @@ -68,12 +206,27 @@ func main() { } defer ln.Close() - fmt.Printf("{\"listen_addr\":%q}\n", ln.Addr().String()) + if packetListener != nil { + go servePacketBridge(packetListener, tunDevice) + } + if *udpEchoPort > 0 { + go serveUDPEcho(context.Background(), server, localClient, *udpEchoPort) + } + + hello := helperHello{ + ListenAddr: ln.Addr().String(), + } + if *packetSocket != "" { + hello.PacketSocket = *packetSocket + } + if err := json.NewEncoder(os.Stdout).Encode(hello); err != nil { + log.Fatalf("write hello: %v", err) + } _ = os.Stdout.Sync() mux := http.NewServeMux() mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { - status, err := snapshot(r.Context(), localClient) + status, err := snapshot(r.Context(), localClient, state) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return @@ -81,6 +234,40 @@ func main() { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(status) }) + mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + ip := r.URL.Query().Get("ip") + if ip == "" { + http.Error(w, "missing ip", http.StatusBadRequest) + return + } + target, err := netip.ParseAddr(ip) + if err != nil { + http.Error(w, fmt.Sprintf("invalid ip: %v", err), http.StatusBadRequest) + return + } + + pingType := tailcfg.PingTSMP + switch r.URL.Query().Get("type") { + case "", "tsmp", "TSMP": + pingType = tailcfg.PingTSMP + case "icmp", "ICMP": + pingType = tailcfg.PingICMP + case "peerapi": + pingType = tailcfg.PingPeerAPI + default: + http.Error(w, "unsupported ping type", http.StatusBadRequest) + return + } + + result, err := localClient.Ping(r.Context(), target, pingType) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&pingResponse{Result: result}) + }) mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) go func() { @@ -96,16 +283,110 @@ func main() { log.Fatal(httpServer.Serve(ln)) } -func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, error) { - status, err := localClient.StatusWithoutPeers(ctx) +func servePacketBridge(listener net.Listener, device *chanTUN) { + for { + conn, err := listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + log.Printf("packet accept: %v", err) + continue + } + log.Printf("packet bridge connected") + if err := bridgePacketConn(conn, device); err != nil && !errors.Is(err, io.EOF) { + log.Printf("packet bridge error: %v", err) + } + _ = conn.Close() + log.Printf("packet bridge disconnected") + } +} + +func bridgePacketConn(conn net.Conn, device *chanTUN) error { + errCh := make(chan error, 2) + + go func() { + for { + pkt, err := readFrame(conn) + if err != nil { + errCh <- err + return + } + select { + case <-device.closed: + errCh <- io.EOF + return + case device.Outbound <- pkt: + } + } + }() + + go func() { + for { + select { + case <-device.closed: + errCh <- io.EOF + return + case pkt, ok := <-device.Inbound: + if !ok { + errCh <- io.EOF + return + } + if err := writeFrame(conn, pkt); err != nil { + errCh <- err + return + } + } + } + }() + + return <-errCh +} + +func readFrame(r io.Reader) ([]byte, error) { + var size [4]byte + if _, err := io.ReadFull(r, size[:]); err != nil { + return nil, err + } + length := binary.BigEndian.Uint32(size[:]) + if length == 0 { + return []byte{}, nil + } + packet := make([]byte, length) + if _, err := io.ReadFull(r, packet); err != nil { + return nil, err + } + return packet, nil +} + +func writeFrame(w io.Writer, packet []byte) error { + var size [4]byte + binary.BigEndian.PutUint32(size[:], uint32(len(packet))) + if _, err := w.Write(size[:]); err != nil { + return err + } + if len(packet) == 0 { + return nil + } + _, err := w.Write(packet) + return err +} + +func snapshot(ctx context.Context, localClient *local.Client, state *helperState) (*statusResponse, error) { + status, err := localClient.Status(ctx) if err != nil { return nil, err } - if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && status.AuthURL == "" { - if err := localClient.StartLoginInteractive(ctx); err != nil { - return nil, err - } - status, err = localClient.StatusWithoutPeers(ctx) + + authURL := status.AuthURL + if authURL == "" { + authURL = state.authURLSnapshot() + } + if status.BackendState == ipn.Running.String() { + state.clearAuthURL() + authURL = "" + } else if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && authURL == "" { + authURL, err = awaitAuthURL(ctx, localClient, state) if err != nil { return nil, err } @@ -113,7 +394,7 @@ func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, response := &statusResponse{ BackendState: status.BackendState, - AuthURL: status.AuthURL, + AuthURL: authURL, Running: status.BackendState == ipn.Running.String(), NeedsLogin: status.BackendState == ipn.NeedsLogin.String(), Health: append([]string(nil), status.Health...), @@ -129,5 +410,114 @@ func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, for _, ip := range status.TailscaleIPs { response.TailscaleIPs = append(response.TailscaleIPs, ip.String()) } + for _, key := range status.Peers() { + peer := status.Peer[key] + if peer == nil { + continue + } + summary := peerSummary{ + Name: peer.HostName, + DNSName: peer.DNSName, + Online: peer.Online, + Active: peer.Active, + Relay: peer.Relay, + CurAddr: peer.CurAddr, + LastSeenUnix: peer.LastSeen.Unix(), + } + for _, ip := range peer.TailscaleIPs { + summary.TailscaleIPs = append(summary.TailscaleIPs, ip.String()) + } + response.Peers = append(response.Peers, summary) + } return response, nil } + +func serveUDPEcho(ctx context.Context, server *tsnet.Server, localClient *local.Client, port int) { + ip, err := awaitTailscaleIP(ctx, localClient) + if err != nil { + log.Printf("udp echo setup failed: %v", err) + return + } + + listenAddr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) + pc, err := server.ListenPacket("udp", listenAddr) + if err != nil { + log.Printf("udp echo listen failed on %s: %v", listenAddr, err) + return + } + defer pc.Close() + + log.Printf("udp echo listening on %s", pc.LocalAddr()) + buf := make([]byte, 64<<10) + for { + n, addr, err := pc.ReadFrom(buf) + if err != nil { + if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { + return + } + log.Printf("udp echo read failed: %v", err) + return + } + if _, err := pc.WriteTo(buf[:n], addr); err != nil { + log.Printf("udp echo write failed: %v", err) + return + } + } +} + +func awaitTailscaleIP(ctx context.Context, localClient *local.Client) (netip.Addr, error) { + for range 60 { + status, err := localClient.StatusWithoutPeers(ctx) + if err == nil { + for _, ip := range status.TailscaleIPs { + if ip.Is4() { + return ip, nil + } + } + for _, ip := range status.TailscaleIPs { + if ip.Is6() { + return ip, nil + } + } + } + select { + case <-ctx.Done(): + return netip.Addr{}, ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } + return netip.Addr{}, errors.New("timed out waiting for tailscale IP") +} + +func awaitAuthURL(ctx context.Context, localClient *local.Client, state *helperState) (string, error) { + watchCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + watcher, err := localClient.WatchIPNBus(watchCtx, ipn.NotifyInitialState) + if err != nil { + return "", err + } + defer watcher.Close() + + if err := localClient.StartLoginInteractive(ctx); err != nil { + return "", err + } + + for { + notify, err := watcher.Next() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return state.authURLSnapshot(), nil + } + return "", err + } + if notify.BrowseToURL != nil && *notify.BrowseToURL != "" { + state.setAuthURL(*notify.BrowseToURL) + return *notify.BrowseToURL, nil + } + if notify.State != nil && *notify.State == ipn.Running { + state.clearAuthURL() + return "", nil + } + } +} diff --git a/burrow/src/main.rs b/burrow/src/main.rs index c91f36f..4ab7700 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -72,6 +72,14 @@ enum Commands { NetworkReorder(NetworkReorderArgs), /// Delete Network NetworkDelete(NetworkDeleteArgs), + /// Discover a Tailnet authority through the daemon + TailnetDiscover(TailnetDiscoverArgs), + /// Probe a Tailnet authority through the daemon + TailnetProbe(TailnetProbeArgs), + /// Send an ICMP echo probe through the active Tailnet tunnel over daemon packet streaming + TailnetPing(TailnetPingArgs), + /// Send a UDP echo probe through the active Tailnet tunnel over daemon packet streaming + TailnetUdpEcho(TailnetUdpEchoArgs), #[cfg(target_os = "linux")] /// Run a command in an unshared Linux namespace using a Burrow backend Exec(ExecArgs), @@ -110,6 +118,36 @@ struct NetworkDeleteArgs { id: i32, } +#[derive(Args)] +struct TailnetDiscoverArgs { + email: String, +} + +#[derive(Args)] +struct TailnetProbeArgs { + authority: String, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct TailnetPingArgs { + remote: String, + #[arg(long, default_value = "burrow-tailnet-smoke")] + payload: String, + #[arg(long, default_value_t = 5000)] + timeout_ms: u64, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct TailnetUdpEchoArgs { + remote: String, + #[arg(long, default_value = "burrow-tailnet-smoke")] + message: String, + #[arg(long, default_value_t = 5000)] + timeout_ms: u64, +} + #[cfg(target_os = "linux")] #[derive(Args)] struct TorExecArgs { @@ -240,6 +278,393 @@ async fn try_network_delete(id: i32) -> Result<()> { Ok(()) } +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_discover(email: &str) -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + let response = client + .tailnet_client + .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { + email: email.to_owned(), + }) + .await? + .into_inner(); + println!("Tailnet Discover Response: {:?}", response); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_probe(authority: &str) -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + let response = client + .tailnet_client + .probe(crate::daemon::rpc::grpc_defs::TailnetProbeRequest { + authority: authority.to_owned(), + }) + .await? + .into_inner(); + println!("Tailnet Probe Response: {:?}", response); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Result<()> { + use std::net::IpAddr; + + use anyhow::Context; + use rand::Rng; + use tokio::{ + sync::mpsc, + time::{timeout, Duration}, + }; + use tokio_stream::wrappers::ReceiverStream; + + use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; + + let remote_ip: IpAddr = remote + .parse() + .with_context(|| format!("invalid remote IP address {remote}"))?; + let message = payload.as_bytes().to_vec(); + + let mut client = BurrowClient::from_uds().await?; + client.tunnel_client.tunnel_start(Empty {}).await?; + + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let config = config_stream + .message() + .await? + .context("tunnel configuration stream ended before yielding a config")?; + let local_ip = select_tailnet_local_ip(&config.addresses, remote_ip)?; + + let identifier = rand::thread_rng().gen::(); + let sequence = 1_u16; + let packet = build_icmp_echo_request(local_ip, remote_ip, identifier, sequence, &message)?; + + let (outbound_tx, outbound_rx) = mpsc::channel::(128); + let mut tunnel_packets = client + .tunnel_client + .tunnel_packets(ReceiverStream::new(outbound_rx)) + .await? + .into_inner(); + + outbound_tx + .send(TunnelPacket { payload: packet }) + .await + .context("failed to send ICMP echo probe into daemon packet stream")?; + log::debug!( + "tailnet ping probe queued from {local_ip} to {remote_ip} identifier={identifier} sequence={sequence}" + ); + drop(outbound_tx); + + let reply = timeout(Duration::from_millis(timeout_ms), async { + loop { + let packet = tunnel_packets + .message() + .await + .context("failed to read packet from daemon packet stream")? + .context("daemon packet stream ended before returning a reply")?; + log::debug!( + "tailnet ping received {} bytes from daemon packet stream", + packet.payload.len() + ); + if let Some(reply) = parse_icmp_echo_reply( + &packet.payload, + local_ip, + remote_ip, + identifier, + sequence, + )? { + break Ok::<_, anyhow::Error>(reply); + } + } + }) + .await + .with_context(|| format!("timed out waiting for ICMP echo reply from {remote_ip}"))??; + + println!("Tailnet Ping Source: {}", reply.source); + println!("Tailnet Ping Destination: {}", reply.destination); + println!( + "Tailnet Ping Payload: {}", + String::from_utf8_lossy(&reply.payload) + ); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> { + use std::net::SocketAddr; + + use anyhow::{bail, Context}; + use futures::{SinkExt, StreamExt}; + use netstack_smoltcp::StackBuilder; + use tokio::{ + sync::mpsc, + time::{timeout, Duration}, + }; + use tokio_stream::wrappers::ReceiverStream; + + use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; + + let remote_addr: SocketAddr = remote + .parse() + .with_context(|| format!("invalid remote socket address {remote}"))?; + + let mut client = BurrowClient::from_uds().await?; + client.tunnel_client.tunnel_start(Empty {}).await?; + + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let config = config_stream + .message() + .await? + .context("tunnel configuration stream ended before yielding a config")?; + let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?; + + let (stack, runner, udp_socket, _) = StackBuilder::default() + .enable_udp(true) + .enable_tcp(true) + .build() + .context("failed to build userspace UDP stack")?; + let runner = runner.context("userspace UDP stack runner unavailable")?; + let udp_socket = udp_socket.context("userspace UDP stack socket unavailable")?; + let (mut stack_sink, mut stack_stream) = stack.split(); + let (mut udp_reader, mut udp_writer) = udp_socket.split(); + + let (outbound_tx, outbound_rx) = mpsc::channel::(128); + let mut tunnel_packets = client + .tunnel_client + .tunnel_packets(ReceiverStream::new(outbound_rx)) + .await? + .into_inner(); + + let ingress_task = tokio::spawn(async move { + loop { + match tunnel_packets.message().await? { + Some(packet) => { + log::debug!( + "tailnet udp echo received {} bytes from daemon packet stream", + packet.payload.len() + ); + stack_sink + .send(packet.payload) + .await + .context("failed to feed inbound tailnet packet into userspace stack")?; + } + None => break, + } + } + Result::<()>::Ok(()) + }); + + let egress_task = tokio::spawn(async move { + while let Some(packet) = stack_stream.next().await { + let payload = + packet.context("failed to read outbound packet from userspace stack")?; + log::debug!( + "tailnet udp echo sending {} bytes into daemon packet stream", + payload.len() + ); + outbound_tx + .send(TunnelPacket { payload }) + .await + .context("failed to forward outbound tailnet packet to daemon")?; + } + Result::<()>::Ok(()) + }); + + let runner_task = tokio::spawn(async move { runner.await.map_err(anyhow::Error::from) }); + + udp_writer + .send((message.as_bytes().to_vec(), local_addr, remote_addr)) + .await + .context("failed to send UDP echo probe into userspace stack")?; + log::debug!( + "tailnet udp echo probe queued from {local_addr} to {remote_addr}" + ); + + let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) + .await + .with_context(|| format!("timed out waiting for UDP echo from {remote_addr}"))? + .context("userspace UDP stack ended before returning a reply")?; + let (payload, reply_source, reply_destination) = response; + let response_text = String::from_utf8_lossy(&payload); + + ingress_task.abort(); + egress_task.abort(); + runner_task.abort(); + + if reply_source != remote_addr { + bail!("received UDP reply from unexpected source {reply_source}"); + } + if reply_destination != local_addr { + bail!("received UDP reply for unexpected local socket {reply_destination}"); + } + if payload != message.as_bytes() { + bail!("UDP echo payload mismatch"); + } + + println!("Tailnet UDP Echo Source: {reply_source}"); + println!("Tailnet UDP Echo Destination: {reply_destination}"); + println!("Tailnet UDP Echo Payload: {response_text}"); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result { + use anyhow::Context; + + let family_is_v4 = remote_ip.is_ipv4(); + addresses + .iter() + .filter_map(|cidr| cidr.split('/').next()) + .filter_map(|ip| ip.parse::().ok()) + .find(|ip| ip.is_ipv4() == family_is_v4) + .with_context(|| { + format!( + "no local {} tailnet address found in daemon config {:?}", + if family_is_v4 { "IPv4" } else { "IPv6" }, + addresses + ) + }) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn select_tailnet_local_socket( + addresses: &[String], + remote_ip: std::net::IpAddr, +) -> Result { + use rand::Rng; + + let local_ip = select_tailnet_local_ip(addresses, remote_ip)?; + let port = rand::thread_rng().gen_range(40000..50000); + Ok(std::net::SocketAddr::new(local_ip, port)) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +struct IcmpEchoReply { + source: std::net::IpAddr, + destination: std::net::IpAddr, + payload: Vec, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn build_icmp_echo_request( + source: std::net::IpAddr, + destination: std::net::IpAddr, + identifier: u16, + sequence: u16, + payload: &[u8], +) -> Result> { + use anyhow::bail; + + let (source, destination) = match (source, destination) { + (std::net::IpAddr::V4(source), std::net::IpAddr::V4(destination)) => (source, destination), + _ => bail!("tailnet ping currently supports IPv4 only"), + }; + + let mut icmp = Vec::with_capacity(8 + payload.len()); + icmp.push(8); + icmp.push(0); + icmp.extend_from_slice(&[0, 0]); + icmp.extend_from_slice(&identifier.to_be_bytes()); + icmp.extend_from_slice(&sequence.to_be_bytes()); + icmp.extend_from_slice(payload); + let icmp_checksum = internet_checksum(&icmp); + icmp[2..4].copy_from_slice(&icmp_checksum.to_be_bytes()); + + let total_len = 20 + icmp.len(); + let mut packet = Vec::with_capacity(total_len); + packet.push(0x45); + packet.push(0); + packet.extend_from_slice(&(total_len as u16).to_be_bytes()); + packet.extend_from_slice(&0u16.to_be_bytes()); + packet.extend_from_slice(&0u16.to_be_bytes()); + packet.push(64); + packet.push(1); + packet.extend_from_slice(&[0, 0]); + packet.extend_from_slice(&source.octets()); + packet.extend_from_slice(&destination.octets()); + let header_checksum = internet_checksum(&packet); + packet[10..12].copy_from_slice(&header_checksum.to_be_bytes()); + packet.extend_from_slice(&icmp); + Ok(packet) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn parse_icmp_echo_reply( + packet: &[u8], + local_ip: std::net::IpAddr, + remote_ip: std::net::IpAddr, + identifier: u16, + sequence: u16, +) -> Result> { + use anyhow::bail; + + let (local_ip, remote_ip) = match (local_ip, remote_ip) { + (std::net::IpAddr::V4(local_ip), std::net::IpAddr::V4(remote_ip)) => (local_ip, remote_ip), + _ => bail!("tailnet ping currently supports IPv4 only"), + }; + + if packet.len() < 20 { + return Ok(None); + } + let version = packet[0] >> 4; + if version != 4 { + return Ok(None); + } + let ihl = (packet[0] & 0x0f) as usize * 4; + if packet.len() < ihl + 8 { + return Ok(None); + } + if packet[9] != 1 { + return Ok(None); + } + + let source = std::net::Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]); + let destination = std::net::Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]); + if source != remote_ip || destination != local_ip { + return Ok(None); + } + + let icmp = &packet[ihl..]; + if icmp[0] != 0 || icmp[1] != 0 { + return Ok(None); + } + let reply_identifier = u16::from_be_bytes([icmp[4], icmp[5]]); + let reply_sequence = u16::from_be_bytes([icmp[6], icmp[7]]); + if reply_identifier != identifier || reply_sequence != sequence { + return Ok(None); + } + + Ok(Some(IcmpEchoReply { + source: std::net::IpAddr::V4(source), + destination: std::net::IpAddr::V4(destination), + payload: icmp[8..].to_vec(), + })) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn internet_checksum(bytes: &[u8]) -> u16 { + let mut sum = 0u32; + let mut chunks = bytes.chunks_exact(2); + for chunk in &mut chunks { + sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32; + } + if let Some(&last) = chunks.remainder().first() { + sum += (last as u32) << 8; + } + while (sum >> 16) != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + !(sum as u16) +} + #[cfg(target_os = "linux")] async fn try_tor_exec(payload_path: &str, command: Vec) -> Result<()> { let exit_code = usernet::run_exec(usernet::ExecInvocation { @@ -348,6 +773,14 @@ async fn main() -> Result<()> { Commands::NetworkList => try_network_list().await?, Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?, Commands::NetworkDelete(args) => try_network_delete(args.id).await?, + Commands::TailnetDiscover(args) => try_tailnet_discover(&args.email).await?, + Commands::TailnetProbe(args) => try_tailnet_probe(&args.authority).await?, + Commands::TailnetPing(args) => { + try_tailnet_ping(&args.remote, &args.payload, args.timeout_ms).await? + } + Commands::TailnetUdpEcho(args) => { + try_tailnet_udp_echo(&args.remote, &args.message, args.timeout_ms).await? + } #[cfg(target_os = "linux")] Commands::Exec(args) => { try_exec( From 9e3e8fa7834bc09a2152feba7ae45f7e38784810 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 4 Apr 2026 22:20:55 -0700 Subject: [PATCH 07/11] Use upstream nsc-autoscaler on burrow forge --- flake.lock | 26 ++- flake.nix | 9 +- nixos/README.md | 4 +- nixos/hosts/burrow-forge/default.nix | 2 +- nixos/modules/burrow-forge.nix | 2 +- nixos/modules/burrow-forgejo-nsc.nix | 234 --------------------------- 6 files changed, 36 insertions(+), 241 deletions(-) delete mode 100644 nixos/modules/burrow-forgejo-nsc.nix diff --git a/flake.lock b/flake.lock index 1bafc37..0067dab 100644 --- a/flake.lock +++ b/flake.lock @@ -123,13 +123,37 @@ "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" } }, + "nsc-autoscaler": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775221037, + "narHash": "sha256-tv6Y3cqn76PEyZpSMMItVW96KKIboovBWTOv5Lt7PXg=", + "ref": "refs/heads/main", + "rev": "2c485752fde28ec3be2f228b571d1906f4bcf917", + "revCount": 10, + "type": "git", + "url": "https://compatible.systems/conrad/nsc-autoscaler.git" + }, + "original": { + "type": "git", + "url": "https://compatible.systems/conrad/nsc-autoscaler.git" + } + }, "root": { "inputs": { "agenix": "agenix", "disko": "disko", "flake-utils": "flake-utils", "hcloud-upload-image-src": "hcloud-upload-image-src", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nsc-autoscaler": "nsc-autoscaler" } }, "systems": { diff --git a/flake.nix b/flake.nix index 5814c19..1e91dcc 100644 --- a/flake.nix +++ b/flake.nix @@ -12,13 +12,18 @@ url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master"; inputs.nixpkgs.follows = "nixpkgs"; }; + nsc-autoscaler = { + url = "git+https://compatible.systems/conrad/nsc-autoscaler.git"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; hcloud-upload-image-src = { url = "tarball+https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0"; flake = false; }; }; - outputs = { self, nixpkgs, flake-utils, agenix, disko, hcloud-upload-image-src }: + outputs = { self, nixpkgs, flake-utils, agenix, disko, nsc-autoscaler, hcloud-upload-image-src }: let supportedSystems = [ "x86_64-linux" @@ -175,7 +180,7 @@ // { nixosModules.burrow-forge = import ./nixos/modules/burrow-forge.nix; nixosModules.burrow-forge-runner = import ./nixos/modules/burrow-forge-runner.nix; - nixosModules.burrow-forgejo-nsc = import ./nixos/modules/burrow-forgejo-nsc.nix; + nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; diff --git a/nixos/README.md b/nixos/README.md index 07b421d..c79d8ce 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -9,7 +9,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `hosts/burrow-forge/default.nix`: host entrypoint - `modules/burrow-forge.nix`: Forgejo, Caddy, PostgreSQL, and admin bootstrap module - `modules/burrow-forge-runner.nix`: Forgejo Actions runner and agent identity bootstrap -- `modules/burrow-forgejo-nsc.nix`: Namespace-backed ephemeral Forgejo runner services +- upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC - `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets @@ -32,7 +32,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`. 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. -6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`. +6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module. 7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. 8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 6c106f4..67c87ec 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -104,7 +104,7 @@ in sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519"; }; - services.burrow.forgejoNsc = { + services.forgejo-nsc = { enable = true; nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; dispatcher = { diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index 0d0f5c8..d74fc65 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -271,7 +271,7 @@ in ''; } // lib.optionalAttrs ( - config.services.burrow.forgejoNsc.enable && config.services.burrow.forgejoNsc.autoscaler.enable + config.services.forgejo-nsc.enable && config.services.forgejo-nsc.autoscaler.enable ) { "${cfg.nscAutoscalerDomain}".extraConfig = '' encode gzip zstd diff --git a/nixos/modules/burrow-forgejo-nsc.nix b/nixos/modules/burrow-forgejo-nsc.nix deleted file mode 100644 index ba116f7..0000000 --- a/nixos/modules/burrow-forgejo-nsc.nix +++ /dev/null @@ -1,234 +0,0 @@ -{ config, lib, pkgs, self, ... }: - -let - inherit (lib) - mkEnableOption - mkIf - mkOption - types - mkAfter - mkDefault - optional - optionalAttrs - optionalString - ; - - cfg = config.services.burrow.forgejoNsc; - dispatcherRuntimeConfig = "${cfg.stateDir}/dispatcher.yaml"; - autoscalerRuntimeConfig = "${cfg.stateDir}/autoscaler.yaml"; - - pendingCheck = configPath: pkgs.writeShellScript "forgejo-nsc-check-pending" '' - set -euo pipefail - if ${pkgs.gnugrep}/bin/grep -q 'PENDING-' '${configPath}'; then - echo "forgejo-nsc config still contains placeholder values (PENDING-); update ${configPath} before starting." >&2 - exit 1 - fi - ''; - - nscTokenPath = "${cfg.stateDir}/nsc.token"; - tokenSync = optionalString (cfg.nscTokenFile != null) '' - install -m 600 ${lib.escapeShellArg cfg.nscTokenFile} ${lib.escapeShellArg nscTokenPath} - chown ${cfg.user}:${cfg.group} ${nscTokenPath} - chmod 600 ${nscTokenPath} - ''; - dispatcherConfigSync = optionalString (cfg.dispatcher.configFile != null) '' - install -m 400 ${lib.escapeShellArg cfg.dispatcher.configFile} ${lib.escapeShellArg dispatcherRuntimeConfig} - chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg dispatcherRuntimeConfig} - chmod 400 ${lib.escapeShellArg dispatcherRuntimeConfig} - ''; - autoscalerConfigSync = optionalString (cfg.autoscaler.configFile != null) '' - install -m 400 ${lib.escapeShellArg cfg.autoscaler.configFile} ${lib.escapeShellArg autoscalerRuntimeConfig} - chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg autoscalerRuntimeConfig} - chmod 400 ${lib.escapeShellArg autoscalerRuntimeConfig} - ''; - - dispatcherEnv = - cfg.extraEnv - // optionalAttrs (cfg.nscTokenFile != null) { NSC_TOKEN_FILE = nscTokenPath; } - // optionalAttrs (cfg.nscTokenSpecFile != null) { NSC_TOKEN_SPEC_FILE = cfg.nscTokenSpecFile; } - // optionalAttrs (cfg.nscEndpoint != null) { NSC_ENDPOINT = cfg.nscEndpoint; }; -in { - options.services.burrow.forgejoNsc = { - enable = mkEnableOption "Forgejo Namespace Cloud runner dispatcher"; - - user = mkOption { - type = types.str; - default = "forgejo-nsc"; - description = "System user that runs the forgejo-nsc services."; - }; - - group = mkOption { - type = types.str; - default = "forgejo-nsc"; - description = "System group for the forgejo-nsc services."; - }; - - stateDir = mkOption { - type = types.str; - default = "/var/lib/forgejo-nsc"; - description = "State directory for the dispatcher/autoscaler."; - }; - - nscTokenFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Optional NSC token file (exported as NSC_TOKEN_FILE)."; - }; - - nscTokenSpecFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Optional NSC token spec file (exported as NSC_TOKEN_SPEC_FILE)."; - }; - - nscEndpoint = mkOption { - type = types.nullOr types.str; - default = null; - description = "Optional NSC endpoint override (exported as NSC_ENDPOINT)."; - }; - - extraEnv = mkOption { - type = types.attrsOf types.str; - default = { }; - description = "Extra environment variables injected into the services."; - }; - - nscPackage = mkOption { - type = types.nullOr types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.nsc or null; - description = "Optional nsc CLI package added to the service PATH."; - }; - - dispatcher = { - enable = mkOption { - type = types.bool; - default = true; - description = "Enable the forgejo-nsc dispatcher service."; - }; - - package = mkOption { - type = types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-dispatcher; - description = "Package providing the forgejo-nsc dispatcher binary."; - }; - - configFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Host-local YAML config file for the dispatcher."; - }; - - allowPending = mkOption { - type = types.bool; - default = false; - description = "Allow placeholder values (PENDING-) in the dispatcher config."; - }; - }; - - autoscaler = { - enable = mkOption { - type = types.bool; - default = false; - description = "Enable the forgejo-nsc autoscaler service."; - }; - - package = mkOption { - type = types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-autoscaler; - description = "Package providing the forgejo-nsc autoscaler binary."; - }; - - configFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Host-local YAML config file for the autoscaler."; - }; - - allowPending = mkOption { - type = types.bool; - default = false; - description = "Allow placeholder values (PENDING-) in the autoscaler config."; - }; - }; - }; - - config = mkIf cfg.enable { - assertions = [ - { - assertion = (!cfg.dispatcher.enable) || cfg.dispatcher.configFile != null; - message = "services.burrow.forgejoNsc.dispatcher.configFile must be set when the dispatcher is enabled."; - } - { - assertion = (!cfg.autoscaler.enable) || cfg.autoscaler.configFile != null; - message = "services.burrow.forgejoNsc.autoscaler.configFile must be set when the autoscaler is enabled."; - } - ]; - - users.groups.${cfg.group} = { }; - users.users.${cfg.user} = { - uid = mkDefault 2011; - isSystemUser = true; - group = cfg.group; - description = "Forgejo Namespace Cloud runner services"; - home = cfg.stateDir; - createHome = true; - shell = pkgs.bashInteractive; - }; - - systemd.tmpfiles.rules = mkAfter [ - "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -" - ]; - - systemd.services.forgejo-nsc-dispatcher = mkIf cfg.dispatcher.enable { - description = "Forgejo Namespace Cloud dispatcher"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - unitConfig.ConditionPathExists = - optional (cfg.dispatcher.configFile != null) cfg.dispatcher.configFile - ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; - serviceConfig = { - Type = "simple"; - User = cfg.user; - Group = cfg.group; - WorkingDirectory = cfg.stateDir; - ExecStart = "${cfg.dispatcher.package}/bin/forgejo-nsc-dispatcher --config ${dispatcherRuntimeConfig}"; - Restart = "on-failure"; - RestartSec = 5; - }; - path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; - environment = dispatcherEnv; - preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ - (optionalString (!cfg.dispatcher.allowPending) (pendingCheck cfg.dispatcher.configFile)) - dispatcherConfigSync - tokenSync - ]); - }; - - systemd.services.forgejo-nsc-autoscaler = mkIf cfg.autoscaler.enable { - description = "Forgejo Namespace Cloud autoscaler"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" "forgejo-nsc-dispatcher.service" ]; - wants = [ "network-online.target" ]; - unitConfig.ConditionPathExists = - optional (cfg.autoscaler.configFile != null) cfg.autoscaler.configFile - ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; - serviceConfig = { - Type = "simple"; - User = cfg.user; - Group = cfg.group; - WorkingDirectory = cfg.stateDir; - ExecStart = "${cfg.autoscaler.package}/bin/forgejo-nsc-autoscaler --config ${autoscalerRuntimeConfig}"; - Restart = "on-failure"; - RestartSec = 5; - }; - path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; - environment = dispatcherEnv; - preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ - (optionalString (!cfg.autoscaler.allowPending) (pendingCheck cfg.autoscaler.configFile)) - autoscalerConfigSync - tokenSync - ]); - }; - }; -} From b15b6624cbeaba430a48a9e4c09ef963bbe45bd3 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 4 Apr 2026 22:21:03 -0700 Subject: [PATCH 08/11] Add Forgejo namespace release workflow --- .forgejo/workflows/release.yml | 60 ++++++++++ Scripts/ci/build-release-artifacts.sh | 20 ++++ Scripts/ci/ensure-nix.sh | 157 ++++++++++++++++++++++++++ Scripts/ci/publish-forgejo-release.sh | 65 +++++++++++ 4 files changed, 302 insertions(+) create mode 100644 .forgejo/workflows/release.yml create mode 100755 Scripts/ci/build-release-artifacts.sh create mode 100755 Scripts/ci/ensure-nix.sh create mode 100755 Scripts/ci/publish-forgejo-release.sh diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..3d1e92a --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release Build + runs-on: namespace-profile-linux-medium + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Bootstrap Nix + shell: bash + run: | + set -euo pipefail + chmod +x Scripts/ci/ensure-nix.sh + Scripts/ci/ensure-nix.sh + + - name: Build release artifacts + shell: bash + env: + RELEASE_REF: ${{ github.ref_name }} + run: | + set -euo pipefail + ref="${RELEASE_REF:-manual-${GITHUB_SHA::7}}" + export RELEASE_REF="${ref}" + chmod +x Scripts/ci/build-release-artifacts.sh + nix develop .#ci -c Scripts/ci/build-release-artifacts.sh + + - name: Upload release artifacts + uses: https://code.forgejo.org/actions/upload-artifact@v4 + with: + name: burrow-release-${{ github.ref_name }} + path: dist/* + if-no-files-found: error + + - name: Publish Forgejo release + if: startsWith(github.ref, 'refs/tags/') + shell: bash + env: + RELEASE_TAG: ${{ github.ref_name }} + API_URL: ${{ github.api_url }} + REPOSITORY: ${{ github.repository }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + chmod +x Scripts/ci/publish-forgejo-release.sh + nix develop .#ci -c Scripts/ci/publish-forgejo-release.sh diff --git a/Scripts/ci/build-release-artifacts.sh b/Scripts/ci/build-release-artifacts.sh new file mode 100755 index 0000000..20b4c06 --- /dev/null +++ b/Scripts/ci/build-release-artifacts.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${repo_root}" + +release_ref="${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}" +target="x86_64-unknown-linux-gnu" +out_dir="${repo_root}/dist" +staging="${out_dir}/burrow-${release_ref}-${target}" + +mkdir -p "${staging}" + +cargo build --locked --release -p burrow --bin burrow +install -m 0755 target/release/burrow "${staging}/burrow" +cp README.md "${staging}/README.md" + +tarball="${out_dir}/burrow-${release_ref}-${target}.tar.gz" +tar -C "${out_dir}" -czf "${tarball}" "$(basename "${staging}")" +shasum -a 256 "${tarball}" > "${tarball}.sha256" diff --git a/Scripts/ci/ensure-nix.sh b/Scripts/ci/ensure-nix.sh new file mode 100755 index 0000000..14be895 --- /dev/null +++ b/Scripts/ci/ensure-nix.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_nix_profile() { + local candidate + for candidate in \ + "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" \ + "${HOME}/.nix-profile/etc/profile.d/nix.sh" + do + if [[ -f "${candidate}" ]]; then + # shellcheck disable=SC1090 + . "${candidate}" + return 0 + fi + done + return 1 +} + +linux_cp_supports_preserve() { + cp --help 2>&1 | grep -q -- '--preserve' +} + +ensure_root_owned_home() { + if [[ "$(id -u)" -ne 0 ]]; then + return 0 + fi + + if [[ ! -d "${HOME}" ]] || [[ ! -O "${HOME}" ]]; then + export HOME="/root" + fi + + mkdir -p "${HOME}" +} + +ensure_linux_nixbld_accounts() { + if [[ "$(id -u)" -ne 0 ]]; then + return 0 + fi + + if command -v getent >/dev/null 2>&1 && getent group nixbld >/dev/null 2>&1; then + return 0 + fi + + if command -v addgroup >/dev/null 2>&1 && ! command -v groupadd >/dev/null 2>&1; then + addgroup -S nixbld >/dev/null 2>&1 || true + for i in $(seq 1 10); do + adduser -S -D -H -h /var/empty -s /sbin/nologin -G nixbld "nixbld${i}" >/dev/null 2>&1 || true + done + return 0 + fi + + if command -v groupadd >/dev/null 2>&1; then + groupadd -r nixbld >/dev/null 2>&1 || true + for i in $(seq 1 10); do + useradd \ + --system \ + --no-create-home \ + --home-dir /var/empty \ + --shell /usr/sbin/nologin \ + --gid nixbld \ + "nixbld${i}" >/dev/null 2>&1 || true + done + return 0 + fi + + echo "linux nix bootstrap requires nixbld group creation support" >&2 + exit 1 +} + +ensure_linux_nix_bootstrap_prereqs() { + if linux_cp_supports_preserve; then + ensure_root_owned_home + ensure_linux_nixbld_accounts + return 0 + fi + + if command -v apk >/dev/null 2>&1; then + apk add --no-cache coreutils xz >/dev/null + elif command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -y >/dev/null + apt-get install -y coreutils xz-utils >/dev/null + elif command -v dnf >/dev/null 2>&1; then + dnf install -y coreutils xz >/dev/null + elif command -v yum >/dev/null 2>&1; then + yum install -y coreutils xz >/dev/null + else + echo "linux nix bootstrap requires GNU cp but no supported package manager was found" >&2 + exit 1 + fi + + linux_cp_supports_preserve || { + echo "linux nix bootstrap still lacks GNU cp after installing prerequisites" >&2 + exit 1 + } + + ensure_root_owned_home + ensure_linux_nixbld_accounts +} + +if ! command -v nix >/dev/null 2>&1; then + if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to install nix" >&2 + exit 1 + fi + + case "$(uname -s)" in + Linux) + ensure_linux_nix_bootstrap_prereqs + curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon + ;; + Darwin) + installer="$(mktemp -t burrow-nix.XXXXXX)" + trap 'rm -f "${installer}"' EXIT + curl -fsSL -o "${installer}" https://install.determinate.systems/nix + chmod +x "${installer}" + if command -v sudo >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + sudo -n sh "${installer}" install --no-confirm + else + sudo sh "${installer}" install --no-confirm + fi + else + sh "${installer}" install --no-confirm + fi + ;; + *) + echo "unsupported platform for nix bootstrap: $(uname -s)" >&2 + exit 1 + ;; + esac +fi + +source_nix_profile || true +export PATH="${HOME}/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" + +config_root="${XDG_CONFIG_HOME:-$HOME/.config}" +config_file="${config_root}/nix/nix.conf" +if [[ -e "${config_file}" && ! -w "${config_file}" ]]; then + config_root="$(mktemp -d -t burrow-nix-config.XXXXXX)" + export XDG_CONFIG_HOME="${config_root}" + config_file="${XDG_CONFIG_HOME}/nix/nix.conf" +fi + +mkdir -p "$(dirname -- "${config_file}")" +cat > "${config_file}" <<'EOF' +experimental-features = nix-command flakes +sandbox = true +fallback = true +substituters = https://cache.nixos.org +trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= +EOF + +command -v nix >/dev/null 2>&1 || { + echo "nix is still unavailable after bootstrap" >&2 + exit 1 +} diff --git a/Scripts/ci/publish-forgejo-release.sh b/Scripts/ci/publish-forgejo-release.sh new file mode 100755 index 0000000..338f71b --- /dev/null +++ b/Scripts/ci/publish-forgejo-release.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${API_URL:?API_URL is required}" +: "${REPOSITORY:?REPOSITORY is required}" +: "${RELEASE_TAG:?RELEASE_TAG is required}" +: "${TOKEN:?TOKEN is required}" + +release_api="${API_URL}/repos/${REPOSITORY}/releases" +tag_api="${release_api}/tags/${RELEASE_TAG}" +release_json="$(mktemp)" +create_json="$(mktemp)" +trap 'rm -f "${release_json}" "${create_json}"' EXIT + +status="$( + curl -sS -o "${release_json}" -w '%{http_code}' \ + -H "Authorization: token ${TOKEN}" \ + "${tag_api}" +)" + +if [[ "${status}" == "404" ]]; then + jq -n \ + --arg tag "${RELEASE_TAG}" \ + --arg name "Burrow ${RELEASE_TAG}" \ + '{ + tag_name: $tag, + target_commitish: $tag, + name: $name, + body: "Automated prerelease built on Forgejo Namespace runners.", + draft: false, + prerelease: true + }' > "${create_json}" + + curl -fsS \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @"${create_json}" \ + "${release_api}" > "${release_json}" +elif [[ "${status}" != "200" ]]; then + echo "failed to query Forgejo release for ${RELEASE_TAG} (HTTP ${status})" >&2 + cat "${release_json}" >&2 + exit 1 +fi + +release_id="$(jq -r '.id' "${release_json}")" +if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then + echo "Forgejo release payload is missing an id" >&2 + cat "${release_json}" >&2 + exit 1 +fi + +for file in dist/*; do + name="$(basename "${file}")" + asset_id="$(jq -r --arg name "${name}" '.assets[]? | select(.name == $name) | .id' "${release_json}" | head -n1)" + if [[ -n "${asset_id}" ]]; then + curl -fsS -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "${release_api}/${release_id}/assets/${asset_id}" >/dev/null + fi + + curl -fsS \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${file}" \ + "${release_api}/${release_id}/assets?name=${name}" >/dev/null +done From c8aa036ade560b76c128700b1e0922186a9b8626 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 4 Apr 2026 23:53:33 -0700 Subject: [PATCH 09/11] Add Tailscale Authentik OIDC app --- Scripts/authentik-sync-tailscale-oidc.sh | 251 ++++++++++++++++++ nixos/hosts/burrow-forge/default.nix | 7 + nixos/modules/burrow-authentik.nix | 73 +++++ nixos/modules/burrow-forge.nix | 4 +- secrets.nix | 1 + .../infra/tailscale-oidc-client-secret.age | 10 + 6 files changed, 344 insertions(+), 2 deletions(-) create mode 100755 Scripts/authentik-sync-tailscale-oidc.sh create mode 100644 secrets/infra/tailscale-oidc-client-secret.age diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh new file mode 100755 index 0000000..54564ad --- /dev/null +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_TAILSCALE_APPLICATION_SLUG:-tailscale}" +application_name="${AUTHENTIK_TAILSCALE_APPLICATION_NAME:-Tailscale}" +provider_name="${AUTHENTIK_TAILSCALE_PROVIDER_NAME:-Tailscale}" +template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" +client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" +redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[ + \"https://login.tailscale.com/a/oauth_response\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-tailscale-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_TAILSCALE_CLIENT_SECRET + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_TAILSCALE_APPLICATION_SLUG + AUTHENTIK_TAILSCALE_APPLICATION_NAME + AUTHENTIK_TAILSCALE_PROVIDER_NAME + AUTHENTIK_TAILSCALE_TEMPLATE_SLUG + AUTHENTIK_TAILSCALE_CLIENT_ID + AUTHENTIK_TAILSCALE_LAUNCH_URL + AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then + echo "Tailscale OIDC client secret is not configured; skipping Authentik Tailscale sync." >&2 + exit 0 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg client_secret "$client_secret" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: "confidential", + client_id: $client_id, + client_secret: $client_secret, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Tailscale OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Tailscale OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Tailscale OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 67c87ec..75b76d4 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -63,6 +63,12 @@ in group = "forgejo"; mode = "0440"; }; + age.secrets.burrowTailscaleOidcClientSecret = { + file = ../../../secrets/infra/tailscale-oidc-client-secret.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -121,6 +127,7 @@ in envFile = config.age.secrets.burrowAuthentikEnv.path; forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; + tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleLoginMode = "redirect"; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 478d0d9..6861f17 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,6 +10,7 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; + tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -131,6 +132,24 @@ in description = "Authentik application slug for Forgejo."; }; + tailscaleProviderSlug = lib.mkOption { + type = lib.types.str; + default = "tailscale"; + description = "Authentik application slug for Tailscale custom OIDC sign-in."; + }; + + tailscaleClientId = lib.mkOption { + type = lib.types.str; + default = "tailscale.burrow.net"; + description = "Client ID Authentik should present to Tailscale."; + }; + + tailscaleClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Authentik Tailscale OIDC client secret."; + }; + forgejoClientId = lib.mkOption { type = lib.types.str; default = "git.burrow.net"; @@ -313,6 +332,13 @@ in fi ''} + ${lib.optionalString (cfg.tailscaleClientSecretFile != null) '' + if [ ! -s ${lib.escapeShellArg cfg.tailscaleClientSecretFile} ]; then + echo "Tailscale client secret missing: ${cfg.tailscaleClientSecretFile}" >&2 + exit 1 + fi + ''} + install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir} install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} @@ -634,6 +660,53 @@ EOF ''; }; + systemd.services.burrow-authentik-tailscale-oidc = lib.mkIf (cfg.tailscaleClientSecretFile != null) { + description = "Reconcile the Burrow Authentik Tailscale OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + tailscaleOidcSyncScript + cfg.envFile + cfg.tailscaleClientSecretFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_TAILSCALE_APPLICATION_SLUG=${lib.escapeShellArg cfg.tailscaleProviderSlug} + export AUTHENTIK_TAILSCALE_APPLICATION_NAME=Tailscale + export AUTHENTIK_TAILSCALE_PROVIDER_NAME=Tailscale + export AUTHENTIK_TAILSCALE_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILSCALE_CLIENT_ID=${lib.escapeShellArg cfg.tailscaleClientId} + export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})" + export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc + export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]' + + ${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index d74fc65..d733135 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -258,13 +258,13 @@ in "${cfg.siteDomain}".extraConfig = '' encode gzip zstd @oidcConfig path /.well-known/openid-configuration - redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/.well-known/openid-configuration 308 + redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/.well-known/openid-configuration 308 @tailnetConfig path /.well-known/burrow-tailnet header @tailnetConfig Content-Type application/json respond @tailnetConfig "{\"domain\":\"${cfg.siteDomain}\",\"provider\":\"headscale\",\"authority\":\"https://${config.services.burrow.headscale.domain}\",\"oidc_issuer\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/\"}" 200 @webfinger path /.well-known/webfinger header @webfinger Content-Type application/jrd+json - respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 + respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 @root path / redir @root ${homeRepoUrl} 308 respond 404 diff --git a/secrets.nix b/secrets.nix index 5a3ac8c..c0b9b53 100644 --- a/secrets.nix +++ b/secrets.nix @@ -17,4 +17,5 @@ in "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/tailscale-oidc-client-secret.age b/secrets/infra/tailscale-oidc-client-secret.age new file mode 100644 index 0000000..e88c2d1 --- /dev/null +++ b/secrets/infra/tailscale-oidc-client-secret.age @@ -0,0 +1,10 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q KfvLMiH7JHE6v74Pp//SqzBP8WU1MNy1/EcqsONTTQQ +Y6SFXWe/5Pru6+3vU6e67bRZDWDkukdfgEX7uQjB4Uw +-> ssh-ed25519 IrZmAg AFn7BP4FktUYH9QvNJPVDdNcEpJjYqmOrisvX9XGV08 +Zho+KNtk1vUQZ55j1xUHdswAj0T0Soji/HC6p1tsVcA +-> X25519 sv50iZjBijWKfp6I+LfRlEJ2sqnj5/2m0hRWz5NqLTk +Hdfvo+87zemSCFWDSlzkpmvHLuvc0tjxEt0ociTPrCg +--- BkQd4O2m/i98rlBcNhczU6Wj0htoiNLQDn0W6yKn1/c + a "WL\#zDRq6.竂}#8²koyq>L\`wƔ>f/Ѵ^,# +hD<>]C \ No newline at end of file From 8de798469bac11fec1906b54b388f9c1e836e795 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 01:34:32 -0700 Subject: [PATCH 10/11] Bind tailnet auth flow to tailscale --- Scripts/authentik-sync-tailnet-auth-flow.sh | 39 ++++++++++++++------- nixos/modules/burrow-authentik.nix | 1 + 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh index bfb00ef..bae760b 100755 --- a/Scripts/authentik-sync-tailnet-auth-flow.sh +++ b/Scripts/authentik-sync-tailnet-auth-flow.sh @@ -4,6 +4,7 @@ set -euo pipefail authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" provider_slug="${AUTHENTIK_TAILNET_PROVIDER_SLUG:-ts}" +provider_slugs_json="${AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON:-}" authentication_flow_name="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME:-Burrow Tailnet Authentication}" authentication_flow_slug="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG:-burrow-tailnet-authentication}" identification_stage_name="${AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME:-burrow-tailnet-identification-stage}" @@ -21,6 +22,7 @@ Required environment: Optional environment: AUTHENTIK_URL AUTHENTIK_TAILNET_PROVIDER_SLUG + AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME @@ -40,6 +42,15 @@ if [[ -z "$bootstrap_token" ]]; then exit 1 fi +if [[ -n "$provider_slugs_json" ]]; then + if ! printf '%s' "$provider_slugs_json" | jq -e 'type == "array" and length > 0 and all(.[]; type == "string" and length > 0)' >/dev/null; then + echo "error: AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON must be a non-empty JSON array of strings" >&2 + exit 1 + fi +else + provider_slugs_json="$(jq -cn --arg slug "$provider_slug" '[$slug]')" +fi + api() { local method="$1" local path="$2" @@ -263,18 +274,20 @@ ensure_flow_binding() { wait_for_authentik -provider_pk="$( +mapfile -t provider_pks < <( api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -r --arg provider_slug "$provider_slug" ' + | jq -r --argjson provider_slugs "$provider_slugs_json" ' .results[]? - | select(.assigned_application_slug == $provider_slug or .slug == $provider_slug) + | select( + (.assigned_application_slug != null and ($provider_slugs | index(.assigned_application_slug) != null)) + or (.slug != null and ($provider_slugs | index(.slug) != null)) + ) | .pk // empty - ' \ - | head -n1 -)" + ' +) -if [[ -z "$provider_pk" ]]; then - echo "error: could not resolve Authentik Tailnet OAuth provider ${provider_slug}" >&2 +if [[ "${#provider_pks[@]}" -eq 0 ]]; then + echo "error: could not resolve any Authentik Tailnet OAuth providers from ${provider_slugs_json}" >&2 exit 1 fi @@ -287,8 +300,10 @@ authentication_flow_pk="$(ensure_authentication_flow)" ensure_flow_binding "$authentication_flow_pk" "$identification_stage_pk" 10 ensure_flow_binding "$authentication_flow_pk" "$user_login_stage_pk" 30 -api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( - jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' -)" >/dev/null +for provider_pk in "${provider_pks[@]}"; do + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( + jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' + )" >/dev/null +done -echo "Synced Burrow Tailnet authentication flow for provider ${provider_slug}." +echo "Synced Burrow Tailnet authentication flow for providers ${provider_slugs_json}." diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 6861f17..1616b36 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -603,6 +603,7 @@ EOF export AUTHENTIK_URL=https://${cfg.domain} export AUTHENTIK_TAILNET_PROVIDER_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON='["${cfg.headscaleProviderSlug}","${cfg.tailscaleProviderSlug}"]' export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME=${lib.escapeShellArg cfg.headscaleAuthenticationFlowName} export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG=${lib.escapeShellArg cfg.headscaleAuthenticationFlowSlug} export AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME=${lib.escapeShellArg cfg.headscaleIdentificationStageName} From 3ebb0a8e61b3420097483bf5a9f033c53e1cd5cf Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 01:36:52 -0700 Subject: [PATCH 11/11] Fix tailnet auth flow provider lookup --- Scripts/authentik-sync-tailnet-auth-flow.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh index bae760b..1c715cc 100755 --- a/Scripts/authentik-sync-tailnet-auth-flow.sh +++ b/Scripts/authentik-sync-tailnet-auth-flow.sh @@ -279,8 +279,8 @@ mapfile -t provider_pks < <( | jq -r --argjson provider_slugs "$provider_slugs_json" ' .results[]? | select( - (.assigned_application_slug != null and ($provider_slugs | index(.assigned_application_slug) != null)) - or (.slug != null and ($provider_slugs | index(.slug) != null)) + ((.assigned_application_slug // empty) as $assigned | ($provider_slugs | index($assigned)) != null) + or ((.slug // empty) as $slug | ($provider_slugs | index($slug)) != null) ) | .pk // empty '