diff --git a/Cargo.lock b/Cargo.lock index b82dc33..f27f498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,6 +444,7 @@ dependencies = [ "dialoguer", "dirs", "flate2", + "fs2", "hostname", "if-addrs", "predicates", @@ -480,6 +481,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1891,6 +1902,28 @@ dependencies = [ "libc", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 6b6ca9b..aefb0f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ ctrlc = { version = "3.4", features = ["termination"] } dialoguer = "0.11" dirs = "6.0" flate2 = "1.1" +fs2 = "0.4" hostname = "0.4" if-addrs = "0.15" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } diff --git a/README.md b/README.md index 9ee532e..9e6002b 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ fleet is mostly an opinionated setup around things your computers already know h - shell and tmux colors make it obvious which machine you are using - a generated fleet skill makes the machine list readable by compatible coding agents -fleet does not proxy normal work after setup. `ssh emerald.local`, scp, sftp, and ordinary remote commands are still just normal ssh. +Fleet does not terminate or relay normal work after setup. `ssh emerald.local`, scp, sftp, and ordinary remote commands remain normal OpenSSH; Fleet's tiny local ProxyCommand only chooses the LAN or an already-connected Tailscale socket before OpenSSH performs its usual key exchange. interactive ssh enters the persistent tmux session by default. bypass it once with: @@ -78,7 +78,7 @@ ssh -t emerald.local 'NO_TMUX=1 exec "$SHELL" -l' ## trust and privacy -fleet has no accounts, hosted control plane, relay, or telemetry. its state and coordination stay on your local network. installing fleet, system packages, codex, or claude code can obviously contact their official download sources. +fleet has no Fleet-hosted accounts, control plane, relay, or telemetry. its state and coordination stay local. when Tailscale is already installed and connected, Fleet can use the user's Tailnet for private remote reachability; Tailscale may use its own coordination service or DERP relays. installing fleet, system packages, codex, or claude code can obviously contact their official download sources. captain discovery uses unauthenticated mdns. `fleet join` shows you the captain and its fingerprint before trusting it, which is trust-on-first-use for a trusted lan, not magic cryptographic proof that the person next to you is not doing something weird. @@ -90,10 +90,10 @@ fleet uses the operating system's openssh client for machine-to-machine commands - macos or debian/ubuntu linux with systemd and apt - bash or zsh -- machines on the same trusted local network +- machines on a trusted local network for initial joining - one captain and one fleet per machine -fleet does **not** do tailscale, task orchestration or windows. it just gives tools access to machines, it does not become the tool using them. +fleet does optional Tailscale routing for members after their normal join or the next `fleet update-all`, while preserving ordinary `ssh .local`; it does not install or administer Tailscale. Fleet also does not do task orchestration or Windows. it gives tools access to machines, it does not become the tool using them. ## gpt-5.6 diff --git a/docs/product-vision.md b/docs/product-vision.md index dccbe40..a37c6d6 100644 --- a/docs/product-vision.md +++ b/docs/product-vision.md @@ -110,7 +110,7 @@ The topology is intentionally asymmetric. The captain can access members. Fleet ### Local first -Fleet state and Fleet protocol traffic stay on the user's network. Fleet has no hosted control plane, account system, relay, or telemetry. Core operations remain useful without internet connectivity after required software has been installed. +Fleet state and Fleet protocol traffic stay on the user's network. Fleet has no hosted control plane, account system, relay, or telemetry. Core operations remain useful without internet connectivity after required software has been installed. When a user already runs Tailscale, Fleet may use it as an optional local endpoint substrate; Fleet does not administer that network. Fleet may download Fleet releases, operating-system packages, and selected tools from their official sources. "Local first" applies to state and coordination, not to ordinary software downloads. @@ -188,7 +188,7 @@ The user installs Fleet on another machine, opens a shell there, and runs `fleet ### Use the fleet -The user or an agent on the captain accesses a member using ordinary SSH and its `.local` hostname. Fleet does not wrap every remote command and does not need to remain in the execution path. +The user or an agent on the captain accesses a member using ordinary SSH and its `.local` hostname. Fleet does not terminate or relay SSH; a small local selector may remain in the connection path only to choose the LAN or an already-connected Tailscale socket. `fleet status` exposes the locally known topology and machine metadata. Detailed liveness monitoring, task tracking, and workload telemetry are not part of the product. @@ -242,7 +242,7 @@ For v0, the skill exists only on the captain. File transfer and skill replicatio - Delegation, orchestration, scheduling, or task tracking - Machine recommendations, descriptions, or workload roles -- Tailscale or other remote-network integration +- Task orchestration, scheduling, and hosted remote-network control planes - Fleet-owned relay, cloud API, accounts, or telemetry - File transfer, synchronization, or repository distribution - Member-to-member SSH trust @@ -258,7 +258,6 @@ For v0, the skill exists only on the captain. File transfer and skill replicatio ## Deferred possibilities, not commitments -- Tailscale as an additional transport while preserving Fleet identity - `fleet sync` or `fleet transfer` for explicit file movement - Skills on members after synchronization exists - Additional installable tools diff --git a/docs/tailscale-integration-spec.md b/docs/tailscale-integration-spec.md new file mode 100644 index 0000000..9817dd1 --- /dev/null +++ b/docs/tailscale-integration-spec.md @@ -0,0 +1,596 @@ +# Fleet + Tailscale Integration Specification + +**Status:** Proposed after first independent review +**Audience:** Fleet maintainers and security reviewers +**Last updated:** 2026-08-01 + +## 1. Product contract + +When Fleet can see that a captain and member already share usable Tailscale connectivity, this command must work unchanged at home and away: + +```sh +ssh emerald.local +``` + +The normal experience has no Fleet-specific Tailscale setup: + +1. On a shared LAN, Fleet normally uses the member's LAN/mDNS address. +2. Away from that LAN, Fleet uses the member's live Tailscale address. +3. The user, agent, editor, Git, `scp`, and `sftp` continue using `emerald.local`. +4. Fleet's existing dedicated SSH key and pinned member host key remain authoritative. +5. If Tailscale is absent, Fleet behaves exactly as it does today. + +The default is: + +> If both machines have a qualified, locally queryable Tailscale installation and Tailnet policy permits TCP/22 to the member's ordinary `sshd`, Fleet quietly uses it. Tailscale SSH need not be enabled. + +Fleet does not install Tailscale, initiate login, accept auth keys, change Tailnet policy, rename nodes, apply tags, enable Tailscale SSH, or configure Tailscale Serve. Those actions require wider user or Tailnet-administrator authority. + +## 2. Scope + +### Included + +- Automatic read-only detection of an already-running Tailscale client. +- Correlation of Fleet members with peers visible to the captain's local Tailscale client. +- Transparent LAN-first/Tailnet-fallback routing for `.local` in OpenSSH. +- Reuse of Fleet's existing `.local` SSH host-key pin across both routes. +- Gradual existing-Fleet migration during normal member update/re-registration. +- Route-aware `fleet status --check` and `fleet doctor`. +- A per-invocation recovery escape hatch. +- macOS and supported Debian/Ubuntu Linux qualification. + +### Excluded + +- Installing or authenticating Tailscale. +- Tailnet API credentials, auth keys, OAuth clients, tags, grants, ACL mutation, DNS mutation, or device approval. +- Tailscale SSH and Tailscale Serve configuration. +- Fleet traffic on port `42170` over the Tailnet. +- Brand-new Fleet joining across different physical networks. +- A Fleet relay, VPN, DNS server, SSH server, or hosted control plane. +- Guaranteed retry after an SSH protocol/authentication failure on a TCP route that already connected. + +Remote joining is a separate future product with its own invitation and trust ceremony. It is not necessary for existing Fleet members to become remotely reachable. + +## 3. Why a local route selector is necessary + +Fleet's name is `emerald.local`. Tailscale exposes a separate MagicDNS name such as `emerald.example.ts.net` and a Tailscale IP. OpenSSH accepts one resolved `HostName`; it does not natively try mDNS and then a different DNS namespace. + +The alternatives do not meet the contract: + +- Always rewriting `emerald.local` to MagicDNS makes local SSH fail whenever Tailscale is unavailable. +- Editing `/etc/hosts` or system DNS corrupts `.local` semantics for unrelated applications. +- Requiring `fleet ssh emerald` or `ssh fleet-emerald` changes the user interface and breaks generic tools. +- `Match exec` hostname rewriting has fragile first-value/config-order semantics and poor diagnostics. + +Fleet therefore adds a narrow local OpenSSH `ProxyCommand`. It selects and opens a TCP connection, then transports opaque bytes. OpenSSH still performs end-to-end SSH encryption, server host-key verification, and user authentication directly with the member's `sshd`. + +This is a local transport adapter, not a network relay: no Fleet server sees the traffic, and the helper does not terminate or interpret SSH. + +## 4. Architecture + +```text +┌──────────────────────── captain ────────────────────────┐ +│ │ +│ ssh emerald.local │ +│ │ │ +│ v │ +│ generated ~/.fleet/ssh_config │ +│ │ ProxyCommand │ +│ v │ +│ fleet transport connect --member │ +│ │ │ +│ ├─ native OS lookup/dial: emerald.local │ +│ │ │ +│ └─ local tailscaled peer lookup -> 100.x / fd7a │ +│ │ +│ outer OpenSSH verifies the existing emerald.local pin │ +└───────────────────────────┬────────────────────────────┘ + │ selected TCP route, SSH E2E +┌───────────────────────────v────────────────────────────┐ +│ member sshd :22 │ +│ existing authorized Fleet captain key │ +│ existing pinned Ed25519 host key │ +└────────────────────────────────────────────────────────┘ +``` + +Three layers remain distinct: + +| Layer | Authority | Purpose | +|---|---|---| +| Tailnet | Tailscale node identity and grants/ACLs | Whether packets may flow | +| Fleet | Fleet UUID and Ed25519 identity | Which machines are members and which peer mapping they report | +| SSH | Fleet captain key and existing `.local` host-key pin | Login and server authentication | + +A route is never membership identity. A Tailscale peer match can select a socket; it cannot replace or rotate the member's SSH host-key pin. + +## 5. Seamless discovery and activation + +### 5.1 No enable step in the happy path + +Fleet performs bounded Tailscale detection during: + +- `fleet init` and `fleet join`; +- update/resume flows; +- captain SSH configuration regeneration; +- `fleet update-all` for existing members. + +If Tailscale is absent and the user has never had a remote-ready route, Fleet says nothing in normal output. If it is present but logged out, that is informational unless previously working remote access regressed. + +Read-only detection of an already-connected client requires no consent prompt. Explicit consent is reserved for mutations, which this feature does not perform. + +### 5.2 Member correlation + +The captain's local Tailscale client is the live source of Tailnet peer addresses. Fleet does not distribute or persist peer IP observations. + +The captain learns the member's self-reported Tailscale identity only through an already authenticated, strict-pinned SSH connection: + +```sh +ssh emerald.local fleet network-hint --json +``` + +`network-hint` is a hidden, read-only command in the member binary. It returns a bounded, versioned response: + +```json +{"version":1,"machineId":"","tailscale":{"dnsName":"emerald.example.ts.net"}} +``` + +The output needs no new Fleet wire signature: it is collected only after OpenSSH authenticates the pinned member host and the captain's authorized key. It is routing metadata, not permission to change any Fleet or SSH identity. + +The captain pulls it: + +- immediately after a successful join, after the member has been saved and its host key pinned; +- after each member update in `fleet update-all`; +- after an explicit `fleet update-all` or a member re-registration; +- on a later lifecycle operation after a previous pull failed. + +An old member without the command remains LAN-only. Pull failures are bounded and retried only at those lifecycle events, never in a tight background loop. + +Mapping rule: + +1. Normalize the member-returned FQDN: ASCII lowercase, remove exactly one trailing dot, enforce DNS label/total-length rules, and reject control/NUL/non-ASCII input in v1. +2. Ask the captain's local Tailscale client to resolve that exact full FQDN. +3. Require at least one valid Tailscale-range address and no ambiguous/conflicting result. +4. Store only the normalized full FQDN as the peer mapping. +5. Re-resolve and range-validate it before every Tailnet dial. + +Fleet MUST NOT automatically map by Fleet machine name, Tailnet short name, or uniqueness among human-readable names. Those may be displayed as explicit repair candidates only. + +Because `tailscale status --json` is explicitly schema-unstable, v1 parses only the member's `Self.DNSName` behind an isolated, version-qualified adapter. Captain-side peer resolution uses the documented `tailscale ip ` command. Unknown/missing fields degrade to LAN-only. + +### 5.3 Live address resolution + +At connection time the selector asks the captain's local Tailscale client for the mapped peer's current addresses. It prefers direct Tailscale IPs over relying on application DNS: + +- IPv4 must be inside `100.64.0.0/10`. +- IPv6 must be inside Tailscale's documented `fd7a:115c:a1e0::/48` range. +- MagicDNS FQDN is retained for display, mapping, and diagnostics, not required for transport. + +This works when MagicDNS or `--accept-dns` is disabled and avoids persisting stale addresses. Fleet uses the supported `tailscale ip ` path on qualified versions; it must never accept arbitrary public/private addresses as Tailnet candidates. + +## 6. OpenSSH configuration + +For member `emerald`, generate: + +```sshconfig +Host emerald.local + User dev + Port 22 + IdentityFile "/Users/me/.fleet/identity/id_ed25519" + IdentitiesOnly yes + UserKnownHostsFile "/Users/me/.fleet/known_hosts" + StrictHostKeyChecking yes + UpdateHostKeys no + ConnectTimeout 8 + ProxyCommand '/absolute/path/to/fleet' transport connect --member --via auto +``` + +Known hosts remains exactly as Fleet manages it today: + +```text +emerald.local ssh-ed25519 +``` + +Normative requirements: + +- `emerald.local` is the only generated user-facing name. +- `Port 22` is explicit so a later user `Host * Port ...` cannot change helper behavior. +- The internal command accepts only a validated member UUID and always connects to that member's pinned port 22. +- It never accepts an arbitrary hostname/address from SSH arguments. +- ProxyCommand changes the socket, not the host identity OpenSSH checks. The original command-line host remains `emerald.local`, so its existing pin authenticates LAN and Tailnet routes. +- `UpdateHostKeys no` prevents a remote server from mutating Fleet's generator-owned pin set. +- Command-line SSH overrides and earlier user config still follow normal OpenSSH precedence; Fleet guarantees behavior for clients honoring the normal generated user config. +- Embedded SSH libraries that ignore `ProxyCommand` are outside baseline compatibility until explicitly qualified. + +### 6.1 Helper path and shell safety + +OpenSSH executes `ProxyCommand` through the user's shell. Fleet writes the absolute path of the currently running Fleet executable and shell-quotes it, so installation paths containing spaces or shell metacharacters remain safe. The generated command contains only that local executable path, fixed literal tokens, and a canonical UUID; no Tailnet name, machine name, address, or network-provided value is interpolated. Fleet regenerates this stanza whenever it changes the member inventory and after a Fleet update. + +The helper itself accepts only a canonical member UUID and the fixed route enum (`auto`, `lan`, or `tailscale`). It loads the UUID mapping from the captain's local inventory and never accepts an arbitrary hostname or address from SSH arguments. + +### 6.2 Safe regeneration + +No host-key or state-schema migration is required. Fleet adds/removes the selector in the existing generated SSH block and keeps the existing known-host format. + +The current regeneration uses Fleet's existing mode-safe atomic replacement +for the generated files. A missing or corrupt Fleet executable causes the +selector to fail closed. + +Those ownership/`ssh -G`/rollback checks are qualification requirements for a +future hardened installer; the current slice keeps the existing Fleet atomic +file writes and validates the helper path before rendering it. + +## 7. Route-selection contract + +### 7.1 Deterministic algorithm + +For `ssh emerald.local`: + +1. Load the member strictly by UUID from protected Fleet state. +2. Start the qualified platform `.local` resolution/dial at `t=0`. +3. Accept a resolved LAN address only when OS route/interface evidence says it is directly connected/on-link with no gateway on an eligible UP, non-loopback, non-Tailscale interface. Reject loopback, unspecified, multicast, Tailscale ranges, and routed candidates. Preserve IPv6 scope/interface IDs. +4. If LAN TCP has not connected at `t=150 ms`, resolve the mapped peer through the local Tailscale adapter and begin Tailnet IP dials. +5. Once Tailnet attempts begin, the first TCP connection to succeed wins. LAN has a head start, not an absolute preference. +6. Bound all resolver, dial, and adapter work by the helper deadline; close + the selected socket's losers when the helper exits. +7. Copy stdin to the socket and the socket to stdout without interpreting data. +8. On stdin EOF, half-close the TCP write side and continue copying server output until remote EOF. +9. Send diagnostics only to stderr; stdout is exclusively the SSH byte stream. +10. Respect an internal deadline shorter than the generated OpenSSH `ConnectTimeout`. + +The current slice bounds the OS `.local` lookup in a short-lived worker and +keeps each dial deadline bounded. Platform qualification should replace that +worker with a killable native DNS-SD/Avahi helper or asynchronous API before +claiming resolver cancellation on every supported platform. + +IPv4/IPv6 attempts use a bounded Happy-Eyeballs-style stagger. All subprocesses, DNS work, sockets, output, and stderr are bounded. Signals close outstanding resources promptly. + +The 150 ms head start is an initial internal constant, not a user-facing tuning knob. Real-network qualification may change it. + +### 7.2 Honest fallback boundary + +The helper selects on TCP connectivity. It cannot see whether the subsequent SSH banner, key exchange, host-key check, or authentication will succeed without becoming an SSH implementation. + +If a stale/spoofed LAN endpoint accepts TCP first but then fails SSH, outer OpenSSH fails closed. That invocation cannot restart its SSH state over Tailscale. This applies to: + +- banner stalls; +- pre-auth disconnects; +- key-exchange failure; +- host-key mismatch; +- authentication failure. + +Fleet MUST NOT weaken host-key checking or claim “first authenticated route wins.” Normal conditions remain seamless; adversarial or stale-route failures are explicit and recoverable. + +### 7.3 Recovery escape hatch + +The normal command remains `ssh emerald.local`. For diagnosis or a poisoned/broken preferred route: + +```sh +fleet connect emerald --via tailscale +fleet connect emerald --via lan +``` + +These commands invoke OpenSSH with a private generated `-F` configuration that forces the Fleet identity, known-hosts file, `emerald.local` pin, port 22, strict checking, `UpdateHostKeys no`, and the helper's forced mode. They set `ControlMaster=no` and disable control-socket reuse so an existing master cannot defeat `--via`. They do not introduce a second normal hostname or ask users to delete known-host entries. `--via` is a per-invocation recovery tool, not persistent configuration. + +### 7.4 OpenSSH multiplexing + +`ControlMaster` may reuse an existing connection without invoking the selector. Across a network move, an alive master continues on its existing route; after it dies, OpenSSH normally creates a connection and runs selection again. Fleet must test common `ControlMaster auto` settings and document that route selection occurs per new TCP master, not per logical SSH command. + +## 8. State model + +This feature does not add distributed endpoint inventories. + +Captain-local network state is a separate, versioned, unknown-field-tolerant file and needs only: + +```toml +version = 1 + +[members] +"" = "emerald.example.ts.net" +``` + +Do not persist peer IPs. Resolve and revalidate the FQDN through local Tailscale before every use. The mapping cannot mutate member name, SSH user, Fleet identity, SSH host key, or SSH port. + +There is no persistent Fleet-wide Tailscale preference. A missing mapping is +LAN-only, while `fleet connect --via lan|tailscale` provides an explicit +per-invocation recovery choice. + +Fleet keeps the mapping file under its existing private state root with a +`0600` atomic replacement. Owner/type/symlink rejection is part of the +hardened installer qualification pass. + +## 9. Protocol and join safety + +### 9.1 Authenticated correlation pull + +Keep discovery and Fleet HTTP protocol 1 unchanged. Correlation is pulled by running the hidden `fleet network-hint --json` command over the existing pinned SSH channel. There is no new join field, HTTP endpoint, signed observation, replay state, timestamp, tombstone, or Tailnet control port. + +A new captain treats an old member without the command as LAN-only. `fleet update-all` updates members first and pulls the hint after each successful update; the captain updates last. Removal/leave deletes the captain-local mapping along with member inventory. + +### 9.2 Current join must remain technically LAN-only + +Today's `/v1/join` accepts a key self-signed by the new member. That proves possession but not authorization to join. A documentation statement that joining is LAN-only is insufficient once the service listens on all interfaces. + +Before shipping Tailnet routing, harden ordinary join admission with a supported-platform topology gate. Inspect the actual accepted socket peer; never trust forwarding headers. Normalize IPv4-mapped IPv6, reject loopback except explicit self-tests, unspecified/multicast, Tailscale ranges/interfaces, tunnel/utun interfaces, and sources whose OS route uses a gateway. Accept only a source whose route scope is link/direct on an eligible UP physical/LAN interface, preserving IPv6 scope IDs. Perform the cheap admission check before parsing the request body. + +Implement this through explicit macOS and Linux interface/route adapters and test VPNs, Docker/VM bridges, subnet routes, IPv4-mapped IPv6, and link-local IPv6. Binding to eligible LAN addresses is preferable where lifecycle/address changes can be handled safely. + +This is exposure reduction, not cryptographic LAN authorization: a same-LAN attacker remains inside v0's documented trusted-LAN TOFU boundary. + +Tailnet policy is defense in depth, not the enrollment gate. Port `42170` is not required or recommended over Tailscale in this release. + +### 9.3 Remote join is separate + +Future remote join requires at least a short-lived, single-use, >=128-bit random invitation secret, hashed at rest, bounded attempts, atomic consumption, explicit expiry, secure delivery, and captain-visible joining-key confirmation. It also needs replay-resistant existing operations and rate/concurrency hardening of the captain service. + +Those requirements are recorded here only to prevent accidental exposure. They are not part of this feature's implementation plan. + +## 10. Status, onboarding, and recovery UX + +### 10.1 Join output + +Keep join's existing outcome promise truthful and compact: + +```text +Joined. From the captain, run `ssh emerald.local`. +``` + +The captain may pull the mapping after registration, but join must not claim remote readiness until a strict-pinned remote SSH check has actually succeeded. Readiness appears in later checked status. Do not front-load implementation/security disclaimers. + +### 10.2 Status + +Plain `fleet status` remains nonblocking and shows only persisted facts: + +```text +NAME ROLE REMOTE SETUP +obsidian captain - +emerald member mapped +ruby member mapped +opal member pending +jade member unavailable +``` + +The design vocabulary is `mapped` (a peer FQDN is stored), `pending` (no +usable hint yet), and `unavailable` (the local adapter cannot use a stored +mapping). The current CLI keeps plain status intentionally small; `status +--check` reports only TCP `reachable`/`unreachable`, while richer mapping +states belong in doctor and the qualification pass. + +`fleet status --check` performs a bounded live selector probe and reports `reachable` or `unreachable`. This is a TCP reachability signal, not proof that SSH authentication succeeded and not a claim about which route a later connection will win. Detailed route timing and strict SSH readiness remain doctor/qualification work. + +### 10.3 Doctor + +`fleet doctor` checks: + +1. Fleet state, ownership, identities, generated config, and pins. +2. Native LAN resolution and bounded TCP/SSH probe. +3. Supported local Tailscale CLI/daemon/authentication state. +4. Member-to-visible-peer correlation and ambiguity. +5. Live Tailnet IP validity. +6. Bounded `tailscale ping` for network-layer evidence only. +7. TCP 22. +8. A non-interactive, strict-pinned SSH no-op probe when safe. + +It must not claim to distinguish Tailnet policy from host firewall, stopped `sshd`, or packet loss unless authoritative evidence exists. Preferred phrasing: + +```text +Tailscale can see emerald, but TCP 22 is not reachable. +This can be caused by Tailnet policy, the host firewall, or sshd. +``` + +Only after observing this failure should doctor show contextual policy guidance. The normal path needs only TCP 22. Tags are not recommended for user-owned laptops; applying a tag replaces user identity. Grants are additive, and Fleet cannot always synthesize a captain-device-only selector without administrator-created identity structure. + +### 10.4 Commands + +The baseline command surface is intentionally small: + +```text +fleet status --check +fleet doctor +fleet connect --via lan|tailscale +``` + +There is no required enable, refresh, publish, policy, or separate status command in the happy path. The forced `fleet connect` command is only a recovery/diagnostic escape hatch; ordinary use remains `ssh .local`. + +## 11. Tailscale adapter contract + +The adapter runs fixed executable paths/arguments without a child shell and applies hard timeouts/output caps. It decodes only required fields and never logs raw peer inventories. + +Platform qualification must define: + +- supported Tailscale distributions and minimum version; +- fixed executable candidates; +- macOS standalone/App Store behavior and any required documented environment such as `TAILSCALE_BE_CLI=1`; +- Linux daemon/operator permission behavior; +- status/IP/ping command availability and schema fixtures; +- behavior when Tailscale is absent, logged out, stopped, upgrading, or returns partial JSON. + +Tailscale connection paths may be direct, DERP-relayed, or peer-relayed. All are healthy. Directness is diagnostic only; Fleet does not wait for a direct path or treat relay as a security failure. + +## 12. Security requirements + +### Endpoint containment + +- Tailnet destinations come only from the captain's local Tailscale client for the confirmed mapped peer. +- Validate all Tailnet IP ranges. +- A member hint alone is never dialed; its exact FQDN must resolve through the captain's local Tailscale client to valid Tailnet addresses. +- LAN destinations come only from qualified `.local` resolution plus on-link route/interface validation. +- Outer OpenSSH always verifies the existing `.local` pin with strict checking. + +### Host-key lifecycle + +Endpoint metadata never rotates a host key. A reinstall/rekey needs an explicit ceremony with local or out-of-band fingerprint confirmation, or proof authorized by the prior pinned identity with clear compromise caveats. Fleet never recommends deleting known hosts or accepting a changed key automatically. + +V1 continues pinning the existing Ed25519 host key. Copying both Fleet identity and SSH host keys creates indistinguishable clones; Fleet should detect simultaneous duplicate observations when possible and require identity reset/rejoin. + +### Local state and binary integrity + +- `~/.fleet` and managed binary/config parents: `0700`. +- Sensitive files: `0600`. +- Validate owner, type, mode, and symlink status before use/replacement in the + hardened installer qualification pass. +- Keep inventory/mapping counts and string/output sizes bounded. +- Preserve the prior generated SSH file when regeneration/validation fails in + the hardened installer qualification pass. +- The local transport executable path is absolute and shell-quoted in generated config; it is never taken from network metadata. +- Never log Fleet/Tailscale private material, application credentials, full peer inventories, or raw peer DNS/IP detail beyond the selected member's diagnostic output. + +### Fail closed + +An unmapped/ambiguous peer is local-only. A wrong SSH host key fails. A broken proxy fails. Tailscale unavailability falls back to LAN when LAN succeeds. Fleet never silently picks an unconfirmed peer to make the experience look smooth. + +## 13. Failure behavior + +| Condition | Normal effect | Recovery | +|---|---|---| +| Tailscale absent | v0 LAN behavior, no noise | none required | +| Tailscale logged out/stopped | LAN still works; checked remote route unavailable | restore Tailscale if remote access desired | +| Member not updated/offline | local behavior; mapping pending | automatic next contact or `update-all` | +| Exact authenticated FQDN resolves locally | Tailnet candidate becomes available | none | +| FQDN missing/conflicting | no guessed Tailnet route | doctor and authenticated re-pull | +| MagicDNS disabled | no transport impact when live IP is available | none | +| TCP 22 blocked | LAN may work; remote marked blocked | doctor shows evidence and focused guidance | +| DERP/peer relay | remote works, possibly slower | none required | +| LAN TCP winner fails SSH | command fails closed; no same-invocation retry | `fleet connect emerald --via tailscale` | +| Tailscale peer recreated | mapping revalidation fails or host key refuses | verify and explicitly remap/rejoin | +| SSH host key changes | all routes refused | explicit rekey ceremony | +| Embedded client ignores ProxyCommand | direct `.local` behavior only/unsupported remote | use system OpenSSH integration | +| ControlMaster survives move | existing master keeps prior connection | normal reconnect after master dies | + +## 14. Documentation changes required + +Existing documentation currently says Fleet does not do Tailscale, requires one trusted LAN, and has no account/control plane/relay. Update these statements carefully: + +- Fleet has no **Fleet-hosted** account, control plane, relay, or telemetry. +- When Tailscale is already present, Fleet can use the user's Tailnet for private remote reachability. +- Tailscale uses its own account/coordination service and may use DERP or peer relays. +- Initial ordinary joining remains restricted to a directly connected trusted LAN. +- Ongoing membership is not the same as current physical-network location. +- Ordinary `ssh .local` remains the product interface. + +Move Tailscale from the product vision's excluded list to “optional supported network substrate,” without expanding Fleet into orchestration or application lifecycle. + +## 15. Implementation plan + +### Phase 0: prerequisite hardening + +1. Enforce directly-connected-LAN admission for current join. +2. Add SSH/config/helper ownership, mode, symlink, and atomic-regeneration checks. +3. Generate explicit `Port 22` and `UpdateHostKeys no` without changing current known-host pins. +4. Add the hidden bounded `fleet network-hint --json` command. + +Exit: all v0 behavior and tests pass without Tailscale; no state or pin migration is required. + +### Phase 1: automatic peer mapping + +1. Build isolated macOS/Linux adapters for member `Self.DNSName` and captain `tailscale ip `, with minimum versions and fixtures. +2. Pull hints over existing pinned SSH after join/update and explicit lifecycle retries. +3. Persist only exact authenticated FQDN mappings and revalidate live IPs on use. +4. Add automatic retry on update/resume/next contact; old members remain local-only. + +Exit: qualified existing fleets acquire mappings without a Tailscale-specific enable command; status does not yet alter normal SSH. + +### Phase 2: forced route proving slice + +1. Add the restricted, shell-quoted local transport helper to generated OpenSSH configuration. +2. Implement forced Tailnet and LAN modes with live validated addresses. +3. Add `fleet connect --via ...` using private SSH config and no multiplex reuse. +4. Integrate live evidence into `status --check` and doctor without mutating mappings during status rendering. + +Exit: forced Tailnet SSH proves adapters, mapping, pins, policy, and recovery with only TCP 22 remotely required. + +### Phase 3: transparent default and qualification + +1. Add the LAN-head-start/Tailnet-first-success automatic mode and generate it into `.local`. +2. Run the real macOS/Linux matrix across same/different LANs, direct/DERP/peer-relay, IPv4/IPv6, MagicDNS off, policy block, slow/captive resolution, suspend/resume, Tailscale upgrade/restart, and network movement. +3. Qualify `ssh`, `scp`, `sftp`, Git-over-SSH, `ControlMaster`, and named editor/agent system-OpenSSH consumers. +4. Exercise member-first `fleet update-all`; old members remain local-only and complete automatically. +5. Update README/product vision/trust documentation and make auto-use the default only for qualified locally queryable Tailscale installations. + +Exit: no manual coordination is required and regression/partial completion is obvious. + +## 16. Verification matrix + +### Unit + +- Tailscale command absence, timeout, nonzero exit, partial/unknown/oversized JSON, version variants, IP-range validation, name normalization, and ambiguity. +- Route timing with a fake clock/dialer: immediate LAN, delayed LAN, Tailnet win, all fail, IPv4/IPv6 stagger, cancellation, global timeout. +- Transparent byte copy, stderr separation, stdin EOF half-close, remote EOF, signals, and child cleanup. +- Hostile helper paths/config values, UUID validation, port pinning, config precedence, and `ssh -G` assertions. +- Transactional generation interruption and old-binary refusal. +- Join-source on-link enforcement including Tailscale ranges and routed/nonlocal sources. + +### Integration + +- No Tailscale: unchanged init/join/status/SSH/leave/update. +- Both routes healthy: LAN normally wins within head start. +- LAN unavailable: identical `ssh emerald.local` uses Tailnet. +- Tailscale unavailable: LAN succeeds. +- Wrong LAN host key: outer SSH refuses; forced Tailnet recovery succeeds. +- MagicDNS disabled: Tailnet IP succeeds. +- Peer renamed/recreated/ambiguous: no unsafe automatic mapping. +- TCP 22 allowed with `42170` denied: all remote use works. +- Uninstall changes no unrelated Tailscale state. +- Offline member during migration completes on next contact. + +### Real platforms and clients + +| Captain | Member | Scenario | +|---|---|---| +| macOS | Ubuntu | same LAN, direct local selection | +| macOS | Ubuntu | different LANs, direct Tailnet | +| Ubuntu | macOS | different LANs, DERP/peer relay | +| Ubuntu | Ubuntu | IPv6 and MagicDNS disabled | +| macOS | Ubuntu | TCP 22 policy/firewall denial | +| macOS | Ubuntu | network move with ControlMaster | + +Test generic system-OpenSSH clients explicitly. Do not claim embedded-library compatibility without qualification. + +## 17. Acceptance criteria + +- [ ] On a newly joined Fleet (or after the next normal `fleet update-all`), a + user with mutually reachable Tailscale performs no Fleet-specific Tailscale + setup; older members remain safely LAN-only until that lifecycle contact. +- [ ] `ssh emerald.local` uses LAN at home and Tailscale away. +- [ ] `scp`, `sftp`, Git, and qualified system-OpenSSH tools use the same name. +- [ ] LAN-only behavior works with Tailscale absent or stopped. +- [ ] Only TCP 22 is required over the Tailnet. +- [ ] Live Tailnet addresses come from the captain's local Tailscale client, not persisted member endpoints. +- [ ] Ambiguous peer mappings are never guessed. +- [ ] Every route validates the same existing `.local` SSH host pin. +- [ ] Wrong keys and broken routes fail closed with a forced-route recovery path. +- [ ] Partial/offline migration retains last-good mappings, falls back to LAN, + and is visible through explicit checks/doctor. +- [ ] Current ordinary join is technically restricted to a directly connected LAN. +- [ ] Fleet stores no Tailscale credentials and mutates no Tailnet administration. +- [ ] Uninstall changes no unrelated Tailscale state. +- [ ] Documentation accurately distinguishes Fleet-local infrastructure from Tailscale's coordination/relay services. + +## 18. Prior art note + +T3 Code was inspected only for ideation. The useful lesson is to treat Tailscale as an optional endpoint provider outside the core machine model. Fleet has no T3-specific API, roadmap, process management, or dependency in this specification. + +## 19. References + +- [OpenSSH `ssh_config`](https://man.openbsd.org/ssh_config) +- [Tailscale CLI](https://tailscale.com/docs/reference/tailscale-cli) +- [Tailscale MagicDNS](https://tailscale.com/docs/features/magicdns) +- [Tailscale machine names](https://tailscale.com/kb/1098/machine-names) +- [Tailscale connection types](https://tailscale.com/docs/reference/connection-types) +- [Tailscale device sharing](https://tailscale.com/docs/features/sharing) +- [Tailscale grants](https://tailscale.com/docs/reference/syntax/grants) +- [Tailscale tags](https://tailscale.com/docs/features/tags) +- [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh) + +## 20. Final recommendation + +Ship one small, opinionated capability: + +```text +same Fleet name + local peer discovery + tiny route selector + existing SSH pin +``` + +Do not build a second distributed endpoint protocol or require users to operate a Tailscale subsystem inside Fleet. Detect what already exists, map it safely, use it automatically, and make the single normal command remain: + +```sh +ssh emerald.local +``` diff --git a/docs/verification.md b/docs/verification.md index f60e648..a17b1f9 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -10,6 +10,15 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test ``` +The ignored real-network Tailscale check requires a second enrolled machine +with SSH reachable on port 22. Run it from a machine that can query the peer's +MagicDNS name: + +```sh +FLEET_TAILSCALE_TEST_PEER=member.example.ts.net \ + cargo test --test tailscale_integration -- --ignored --nocapture +``` + ## Local installer check ```sh diff --git a/src/cli.rs b/src/cli.rs index db75eb1..86cfca3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,11 +45,19 @@ pub enum Command { /// Update Fleet on every machine, members first and captain last. #[command(name = "update-all", visible_alias = "updateall")] UpdateAll, + /// Recover or diagnose a member SSH connection. + Connect(ConnectArgs), /// Show coding-agent usage recorded on a machine. Usage(UsageArgs), /// Run the captain registration service. #[command(hide = true)] Daemon(DaemonArgs), + /// Emit local network correlation metadata for another Fleet machine. + #[command(name = "network-hint", hide = true)] + NetworkHint(NetworkHintArgs), + /// Open an SSH transport for generated OpenSSH configuration. + #[command(hide = true)] + Transport(TransportArgs), } #[derive(Debug, Args)] @@ -154,6 +162,15 @@ pub struct UsageArgs { pub machines: Vec, } +#[derive(Debug, Args)] +pub struct ConnectArgs { + /// Member name, with or without .local, or member ID. + pub member: String, + /// Force one route for recovery/diagnostics. + #[arg(long, value_enum)] + pub via: TransportRoute, +} + #[derive(Debug, Args)] pub struct DaemonArgs { /// Listen address; normally supplied by the service definition. @@ -161,6 +178,53 @@ pub struct DaemonArgs { pub listen: String, } +#[derive(Debug, Args)] +pub struct NetworkHintArgs { + /// Emit the stable machine-readable schema. + #[arg(long, required = true)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct TransportArgs { + #[command(subcommand)] + pub command: TransportCommand, +} + +#[derive(Debug, Subcommand)] +pub enum TransportCommand { + /// Connect standard input and output to a Fleet member's SSH port. + Connect(TransportConnectArgs), +} + +#[derive(Debug, Args)] +pub struct TransportConnectArgs { + /// Fleet member identity. Generated configuration always supplies this value. + #[arg(long)] + pub member: uuid::Uuid, + /// Restrict route selection for recovery and diagnostics. + #[arg(long, value_enum, default_value_t = TransportRoute::Auto)] + pub via: TransportRoute, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum TransportRoute { + #[default] + Auto, + Lan, + Tailscale, +} + +impl std::fmt::Display for TransportRoute { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Auto => "auto", + Self::Lan => "lan", + Self::Tailscale => "tailscale", + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "kebab-case")] pub enum Color { diff --git a/src/commands.rs b/src/commands.rs index 6e41f72..656ad7e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,5 +1,6 @@ use crate::cli::{ - Cli, Color, Command, InitArgs, JoinArgs, LeaveArgs, RemoveArgs, RestartArgs, Tool, UsageArgs, + Cli, Color, Command, ConnectArgs, InitArgs, JoinArgs, LeaveArgs, RemoveArgs, RestartArgs, Tool, + UsageArgs, }; use crate::discovery::{CaptainAdvertisement, CaptainConnection, DEFAULT_PORT}; use crate::identity; @@ -15,7 +16,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; use std::io::{self, IsTerminal}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::TcpStream; use std::process::Command as ProcessCommand; use std::sync::{ Arc, @@ -38,9 +39,78 @@ pub fn run(cli: Cli) -> Result<()> { Command::Restart(args) => restart(&paths, args), Command::Update => update(&paths), Command::UpdateAll => update_all(&paths), + Command::Connect(args) => connect(&paths, args), Command::Usage(args) => usage(&paths, args), Command::Daemon(args) => service::run(&paths, &args.listen), + Command::NetworkHint(args) => network_hint(&paths, args.json), + Command::Transport(args) => match args.command { + crate::cli::TransportCommand::Connect(args) => { + crate::ssh_client::transport_connect(&paths, args.member, args.via) + } + }, + } +} + +fn network_hint(paths: &StatePaths, json: bool) -> Result<()> { + debug_assert!(json, "clap requires --json for network-hint"); + let config = paths.require()?; + let report = crate::network_hint::inspect(config.machine.id)?; + println!("{}", serde_json::to_string(&report)?); + Ok(()) +} + +fn connect(paths: &StatePaths, args: ConnectArgs) -> Result<()> { + let config = paths.require()?; + if config.role != Role::Captain { + bail!("only the captain can connect to Fleet members"); + } + let query = args.member.trim_end_matches(".local"); + let member = skill::load_members(paths)? + .into_iter() + .find(|member| member.name == query || member.id.to_string() == query) + .with_context(|| format!("no member named {query} is registered"))?; + let executable = std::env::current_exe().context("locate Fleet executable")?; + let proxy = crate::ssh_client::proxy_command(&executable, member.id, args.via)?; + let connect_timeout = format!( + "ConnectTimeout={}", + crate::ssh_client::PROXY_CONNECT_TIMEOUT_SECS + ); + let mut command = ProcessCommand::new("ssh"); + command.args([ + "-o", + "Port=22", + "-o", + &connect_timeout, + "-o", + "IdentitiesOnly=yes", + "-o", + &format!("User={}", member.ssh_user), + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "-o", + "BatchMode=no", + "-o", + "StrictHostKeyChecking=yes", + "-o", + "UpdateHostKeys=no", + "-o", + &format!( + "IdentityFile={}", + paths.identity_dir().join("id_ed25519").display() + ), + "-o", + &format!("UserKnownHostsFile={}", paths.known_hosts().display()), + "-o", + &format!("ProxyCommand={proxy}"), + &member.host(), + ]); + let status = command.status().context("start SSH connection")?; + if !status.success() { + bail!("SSH exited with {status}"); } + Ok(()) } const REMOTE_USAGE_COMMAND: &str = r#" @@ -625,6 +695,11 @@ fn update_all(paths: &StatePaths) -> Result<()> { members.len() + 1 ); let failures = update_members_with(&members, update_member); + for member in &members { + if !failures.iter().any(|host| host == &member.host()) { + let _ = crate::ssh_client::refresh_mapping(paths, member); + } + } println!("Updating {} (captain)...", config.machine.host()); let captain_result = update(paths); @@ -694,6 +769,9 @@ fn update(paths: &StatePaths) -> Result<()> { .load()? .is_some_and(|config| config.role == Role::Captain) { + // Keep the local ProxyCommand pointing at the installed + // executable when an updater changes its path. + crate::ssh_client::regenerate(paths)?; Platform::new(false)?.restart_captain_service()?; println!("Restarted the Fleet captain service."); } @@ -1104,17 +1182,26 @@ fn status(paths: &StatePaths, json: bool, check: bool, watch: bool) -> Result<() return Ok(()); } let config = paths.require()?; + let members = if config.role == Role::Captain { + skill::load_members(paths)? + } else { + vec![] + }; if json { - let members = if config.role == Role::Captain { - skill::load_members(paths)? - } else { - vec![] - }; let health = check.then(|| { if config.role == Role::Captain { members .iter() - .map(|member| (member.host(), reachability(&member.host()))) + .map(|member| { + let health = crate::ssh_client::probe_transport( + paths, + member.id, + crate::cli::TransportRoute::Auto, + ) + .map(|_| "reachable") + .unwrap_or("unreachable"); + (member.host(), health) + }) .collect::>() } else { config @@ -1143,7 +1230,6 @@ fn status(paths: &StatePaths, json: bool, check: bool, watch: bool) -> Result<() } match config.role { Role::Captain => { - let members = skill::load_members(paths)?; let width = terminal_width(); let colors = io::stdout().is_terminal(); println!("CAPTAIN"); @@ -1159,7 +1245,15 @@ fn status(paths: &StatePaths, json: bool, check: bool, watch: bool) -> Result<() let rows = members .into_iter() .map(|member| { - let health = check.then(|| reachability(&member.host())); + let health = check.then(|| { + crate::ssh_client::probe_transport( + paths, + member.id, + crate::cli::TransportRoute::Auto, + ) + .map(|_| "reachable") + .unwrap_or("unreachable") + }); (member, health) }) .collect::>(); @@ -1249,6 +1343,7 @@ fn leave(paths: &StatePaths, args: LeaveArgs) -> Result<()> { platform.stop_captain_service()?; skill::uninstall(paths)?; crate::ssh_client::uninstall(paths)?; + let _ = fs::remove_file(paths.root.join("tailscale.toml")); if paths.inventory_dir().exists() { fs::remove_dir_all(paths.inventory_dir()).context("remove captain inventory")?; } @@ -1289,6 +1384,7 @@ fn remove(paths: &StatePaths, args: RemoveArgs) -> Result<()> { return Ok(()); } skill::remove_member(paths, member.id)?; + crate::tailscale::remove_mapping(paths, member.id)?; crate::ssh_client::regenerate(paths)?; println!( "Removed {}.local from the captain inventory. The member machine was not changed; run `fleet leave` there to revoke captain access.", @@ -1347,7 +1443,19 @@ fn doctor(paths: &StatePaths) -> Result<()> { let members = skill::load_members(paths)?; println!(" ✓ Captain inventory contains {} member(s)", members.len()); for member in members { - println!(" {} {}", reachability(&member.host()), member.host()); + let health = crate::ssh_client::probe_transport( + paths, + member.id, + crate::cli::TransportRoute::Auto, + ) + .map(|_| "online") + .unwrap_or("unreachable"); + println!(" {health} {}", member.host()); + match crate::tailscale::mapped_peer(paths, member.id) { + Ok(Some(peer)) => println!(" Tailscale mapping: {peer}"), + Ok(None) => println!(" Tailscale mapping: LAN-only/pending"), + Err(error) => println!(" Tailscale mapping: unavailable ({error:#})"), + } } } Role::Member => { @@ -1361,20 +1469,6 @@ fn doctor(paths: &StatePaths) -> Result<()> { Ok(()) } -fn reachability(host: &str) -> &'static str { - let address = format!("{host}:22"); - let reachable = address - .to_socket_addrs() - .ok() - .and_then(|mut addresses| { - addresses.find(|address| { - TcpStream::connect_timeout(address, Duration::from_millis(700)).is_ok() - }) - }) - .is_some(); - if reachable { "online" } else { "unreachable" } -} - fn captain_health(captain: &crate::state::CaptainRef) -> &'static str { if crate::discovery::connect_pinned(None, captain).is_ok() { "✓" diff --git a/src/lib.rs b/src/lib.rs index 9f7de98..9af01b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,10 +3,13 @@ pub mod commands; pub mod discovery; pub mod identity; pub mod logging; +mod network; +pub mod network_hint; pub mod platform; pub mod remote; pub mod service; pub mod skill; pub mod ssh_client; pub mod state; +pub mod tailscale; pub mod updater; diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 0000000..c80f237 --- /dev/null +++ b/src/network.rs @@ -0,0 +1,284 @@ +use crate::tailscale; +use anyhow::{Context, Result}; +use if_addrs::{IfAddr, Interface}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4}; + +/// Normalize an IPv4-mapped IPv6 address before applying network policy. +pub(crate) fn normalize_ipv4_mapped(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(address) => address + .to_ipv4() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + } +} + +/// Return whether an address is a possible directly connected LAN peer. +/// +/// This deliberately does not treat a valid private address as sufficient: +/// the address must also share a subnet with an eligible, operational local +/// interface. That keeps routed networks, VPNs, and Tailscale outside the +/// ordinary LAN path. +pub(crate) fn is_directly_connected_lan_peer(peer: SocketAddr) -> Result { + let interfaces = if_addrs::get_if_addrs().context("inspect local network interfaces")?; + Ok(is_directly_connected_lan_peer_with_interfaces( + peer, + &interfaces, + )) +} + +/// Keep only resolved addresses that can be reached directly through an +/// eligible local interface. IPv4-mapped addresses are converted to native +/// IPv4 socket addresses; all other socket metadata, including IPv6 scope +/// IDs, is preserved for the eventual dial. +pub(crate) fn filter_direct_lan_addresses(addresses: Vec) -> Result> { + let interfaces = if_addrs::get_if_addrs().context("inspect local network interfaces")?; + Ok(filter_direct_lan_addresses_with_interfaces( + addresses, + &interfaces, + )) +} + +fn is_directly_connected_lan_peer_with_interfaces( + peer: SocketAddr, + interfaces: &[Interface], +) -> bool { + let peer = normalize_socket_addr(peer); + let peer_ip = peer.ip(); + is_valid_direct_peer(peer_ip) + && interfaces + .iter() + .any(|interface| eligible_interface(interface) && interface_is_on_link(interface, peer)) +} + +fn filter_direct_lan_addresses_with_interfaces( + addresses: Vec, + interfaces: &[Interface], +) -> Vec { + addresses + .into_iter() + .map(normalize_socket_addr) + .filter(|address| is_directly_connected_lan_peer_with_interfaces(*address, interfaces)) + .collect() +} + +fn normalize_socket_addr(address: SocketAddr) -> SocketAddr { + match address { + SocketAddr::V6(address) => address + .ip() + .to_ipv4() + .map(|ipv4| SocketAddr::V4(SocketAddrV4::new(ipv4, address.port()))) + .unwrap_or(SocketAddr::V6(address)), + address => address, + } +} + +pub(crate) fn ssh_host_from_socket_addr(address: SocketAddr) -> String { + match normalize_socket_addr(address) { + SocketAddr::V4(address) => address.ip().to_string(), + SocketAddr::V6(address) if address.scope_id() != 0 => { + format!("{}%{}", address.ip(), address.scope_id()) + } + SocketAddr::V6(address) => address.ip().to_string(), + } +} + +fn is_valid_direct_peer(peer: IpAddr) -> bool { + let peer = normalize_ipv4_mapped(peer); + !peer.is_unspecified() + && !peer.is_loopback() + && !peer.is_multicast() + && !tailscale::is_tailscale_ip(peer) +} + +fn eligible_interface(interface: &Interface) -> bool { + if !interface.is_oper_up() + || interface.is_loopback() + || interface.is_p2p() + || tailscale::is_tailscale_ip(normalize_ipv4_mapped(interface.ip())) + { + return false; + } + let name = interface.name.to_ascii_lowercase(); + [ + "lo", + "lo0", + "awdl", + "llw", + "bridge", + "docker", + "br-", + "virbr", + "veth", + "tailscale", + "utun", + "tun", + "tap", + "wg", + "zt", + ] + .iter() + .all(|prefix| name != *prefix && !name.starts_with(prefix)) +} + +fn interface_is_on_link(interface: &Interface, peer: SocketAddr) -> bool { + match (&interface.addr, peer) { + (IfAddr::V4(local), SocketAddr::V4(peer)) => { + same_ipv4_subnet(local.ip, *peer.ip(), local.netmask) + } + (IfAddr::V6(local), SocketAddr::V6(peer)) => { + if is_ipv6_link_local(*peer.ip()) + && (peer.scope_id() == 0 || interface.index != Some(peer.scope_id())) + { + return false; + } + same_ipv6_subnet(local.ip, *peer.ip(), local.netmask) + } + _ => false, + } +} + +fn same_ipv4_subnet(local: Ipv4Addr, peer: Ipv4Addr, netmask: Ipv4Addr) -> bool { + u32::from(local) & u32::from(netmask) == u32::from(peer) & u32::from(netmask) +} + +fn same_ipv6_subnet(local: Ipv6Addr, peer: Ipv6Addr, netmask: Ipv6Addr) -> bool { + u128::from(local) & u128::from(netmask) == u128::from(peer) & u128::from(netmask) +} + +fn is_ipv6_link_local(address: Ipv6Addr) -> bool { + let octets = address.octets(); + octets[0] == 0xfe && octets[1] & 0xc0 == 0x80 +} + +#[cfg(test)] +mod tests { + use super::*; + use if_addrs::{IfOperStatus, Ifv4Addr, Ifv6Addr}; + use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV6}; + + fn ipv4_interface(name: &str, ip: &str, netmask: &str, index: u32) -> Interface { + Interface { + name: name.into(), + addr: IfAddr::V4(Ifv4Addr { + ip: ip.parse().unwrap(), + netmask: netmask.parse().unwrap(), + prefixlen: 24, + broadcast: None, + }), + index: Some(index), + oper_status: IfOperStatus::Up, + is_p2p: false, + #[cfg(windows)] + adapter_name: String::new(), + } + } + + fn ipv6_interface(name: &str, ip: &str, netmask: &str, index: u32) -> Interface { + Interface { + name: name.into(), + addr: IfAddr::V6(Ifv6Addr { + ip: ip.parse().unwrap(), + netmask: netmask.parse().unwrap(), + prefixlen: 64, + broadcast: None, + }), + index: Some(index), + oper_status: IfOperStatus::Up, + is_p2p: false, + #[cfg(windows)] + adapter_name: String::new(), + } + } + + #[test] + fn mapped_ipv4_is_normalized_before_policy_and_dialing() { + let interfaces = vec![ipv4_interface("en0", "192.168.1.10", "255.255.255.0", 4)]; + let mapped = "[::ffff:192.168.1.20]:22".parse().unwrap(); + let filtered = filter_direct_lan_addresses_with_interfaces(vec![mapped], &interfaces); + + assert_eq!(filtered, vec!["192.168.1.20:22".parse().unwrap()]); + } + + #[test] + fn routed_virtual_and_special_addresses_are_rejected() { + let interfaces = vec![ + ipv4_interface("en0", "192.168.1.10", "255.255.255.0", 4), + ipv4_interface("tailscale0", "100.64.0.2", "255.192.0.0", 8), + ]; + let candidates = vec![ + "192.168.1.20:22".parse().unwrap(), + "192.168.2.20:22".parse().unwrap(), + "100.64.0.3:22".parse().unwrap(), + "127.0.0.1:22".parse().unwrap(), + "224.0.0.1:22".parse().unwrap(), + ]; + + assert_eq!( + filter_direct_lan_addresses_with_interfaces(candidates, &interfaces), + vec!["192.168.1.20:22".parse().unwrap()] + ); + } + + #[test] + fn link_local_ipv6_requires_the_resolver_scope_to_match_the_interface() { + let interfaces = vec![ipv6_interface( + "en0", + "fe80::10", + "ffff:ffff:ffff:ffff::", + 7, + )]; + let matching = SocketAddr::V6(SocketAddrV6::new("fe80::20".parse().unwrap(), 22, 0, 7)); + let wrong_interface = + SocketAddr::V6(SocketAddrV6::new("fe80::20".parse().unwrap(), 22, 0, 8)); + let missing_scope = + SocketAddr::V6(SocketAddrV6::new("fe80::20".parse().unwrap(), 22, 0, 0)); + + assert!(is_directly_connected_lan_peer_with_interfaces( + matching, + &interfaces + )); + assert!(!is_directly_connected_lan_peer_with_interfaces( + wrong_interface, + &interfaces + )); + assert!(!is_directly_connected_lan_peer_with_interfaces( + missing_scope, + &interfaces + )); + } + + #[test] + fn tunnel_interface_is_not_an_eligible_lan_path() { + let interfaces = vec![ipv4_interface("tun0", "192.168.1.10", "255.255.255.0", 9)]; + assert!(!is_directly_connected_lan_peer_with_interfaces( + "192.168.1.20:22".parse().unwrap(), + &interfaces + )); + } + + #[test] + fn subnet_masks_are_applied_to_both_ip_families() { + assert!(same_ipv4_subnet( + Ipv4Addr::new(192, 168, 1, 10), + Ipv4Addr::new(192, 168, 1, 20), + Ipv4Addr::new(255, 255, 255, 0) + )); + assert!(!same_ipv4_subnet( + Ipv4Addr::new(192, 168, 1, 10), + Ipv4Addr::new(192, 168, 2, 20), + Ipv4Addr::new(255, 255, 255, 0) + )); + assert!(same_ipv6_subnet( + Ipv6Addr::from(0xfd00u128 << 64 | 0x10), + Ipv6Addr::from(0xfd00u128 << 64 | 0x20), + Ipv6Addr::from(u128::MAX << 64) + )); + assert!(!same_ipv6_subnet( + Ipv6Addr::from(0xfd00u128 << 64 | 0x10), + Ipv6Addr::from(0xfd01u128 << 64 | 0x20), + Ipv6Addr::from(u128::MAX << 64) + )); + } +} diff --git a/src/network_hint.rs b/src/network_hint.rs new file mode 100644 index 0000000..07ed65e --- /dev/null +++ b/src/network_hint.rs @@ -0,0 +1,69 @@ +use anyhow::Result; +use serde::Serialize; +use uuid::Uuid; + +const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkHint { + version: u32, + machine_id: Uuid, + tailscale: Option, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TailscaleHint { + dns_name: String, +} + +/// Inspect optional local network metadata without changing Fleet or Tailscale state. +/// +/// An unavailable, stopped, or logged-out Tailscale client is equivalent to no +/// hint. The adapter itself owns command deadlines, output limits, and parsing. +pub fn inspect(machine_id: Uuid) -> Result { + let dns_name = crate::tailscale::self_dns_name().ok().flatten(); + Ok(from_dns_name(machine_id, dns_name)) +} + +fn from_dns_name(machine_id: Uuid, dns_name: Option) -> NetworkHint { + NetworkHint { + version: SCHEMA_VERSION, + machine_id, + tailscale: dns_name.map(|dns_name| TailscaleHint { dns_name }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_contains_machine_identity_and_optional_dns_name() { + let id = Uuid::nil(); + let report = from_dns_name(id, Some("emerald.example.ts.net".into())); + assert_eq!( + serde_json::to_value(report).unwrap(), + serde_json::json!({ + "version": 1, + "machineId": id, + "tailscale": { "dnsName": "emerald.example.ts.net" } + }) + ); + } + + #[test] + fn schema_represents_an_absent_client_without_omitting_the_field() { + let id = Uuid::nil(); + let report = from_dns_name(id, None); + assert_eq!( + serde_json::to_value(report).unwrap(), + serde_json::json!({ + "version": 1, + "machineId": id, + "tailscale": null + }) + ); + } +} diff --git a/src/remote.rs b/src/remote.rs index 90cab5b..7374037 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -14,21 +14,32 @@ const SSH_KEEPALIVE_SECS: &str = "5"; /// liveness settings. The outer deadline also covers DNS resolution and a remote /// process that stops producing output, which OpenSSH's ConnectTimeout does not. pub fn ssh_output(host: &str, remote_command: &str, timeout: Duration) -> Result { + ssh_output_with_proxy(host, remote_command, timeout, None) +} + +/// Run a command over the member's pinned SSH configuration while bypassing +/// Fleet's route selector. This is used only while learning a member's +/// optional network metadata, so the bootstrap path cannot depend on the +/// mapping it is about to create. +pub fn ssh_output_direct(host: &str, remote_command: &str, timeout: Duration) -> Result { + ssh_output_with_proxy(host, remote_command, timeout, Some("none")) +} + +fn ssh_output_with_proxy( + host: &str, + remote_command: &str, + timeout: Duration, + proxy_command: Option<&str>, +) -> Result { let mut command = Command::new("ssh"); - command.args([ - "-o", - "BatchMode=yes", - "-o", - "ConnectionAttempts=1", - "-o", - &format!("ConnectTimeout={SSH_CONNECT_TIMEOUT_SECS}"), - "-o", - &format!("ServerAliveInterval={SSH_KEEPALIVE_SECS}"), - "-o", - "ServerAliveCountMax=2", - host, - remote_command, - ]); + command.args(["-o", "BatchMode=yes", "-o", "ConnectionAttempts=1"]); + command.args(["-o", &format!("ConnectTimeout={SSH_CONNECT_TIMEOUT_SECS}")]); + command.args(["-o", &format!("ServerAliveInterval={SSH_KEEPALIVE_SECS}")]); + command.args(["-o", "ServerAliveCountMax=2"]); + if let Some(proxy_command) = proxy_command { + command.args(["-o", &format!("ProxyCommand={proxy_command}")]); + } + command.args([host, remote_command]); output_with_timeout(&mut command, timeout) .with_context(|| format!("communicate with {host} over SSH")) } diff --git a/src/service.rs b/src/service.rs index 1c952b0..222f33e 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,11 +1,12 @@ use crate::discovery::{CaptainAdvertisement, CaptainConnection, SERVICE_TYPE}; use crate::identity; +use crate::network; use crate::skill; use crate::state::{Machine, Role, StatePaths}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use std::io::Read; -use std::net::IpAddr; +use std::net::SocketAddr; use std::process::{Child, Command, Output, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -99,7 +100,9 @@ fn handle_request( match (request.method(), request.url()) { (&Method::Get, "/v1/identity") => json_response(StatusCode(200), advertisement), (&Method::Post, "/v1/join") => { - let peer_ip = request.remote_addr().map(|address| address.ip()); + let peer = request.remote_addr(); + validate_join_source(peer.copied())?; + let peer_addr = peer.copied(); let body = read_body(request)?; let signed: SignedRequest = serde_json::from_slice(&body).context("decode join request")?; @@ -116,12 +119,28 @@ fn handle_request( let members = skill::load_members(paths)?; validate_topology_conflicts(advertisement, &members, ®istration.machine)?; validate_existing_pin(&members, ®istration.machine)?; - if let Err(error) = verify_member(paths, ®istration.machine, peer_ip) { + if let Err(error) = verify_member(paths, ®istration.machine, peer_addr) { let _ = crate::ssh_client::regenerate(paths); return Err(error.context("captain could not verify passwordless SSH")); } skill::save_member(paths, ®istration.machine)?; crate::ssh_client::regenerate(paths)?; + // Mapping discovery is optional metadata. Do not hold the single + // request worker open while a member's SSH command or Tailscale + // daemon is slow; the next update or explicit check can retry it. + let refresh_paths = paths.clone(); + let refresh_machine = registration.machine.clone(); + std::thread::spawn(move || { + if let Err(error) = + crate::ssh_client::refresh_mapping(&refresh_paths, &refresh_machine) + { + crate::logging::detail( + &refresh_paths, + "tailscale-mapping", + format!("{}: {error:#}", refresh_machine.name), + ); + } + }); json_response(StatusCode(201), &serde_json::json!({"joined": true})) } (&Method::Post, "/v1/leave") => { @@ -139,6 +158,7 @@ fn handle_request( identity::verify(&member.public_identity, &payload, &signed.signature) .context("leave request was not signed by the registered member")?; skill::remove_member(paths, leave.id)?; + crate::tailscale::remove_mapping(paths, leave.id)?; crate::ssh_client::regenerate(paths)?; json_response(StatusCode(200), &serde_json::json!({"left": true})) } @@ -163,6 +183,20 @@ fn handle_request( } } +/// Keep the existing enrollment ceremony on a directly connected LAN. +/// +/// The captain listener is intentionally bound to all addresses, so rejecting +/// only Tailscale address space is insufficient: another VPN, routed subnet, +/// or exposed interface could otherwise enroll a fresh Fleet identity. The +/// source must be on-link with an operational, non-tunnel interface. +fn validate_join_source(peer: Option) -> Result<()> { + let peer = peer.context("Fleet could not identify the join source")?; + if network::is_directly_connected_lan_peer(peer)? { + return Ok(()); + } + bail!("Fleet joining is only accepted from a directly connected local network") +} + fn read_body(request: &mut tiny_http::Request) -> Result> { let length = request.body_length().unwrap_or(0); if length > 64 * 1024 { @@ -312,15 +346,22 @@ struct SshVerificationTarget { host_key_alias: String, } -fn ssh_verification_target(machine: &Machine, peer_ip: Option) -> SshVerificationTarget { +fn ssh_verification_target( + machine: &Machine, + peer_addr: Option, +) -> SshVerificationTarget { let host = machine.host(); SshVerificationTarget { - connect_host: peer_ip.map_or_else(|| host.clone(), |address| address.to_string()), + connect_host: peer_addr.map_or_else(|| host.clone(), network::ssh_host_from_socket_addr), host_key_alias: host, } } -fn verify_member(paths: &StatePaths, machine: &Machine, peer_ip: Option) -> Result<()> { +fn verify_member( + paths: &StatePaths, + machine: &Machine, + peer_addr: Option, +) -> Result<()> { let host_key = machine .ssh_host_key .as_deref() @@ -342,7 +383,7 @@ fn verify_member(paths: &StatePaths, machine: &Machine, peer_ip: Option) crate::state::atomic_write(&paths.known_hosts(), next.as_bytes(), 0o600)?; let identity = identity::ensure(paths)?; - let target = ssh_verification_target(machine, peer_ip); + let target = ssh_verification_target(machine, peer_addr); let destination = format!("{}@{host}", machine.ssh_user); let mut command = Command::new("ssh"); command @@ -607,6 +648,7 @@ fn post_for_json Deserialize<'de>>( mod tests { use super::*; use crate::cli::Color; + use std::net::IpAddr; use std::sync::atomic::AtomicUsize; const KEY_ONE: &str = @@ -764,12 +806,21 @@ mod tests { #[test] fn ssh_verification_uses_join_peer_while_pinning_the_fleet_hostname() { let machine = valid_machine(); - let target = ssh_verification_target(&machine, Some("192.168.1.69".parse().unwrap())); + let target = ssh_verification_target(&machine, Some("192.168.1.69:22".parse().unwrap())); assert_eq!(target.connect_host, "192.168.1.69"); assert_eq!(target.host_key_alias, "emerald.local"); } + #[test] + fn ssh_verification_preserves_an_ipv6_join_scope() { + let machine = valid_machine(); + let target = ssh_verification_target(&machine, Some("[fe80::69%7]:22".parse().unwrap())); + + assert_eq!(target.connect_host, "fe80::69%7"); + assert_eq!(target.host_key_alias, "emerald.local"); + } + #[test] fn ssh_verification_retries_a_temporarily_unreachable_member() { let attempts = AtomicUsize::new(0); @@ -789,6 +840,15 @@ mod tests { assert_eq!(attempts.load(Ordering::SeqCst), 3); } + #[test] + fn join_source_normalizes_mapped_ipv4_addresses() { + let mapped: IpAddr = "::ffff:192.168.1.20".parse().unwrap(); + assert_eq!( + network::normalize_ipv4_mapped(mapped), + "192.168.1.20".parse::().unwrap() + ); + } + #[test] fn ssh_verification_does_not_retry_security_or_authentication_failures() { let attempts = AtomicUsize::new(0); diff --git a/src/ssh_client.rs b/src/ssh_client.rs index 3bf4a42..2c99d14 100644 --- a/src/ssh_client.rs +++ b/src/ssh_client.rs @@ -1,8 +1,29 @@ +use crate::cli::TransportRoute; use crate::skill; use crate::state::{StatePaths, atomic_write}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; +use serde::Deserialize; use std::fs; +use std::io; +use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; +use std::sync::mpsc; +use std::time::Duration; +use uuid::Uuid; + +const REMOTE_NETWORK_HINT_COMMAND: &str = r#" +fleet_bin=$(command -v fleet 2>/dev/null || true) +if [ -z "$fleet_bin" ] && [ -x "$HOME/.local/bin/fleet" ]; then + fleet_bin="$HOME/.local/bin/fleet" +fi +if [ -z "$fleet_bin" ]; then + echo 'Fleet executable was not found on the member' >&2 + exit 127 +fi +exec "$fleet_bin" network-hint --json +"#; + +pub(crate) const PROXY_CONNECT_TIMEOUT_SECS: u64 = 8; pub fn initialize(paths: &StatePaths) -> Result<()> { let home = dirs::home_dir().context("could not determine home directory")?; @@ -75,15 +96,28 @@ fn without_fleet_include(existing: &str, include: &str) -> Option { } pub fn regenerate(paths: &StatePaths) -> Result<()> { + let executable = std::env::current_exe().context("locate the Fleet executable")?; + regenerate_with_executable(paths, &executable) +} + +fn regenerate_with_executable(paths: &StatePaths, executable: &Path) -> Result<()> { + let executable = executable + .to_str() + .context("Fleet executable path is not valid UTF-8")?; + if !Path::new(executable).is_absolute() { + bail!("Fleet executable path must be absolute: {executable}"); + } let mut output = String::from("# Generated by Fleet. Do not edit.\n"); let mut known_hosts = String::from("# Generated by Fleet. Do not edit.\n"); for member in skill::load_members(paths)? { output.push_str(&format!( - "\nHost {}\n User {}\n IdentityFile {}\n IdentitiesOnly yes\n UserKnownHostsFile {}\n StrictHostKeyChecking yes\n", + "\nHost {}\n User {}\n Port 22\n ConnectTimeout {}\n IdentityFile {}\n IdentitiesOnly yes\n UserKnownHostsFile {}\n StrictHostKeyChecking yes\n UpdateHostKeys no\n ProxyCommand {}\n", member.host(), member.ssh_user, + PROXY_CONNECT_TIMEOUT_SECS, quote_path(&paths.identity_dir().join("id_ed25519")), quote_path(&paths.known_hosts()), + proxy_command(Path::new(executable), member.id, TransportRoute::Auto)?, )); if let Some(host_key) = &member.ssh_host_key { let mut fields = host_key.split_whitespace(); @@ -96,6 +130,315 @@ pub fn regenerate(paths: &StatePaths) -> Result<()> { atomic_write(&paths.known_hosts(), known_hosts.as_bytes(), 0o600) } +/// Implements the byte-stream side of Fleet's OpenSSH ProxyCommand. +/// +/// The UUID is resolved only through the protected captain inventory. The +/// helper owns socket selection while OpenSSH retains hostname, host-key, and +/// user-authentication responsibility. +pub fn transport_connect(paths: &StatePaths, member_id: Uuid, route: TransportRoute) -> Result<()> { + let member = skill::load_members(paths)? + .into_iter() + .find(|member| member.id == member_id) + .context("Fleet transport member is not in the captain inventory")?; + let stream = select_transport(paths, member_id, &member.host(), route)?; + proxy_stdio(stream) +} + +pub fn probe_transport(paths: &StatePaths, member_id: Uuid, route: TransportRoute) -> Result<()> { + let member = skill::load_members(paths)? + .into_iter() + .find(|member| member.id == member_id) + .context("Fleet transport member is not in the captain inventory")?; + let _stream = select_transport_with_timeout( + paths, + member_id, + &member.host(), + route, + Duration::from_millis(700), + )?; + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct NetworkHintResponse { + version: u32, + #[serde(rename = "machineId")] + machine_id: Uuid, + #[serde(default)] + tailscale: Option, +} + +#[derive(Debug, Deserialize)] +struct NetworkHintTailscale { + #[serde(rename = "dnsName")] + dns_name: String, +} + +/// Pull a member's local Tailscale identity over the already pinned SSH path. +/// A mapping is saved only after the captain's own Tailscale client resolves it +/// to a valid Tailscale address. +pub fn refresh_mapping(paths: &StatePaths, member: &crate::state::Machine) -> Result { + // Bootstrap without a mapping over the original LAN path. Once a mapping + // exists, use the normal selector so update-all can refresh a member while + // the captain is away from the LAN or after its MagicDNS name changes. + let has_mapping = crate::tailscale::mapped_peer(paths, member.id) + .ok() + .flatten() + .is_some(); + let output = if has_mapping { + crate::remote::ssh_output( + &member.host(), + REMOTE_NETWORK_HINT_COMMAND, + Duration::from_secs(8), + )? + } else { + crate::remote::ssh_output_direct( + &member.host(), + REMOTE_NETWORK_HINT_COMMAND, + Duration::from_secs(8), + )? + }; + if !output.status.success() { + anyhow::bail!("member network hint exited with {}", output.status); + } + if output.stdout.len() > 64 * 1024 { + bail!("member network hint exceeded the 64 KiB output limit"); + } + let hint: NetworkHintResponse = + serde_json::from_slice(&output.stdout).context("decode member network hint")?; + if hint.version != 1 || hint.machine_id != member.id { + bail!("member network hint has an unsupported or mismatched identity"); + } + let Some(tailscale) = hint.tailscale else { + // A missing hint is not an authoritative deletion. The member may be + // temporarily logged out or its daemon may be restarting; retain the + // last-known mapping and let the selector fail over to LAN. + return Ok(false); + }; + let fqdn = crate::tailscale::normalize_fqdn(&tailscale.dns_name)?; + crate::tailscale::peer_ips(&fqdn) + .with_context(|| format!("resolve member Tailscale peer {fqdn}"))?; + if !skill::load_members(paths)? + .iter() + .any(|candidate| candidate.id == member.id) + { + return Ok(false); + } + crate::tailscale::save_mapping(paths, member.id, &fqdn)?; + Ok(true) +} + +fn select_transport( + paths: &StatePaths, + member_id: Uuid, + host: &str, + route: TransportRoute, +) -> Result { + select_transport_with_timeout(paths, member_id, host, route, Duration::from_secs(7)) +} + +fn select_transport_with_timeout( + paths: &StatePaths, + member_id: Uuid, + host: &str, + route: TransportRoute, + timeout: Duration, +) -> Result { + match route { + TransportRoute::Lan => connect_lan(host, timeout), + TransportRoute::Tailscale => connect_tailscale(paths, member_id, timeout), + TransportRoute::Auto => { + // A mapping is learned over the pinned LAN path after joining. Do + // not spend time probing an unavailable Tailnet for older fleets + // that predate this metadata; they retain the original LAN path. + let mapped = match crate::tailscale::mapped_peer(paths, member_id) { + Ok(mapped) => mapped, + Err(error) => { + eprintln!("Fleet Tailscale mapping unavailable: {error:#}; using LAN"); + None + } + }; + if mapped.is_none() { + return connect_lan(host, timeout); + } + connect_auto(paths, member_id, host, timeout) + } + } +} + +fn connect_lan(host: &str, timeout: Duration) -> Result { + let host = host.to_owned(); + let started = std::time::Instant::now(); + let (sender, receiver) = mpsc::channel(); + let resolver_host = host.clone(); + let error_host = host.clone(); + std::thread::spawn(move || { + let result = (resolver_host.as_str(), 22) + .to_socket_addrs() + .map(|addresses| addresses.collect::>()) + .map_err(|error| anyhow::anyhow!("resolve {error_host}: {error}")); + let _ = sender.send(result); + }); + let addresses = receiver + .recv_timeout(timeout) + .map_err(|_| anyhow::anyhow!("resolve {host} timed out"))??; + let addresses = crate::network::filter_direct_lan_addresses(addresses) + .context("filter resolved LAN addresses")?; + connect_addresses(addresses, timeout.saturating_sub(started.elapsed())) + .with_context(|| format!("connect to {host} on SSH port 22")) +} + +fn connect_auto( + paths: &StatePaths, + member_id: Uuid, + host: &str, + timeout: Duration, +) -> Result { + let started = std::time::Instant::now(); + let (sender, receiver) = mpsc::channel(); + let lan_host = host.to_owned(); + let lan_sender = sender.clone(); + std::thread::spawn(move || { + let result = connect_lan(&lan_host, timeout).map_err(|error| format!("{error:#}")); + let _ = lan_sender.send(("LAN", result)); + }); + + let mut errors = Vec::new(); + let head_start = timeout.min(Duration::from_millis(150)); + if let Ok((route, result)) = receiver.recv_timeout(head_start) { + match result { + Ok(stream) => return Ok(stream), + Err(error) => errors.push(format!("{route}: {error}")), + } + } + + let tail_paths = paths.clone(); + let tail_sender = sender.clone(); + let tailscale_timeout = timeout.saturating_sub(started.elapsed()); + std::thread::spawn(move || { + let result = connect_tailscale(&tail_paths, member_id, tailscale_timeout) + .map_err(|error| format!("{error:#}")); + let _ = tail_sender.send(("Tailscale", result)); + }); + drop(sender); + + let deadline = started + timeout; + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + match receiver.recv_timeout(remaining) { + Ok((_route, Ok(stream))) => return Ok(stream), + Ok((route, Err(error))) => errors.push(format!("{route}: {error}")), + Err(_) => break, + } + if errors.len() >= 2 { + break; + } + } + bail!("no route connected: {}", errors.join("; ")) +} + +fn connect_tailscale(paths: &StatePaths, member_id: Uuid, timeout: Duration) -> Result { + let started = std::time::Instant::now(); + let fqdn = crate::tailscale::mapped_peer(paths, member_id)? + .context("no Tailscale peer mapping is recorded for this member; try `--via lan`")?; + let addresses = + crate::tailscale::peer_ips_with_timeout(&fqdn, timeout.saturating_sub(started.elapsed()))? + .into_iter() + .map(|ip| SocketAddr::new(ip, 22)) + .collect::>(); + connect_addresses(addresses, timeout.saturating_sub(started.elapsed())) + .with_context(|| format!("connect to Tailscale peer {fqdn} on SSH port 22")) +} + +fn connect_addresses(addresses: Vec, timeout: Duration) -> Result { + if addresses.is_empty() { + bail!("no route connected: no addresses were resolved"); + } + let started = std::time::Instant::now(); + let (sender, receiver) = mpsc::channel(); + let total = addresses.len(); + for address in addresses { + let sender = sender.clone(); + std::thread::spawn(move || { + let result = TcpStream::connect_timeout(&address, timeout) + .map_err(|error| format!("{address}: {error}")); + let _ = sender.send(result); + }); + } + drop(sender); + let mut errors = Vec::new(); + for _ in 0..total { + let remaining = timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + break; + } + match receiver.recv_timeout(remaining) { + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(error)) => errors.push(error), + Err(_) => break, + } + } + anyhow::bail!( + "no route connected{}", + if errors.is_empty() { + String::new() + } else { + format!(": {}", errors.join("; ")) + } + ) +} + +fn proxy_stdio(mut stream: TcpStream) -> Result<()> { + let mut upload = stream.try_clone().context("clone SSH transport socket")?; + let upload_thread = std::thread::spawn(move || -> io::Result<()> { + io::copy(&mut io::stdin(), &mut upload)?; + upload.shutdown(Shutdown::Write) + }); + + let download_result = io::copy(&mut stream, &mut io::stdout()).context("read SSH transport"); + let upload_result = upload_thread + .join() + .map_err(|_| anyhow::anyhow!("SSH stdin forwarding thread panicked"))? + .context("forward SSH stdin"); + download_result?; + upload_result?; + Ok(()) +} + +fn safe_proxy_executable(path: &Path) -> Result<&str> { + let value = path + .to_str() + .context("Fleet executable path is not valid UTF-8")?; + if !path.is_absolute() || value.bytes().any(|byte| byte == b'\r' || byte == b'\n') { + anyhow::bail!( + "Fleet executable path cannot be represented safely: {}", + path.display() + ); + } + Ok(value) +} + +/// Render the shell command OpenSSH runs for a ProxyCommand. OpenSSH invokes +/// this value through the user's shell, so the executable is single-quoted +/// even when its installation path contains spaces or shell metacharacters. +pub(crate) fn proxy_command( + executable: &Path, + member_id: Uuid, + route: TransportRoute, +) -> Result { + let executable = safe_proxy_executable(executable)?; + Ok(format!( + "{} transport connect --member {} --via {}", + shell_quote(executable), + member_id, + route + )) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('%', "%%").replace('\'', "'\\''")) +} + fn quote_path(path: &Path) -> String { format!("\"{}\"", path.display().to_string().replace('"', "\\\"")) } @@ -144,16 +487,23 @@ mod tests { member.name = "emerald".into(); member.ssh_host_key = Some("ssh-ed25519 AAAAHOST".into()); skill::save_member(&paths, &member).unwrap(); - regenerate(&paths).unwrap(); + regenerate_with_executable(&paths, Path::new("/usr/local/bin/fleet")).unwrap(); let generated = fs::read_to_string(paths.root.join("ssh_config")).unwrap(); let known_hosts = fs::read_to_string(paths.known_hosts()).unwrap(); assert!(generated.contains("Host emerald.local")); assert!(generated.contains("User dev")); assert!(generated.contains("StrictHostKeyChecking yes")); + assert!(generated.contains("UpdateHostKeys no")); + assert!(generated.contains("Port 22")); + assert!(generated.contains("ConnectTimeout 8")); + assert!(generated.contains(&format!( + "ProxyCommand '/usr/local/bin/fleet' transport connect --member {} --via auto", + member.id + ))); assert!(known_hosts.contains("emerald.local ssh-ed25519 AAAAHOST")); skill::remove_member(&paths, member.id).unwrap(); - regenerate(&paths).unwrap(); + regenerate_with_executable(&paths, Path::new("/usr/local/bin/fleet")).unwrap(); assert!( !fs::read_to_string(paths.known_hosts()) .unwrap() @@ -179,4 +529,37 @@ mod tests { let rendered = with_fleet_include(existing, include); assert!(rendered.find(include).unwrap() < rendered.find("Host *").unwrap()); } + + #[test] + fn proxy_executable_accepts_shell_metacharacters_with_quoting() { + assert!(safe_proxy_executable(Path::new("/home/dev/.local/bin/fleet")).is_ok()); + assert!(safe_proxy_executable(Path::new("/tmp/fleet build")).is_ok()); + assert_eq!( + shell_quote("/tmp/fleet build;echo 'oops'"), + "'/tmp/fleet build;echo '\\''oops'\\'''" + ); + assert!(safe_proxy_executable(Path::new("/tmp/fleet\nHost *")).is_err()); + assert!(safe_proxy_executable(Path::new("relative/fleet")).is_err()); + } + + #[test] + fn network_hint_requires_the_member_identity_and_camel_case_fields() { + let id = Uuid::new_v4(); + let hint: NetworkHintResponse = serde_json::from_value(serde_json::json!({ + "version": 1, + "machineId": id, + "tailscale": { "dnsName": "emerald.example.ts.net" } + })) + .unwrap(); + assert_eq!(hint.version, 1); + assert_eq!(hint.machine_id, id); + assert_eq!(hint.tailscale.unwrap().dns_name, "emerald.example.ts.net"); + } + + #[test] + fn network_hint_command_finds_user_local_fleet_installations() { + assert!(REMOTE_NETWORK_HINT_COMMAND.contains("command -v fleet")); + assert!(REMOTE_NETWORK_HINT_COMMAND.contains("$HOME/.local/bin/fleet")); + assert!(REMOTE_NETWORK_HINT_COMMAND.contains("exec \"$fleet_bin\" network-hint --json")); + } } diff --git a/src/tailscale.rs b/src/tailscale.rs new file mode 100644 index 0000000..30b9314 --- /dev/null +++ b/src/tailscale.rs @@ -0,0 +1,486 @@ +//! Read-only access to the local Tailscale client. + +use crate::state::{StatePaths, atomic_write}; +use anyhow::{Context, Result, bail}; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs::OpenOptions; +use std::io::Read; +use std::net::IpAddr; +use std::path::PathBuf; +use std::process::{Child, Command, Output, Stdio}; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +const TAILSCALE: &str = "tailscale"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(3); +const OUTPUT_LIMIT: usize = 1024 * 1024; +const MAPPING_VERSION: u32 = 1; + +static MAPPING_LOCK: OnceLock> = OnceLock::new(); + +#[derive(Debug, Default, Serialize, Deserialize)] +struct MappingFile { + version: u32, + #[serde(default)] + members: BTreeMap, +} + +fn mapping_path(paths: &StatePaths) -> std::path::PathBuf { + paths.root.join("tailscale.toml") +} + +fn mapping_lock_path(paths: &StatePaths) -> std::path::PathBuf { + paths.root.join("tailscale.lock") +} + +pub fn mapped_peer(paths: &StatePaths, member: Uuid) -> Result> { + let path = mapping_path(paths); + if !path.exists() { + return Ok(None); + } + let source = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let file: MappingFile = + toml::from_str(&source).with_context(|| format!("parse {}", path.display()))?; + if file.version != MAPPING_VERSION { + bail!("unsupported Tailscale mapping version {}", file.version); + } + file.members + .get(&member) + .map(|value| normalize_fqdn(value)) + .transpose() +} + +pub fn save_mapping(paths: &StatePaths, member: Uuid, fqdn: &str) -> Result<()> { + with_mapping_lock(paths, || { + let mut file = load_mapping_file(paths)?; + file.members.insert(member, normalize_fqdn(fqdn)?); + save_mapping_file(paths, &file) + }) +} + +pub fn remove_mapping(paths: &StatePaths, member: Uuid) -> Result<()> { + with_mapping_lock(paths, || { + let mut file = load_mapping_file(paths)?; + if file.members.remove(&member).is_some() { + save_mapping_file(paths, &file)?; + } + Ok(()) + }) +} + +fn with_mapping_lock(paths: &StatePaths, operation: impl FnOnce() -> Result) -> Result { + let lock = MAPPING_LOCK.get_or_init(|| Mutex::new(())); + let _guard = lock + .lock() + .map_err(|_| anyhow::anyhow!("Tailscale mapping lock is poisoned"))?; + std::fs::create_dir_all(&paths.root) + .with_context(|| format!("create mapping directory {}", paths.root.display()))?; + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let lock_file = options + .open(mapping_lock_path(paths)) + .context("open Tailscale mapping lock")?; + lock_file + .lock_exclusive() + .context("lock Tailscale mappings")?; + + let result = operation(); + let unlock_result = lock_file.unlock().context("unlock Tailscale mappings"); + match (result, unlock_result) { + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Ok(value), Ok(())) => Ok(value), + } +} + +fn load_mapping_file(paths: &StatePaths) -> Result { + let path = mapping_path(paths); + if !path.exists() { + return Ok(MappingFile { + version: MAPPING_VERSION, + members: BTreeMap::new(), + }); + } + let source = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let file: MappingFile = + toml::from_str(&source).with_context(|| format!("parse {}", path.display()))?; + if file.version != MAPPING_VERSION { + bail!("unsupported Tailscale mapping version {}", file.version); + } + Ok(file) +} + +fn save_mapping_file(paths: &StatePaths, file: &MappingFile) -> Result<()> { + std::fs::create_dir_all(&paths.root)?; + let source = toml::to_string_pretty(file).context("serialize Tailscale mappings")?; + atomic_write(&mapping_path(paths), source.as_bytes(), 0o600) +} + +/// Return the normalized MagicDNS name reported for the local Tailscale node. +/// +/// `Ok(None)` means the client returned no self DNS name. An absent, stopped, or +/// logged-out client is an error so callers can distinguish it in diagnostics. +pub fn self_dns_name() -> Result> { + let output = run_tailscale(&["status", "--json"])?; + let status: Status = + serde_json::from_slice(&output).context("decode `tailscale status --json` output")?; + status + .self_node + .dns_name + .as_deref() + .map(normalize_fqdn) + .transpose() +} + +/// Resolve a full Tailscale DNS name through the local client. +/// +/// Only addresses in Tailscale's documented IPv4 and IPv6 ranges are returned. +/// Any other address makes the whole result fail closed. +pub fn peer_ips(peer_fqdn: &str) -> Result> { + peer_ips_with_timeout(peer_fqdn, COMMAND_TIMEOUT) +} + +pub(crate) fn peer_ips_with_timeout(peer_fqdn: &str, timeout: Duration) -> Result> { + let peer_fqdn = normalize_fqdn(peer_fqdn)?; + let output = run_tailscale_with_timeout(&["ip", &peer_fqdn], timeout)?; + parse_peer_ips(&output) +} + +/// Normalize and validate a full ASCII DNS name used for peer correlation. +pub fn normalize_fqdn(value: &str) -> Result { + if value.is_empty() || value.trim() != value || !value.is_ascii() { + bail!("Tailscale DNS name must be non-empty ASCII without surrounding whitespace"); + } + let value = value.strip_suffix('.').unwrap_or(value); + if value.is_empty() || value.len() > 253 || !value.contains('.') { + bail!("Tailscale DNS name must be a full DNS name of at most 253 characters"); + } + for label in value.split('.') { + let bytes = label.as_bytes(); + if bytes.is_empty() + || bytes.len() > 63 + || !bytes[0].is_ascii_alphanumeric() + || !bytes[bytes.len() - 1].is_ascii_alphanumeric() + || !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + bail!("Tailscale DNS name contains an invalid DNS label"); + } + } + Ok(value.to_ascii_lowercase()) +} + +/// Whether an address belongs to Tailscale's documented address space. +pub fn is_tailscale_ip(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + octets[0] == 100 && (64..=127).contains(&octets[1]) + } + IpAddr::V6(address) => address.octets()[..6] == [0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0], + } +} + +#[derive(Deserialize)] +struct Status { + #[serde(rename = "Self", default)] + self_node: SelfNode, +} + +#[derive(Default, Deserialize)] +struct SelfNode { + #[serde(rename = "DNSName", default)] + dns_name: Option, +} + +fn parse_peer_ips(output: &[u8]) -> Result> { + let text = std::str::from_utf8(output).context("`tailscale ip` returned non-UTF-8 output")?; + let mut addresses = Vec::new(); + for line in text.lines() { + let value = line.trim(); + if value.is_empty() { + continue; + } + let address: IpAddr = value + .parse() + .with_context(|| format!("`tailscale ip` returned an invalid address: {value}"))?; + if !is_tailscale_ip(address) { + bail!("`tailscale ip` returned an address outside Tailscale ranges: {address}"); + } + if !addresses.contains(&address) { + addresses.push(address); + } + } + if addresses.is_empty() { + bail!("`tailscale ip` returned no addresses"); + } + Ok(addresses) +} + +fn run_tailscale(arguments: &[&str]) -> Result> { + run_tailscale_with_timeout(arguments, COMMAND_TIMEOUT) +} + +fn run_tailscale_with_timeout(arguments: &[&str], timeout: Duration) -> Result> { + let program = tailscale_program(); + let mut command = Command::new(&program); + command + .args(arguments) + // The macOS app-bundled CLI requires this to select its non-GUI + // backend. It is harmless for standalone and non-macOS clients. + .env("TAILSCALE_BE_CLI", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_process_group(&mut command); + let mut child = command + .spawn() + .with_context(|| format!("start `{}` {}", program.display(), arguments.join(" ")))?; + let stdout = child.stdout.take().context("capture Tailscale stdout")?; + let stderr = child.stderr.take().context("capture Tailscale stderr")?; + let stdout_reader = thread::spawn(move || read_bounded(stdout)); + let stderr_reader = thread::spawn(move || read_bounded(stderr)); + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().context("wait for Tailscale command")? { + break status; + } + if started.elapsed() >= timeout { + terminate_process_group(&mut child); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + bail!("`{TAILSCALE} {}` timed out", arguments.join(" ")); + } + thread::sleep(Duration::from_millis(20)); + }; + let stdout = stdout_reader + .join() + .map_err(|_| anyhow::anyhow!("Tailscale stdout reader panicked"))??; + let stderr = stderr_reader + .join() + .map_err(|_| anyhow::anyhow!("Tailscale stderr reader panicked"))??; + let output = Output { + status, + stdout: stdout.bytes, + stderr: stderr.bytes, + }; + if stdout.truncated || stderr.truncated { + bail!( + "`{TAILSCALE} {}` exceeded the {OUTPUT_LIMIT}-byte output limit", + arguments.join(" ") + ); + } + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr); + let detail = detail.trim(); + bail!( + "`{TAILSCALE} {}` failed with {}{}", + arguments.join(" "), + output.status, + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + ); + } + Ok(output.stdout) +} + +fn tailscale_program() -> PathBuf { + // An explicitly empty PATH is used by tests and by callers that want to + // disable optional Tailscale discovery. Do not bypass that contract with + // well-known absolute paths. + if std::env::var_os("PATH").is_some_and(|path| path.is_empty()) { + return PathBuf::from(TAILSCALE); + } + if let Ok(path) = which::which(TAILSCALE) { + return path; + } + [ + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", + "/usr/bin/tailscale", + "/bin/tailscale", + ] + .iter() + .map(PathBuf::from) + .find(|path| path.is_file()) + .unwrap_or_else(|| PathBuf::from(TAILSCALE)) +} + +struct BoundedOutput { + bytes: Vec, + truncated: bool, +} + +fn read_bounded(mut reader: impl Read) -> std::io::Result { + let mut bytes = Vec::new(); + let mut truncated = false; + let mut buffer = [0_u8; 8192]; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + let remaining = OUTPUT_LIMIT.saturating_sub(bytes.len()); + bytes.extend_from_slice(&buffer[..count.min(remaining)]); + truncated |= count > remaining; + } + Ok(BoundedOutput { bytes, truncated }) +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + use std::os::unix::process::CommandExt; + command.process_group(0); +} + +#[cfg(not(unix))] +fn configure_process_group(_command: &mut Command) {} + +#[cfg(unix)] +fn terminate_process_group(child: &mut Child) { + let _ = Command::new("kill") + .args(["-KILL", "--", &format!("-{}", child.id())]) + .status(); + let _ = child.kill(); +} + +#[cfg(not(unix))] +fn terminate_process_group(child: &mut Child) { + let _ = child.kill(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + use std::sync::{Arc, Barrier}; + use std::thread; + + #[test] + fn parses_self_dns_name_fixture() { + let status: Status = serde_json::from_slice( + br#"{"Version":"1.98.0","Self":{"DNSName":"Emerald.Example.TS.NET."},"Peer":{}}"#, + ) + .unwrap(); + assert_eq!( + normalize_fqdn(status.self_node.dns_name.as_deref().unwrap()).unwrap(), + "emerald.example.ts.net" + ); + } + + #[test] + fn self_dns_name_may_be_absent() { + let status: Status = serde_json::from_slice(br#"{"Self":{},"Peer":{}}"#).unwrap(); + assert_eq!(status.self_node.dns_name, None); + } + + #[test] + fn fqdn_validation_is_strict() { + assert_eq!( + normalize_fqdn("Node.Tailnet.TS.NET.").unwrap(), + "node.tailnet.ts.net" + ); + for invalid in [ + "node", + " node.tail.ts.net", + "node..ts.net", + "-node.tail.ts.net", + "node_.tail.ts.net", + "node.tail.ts.net..", + ] { + assert!(normalize_fqdn(invalid).is_err(), "accepted {invalid:?}"); + } + } + + #[test] + fn validates_documented_tailscale_ranges() { + assert!(is_tailscale_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)))); + assert!(is_tailscale_ip(IpAddr::V4(Ipv4Addr::new( + 100, 127, 255, 254 + )))); + assert!(!is_tailscale_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1)))); + assert!(is_tailscale_ip("fd7a:115c:a1e0::1".parse().unwrap())); + assert!(!is_tailscale_ip("fd7a:115c:a1e1::1".parse().unwrap())); + } + + #[test] + fn parses_deduplicated_peer_addresses() { + let addresses = parse_peer_ips(b"100.64.1.2\nfd7a:115c:a1e0::2\n100.64.1.2\n").unwrap(); + assert_eq!( + addresses, + [ + "100.64.1.2".parse::().unwrap(), + "fd7a:115c:a1e0::2".parse::().unwrap() + ] + ); + } + + #[test] + fn rejects_empty_invalid_and_non_tailscale_ip_output() { + assert!(parse_peer_ips(b"").is_err()); + assert!(parse_peer_ips(b"not-an-ip\n").is_err()); + assert!(parse_peer_ips(b"192.168.1.2\n").is_err()); + } + + #[test] + fn mappings_round_trip_and_normalize_names() { + let temp = tempfile::tempdir().unwrap(); + let paths = StatePaths { + root: temp.path().join(".fleet"), + }; + let member = Uuid::new_v4(); + save_mapping(&paths, member, "Emerald.Example.TS.NET.").unwrap(); + assert_eq!( + mapped_peer(&paths, member).unwrap().as_deref(), + Some("emerald.example.ts.net") + ); + remove_mapping(&paths, member).unwrap(); + assert!(mapped_peer(&paths, member).unwrap().is_none()); + } + + #[test] + fn concurrent_mapping_updates_preserve_every_member() { + let temp = tempfile::tempdir().unwrap(); + let paths = Arc::new(StatePaths { + root: temp.path().join(".fleet"), + }); + let barrier = Arc::new(Barrier::new(8)); + let members: Vec<_> = (0..8).map(|_| Uuid::new_v4()).collect(); + let handles = members + .iter() + .enumerate() + .map(|(index, member)| { + let paths = paths.clone(); + let barrier = barrier.clone(); + let member = *member; + thread::spawn(move || { + barrier.wait(); + save_mapping(&paths, member, &format!("member{index}.example.ts.net")).unwrap(); + }) + }) + .collect::>(); + for handle in handles { + handle.join().unwrap(); + } + for member in members { + assert!(mapped_peer(&paths, member).unwrap().is_some()); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs index eec45f7..f5c7130 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -50,10 +50,45 @@ fn help_exposes_only_the_v0_lifecycle() { .stdout(predicate::str::contains("update")) .stdout(predicate::str::contains("update-all")) .stdout(predicate::str::contains("usage")) + .stdout(predicate::str::contains("network-hint").not()) .stdout(predicate::str::contains("transfer").not()) .stdout(predicate::str::contains("sync").not()); } +#[test] +fn network_hint_requires_json_and_fleet_state() { + let home = TempDir::new().unwrap(); + test_command(&home) + .arg("network-hint") + .assert() + .failure() + .stderr(predicate::str::contains("--json")); + test_command(&home) + .args(["network-hint", "--json"]) + .assert() + .failure() + .stderr(predicate::str::contains("not in a fleet")); +} + +#[test] +fn network_hint_emits_a_stable_empty_schema_without_tailscale() { + let home = TempDir::new().unwrap(); + let config = captain_config(); + let machine_id = config.machine.id; + let paths = StatePaths { + root: home.path().join(".fleet"), + }; + paths.save(&config).unwrap(); + test_command(&home) + .env("PATH", "") + .args(["network-hint", "--json"]) + .assert() + .success() + .stdout(predicate::eq(format!( + "{{\"version\":1,\"machineId\":\"{machine_id}\",\"tailscale\":null}}\n" + ))); +} + #[test] fn usage_rejects_an_unknown_machine_before_starting_ssh() { let home = TempDir::new().unwrap(); diff --git a/tests/tailscale_integration.rs b/tests/tailscale_integration.rs new file mode 100644 index 0000000..505ef00 --- /dev/null +++ b/tests/tailscale_integration.rs @@ -0,0 +1,65 @@ +//! Opt-in integration coverage for a real Fleet TCP route over Tailscale. +//! +//! Run this on an enrolled machine while a second enrolled machine is online +//! and accepting SSH on its ordinary port 22: +//! +//! FLEET_TAILSCALE_TEST_PEER=member.example.ts.net \ +//! cargo test --test tailscale_integration -- --ignored --nocapture + +use fleet::cli::{Color, TransportRoute}; +use fleet::skill; +use fleet::ssh_client; +use fleet::state::{LocalConfig, Machine, Role, STATE_VERSION, StatePaths}; +use fleet::tailscale; +use tempfile::tempdir; +use uuid::Uuid; + +#[test] +#[ignore = "requires two enrolled Tailscale machines with SSH reachable on port 22"] +fn auto_route_reaches_a_real_second_machine_over_tailscale() { + let peer = std::env::var("FLEET_TAILSCALE_TEST_PEER") + .expect("set FLEET_TAILSCALE_TEST_PEER to the second machine's full MagicDNS name"); + let temporary = tempdir().unwrap(); + let paths = StatePaths { + root: temporary.path().join(".fleet"), + }; + let captain = Machine { + id: Uuid::new_v4(), + name: "integration-captain".into(), + color: Color::Violet, + ssh_user: "fleet-test".into(), + os: "linux".into(), + arch: "x86_64".into(), + tools: vec![], + public_identity: "ssh-ed25519 AAAATEST".into(), + ssh_host_key: None, + }; + paths + .save(&LocalConfig { + version: STATE_VERSION, + role: Role::Captain, + machine: captain, + captain: None, + }) + .unwrap(); + let member_id = Uuid::new_v4(); + skill::save_member( + &paths, + &Machine { + id: member_id, + name: "integration-member".into(), + color: Color::Cyan, + ssh_user: "fleet-test".into(), + os: "linux".into(), + arch: "x86_64".into(), + tools: vec![], + public_identity: "ssh-ed25519 AAAAMEMBER".into(), + ssh_host_key: None, + }, + ) + .unwrap(); + tailscale::save_mapping(&paths, member_id, &peer).unwrap(); + + ssh_client::probe_transport(&paths, member_id, TransportRoute::Auto) + .expect("Fleet could not reach the second machine through its Tailscale peer"); +}