diff --git a/pkgs/mcpx/docs/configuration.md b/pkgs/mcpx/docs/configuration.md index 72328fb14..b51769d7b 100644 --- a/pkgs/mcpx/docs/configuration.md +++ b/pkgs/mcpx/docs/configuration.md @@ -66,7 +66,7 @@ with no symptom. A daemon is started once, in whatever environment it happened to have, and then serves every command for hours. A flag on one of those commands that -governs something the *daemon* does was simply lost: accepted, validated, +governs something the _daemon_ does was simply lost: accepted, validated, printed back, and then ignored, because the process that would act on it had read its configuration an hour earlier. Nothing anywhere said so. @@ -95,22 +95,22 @@ would be silently inert. ## Reaching them -| surface | read | change | -| --- | --- | --- | -| config file | any `mcpx` config on the search path | edit it | -| environment | `MCPX_*` | export it | -| CLI | `mcpx settings list`, `mcpx settings get ` | `mcpx settings set [--persist runtime\|project\|user]`, `mcpx settings unset ` | -| `/v1` | `GET /v1/settings`, `GET /v1/settings/{path}` | `PUT /v1/settings/{path}`, `DELETE /v1/settings/{path}` | -| MCP | `mcpx_settings_list`, `mcpx_settings_get` | `mcpx_settings_set`, `mcpx_settings_unset` | +| surface | read | change | +| ----------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| config file | any `mcpx` config on the search path | edit it | +| environment | `MCPX_*` | export it | +| CLI | `mcpx settings list`, `mcpx settings get ` | `mcpx settings set [--persist runtime\|project\|user]`, `mcpx settings unset ` | +| `/v1` | `GET /v1/settings`, `GET /v1/settings/{path}` | `PUT /v1/settings/{path}`, `DELETE /v1/settings/{path}` | +| MCP | `mcpx_settings_list`, `mcpx_settings_get` | `mcpx_settings_set`, `mcpx_settings_unset` | -`mcpx settings list` resolves locally, which is what the *client* will use. +`mcpx settings list` resolves locally, which is what the _client_ will use. `--daemon` asks the daemon what it resolved, which is what a daemon-scoped setting actually is. The two differing is information, not a bug: it is exactly the case that used to be invisible. `--persist runtime` changes the running daemon and nothing on disk, which is what an experiment wants. `project` and `user` write the corresponding -configuration file *and* apply at once where the setting allows it -- a change +configuration file _and_ apply at once where the setting allows it -- a change written to a file but not applied would leave `mcpx settings get` answering with the old value, which reads as the write having failed. @@ -119,11 +119,11 @@ with the old value, which reads as the write having failed. Servers are configuration too, and until now adding one meant an editor and a restart. -| surface | list | add | remove | -| --- | --- | --- | --- | -| CLI | `mcpx servers list` | `mcpx servers add -- ...`, `mcpx registry add --write` | `mcpx servers remove ` | -| `/v1` | `GET /v1/servers` | `POST /v1/servers` | `DELETE /v1/servers/{name}` | -| MCP | `mcpx_servers_list` | `mcpx_servers_add` | `mcpx_servers_remove` | +| surface | list | add | remove | +| ------- | ------------------- | ---------------------------------------------------------------------- | ---------------------------- | +| CLI | `mcpx servers list` | `mcpx servers add -- ...`, `mcpx registry add --write` | `mcpx servers remove ` | +| `/v1` | `GET /v1/servers` | `POST /v1/servers` | `DELETE /v1/servers/{name}` | +| MCP | `mcpx_servers_list` | `mcpx_servers_add` | `mcpx_servers_remove` | The entry is written to a configuration file and the daemon reloads, so the server is callable immediately and is still there tomorrow. A server whose @@ -137,14 +137,14 @@ edit by hand therefore takes effect within `daemon.reapInterval` without anything being restarted. Making that work required changing what identifies a daemon. The socket used -to be keyed to the *contents* of the config files, so that editing one got a +to be keyed to the _contents_ of the config files, so that editing one got a fresh daemon rather than a stale one. A daemon that reloads changes the contents of the files it is keyed by, which moves its own key, which makes it unreachable by the client that just edited it -- and hands the next command a different daemon that happens to match the new key with old servers loaded. That was observed: `mcpx servers remove` succeeded, the file was correct, and the next `mcpx servers list` showed the removed server. The key is now the -file *set*, and staleness is handled where it belongs. +file _set_, and staleness is handled where it belongs. ### Hiding tools @@ -157,19 +157,18 @@ the MCP pass-through -- and a call to it by name is refused with 400. { "pool": { "excludeTools": ["delete_*"] }, "mcpServers": { - "datadog": { "command": "mcp-remote", "args": ["..."], - "mcpx": { "excludeTools": ["execute_*", "/^(update|upsert)_.*dashboard/"] } } + "datadog": { "command": "mcp-remote", "args": ["..."], "mcpx": { "excludeTools": ["execute_*", "/^(update|upsert)_.*dashboard/"] } } } } ``` Each entry is one of: -| entry | means | example | -| --- | --- | --- | -| `/.../` | a Go regular expression, unanchored as regexps are | `/delete/`, `/^delete_/` | -| contains `*`, `?` or `[` | a glob over the whole name: `*` any run, `?` one character, `[a-z]` / `[!a-z]` a class | `delete_*`, `*_monitor` | -| anything else | the exact tool name | `delete_datadog_workflow` | +| entry | means | example | +| ------------------------ | -------------------------------------------------------------------------------------- | ------------------------- | +| `/.../` | a Go regular expression, unanchored as regexps are | `/delete/`, `/^delete_/` | +| contains `*`, `?` or `[` | a glob over the whole name: `*` any run, `?` one character, `[a-z]` / `[!a-z]` a class | `delete_*`, `*_monitor` | +| anything else | the exact tool name | `delete_datadog_workflow` | A pattern that cannot compile is a configuration error naming the field, not a rule that silently matches nothing. @@ -218,7 +217,7 @@ drifted: two settings were missing an alias their flag really answers to. `mcpx config --schema --plumbing` prints the same inventory, and `mcpx settings list` prints it with the values that are actually in force. -Entries marked *(plumbing)* are internals. They work and they are supported, +Entries marked _(plumbing)_ are internals. They work and they are supported, but there is no ordinary reason to change one, so they are kept out of ordinary help rather than hidden -- a flag a user can find in the source and that `--help` denies exists is worse than a long list. @@ -308,6 +307,7 @@ and a switch to turn the report off would have kept it quiet. | `daemon.saveInterval` | duration | `5m` | daemon | no | `--daemon-save-interval` | `MCPX_DAEMON_SAVE_INTERVAL` | *(plumbing)* how often daemon state is written to disk | | `daemon.socketProbeTimeout` | duration | `500ms` | daemon | no | `--daemon-socket-probe-timeout` | `MCPX_DAEMON_SOCKET_PROBE_TIMEOUT` | *(plumbing)* how long a socket left by a crashed daemon is given to answer | | `daemon.takeoverTimeout` | duration | `15s` | daemon | no | `--daemon-takeover-timeout` | `MCPX_DAEMON_TAKEOVER_TIMEOUT` | *(plumbing)* how long a handoff may take, and how long the old daemon waits for its successor to hang up | +| `daemon.tsnet.hostname` | string | *(empty)* | daemon | no | `--daemon-tsnet-hostname` | `MCPX_DAEMON_TSNET_HOSTNAME` | also serve the daemon on a tailnet, under this name | | `daemon.warm` | bool | `true` | daemon | no | `--daemon-warm`, `--warm` | `MCPX_DAEMON_WARM` | read every server's schemas in the background at startup | | `daemon.warmTimeout` | duration | `3m0s` | daemon | no | `--daemon-warm-timeout` | `MCPX_DAEMON_WARM_TIMEOUT` | *(plumbing)* how long the background schema fetch may take | | `daemon.watchConfig` | bool | `true` | daemon | yes | `--daemon-watch-config` | `MCPX_DAEMON_WATCH_CONFIG` | re-read the configuration files when they change on disk | diff --git a/pkgs/mcpx/go.mod b/pkgs/mcpx/go.mod index 047441c3c..a2d48db04 100644 --- a/pkgs/mcpx/go.mod +++ b/pkgs/mcpx/go.mod @@ -10,10 +10,28 @@ require ( github.com/tetratelabs/wazero v1.9.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.60.0 + tailscale.com v1.86.5 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2 v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect + github.com/aws/smithy-go v1.22.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect @@ -22,23 +40,72 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gaissmai/bart v0.18.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/illarion/gonotify/v3 v3.0.2 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/sdnotify v1.0.0 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/miekg/dns v1.1.58 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/safchain/ethtool v0.3.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect + github.com/vishvananda/netns v0.0.4 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.50.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/pkgs/mcpx/go.sum b/pkgs/mcpx/go.sum index 07f24714e..a65a6e835 100644 --- a/pkgs/mcpx/go.sum +++ b/pkgs/mcpx/go.sum @@ -1,5 +1,47 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= +github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= +github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= +github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -20,24 +62,94 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= +github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= +github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= +github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= +github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -48,43 +160,147 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= +gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= +honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= +honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= @@ -113,3 +329,7 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.86.5 h1:yBtWFjuLYDmxVnfnvPbZNZcKADCYgNfMd0rUAOA9XCs= +tailscale.com v1.86.5/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= diff --git a/pkgs/mcpx/internal/cli/client.go b/pkgs/mcpx/internal/cli/client.go index ecc3fd831..f57684e26 100644 --- a/pkgs/mcpx/internal/cli/client.go +++ b/pkgs/mcpx/internal/cli/client.go @@ -151,6 +151,28 @@ func NewClientAt(paths daemon.Paths, configPath, endpoint string) *Client { } return c } + // A tsnet:// endpoint names a daemon by tailnet identity (#1967). The + // client joins the tailnet as an ephemeral node to dial it; there is + // no address to fall back to, and the dialer says so when it cannot + // join (no TS_AUTHKEY) instead of retrying the name as plain TCP. + if host, port, isTSNet, perr := daemon.ParseTSNetEndpoint(c.endpoint); isTSNet { + dialer := daemon.NewTSNetDialer("mcpx-client") + dial := dialer.DialContext + if perr != nil { + dial = func(context.Context, string, string) (net.Conn, error) { return nil, perr } + } + c.endpoint = "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + c.hc = &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + DialContext: dial, + MaxIdleConns: set.Int("http.remoteIdleConns"), + MaxIdleConnsPerHost: set.Int("http.remoteIdleConns"), + IdleConnTimeout: idle, + }, + } + return c + } c.hc = &http.Client{ Timeout: timeout, Transport: &http.Transport{ diff --git a/pkgs/mcpx/internal/daemon/origin.go b/pkgs/mcpx/internal/daemon/origin.go index 7f9fe6955..7d81640d4 100644 --- a/pkgs/mcpx/internal/daemon/origin.go +++ b/pkgs/mcpx/internal/daemon/origin.go @@ -38,10 +38,25 @@ func (s *Server) refuseBrowserPages(next http.Handler) http.Handler { "add it to transport.allowedOrigins if it should", origin)) return } - if overTCP(r) && s.loopbackOnly() && !s.loopbackHost(r.Host) { - writeErr(w, http.StatusForbidden, fmt.Errorf("host %q is not this daemon's; "+ - "a loopback daemon answers only to a loopback name", r.Host)) - return + if overTCP(r) && s.loopbackOnly() { + if ip, ok := tailnetLocalIP(r); ok { + // This request arrived on the tailnet listener, whose + // clients name the daemon by its MagicDNS name, not by + // a loopback name — the loopback rule would refuse + // every legitimate tsnet caller. The tailnet is an + // authenticated network, so the Host rule there is + // the mirror image: the name must be this daemon's + // own tailnet name or address. + if !s.tailnetHost(r.Host, ip) { + writeErr(w, http.StatusForbidden, fmt.Errorf("host %q is not this daemon's tailnet name; "+ + "the tailnet listener answers only to its own name", r.Host)) + return + } + } else if !s.loopbackHost(r.Host) { + writeErr(w, http.StatusForbidden, fmt.Errorf("host %q is not this daemon's; "+ + "a loopback daemon answers only to a loopback name", r.Host)) + return + } } next.ServeHTTP(w, r) }) @@ -62,6 +77,52 @@ func (s *Server) loopbackOnly() bool { return strings.EqualFold(s.Address, "localhost") || (ip != nil && ip.IsLoopback()) } +// tailnetLocalIP returns the local address of a request that arrived on +// a tailnet listener: a TCP address in Tailscale's ranges (100.64.0.0/10 +// or the fd7a:115c:a1e0::/48 ULA). ok is false for any other request. +func tailnetLocalIP(r *http.Request) (net.IP, bool) { + addr, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok || addr.Network() != "tcp" { + return nil, false + } + host, _, err := net.SplitHostPort(addr.String()) + if err != nil { + host = addr.String() + } + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return nil, false + } + if v4 := ip.To4(); v4 != nil { + return ip, v4[0] == 100 && v4[1]&0xc0 == 0x40 + } + ula := net.ParseIP("fd7a:115c:a1e0::") + return ip, ip.Mask(net.CIDRMask(48, 128)).Equal(ula) +} + +// tailnetHost reports whether host names this daemon on its tailnet: its +// own tailnet IP, or its configured tsnet hostname (short, or as a +// prefix of the full MagicDNS name a client dialed). +func (s *Server) tailnetHost(host string, localIP net.IP) bool { + h := host + if split, _, err := net.SplitHostPort(host); err == nil { + h = split + } + h = strings.TrimSuffix(strings.Trim(h, "[]"), ".") + if ip := net.ParseIP(h); ip != nil { + return ip.Equal(localIP) + } + name := strings.TrimSuffix(s.set.String("daemon.tsnet.hostname"), ".") + if name == "" { + return false + } + lower := strings.ToLower(h) + // The full MagicDNS form is ..ts.net; a bare + // prefix match would also accept mcpx-daemon.evil.example. + return strings.EqualFold(h, name) || + (strings.HasPrefix(lower, strings.ToLower(name)+".") && strings.HasSuffix(lower, ".ts.net")) +} + func (s *Server) loopbackHost(host string) bool { h := host if split, _, err := net.SplitHostPort(host); err == nil { diff --git a/pkgs/mcpx/internal/daemon/origin_tsnet_test.go b/pkgs/mcpx/internal/daemon/origin_tsnet_test.go new file mode 100644 index 000000000..a5a6a44e3 --- /dev/null +++ b/pkgs/mcpx/internal/daemon/origin_tsnet_test.go @@ -0,0 +1,112 @@ +package daemon + +import ( + "context" + "io" + "log" + "net" + "net/http" + "testing" + "time" + + "github.com/dezren39/mcpx/internal/settings" +) + +// The tailnet listener's clients name the daemon by its MagicDNS name; +// the loopback Host rule would refuse every one of them. These tests +// pin the distinction the Host check now makes, and that the check did +// not get weaker for it: on the tailnet listener only this daemon's own +// name or address is accepted. + +func requestWithLocalAddr(t *testing.T, local string) *http.Request { + t.Helper() + r, err := http.NewRequest(http.MethodGet, "http://example.invalid/v1/status", nil) + if err != nil { + t.Fatal(err) + } + host, port, err := net.SplitHostPort(local) + if err != nil { + t.Fatal(err) + } + var p int + if _, err := net.LookupPort("tcp", port); err == nil { + p, _ = net.LookupPort("tcp", port) + } + addr := &net.TCPAddr{IP: net.ParseIP(host), Port: p} + return r.WithContext(context.WithValue(r.Context(), http.LocalAddrContextKey, addr)) +} + +func TestTailnetLocalIP(t *testing.T) { + cases := []struct { + local string + ok bool + }{ + {"100.101.102.103:80", true}, // Tailscale CGNAT range + {"100.64.0.1:80", true}, // range start + {"100.127.255.254:80", true}, // range end + {"100.128.0.1:80", false}, // just outside 100.64.0.0/10 + {"127.0.0.1:8065", false}, // loopback is not the tailnet + {"192.168.1.10:8065", false}, // LAN is not the tailnet + {"[fd7a:115c:a1e0::1]:80", true}, // Tailscale ULA + {"[fd7a:115c:a1e1::1]:80", false}, + } + for _, tc := range cases { + _, ok := tailnetLocalIP(requestWithLocalAddr(t, tc.local)) + if ok != tc.ok { + t.Errorf("tailnetLocalIP(local=%s) ok = %v; want %v", tc.local, ok, tc.ok) + } + } +} + +func TestTailnetHost(t *testing.T) { + sch, err := settings.New(settings.Registry()) + if err != nil { + t.Fatal(err) + } + set := settings.NewSet(sch).WithOverrides(map[string]string{ + "daemon.tsnet.hostname": "mcpx-daemon", + }, "test") + srv := &Server{set: set} + local := net.ParseIP("100.101.102.103") + cases := []struct { + host string + want bool + }{ + {"mcpx-daemon", true}, + {"mcpx-daemon:80", true}, + {"mcpx-daemon.my-tailnet.ts.net", true}, + {"mcpx-daemon.my-tailnet.ts.net:80", true}, + {"MCPX-Daemon.My-Tailnet.ts.net", true}, + {"100.101.102.103", true}, + {"100.101.102.103:80", true}, + {"other-daemon.my-tailnet.ts.net", false}, + {"mcpx-daemon.evil.example", false}, + {"100.101.102.104", false}, // another peer's address + {"127.0.0.1", false}, + {"localhost", false}, + {"", false}, + } + for _, tc := range cases { + if got := srv.tailnetHost(tc.host, local); got != tc.want { + t.Errorf("tailnetHost(%q) = %v; want %v", tc.host, got, tc.want) + } + } +} + +// A connection with no descriptor (a tsnet gonet connection) must be +// drained by sendConns, not fail the takeover. +func TestSendConnsDrainsDescriptorless(t *testing.T) { + srv := &Server{logger: log.New(io.Discard, "", 0)} + mine, peer := net.Pipe() + defer peer.Close() + hc := newHandoffConn(mine, false) + if err := srv.sendConns(nil, []*handoffConn{hc}); err != nil { + t.Fatalf("sendConns with a descriptorless connection = %v; want nil (drained)", err) + } + // The drained connection is closed: the peer sees EOF. + _ = peer.SetReadDeadline(time.Now().Add(5 * time.Second)) + buf := make([]byte, 1) + if _, err := peer.Read(buf); err == nil { + t.Fatal("peer read after drain = nil error; want EOF/closed") + } +} diff --git a/pkgs/mcpx/internal/daemon/server.go b/pkgs/mcpx/internal/daemon/server.go index b0534c66f..831a2b67d 100644 --- a/pkgs/mcpx/internal/daemon/server.go +++ b/pkgs/mcpx/internal/daemon/server.go @@ -77,6 +77,10 @@ type Server struct { // daemon's only accept path, so a handover can see every connection. tcpLn net.Listener unixLn net.Listener + // tsnetLn is set only when daemon.tsnet.hostname names a tailnet + // identity for this daemon (#1967); nil on every daemon that did not + // ask for one. + tsnetLn net.Listener // conns tracks every connection the HTTP server holds, for a handover. conns connTracker endpoint string @@ -249,18 +253,33 @@ func (s *Server) Registry() *Registry { return s.reg } // Endpoint is the loopback base URL once Listen has run. func (s *Server) Endpoint() string { return s.endpoint } +// TSNetStatus reports this daemon's tailnet identity and, per peer, +// whether traffic is direct or relayed. ok is false on a daemon with no +// tsnet listener. +func (s *Server) TSNetStatus(ctx context.Context) (status TSNetStatus, ok bool, err error) { + cl, isConn := s.tsnetLn.(*connListener) + if !isConn || cl == nil { + return TSNetStatus{}, false, nil + } + tl, isTSNet := cl.inner.(*tsnetListener) + if !isTSNet { + return TSNetStatus{}, false, nil + } + status, err = tl.Status(ctx) + if err != nil { + return TSNetStatus{}, true, err + } + return status, true, nil +} + // Listen binds both listeners and publishes the daemon record. func (s *Server) Listen(tcpPort int) error { // A stale socket from a crashed daemon must not block a fresh one. if err := s.clearStaleSocket(); err != nil { return err } - unixLn, err := net.Listen("unix", s.paths.Socket) + unixLn, err := unixTransport{socket: s.paths.Socket}.Listen() if err != nil { - return fmt.Errorf("listen %s: %w", s.paths.Socket, err) - } - if err := os.Chmod(s.paths.Socket, defaults.PrivateMode); err != nil { - unixLn.Close() return err } // Loopback unless told otherwise. Binding wider is a deliberate act: @@ -270,18 +289,75 @@ func (s *Server) Listen(tcpPort int) error { if host == "" { host = s.set.String("daemon.address") } - tcpLn, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(tcpPort))) + tcpLn, err := tcpTransport{address: net.JoinHostPort(host, strconv.Itoa(tcpPort))}.Listen() if err != nil { unixLn.Close() - return fmt.Errorf("listen tcp: %w", err) + return err } s.unixLn = newConnListener(unixLn, &s.conns, nil) s.tcpLn = newConnListener(tcpLn, &s.conns, nil) + // An identity listener, when one is named: the same API on the + // daemon's tailnet, reachable by name from another network. A + // failure here is fatal to Listen — a daemon that was asked for a + // tsnet route and silently does not have one would be found out by + // the first remote caller, not by its operator. + if err := s.listenTSNet(); err != nil { + unixLn.Close() + tcpLn.Close() + return err + } s.endpoint = "http://" + tcpLn.Addr().String() s.started = time.Now() return s.publish() } +// listenTSNet starts the daemon's tsnet identity listener when +// daemon.tsnet.hostname names one, and records it on the server. It is +// shared by Listen, by a takeover successor (which must recreate the +// listener: the node is in-process state and cannot be passed), and by +// a handover that did not commit (which must give the route back). +func (s *Server) listenTSNet() error { + hostname := s.set.String("daemon.tsnet.hostname") + if hostname == "" { + return nil + } + tsLn, err := tsnetTransport{ + hostname: hostname, + stateDir: s.tsnetStateDir(), + authKey: envTSNetAuthKey(), + }.Listen() + if err != nil { + return err + } + s.tsnetLn = newConnListener(tsLn, &s.conns, nil) + return nil +} + +// tsnetStateDir is where this daemon's tsnet node keeps its identity +// (its node keys) across restarts. It is keyed by the daemon's +// configuration identity -- the same config file-set fingerprint that +// keys the socket and info file in Paths.ForConfig. Paths.ForConfig +// keys Socket and Info but leaves State shared, so without this key +// two daemons serving different configurations out of one state +// directory would share one node-state directory: the second daemon +// would load the first daemon's node keys, tsnet would ignore its +// auth key (persisted state wins), and both would compete for one +// tailnet identity. tsnet requires a unique state directory per +// service. The key is the file set, not the contents, so it is stable +// across a takeover: the successor runs the same configuration and +// lands on the same directory, keeping the identity. A daemon with no +// configuration keeps the unkeyed directory -- it is the only daemon +// its state directory can hold. +func (s *Server) tsnetStateDir() string { + dir := filepath.Join(s.paths.State, "tsnet") + if cfg := s.currentConfig(); cfg != nil { + if key := FingerprintConfig(cfg.Sources); key != "" { + dir = filepath.Join(dir, key) + } + } + return dir +} + // publish writes the daemon record clients find the daemon by. func (s *Server) publish() error { cfgPath := "" @@ -310,7 +386,11 @@ func (s *Server) startHTTP() { ConnState: s.observeConn, } s.httpSrv = srv - for _, ln := range []net.Listener{s.unixLn, s.tcpLn} { + listeners := []net.Listener{s.unixLn, s.tcpLn} + if s.tsnetLn != nil { + listeners = append(listeners, s.tsnetLn) + } + for _, ln := range listeners { s.httpServing.Add(1) go func() { err := srv.Serve(ln) diff --git a/pkgs/mcpx/internal/daemon/takeover.go b/pkgs/mcpx/internal/daemon/takeover.go index 1f0b11d1d..181f4418e 100644 --- a/pkgs/mcpx/internal/daemon/takeover.go +++ b/pkgs/mcpx/internal/daemon/takeover.go @@ -312,6 +312,14 @@ func (s *Server) handOver(c *net.UnixConn) error { _ = restore(ho) return err } + // The tsnet node is in-process state: no descriptor passes it to + // the successor. Close it here — after the connections are drained + // — so the tailnet identity is free for the successor to claim in + // its commit, which recreates the listener from the same settings + // and state directory. A handover that does not commit restores it + // (restoreHTTP), so this is not the point of no return it looks + // like. + s.closeTSNetNode() if err := send(c, kindEnd, nil, nil); err != nil { _ = restore(ho) return err @@ -354,6 +362,17 @@ func (s *Server) passConns(ul, tl *connListener, grace time.Duration) ([]*handof s.conns.setHandingOver(true) ul.stop() tl.stop() + // The tailnet listener stops accepting here too, before the sweep + // below and long before Registry.Detach: a tailnet connection + // accepted after the sweep would be gathered by nobody, then reach + // a pool Detach has already closed — or be cut by the node shutdown + // in closeTSNetNode instead of drained. Stopping the tracked + // wrapper closes only its inner listener; the node stays this + // process's until closeTSNetNode, and restoreHTTP listens on the + // same node again if the handover does not commit. + if cl, ok := s.tsnetLn.(*connListener); ok && cl != nil { + cl.stop() + } ctx, cancel := context.WithTimeout(context.Background(), grace) defer cancel() tick := time.NewTicker(defaults.TakeoverPoll) @@ -400,7 +419,19 @@ func (s *Server) sendConns(c *net.UnixConn, passing []*handoffConn) error { for _, hc := range passing { f, err := hc.file() if err != nil { - return err + // A connection with no descriptor — a tsnet gonet + // connection, which lives in userspace netstack rather + // than in a kernel socket — cannot be passed. Draining + // it (closing, so its client reconnects to the + // successor) is the honest substitute; failing the + // whole takeover because a tailnet client happened to + // hold an idle keep-alive is not. + s.logger.Printf("takeover: closing a connection that cannot be passed: %v", err) + // release, not Close: a frozen connection's Close is a + // no-op by design (the handover owns it), and this one + // is being given up, not handed on. + hc.release() + continue } err = exchange(c, kindConn, nil, f) closeFile(f) @@ -419,6 +450,32 @@ func (s *Server) releasePassed(passing []*handoffConn) { } } +// closeTSNetNode shuts the daemon's tsnet node down during a handover, +// releasing the tailnet identity for the successor. s.tsnetLn is +// cleared, which is how restoreHTTP knows the route has to be +// recreated if the handover does not commit. +func (s *Server) closeTSNetNode() { + cl, ok := s.tsnetLn.(*connListener) + if !ok || cl == nil { + return + } + tl, ok := cl.inner.(*tsnetListener) + if !ok { + return + } + s.tsnetLn = nil + // Stop the tracked wrapper before closing the node. stop marks the + // listener stopped, so the serve loop's blocked Accept converts the + // tsnet listener-close error to http.ErrServerClosed instead of + // forwarding it to httpErr, where Serve would treat it as fatal and + // shut this predecessor down (socket and info file included) while + // the successor is still committing. + cl.stop() + if err := tl.CloseNode(); err != nil { + s.logger.Printf("takeover: closing the tsnet node: %v", err) + } +} + // restoreHTTP serves again after a handover that did not complete. Listeners // are put back from the duplicates kept for that purpose, and every frozen // connection is given back to the new server, which reads it again. @@ -456,6 +513,27 @@ func (s *Server) restoreHTTP(unixF, tcpF *os.File, passing []*handoffConn) error } s.unixLn = newConnListener(ul, &s.conns, unixQ) s.tcpLn = newConnListener(tl, &s.conns, tcpQ) + if s.tsnetLn == nil { + // A handover that got as far as closing the tsnet node must + // give the identity route back when it does not commit. The + // failure is logged, not fatal: the local routes are already + // restored, and a daemon that serves nothing helps no one. + if err := s.listenTSNet(); err != nil { + s.logger.Printf("takeover: restoring the tsnet listener: %v", err) + } + } else if cl, ok := s.tsnetLn.(*connListener); ok && cl != nil && cl.isStopped() { + // passConns stopped the tailnet listener but the node was + // never closed (closeTSNetNode runs only on the commit + // path): listen on the same node again instead of joining + // a second one. + if tl, ok := cl.inner.(*tsnetListener); ok { + if ln, err := tl.node.Listen(); err != nil { + s.logger.Printf("takeover: restoring the tsnet listener: %v", err) + } else { + s.tsnetLn = newConnListener(ln, &s.conns, nil) + } + } + } s.startHTTP() return nil } @@ -641,6 +719,18 @@ func (s *Server) commitTakeover(uc *net.UnixConn, in *takeoverIn, endFd *os.File tl.Close() return fail(err) } + // The predecessor closed its tsnet node before kindEnd (see + // handOver): recreate the identity listener from this daemon's own + // settings before committing, so a successor that cannot restore + // the tailnet route refuses the takeover and the predecessor keeps + // serving — a committed successor silently missing the route the + // daemon was configured for is the failure this prevents. + if err := s.listenTSNet(); err != nil { + ad.Abort() + ul.Close() + tl.Close() + return fail(fmt.Errorf("takeover: restoring the tsnet listener: %w", err)) + } if err := sendFrame(uc, takeoverFrame{Kind: kindCommit}, nil); err != nil { ad.Abort() ul.Close() diff --git a/pkgs/mcpx/internal/daemon/transport.go b/pkgs/mcpx/internal/daemon/transport.go new file mode 100644 index 000000000..42e640d27 --- /dev/null +++ b/pkgs/mcpx/internal/daemon/transport.go @@ -0,0 +1,63 @@ +package daemon + +import ( + "fmt" + "net" + "os" + + "github.com/dezren39/mcpx/internal/defaults" +) + +// Transport produces one of the daemon's listeners. The daemon's accept +// path used to be two hardcoded net.Listen calls in Server.Listen; naming +// the seam is what lets an identity transport (tsnet, transport_tsnet.go) +// serve the same HTTP API without the daemon learning anything about how +// the listener was reached (#1967: reach a daemon by identity, not by +// address). A Transport that cannot listen returns a clear error — there +// is deliberately no fallback to another transport, because silently +// serving on a different route than the one configured would be worse +// than failing. +type Transport interface { + // Name identifies the transport in logs and status ("unix", "tcp", + // "tsnet"). + Name() string + // Listen binds the transport's listener. + Listen() (net.Listener, error) +} + +// unixTransport serves the local socket: no network stack, and the +// filesystem permissions are the access control. +type unixTransport struct { + socket string +} + +func (t unixTransport) Name() string { return "unix" } + +func (t unixTransport) Listen() (net.Listener, error) { + ln, err := net.Listen("unix", t.socket) + if err != nil { + return nil, fmt.Errorf("listen %s: %w", t.socket, err) + } + if err := os.Chmod(t.socket, defaults.PrivateMode); err != nil { + ln.Close() + return nil, err + } + return ln, nil +} + +// tcpTransport serves a host:port address. The caller decides the host; +// Listen in server.go keeps the loopback-by-default rule, because the API +// is unauthenticated and the network it is on is the access control. +type tcpTransport struct { + address string +} + +func (t tcpTransport) Name() string { return "tcp" } + +func (t tcpTransport) Listen() (net.Listener, error) { + ln, err := net.Listen("tcp", t.address) + if err != nil { + return nil, fmt.Errorf("listen tcp: %w", err) + } + return ln, nil +} diff --git a/pkgs/mcpx/internal/daemon/transport_test.go b/pkgs/mcpx/internal/daemon/transport_test.go new file mode 100644 index 000000000..4ba3de2d3 --- /dev/null +++ b/pkgs/mcpx/internal/daemon/transport_test.go @@ -0,0 +1,114 @@ +package daemon + +import ( + "context" + "net" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dezren39/mcpx/internal/config" +) + +// The two listeners the daemon has always had now run through the +// Transport interface; this pins that the seam did not change what they +// do — bind, and be reachable at the address they report. +func TestBuiltinTransportsListen(t *testing.T) { + for _, tr := range []Transport{ + unixTransport{socket: filepath.Join(t.TempDir(), "mcpx.sock")}, + tcpTransport{address: "127.0.0.1:0"}, + } { + ln, err := tr.Listen() + if err != nil { + t.Fatalf("%s transport: %v", tr.Name(), err) + } + defer ln.Close() + go func() { + conn, err := ln.Accept() + if err == nil { + conn.Close() + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var d net.Dialer + conn, err := d.DialContext(ctx, ln.Addr().Network(), ln.Addr().String()) + if err != nil { + t.Fatalf("%s transport: dial %s: %v", tr.Name(), ln.Addr(), err) + } + conn.Close() + } +} + +func TestParseTSNetEndpoint(t *testing.T) { + cases := []struct { + endpoint string + host string + port int + ok bool + wantErr bool + }{ + {"tsnet://laptop.tailnet.ts.net", "laptop.tailnet.ts.net", TSNetPort, true, false}, + {"tsnet://laptop.tailnet.ts.net:8443", "laptop.tailnet.ts.net", 8443, true, false}, + {"tsnet://", "", 0, true, true}, + {"tsnet://host:notaport", "", 0, true, true}, + {"http://127.0.0.1:8065", "", 0, false, false}, + {"unix:///tmp/mcpx.sock", "", 0, false, false}, + {"", "", 0, false, false}, + } + for _, tc := range cases { + host, port, ok, err := ParseTSNetEndpoint(tc.endpoint) + if ok != tc.ok || host != tc.host || port != tc.port || (err != nil) != tc.wantErr { + t.Errorf("ParseTSNetEndpoint(%q) = %q, %d, %v, %v; want %q, %d, %v, err=%v", + tc.endpoint, host, port, ok, err, tc.host, tc.port, tc.ok, tc.wantErr) + } + } +} + +// A tsnet listener with no auth key must fail with the reason, never +// quietly serve somewhere else: the whole point of the identity route +// is that it is the route the operator named. +func TestTSNetTransportRequiresAuthKey(t *testing.T) { + _, err := tsnetTransport{hostname: "mcpx-daemon", stateDir: t.TempDir()}.Listen() + if err == nil || !strings.Contains(err.Error(), TSNetAuthKeyEnv) { + t.Fatalf("Listen without auth key = %v; want an error naming %s", err, TSNetAuthKeyEnv) + } +} + +func TestTSNetDialerRequiresAuthKey(t *testing.T) { + d := &TSNetDialer{hostname: "mcpx-client"} + _, err := d.DialContext(context.Background(), "tcp", "laptop.tailnet.ts.net:80") + if err == nil || !strings.Contains(err.Error(), TSNetAuthKeyEnv) { + t.Fatalf("Dial without auth key = %v; want an error naming %s", err, TSNetAuthKeyEnv) + } +} + +// The tsnet node state directory is per-daemon, keyed by the +// configuration file set: two daemons sharing one state directory +// must not load each other's node keys (Codex finding on PR #2203), +// and one daemon's directory must not move when its configuration is +// reloaded or handed over -- the fingerprint is over the file set, +// not the contents. +func TestTSNetStateDirKeyedByConfig(t *testing.T) { + state := t.TempDir() + mk := func(sources ...string) *Server { + s := &Server{paths: Paths{State: state}} + if sources != nil { + s.cfg = &config.Config{Sources: sources} + } + return s + } + a := mk("/proj-a/.mcpx.json") + b := mk("/proj-b/.mcpx.json") + if a.tsnetStateDir() == b.tsnetStateDir() { + t.Fatalf("two configurations share a tsnet state dir: %s", a.tsnetStateDir()) + } + if again := mk("/proj-a/.mcpx.json"); again.tsnetStateDir() != a.tsnetStateDir() { + t.Fatalf("same configuration, different tsnet state dir: %s vs %s", again.tsnetStateDir(), a.tsnetStateDir()) + } + want := filepath.Join(state, "tsnet") + if got := mk().tsnetStateDir(); got != want { + t.Fatalf("no configuration: got %s, want the unkeyed %s", got, want) + } +} diff --git a/pkgs/mcpx/internal/daemon/transport_tsnet.go b/pkgs/mcpx/internal/daemon/transport_tsnet.go new file mode 100644 index 000000000..e6e60beda --- /dev/null +++ b/pkgs/mcpx/internal/daemon/transport_tsnet.go @@ -0,0 +1,328 @@ +package daemon + +import ( + "context" + "fmt" + "net" + "net/netip" + "net/url" + "os" + "strconv" + "strings" + "sync" + + "tailscale.com/ipn/store/mem" + "tailscale.com/tsnet" + + "github.com/dezren39/mcpx/internal/defaults" +) + +// Identity transport (#1967): a daemon reachable by its tailnet identity +// (a MagicDNS name) rather than by an address — from another network, +// with no port forwarded and no public IP. The decision doc +// (dezren39/nix planning/plans/mcpx-identity-transport.md) picked tsnet: +// it embeds a Tailscale node in this Go process, so mcpx stays a pure-Go +// CGO_ENABLED=0 binary; iroh via cgo was rejected there and is not +// revisited here. + +// TSNetAuthKeyEnv is where the tsnet transports read their Tailscale +// auth key: the standard variable name for tsnet programs. The key is a +// credential — it is never a setting, so it can never land in a config +// file, a settings dump, or this package's logs. +const TSNetAuthKeyEnv = "TS_AUTHKEY" + +// TSNetPort is the port the daemon's tsnet listener serves on, and the +// default port of a tsnet:// endpoint. +const TSNetPort = 80 + +// tsnetTransport serves the daemon API on a tailnet. StateDir holds the +// node's identity (its keys) across restarts, so the daemon keeps the +// same MagicDNS name. +type tsnetTransport struct { + hostname string + stateDir string + authKey string +} + +func (t tsnetTransport) Name() string { return "tsnet" } + +// tsnetUpTimeout bounds how long a node may take to authenticate and +// reach Running before its listener is believed. tsnet's Start is +// asynchronous: without this wait a daemon with an expired or revoked +// auth key would report startup success while its identity route never +// joins the tailnet. +var tsnetUpTimeout = defaults.TSNetUpTimeout + +func (t tsnetTransport) Listen() (net.Listener, error) { + node, err := newTSNetNode(t.hostname, t.stateDir, t.authKey, false) + if err != nil { + return nil, err + } + ln, err := node.Listen() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), tsnetUpTimeout) + defer cancel() + if _, err := node.server.Up(ctx); err != nil { + _ = ln.Close() + _ = node.Close() + return nil, fmt.Errorf("tsnet node %s did not join the tailnet within %s (auth key expired or revoked?): %w", t.hostname, tsnetUpTimeout, err) + } + return ln, nil +} + +// tsnetNode is a running tsnet server plus the listener it produced, so +// status queries can reach the node's local API. +type tsnetNode struct { + server *tsnet.Server +} + +func newTSNetNode(hostname, stateDir, authKey string, ephemeral bool) (*tsnetNode, error) { + if strings.TrimSpace(hostname) == "" { + return nil, fmt.Errorf("tsnet transport: no hostname: set daemon.tsnet.hostname to this daemon's tailnet name") + } + if authKey == "" { + return nil, fmt.Errorf("tsnet transport: %s is not set: the daemon cannot join the tailnet without an auth key, and it will not fall back to another route — create one in the Tailscale admin console and export it as %s", TSNetAuthKeyEnv, TSNetAuthKeyEnv) + } + srv := &tsnet.Server{ + Hostname: hostname, + Dir: stateDir, + AuthKey: authKey, + Ephemeral: ephemeral, + } + if ephemeral { + // An ephemeral client holds no state of its own, and an empty + // Dir does NOT give it one: tsnet then defaults to a + // file-backed store under the executable's config directory, + // so a later invocation with a different tailnet's key would + // silently stay attached to the first tailnet's node (an + // existing node makes tsnet ignore AuthKey). An in-memory + // store keeps each client process on exactly the tailnet its + // key names. + srv.Store = new(mem.Store) + } + return &tsnetNode{server: srv}, nil +} + +// Close shuts the node down, releasing its tailnet identity. +func (n *tsnetNode) Close() error { return n.server.Close() } + +// Listen serves the daemon's HTTP API on the node's tailnet address. +func (n *tsnetNode) Listen() (net.Listener, error) { + ln, err := n.server.Listen("tcp", ":"+strconv.Itoa(TSNetPort)) + if err != nil { + return nil, fmt.Errorf("tsnet listen %s: %w", n.server.Hostname, err) + } + return &tsnetListener{Listener: ln, node: n}, nil +} + +// tsnetListener keeps the node reachable from the listener the daemon +// holds, so status can ask it about peers. +type tsnetListener struct { + net.Listener + node *tsnetNode +} + +// CloseNode closes the listener and shuts the node down, releasing the +// tailnet identity so another process can take it over. +func (l *tsnetListener) CloseNode() error { + _ = l.Listener.Close() + return l.node.Close() +} + +// TSNetPeer is one tailnet peer as the status surface reports it: +// whether it is online, and whether traffic to it goes direct or via a +// DERP relay. +type TSNetPeer struct { + DNSName string + Online bool + Active bool + Connection string // "direct", "relayed", or "" when neither is known + Relay string // DERP region code when relayed +} + +// TSNetStatus is the tsnet half of daemon status: this node's identity +// and, per peer, direct-vs-relayed — the distinction that decides +// whether an identity endpoint is fast or merely reachable. +type TSNetStatus struct { + Hostname string + DNSName string + IPs []string + Peers []TSNetPeer +} + +// Status reads the node's local API for its identity and peer states. +func (l *tsnetListener) Status(ctx context.Context) (TSNetStatus, error) { + lc, err := l.node.server.LocalClient() + if err != nil { + return TSNetStatus{}, fmt.Errorf("tsnet status: %w", err) + } + st, err := lc.Status(ctx) + if err != nil { + return TSNetStatus{}, fmt.Errorf("tsnet status: %w", err) + } + out := TSNetStatus{Hostname: l.node.server.Hostname} + if st.Self != nil { + out.DNSName = strings.TrimSuffix(st.Self.DNSName, ".") + for _, ip := range st.Self.TailscaleIPs { + out.IPs = append(out.IPs, ip.String()) + } + } + for _, ps := range st.Peer { + peer := TSNetPeer{ + DNSName: strings.TrimSuffix(ps.DNSName, "."), + Online: ps.Online, + Active: ps.Active, + Relay: ps.Relay, + } + switch { + case ps.CurAddr != "": + peer.Connection = "direct" + case ps.Relay != "": + peer.Connection = "relayed" + } + out.Peers = append(out.Peers, peer) + } + return out, nil +} + +// ParseTSNetEndpoint parses a tsnet:// daemon endpoint — +// tsnet://[:] — into the host a client dials and +// the port. ok is false when the endpoint is not a tsnet one at all; a +// malformed tsnet endpoint is an error, never a silent reinterpretation +// as some other route. +func ParseTSNetEndpoint(endpoint string) (host string, port int, ok bool, err error) { + rest, isTSNet := strings.CutPrefix(endpoint, "tsnet://") + if !isTSNet { + return "", 0, false, nil + } + u, perr := url.Parse("tsnet://" + rest) + if perr != nil || u.Hostname() == "" { + return "", 0, true, fmt.Errorf("invalid tsnet endpoint %q: want tsnet://[:%d]", endpoint, TSNetPort) + } + port = TSNetPort + if p := u.Port(); p != "" { + n, cerr := strconv.Atoi(p) + if cerr != nil || n < 1 || n > 65535 { + return "", 0, true, fmt.Errorf("invalid tsnet endpoint %q: bad port %q", endpoint, p) + } + port = n + } + return u.Hostname(), port, true, nil +} + +// TSNetDialer dials a tsnet:// endpoint from a client process. The +// client joins the tailnet as an ephemeral node on first use: it exists +// only to reach the daemon, holds no state, and disappears with the +// process. Without an auth key the dial fails with the reason — a +// tsnet endpoint is never silently retried as a plain TCP address. +type TSNetDialer struct { + hostname string + authKey string + + once sync.Once + node *tsnetNode + err error + + joinOnce sync.Once + joinErr error +} + +// ensureJoined waits for the ephemeral node to join the tailnet, once, +// with the tsnet up budget (daemon.tsnetUpTimeout) rather than any +// single request's deadline. A cold join — registration plus auth — +// routinely takes longer than the client's short health-probe timeout +// (autostart.pingTimeout, 2s): bounding the first Up by the caller's +// context made Connect reject a healthy daemon before sending its real +// request, and every retry in a new process paid the same cold join +// because client state is in memory only. After a successful join the +// wait is a no-op; a failed join is reported once, with the reason. +func (d *TSNetDialer) ensureJoined() error { + d.joinOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), tsnetUpTimeout) + defer cancel() + if _, err := d.node.server.Up(ctx); err != nil { + d.joinErr = fmt.Errorf("tsnet client did not join the tailnet within %s: %w", tsnetUpTimeout, err) + } + }) + return d.joinErr +} + +// NewTSNetDialer builds a dialer for one client hostname (the name this +// client appears under on the tailnet). +func NewTSNetDialer(hostname string) *TSNetDialer { + return &TSNetDialer{hostname: hostname, authKey: envTSNetAuthKey()} +} + +func envTSNetAuthKey() string { + return strings.TrimSpace(os.Getenv(TSNetAuthKeyEnv)) +} + +// DialContext dials addr ("host:port", as an http.Transport hands it +// over) through the client's tsnet node. +func (d *TSNetDialer) DialContext(ctx context.Context, _, addr string) (net.Conn, error) { + d.once.Do(func() { + d.node, d.err = newTSNetNode(d.hostname, "", d.authKey, true) + }) + if d.err != nil { + return nil, d.err + } + if err := d.ensureJoined(); err != nil { + return nil, err + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("dial tsnet %s: %w", addr, err) + } + if err := d.verifyTailnetDestination(ctx, host); err != nil { + return nil, err + } + conn, err := d.node.server.Dial(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("dial tsnet %s: %w", addr, err) + } + return conn, nil +} + +// verifyTailnetDestination refuses to dial anything that is not a peer +// (or this client itself) on the authenticated tailnet. tsnet's Dial is +// NOT tailnet-only: for a destination that is not a peer it falls back +// to system DNS and an ordinary TCP dial, so tsnet://127.0.0.1:8080 — +// or a public name — would otherwise receive an unprotected request +// over a route the user explicitly chose for its identity. Failing +// closed with the reason is the only honest answer. +func (d *TSNetDialer) verifyTailnetDestination(ctx context.Context, host string) error { + st, err := d.node.server.Up(ctx) + if err != nil { + return fmt.Errorf("tsnet client did not join the tailnet: %w", err) + } + names := map[string]bool{} + ips := map[string]bool{} + add := func(dnsName string, tailIPs []netip.Addr) { + name := strings.TrimSuffix(strings.ToLower(dnsName), ".") + if name != "" { + names[name] = true + if short, _, ok := strings.Cut(name, "."); ok { + names[short] = true + } + } + for _, ip := range tailIPs { + ips[ip.String()] = true + } + } + if st.Self != nil { + add(st.Self.DNSName, st.Self.TailscaleIPs) + } + for _, ps := range st.Peer { + add(ps.DNSName, ps.TailscaleIPs) + } + if ip := net.ParseIP(host); ip != nil { + if ips[ip.String()] { + return nil + } + } else if names[strings.TrimSuffix(strings.ToLower(host), ".")] { + return nil + } + return fmt.Errorf("tsnet destination %q is not a peer on this tailnet: refusing to dial outside the authenticated tailnet", host) +} diff --git a/pkgs/mcpx/internal/defaults/defaults.go b/pkgs/mcpx/internal/defaults/defaults.go index 624cfaa8f..ee4a4ab42 100644 --- a/pkgs/mcpx/internal/defaults/defaults.go +++ b/pkgs/mcpx/internal/defaults/defaults.go @@ -47,8 +47,9 @@ type Defaults struct { Trace bool `json:"trace"` } `json:"logging"` Daemon struct { - ReapInterval string `json:"reapInterval"` - SaveInterval string `json:"saveInterval"` + ReapInterval string `json:"reapInterval"` + SaveInterval string `json:"saveInterval"` + TSNetUpTimeout string `json:"tsnetUpTimeout"` } `json:"daemon"` Secrets struct { RequestRetention string `json:"requestRetention"` @@ -475,8 +476,11 @@ var ( LogIncludes = builtin.Logging.Include LogTrace = builtin.Logging.Trace - ReapInterval = mustDur(builtin.Daemon.ReapInterval, "daemon.reapInterval") - SaveInterval = mustDur(builtin.Daemon.SaveInterval, "daemon.saveInterval") + ReapInterval = mustDur(builtin.Daemon.ReapInterval, "daemon.reapInterval") + SaveInterval = mustDur(builtin.Daemon.SaveInterval, "daemon.saveInterval") + // TSNetUpTimeout bounds how long a tsnet node may take to authenticate + // and reach Running before its listener is believed. + TSNetUpTimeout = mustDur(builtin.Daemon.TSNetUpTimeout, "daemon.tsnetUpTimeout") SecretRequestRetention = mustDur(builtin.Secrets.RequestRetention, "secrets.requestRetention") ShutdownGrace = mustDur(builtin.Plumbing.ShutdownGrace, "plumbing.shutdownGrace") diff --git a/pkgs/mcpx/internal/defaults/defaults.json b/pkgs/mcpx/internal/defaults/defaults.json index 7256fb8f4..8894f9a7e 100644 --- a/pkgs/mcpx/internal/defaults/defaults.json +++ b/pkgs/mcpx/internal/defaults/defaults.json @@ -26,7 +26,8 @@ }, "daemon": { "reapInterval": "30s", - "saveInterval": "5m" + "saveInterval": "5m", + "tsnetUpTimeout": "30s" }, "secrets": { "requestRetention": "10m" diff --git a/pkgs/mcpx/internal/settings/registry.go b/pkgs/mcpx/internal/settings/registry.go index babfa86ce..cdbb701d8 100644 --- a/pkgs/mcpx/internal/settings/registry.go +++ b/pkgs/mcpx/internal/settings/registry.go @@ -440,6 +440,20 @@ func daemonSettings() []Setting { Commands: []string{"run", "exec", "call", "ls", "types", "catalog", "search", "status", "client", "serve", "tui", "prompts", "resources"}, }, + { + Path: "daemon.tsnet.hostname", Kind: KindString, Default: "", + Scope: ScopeDaemon, + Commands: []string{"daemon", "status"}, + Name: "Tailnet hostname", + Short: "also serve the daemon on a tailnet, under this name", + Long: "Empty serves only the local socket and TCP port. A name " + + "joins a tailnet as that identity (tsnet) and serves the same " + + "API there, so the daemon is reachable by name from another " + + "network with no port forwarded. The node joins with the auth " + + "key in the TS_AUTHKEY environment variable; with no key the " + + "daemon refuses to start rather than silently serving only " + + "the local routes. Clients reach it with a tsnet:// endpoint.", + }, { Path: "daemon.address", Kind: KindString, Default: "127.0.0.1", Scope: ScopeDaemon, FlagAliases: []string{"address"}, diff --git a/pkgs/mcpx/package.nix b/pkgs/mcpx/package.nix index d35e19764..b3dbb368e 100644 --- a/pkgs/mcpx/package.nix +++ b/pkgs/mcpx/package.nix @@ -50,7 +50,7 @@ buildGo127Module { # # Everything else -- the MCP client, the process pools, the JSON Schema to # TypeScript compiler, the CLI -- is standard library. - vendorHash = "sha256-Nn3r2jL1K3swJRhTJUxhbHKeQ2cDfphW6IVuAwBP8do="; + vendorHash = "sha256-XTK49aropeKTGu/zlbjgtbT0BlktvUhsOfdpHrKoc5M="; subPackages = [ "cmd/mcpx" ]; diff --git a/treefmt.toml b/treefmt.toml index 8a69ceb9d..e56f5dc1b 100755 --- a/treefmt.toml +++ b/treefmt.toml @@ -9,6 +9,7 @@ excludes = [ "**/package-lock.json", # npm-generated; biome ignores it by default, so passing it in fails the run "pkgs/cold-store-reaper/worker/dist/**", # esbuild bundle output; deployed as built, formatting it would rewrite the artifact "pkgs/mcpx/docs/parity.md", # generated; TestParityDocumentIsCurrent compares it byte-for-byte with the generator, so prettier's table padding can never be applied + "pkgs/mcpx/docs/configuration.md", # generated section; TestTheDocumentationListsEverySetting compares it byte-for-byte with settings.Registry(), so prettier's table padding can never be applied "pkgs/mcpx/docs/spec/conformance-matrix.md", # generated; TestMatrix compares it byte-for-byte with the generator, so prettier's table padding can never be applied "pkgs/mcpx/docs/spec/requirements.md", # catalogue source; genreq parses its tables and TestMatrix hashes it, so prettier's table padding rewrites every row and can never be applied "pkgs/mcpx/internal/conformance/requirements.json", # generated by cmd/genreq; TestMatrix compares it byte-for-byte with the generator's Encode form, so biome's JSON formatting can never be applied