From ff4c1c4455b3c8bc36bff66b7b1700d9044cc814 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Tue, 12 May 2026 00:46:05 +0000 Subject: [PATCH 1/7] Add portal reverse proxy support --- cmd/aperture/main.go | 6 +- go.mod | 50 +++++- go.sum | 260 +++++++++++++++++++++++++++---- internal/config/global.go | 89 +++++++++-- internal/config/settings.go | 55 ++++++- internal/config/state_test.go | 50 +++++- internal/portals/manager.go | 226 +++++++++++++++++++++++++++ internal/portals/manager_test.go | 102 ++++++++++++ internal/tui/menus.go | 158 ++++++++++++++++--- internal/tui/tui.go | 257 ++++++++++++++++++++++++++---- internal/tui/tui_test.go | 37 ++++- 11 files changed, 1184 insertions(+), 106 deletions(-) create mode 100644 internal/portals/manager.go create mode 100644 internal/portals/manager_test.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 5489706..65f6f4f 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -13,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/portals" "github.com/tailscale/aperture-cli/internal/profiles" "github.com/tailscale/aperture-cli/internal/tui" @@ -128,7 +129,10 @@ func main() { // Register Claude Desktop on supported platforms (darwin, windows). profiles.RegisterIfSupported() - p := tea.NewProgram(tui.NewModel(g, buildVersion)) + portalManager := portals.NewManager(g.Debug) + defer portalManager.Close() + + p := tea.NewProgram(tui.NewModel(g, buildVersion, portalManager)) if _, err := p.Run(); err != nil { slog.Error("launcher error", "err", err) os.Exit(1) diff --git a/go.mod b/go.mod index 3a91b9f..00221c1 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,72 @@ module github.com/tailscale/aperture-cli -go 1.26 +go 1.26.2 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + tailscale.com v1.98.1 ) require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/creachadair/msync v0.7.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // 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/pires/go-proxyproto v0.8.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // 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-20260427181203-e3ac4a0afb4e // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.12.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-20260224225140-573d5e7127a8 // indirect ) diff --git a/go.sum b/go.sum index b9c4cd0..6de9b86 100644 --- a/go.sum +++ b/go.sum @@ -1,72 +1,268 @@ +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.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/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/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +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.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +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.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +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.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= 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/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -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.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +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/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA= +github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= +github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= +github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= +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.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +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/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +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/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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +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-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/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-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +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/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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/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.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +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/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +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/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= +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-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs= +github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= +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/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.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/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-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +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.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +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.98.1 h1:dEiQ3OqCzzcjTsP4m+CQTwz7ZdtdhxPbccY7AjUWno0= +tailscale.com v1.98.1/go.mod h1:6WwM2RnFW9gOQjdonp4c4QINm9odc1NlBQykKBncK/Q= diff --git a/internal/config/global.go b/internal/config/global.go index 17a3b98..065a36b 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -1,5 +1,10 @@ package config +import ( + "fmt" + "strings" +) + // Global is the live mutable app-level state threaded through the TUI and // every client package. It holds the current Aperture endpoint, the user's // persisted settings, the last-launch record, and the provider list fetched @@ -53,13 +58,24 @@ func (g *Global) SetYolo(on bool) error { return SaveSettings(g.Settings) } -// SetApertureHost rotates the given URL to the front of the endpoint list -// (adding it if missing), updates ApertureHost, and persists. -func (g *Global) SetApertureHost(url string) error { - g.ApertureHost = url - eps := []Endpoint{{URL: url}} +// ActiveEndpoint returns the persisted endpoint currently selected by the +// user. The runtime ApertureHost may differ for portal endpoints because it +// points at the local reverse proxy. +func (g *Global) ActiveEndpoint() Endpoint { + if len(g.Settings.Endpoints) == 0 { + return Endpoint{URL: DefaultLocation} + } + return g.Settings.Endpoints[0] +} + +// SetActiveEndpoint rotates the endpoint to the front of the endpoint list +// (adding it if missing), updates ApertureHost to the endpoint URL, and +// persists. Portal activation later rewrites ApertureHost to localhost. +func (g *Global) SetActiveEndpoint(ep Endpoint) error { + g.ApertureHost = ep.URL + eps := []Endpoint{ep} for _, ep := range g.Settings.Endpoints { - if ep.URL != url { + if !sameEndpoint(ep, eps[0]) { eps = append(eps, ep) } } @@ -67,15 +83,21 @@ func (g *Global) SetApertureHost(url string) error { return SaveSettings(g.Settings) } -// UpsertEndpoint appends the URL to the endpoint list if not already present, +// SetApertureHost rotates the direct URL to the front of the endpoint list +// (adding it if missing), updates ApertureHost, and persists. +func (g *Global) SetApertureHost(url string) error { + return g.SetActiveEndpoint(Endpoint{URL: url}) +} + +// UpsertEndpoint appends the endpoint to the endpoint list if not already present, // without changing which endpoint is active, and persists. -func (g *Global) UpsertEndpoint(url string) error { - for _, ep := range g.Settings.Endpoints { - if ep.URL == url { +func (g *Global) UpsertEndpoint(ep Endpoint) error { + for _, existing := range g.Settings.Endpoints { + if sameEndpoint(existing, ep) { return nil } } - g.Settings.Endpoints = append(g.Settings.Endpoints, Endpoint{URL: url}) + g.Settings.Endpoints = append(g.Settings.Endpoints, ep) return SaveSettings(g.Settings) } @@ -96,6 +118,51 @@ func (g *Global) RemoveEndpoint(idx int) error { return SaveSettings(g.Settings) } +// AddPortal creates, saves, and returns a portal with a generated stable ID. +func (g *Global) AddPortal(name string) (Portal, error) { + name = strings.TrimSpace(name) + if name == "" { + return Portal{}, fmt.Errorf("portal name is empty") + } + id, err := newPortalID(g.Settings.Portals) + if err != nil { + return Portal{}, err + } + p := Portal{ID: id, Name: name} + g.Settings.Portals = append(g.Settings.Portals, p) + if err := SaveSettings(g.Settings); err != nil { + return Portal{}, err + } + return p, nil +} + +// RemovePortal deletes a portal if no endpoint still references it. +func (g *Global) RemovePortal(id string) error { + for _, ep := range g.Settings.Endpoints { + if ep.PortalID == id { + return fmt.Errorf("portal is used by endpoint %s", ep.URL) + } + } + for i, p := range g.Settings.Portals { + if p.ID != id { + continue + } + g.Settings.Portals = append(g.Settings.Portals[:i], g.Settings.Portals[i+1:]...) + return SaveSettings(g.Settings) + } + return nil +} + +// Portal returns the configured portal with id. +func (g *Global) Portal(id string) (Portal, bool) { + for _, p := range g.Settings.Portals { + if p.ID == id { + return p, true + } + } + return Portal{}, false +} + // RecordLaunch stores the launch record to disk and updates the in-memory copy. func (g *Global) RecordLaunch(s LaunchState) error { g.LastLaunch = s diff --git a/internal/config/settings.go b/internal/config/settings.go index 9b6700d..2974bdf 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -5,9 +5,13 @@ package config import ( + "crypto/rand" + "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" + "strings" ) // DefaultLocation is the fallback Aperture endpoint URL used when the user @@ -16,11 +20,22 @@ const DefaultLocation = "http://ai" // Endpoint holds the URL and per-endpoint configuration for an Aperture proxy. type Endpoint struct { - URL string `json:"url"` + URL string `json:"url"` + PortalID string `json:"portalId,omitempty"` +} + +// Portal is an embedded tsnet node used to reach Aperture without requiring +// Tailscale to run on the host. +type Portal struct { + ID string `json:"id"` + Name string `json:"name"` } // Settings holds persistent launcher configuration managed by the user. type Settings struct { + // Portals is the set of embedded tsnet nodes the user has configured. + Portals []Portal `json:"portals,omitempty"` + // Endpoints is the ordered list of Aperture proxy endpoints. // The first entry is used as the active endpoint on startup. Endpoints []Endpoint `json:"endpoints,omitempty"` @@ -82,3 +97,41 @@ func defaultSettings() Settings { Endpoints: []Endpoint{{URL: DefaultLocation}}, } } + +// PortalStateDir returns the tsnet state directory for a portal ID. +func PortalStateDir(id string) (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + suffix := strings.TrimPrefix(id, "portal-") + if suffix == "" { + return "", fmt.Errorf("portal ID is empty") + } + return filepath.Join(dir, "aperture", "portals", suffix), nil +} + +func sameEndpoint(a, b Endpoint) bool { + return a.URL == b.URL && a.PortalID == b.PortalID +} + +func newPortalID(existing []Portal) (string, error) { + for range 10 { + var b [3]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + id := "portal-" + hex.EncodeToString(b[:]) + found := false + for _, p := range existing { + if p.ID == id { + found = true + break + } + } + if !found { + return id, nil + } + } + return "", fmt.Errorf("could not generate a unique portal ID") +} diff --git a/internal/config/state_test.go b/internal/config/state_test.go index 705a51b..6e7ecf7 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -75,9 +75,12 @@ func TestSettings_RoundTrip(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) want := config.Settings{ + Portals: []config.Portal{ + {ID: "portal-abcdef", Name: "Work"}, + }, Endpoints: []config.Endpoint{ {URL: "http://ai"}, - {URL: "http://aperture.example.com"}, + {URL: "http://aperture.example.com", PortalID: "portal-abcdef"}, }, YoloMode: true, } @@ -92,6 +95,12 @@ func TestSettings_RoundTrip(t *testing.T) { if len(got.Endpoints) != 2 || got.Endpoints[0].URL != "http://ai" { t.Errorf("endpoints = %+v", got.Endpoints) } + if len(got.Portals) != 1 || got.Portals[0].ID != "portal-abcdef" { + t.Errorf("portals = %+v", got.Portals) + } + if got.Endpoints[1].PortalID != "portal-abcdef" { + t.Errorf("portal endpoint = %+v", got.Endpoints[1]) + } if !got.YoloMode { t.Error("YoloMode = false, want true") } @@ -125,6 +134,45 @@ func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { } } +func TestGlobal_SetActiveEndpoint_DistinguishesPortal(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + g := &config.Global{ + Settings: config.Settings{ + Endpoints: []config.Endpoint{ + {URL: "http://ai"}, + {URL: "http://ai", PortalID: "portal-abcdef"}, + }, + }, + } + if err := g.SetActiveEndpoint(config.Endpoint{URL: "http://ai", PortalID: "portal-abcdef"}); err != nil { + t.Fatal(err) + } + if g.Settings.Endpoints[0].PortalID != "portal-abcdef" { + t.Errorf("front endpoint = %+v, want portal endpoint", g.Settings.Endpoints[0]) + } + if len(g.Settings.Endpoints) != 2 { + t.Errorf("endpoints len = %d, want 2", len(g.Settings.Endpoints)) + } +} + +func TestPortalStateDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + got, err := config.PortalStateDir("portal-abcdef") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(tmp, ".config", "aperture", "portals", "abcdef") + if got != want { + t.Errorf("PortalStateDir = %q, want %q", got, want) + } +} + func TestClientConfig_TypedStore(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) diff --git a/internal/portals/manager.go b/internal/portals/manager.go new file mode 100644 index 0000000..e90144d --- /dev/null +++ b/internal/portals/manager.go @@ -0,0 +1,226 @@ +// Package portals runs embedded tsnet reverse proxies for Aperture endpoints. +package portals + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + + "github.com/tailscale/aperture-cli/internal/config" + "tailscale.com/tsnet" +) + +// Manager owns active tsnet nodes and localhost reverse proxies. +type Manager struct { + mu sync.Mutex + + debug bool + nodes map[string]*nodeRuntime + + newNode func(portal config.Portal, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode +} + +type nodeRuntime struct { + node tailnetNode + proxies map[string]*proxyRuntime +} + +type proxyRuntime struct { + localURL string + server *http.Server + listener net.Listener +} + +type tailnetNode interface { + Up(context.Context) error + DialContext(context.Context, string, string) (net.Conn, error) + Close() error +} + +type tsnetNode struct { + server *tsnet.Server +} + +func (n *tsnetNode) Up(ctx context.Context) error { + _, err := n.server.Up(ctx) + return err +} + +func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return n.server.Dial(ctx, network, address) +} + +func (n *tsnetNode) Close() error { + return n.server.Close() +} + +// NewManager returns a portal manager. When debug is true, verbose tsnet +// backend logs are also emitted to the supplied activation log sink. +func NewManager(debug bool) *Manager { + m := &Manager{ + debug: debug, + nodes: make(map[string]*nodeRuntime), + } + m.newNode = func(portal config.Portal, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { + s := &tsnet.Server{ + Dir: stateDir, + Hostname: portal.ID, + UserLogf: userLogf, + } + if debug { + s.Logf = debugLogf + } + return &tsnetNode{server: s} + } + return m +} + +// Activate starts or reuses a portal reverse proxy for remoteURL and returns +// the localhost URL clients should use. +func (m *Manager) Activate(ctx context.Context, portal config.Portal, remoteURL string, logf func(string)) (string, error) { + if m == nil { + return "", fmt.Errorf("portal manager is not configured") + } + if logf == nil { + logf = func(string) {} + } + target, err := parseTarget(remoteURL) + if err != nil { + return "", err + } + + m.mu.Lock() + rt := m.nodes[portal.ID] + needUp := false + if rt == nil { + stateDir, err := config.PortalStateDir(portal.ID) + if err != nil { + m.mu.Unlock() + return "", err + } + userLogf := func(format string, args ...any) { + logf(fmt.Sprintf(format, args...)) + } + debugLogf := func(format string, args ...any) { + if m.debug { + logf(fmt.Sprintf(format, args...)) + } + } + node := m.newNode(portal, stateDir, userLogf, debugLogf) + rt = &nodeRuntime{ + node: node, + proxies: make(map[string]*proxyRuntime), + } + m.nodes[portal.ID] = rt + needUp = true + } + m.mu.Unlock() + + if needUp { + logf("Starting portal " + portal.Name + " (" + portal.ID + ")") + if err := rt.node.Up(ctx); err != nil { + m.mu.Lock() + if m.nodes[portal.ID] == rt { + delete(m.nodes, portal.ID) + } + m.mu.Unlock() + return "", err + } + logf("Portal connected.") + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.nodes[portal.ID] != rt { + return "", fmt.Errorf("portal stopped before activation completed") + } + key := target.String() + if proxy := rt.proxies[key]; proxy != nil { + return proxy.localURL, nil + } + + proxy, err := startProxy(rt.node, target) + if err != nil { + return "", err + } + rt.proxies[key] = proxy + logf("Listening on " + proxy.localURL) + return proxy.localURL, nil +} + +// Close shuts down all active reverse proxies and tsnet nodes. +func (m *Manager) Close() error { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + + var errs []error + for id, rt := range m.nodes { + for key, proxy := range rt.proxies { + if err := proxy.server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errs = append(errs, err) + } + if err := proxy.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + errs = append(errs, err) + } + delete(rt.proxies, key) + } + if err := rt.node.Close(); err != nil { + errs = append(errs, err) + } + delete(m.nodes, id) + } + return errors.Join(errs...) +} + +func parseTarget(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("endpoint URL is empty") + } + target, err := url.Parse(raw) + if err != nil { + return nil, err + } + if target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("endpoint URL must include scheme and host") + } + return target, nil +} + +func startProxy(node tailnetNode, target *url.URL) (*proxyRuntime, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = node.DialContext + + proxy := httputil.NewSingleHostReverseProxy(target) + director := proxy.Director + proxy.Director = func(req *http.Request) { + director(req) + req.Host = target.Host + } + proxy.Transport = transport + + srv := &http.Server{Handler: proxy} + go func() { + _ = srv.Serve(ln) + }() + + return &proxyRuntime{ + localURL: "http://" + ln.Addr().String(), + server: srv, + listener: ln, + }, nil +} diff --git a/internal/portals/manager_test.go b/internal/portals/manager_test.go new file mode 100644 index 0000000..dbbf629 --- /dev/null +++ b/internal/portals/manager_test.go @@ -0,0 +1,102 @@ +package portals + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +type fakeNode struct { + backendAddr string + up int + closed bool +} + +func (n *fakeNode) Up(context.Context) error { + n.up++ + return nil +} + +func (n *fakeNode) DialContext(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, n.backendAddr) +} + +func (n *fakeNode) Close() error { + n.closed = true + return nil +} + +func TestActivateStartsReverseProxy(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != "aperture.tailnet" { + t.Errorf("Host = %q, want aperture.tailnet", r.Host) + } + if r.URL.Path != "/api/providers" { + t.Errorf("path = %q, want /api/providers", r.URL.Path) + } + _, _ = w.Write([]byte(`[{"id":"anthropic"}]`)) + })) + defer backend.Close() + + var node *fakeNode + m := NewManager(false) + m.newNode = func(config.Portal, string, func(string, ...any), func(string, ...any)) tailnetNode { + node = &fakeNode{backendAddr: backend.Listener.Addr().String()} + return node + } + + var logs []string + localURL, err := m.Activate(context.Background(), config.Portal{ID: "portal-abcdef", Name: "Work"}, "http://aperture.tailnet", func(line string) { + logs = append(logs, line) + }) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(localURL, "http://127.0.0.1:") { + t.Fatalf("localURL = %q, want localhost URL", localURL) + } + + resp, err := http.Get(localURL + "/api/providers") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != `[{"id":"anthropic"}]` { + t.Errorf("body = %s", body) + } + if node.up != 1 { + t.Errorf("Up called %d times, want 1", node.up) + } + if len(logs) == 0 { + t.Error("expected activation logs") + } + + localURL2, err := m.Activate(context.Background(), config.Portal{ID: "portal-abcdef", Name: "Work"}, "http://aperture.tailnet", nil) + if err != nil { + t.Fatal(err) + } + if localURL2 != localURL { + t.Errorf("reused localURL = %q, want %q", localURL2, localURL) + } + if node.up != 1 { + t.Errorf("Up called %d times after reuse, want 1", node.up) + } + + if err := m.Close(); err != nil { + t.Fatal(err) + } + if !node.closed { + t.Error("node was not closed") + } +} diff --git a/internal/tui/menus.go b/internal/tui/menus.go index dda0b79..a88f7ba 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" ) @@ -105,6 +106,10 @@ func (m *model) settingsMenu() *menu.Menu { return &menu.Menu{ Title: "Settings", Items: []menu.MenuItem{ + { + Label: "Portals", + Action: func() menu.Result { return menu.Result{Next: m.portalsMenu()} }, + }, { Label: "Aperture Endpoints", Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, @@ -125,43 +130,84 @@ func (m *model) settingsMenu() *menu.Menu { } } +func (m *model) portalsMenu() *menu.Menu { + items := []menu.MenuItem{ + { + Label: "Portals connect Aperture through an embedded Tailscale node, so this host does not need tailscaled running.", + Disabled: true, + }, + } + for _, p := range m.g.Settings.Portals { + p := p + items = append(items, menu.MenuItem{ + Label: p.Name, + Description: p.ID, + Action: func() menu.Result { return menu.Result{} }, + }) + } + items = append(items, menu.MenuItem{ + Label: "add", + Shortcut: "a", + Hidden: true, + Action: func() menu.Result { + m.promptForInput("Add Portal:", "Name", func(v string) tea.Cmd { + if _, err := m.g.AddPortal(v); err != nil { + return func() tea.Msg { return menu.SimpleDoneMsg{Err: err} } + } + m.refreshPortalsMenu() + return nil + }) + return menu.Result{} + }, + }) + items = append(items, menu.MenuItem{ + Label: "delete", + Shortcut: "d", + Hidden: true, + Action: func() menu.Result { + idx := m.cursor() - 1 + if idx < 0 || idx >= len(m.g.Settings.Portals) { + return menu.Result{} + } + if err := m.g.RemovePortal(m.g.Settings.Portals[idx].ID); err != nil { + return errResult(err.Error()) + } + return menu.Result{Replace: m.portalsMenu()} + }, + }) + return &menu.Menu{ + Title: "Portals", + Items: items, + Hint: "d to remove · a to add · Esc to go back", + } +} + // endpointsMenu lists configured endpoints with add/delete affordances. // Selecting an entry rotates it to the front and re-runs preflight. func (m *model) endpointsMenu() *menu.Menu { items := make([]menu.MenuItem, 0, len(m.g.Settings.Endpoints)+3) for i, ep := range m.g.Settings.Endpoints { - url := ep.URL - label := url + ep := ep + label := m.endpointLabel(ep) if i == 0 { - label = greenStyle.Render(url + " (active)") + label = greenStyle.Render(label + " (active)") } items = append(items, menu.MenuItem{ Label: label, Action: func() menu.Result { - if err := m.g.SetApertureHost(url); err != nil { + if err := m.g.SetActiveEndpoint(ep); err != nil { return errResult(err.Error()) } - m.step = stepPreflight - return menu.Result{Cmd: runPreflight(url)} + return menu.Result{Cmd: m.activateEndpointCmd(ep)} }, }) } - // Hidden: "a" prompts for a new endpoint. Surfaced via the footer hint. + // Hidden: "a" opens the endpoint connection flow. Surfaced via the footer hint. items = append(items, menu.MenuItem{ Label: "add", Shortcut: "a", Hidden: true, - Action: func() menu.Result { - m.promptForInput("Add Endpoint:", "", func(v string) tea.Cmd { - _ = m.g.UpsertEndpoint(v) - if len(m.stack) > 0 { - m.stack[len(m.stack)-1] = m.endpointsMenu() - m.cursors[len(m.cursors)-1] = 0 - } - return nil - }) - return menu.Result{} - }, + Action: func() menu.Result { return menu.Result{Next: m.addEndpointConnectionMenu()} }, }) // Hidden: "d" deletes the row under the cursor. items = append(items, menu.MenuItem{ @@ -189,7 +235,7 @@ func (m *model) endpointsMenu() *menu.Menu { Hint: "Enter to select · d to remove · a to add · " + backHint, OnBack: func() tea.Cmd { if m.forcedToEndpoint { - return tea.Quit + return m.quitCmd() } m.popOne() return tea.ClearScreen @@ -197,6 +243,80 @@ func (m *model) endpointsMenu() *menu.Menu { } } +func (m *model) addEndpointConnectionMenu() *menu.Menu { + return &menu.Menu{ + Title: "Endpoint Connection", + Items: []menu.MenuItem{ + { + Label: "Direct", + Action: func() menu.Result { + m.promptForInput("Add Direct Endpoint:", "URL", func(v string) tea.Cmd { + _ = m.g.UpsertEndpoint(config.Endpoint{URL: strings.TrimSpace(v)}) + m.refreshEndpointsMenu() + return nil + }) + return menu.Result{} + }, + }, + { + Label: "Portal", + Action: func() menu.Result { return menu.Result{Next: m.endpointPortalMenu()} }, + }, + }, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) endpointPortalMenu() *menu.Menu { + if len(m.g.Settings.Portals) == 0 { + return &menu.Menu{ + Title: "Choose a portal", + Items: []menu.MenuItem{ + { + Label: "No portals configured.", + Disabled: true, + }, + { + Label: "Add Portal", + Action: func() menu.Result { return menu.Result{Next: m.portalsMenu()} }, + }, + }, + Hint: "Enter to add a portal · Esc to go back", + } + } + items := make([]menu.MenuItem, 0, len(m.g.Settings.Portals)) + for _, p := range m.g.Settings.Portals { + p := p + items = append(items, menu.MenuItem{ + Label: p.Name, + Description: p.ID, + Action: func() menu.Result { + m.promptForInput("Add Portal Endpoint:", "URL", func(v string) tea.Cmd { + _ = m.g.UpsertEndpoint(config.Endpoint{URL: strings.TrimSpace(v), PortalID: p.ID}) + m.refreshEndpointsMenu() + return nil + }) + return menu.Result{} + }, + }) + } + return &menu.Menu{ + Title: "Choose a portal", + Items: items, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) endpointLabel(ep config.Endpoint) string { + if ep.PortalID == "" { + return ep.URL + " (direct)" + } + if p, ok := m.g.Portal(ep.PortalID); ok { + return ep.URL + " via " + p.Name + } + return ep.URL + " via " + ep.PortalID +} + // installAgentsMenu lists uninstalled clients and confirms/runs each install. func (m *model) installAgentsMenu() *menu.Menu { var items []menu.MenuItem diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9267b7c..06340bc 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,6 +7,7 @@ package tui import ( + "context" "encoding/json" "fmt" "io" @@ -19,6 +20,7 @@ import ( "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" + "github.com/tailscale/aperture-cli/internal/portals" ) type step int @@ -45,17 +47,19 @@ var ( // NewModel returns the TUI model. g holds the persisted launcher state // (settings, endpoints, last launch). buildVersion is shown at the bottom // of the client picker. -func NewModel(g *config.Global, buildVersion string) tea.Model { +func NewModel(g *config.Global, buildVersion string, portalManager *portals.Manager) tea.Model { return &model{ - g: g, - buildVersion: buildVersion, - step: stepPreflight, + g: g, + buildVersion: buildVersion, + portalManager: portalManager, + step: stepPreflight, } } type model struct { - g *config.Global - buildVersion string + g *config.Global + buildVersion string + portalManager *portals.Manager step step @@ -81,10 +85,14 @@ type model struct { // Preflight state. preflightErr string forcedToEndpoint bool // true when preflight failure dropped user on endpoints menu + preflightLabel string + portalLogCh chan string + portalLogs []string + portalCancel context.CancelFunc } func (m *model) Init() tea.Cmd { - return runPreflight(m.g.ApertureHost) + return m.activateEndpointCmd(m.g.ActiveEndpoint()) } // preflightResult is emitted when the /api/providers check completes. @@ -94,30 +102,133 @@ type preflightResult struct { err error } +type endpointActivationResult struct { + endpoint config.Endpoint + host string + providers []config.ProviderInfo + err error +} + +type portalLogMsg string +type portalLogDoneMsg struct{} +type quitMsg struct{ Err error } + func runPreflight(host string) tea.Cmd { return func() tea.Msg { - client := &http.Client{Timeout: 10 * time.Second} - url := strings.TrimRight(host, "/") + "/api/providers" - resp, err := client.Get(url) - if err != nil { - return preflightResult{host: host, err: err} + provs, err := fetchProviders(host) + return preflightResult{host: host, providers: provs, err: err} + } +} + +func fetchProviders(host string) ([]config.ProviderInfo, error) { + client := &http.Client{Timeout: 10 * time.Second} + url := strings.TrimRight(host, "/") + "/api/providers" + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var provs []config.ProviderInfo + if err := json.Unmarshal(body, &provs); err != nil { + return nil, fmt.Errorf("could not parse providers response: %w", err) + } + return provs, nil +} + +func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { + m.step = stepPreflight + m.preflightErr = "" + m.portalLogs = nil + m.portalLogCh = nil + if m.portalCancel != nil { + m.portalCancel() + m.portalCancel = nil + } + + if ep.PortalID == "" { + m.preflightLabel = "Checking " + ep.URL + " ..." + return func() tea.Msg { + provs, err := fetchProviders(ep.URL) + return endpointActivationResult{endpoint: ep, host: ep.URL, providers: provs, err: err} } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return preflightResult{ - host: host, - err: fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url), + } + + portal, ok := m.g.Portal(ep.PortalID) + if !ok { + m.preflightLabel = "Checking " + ep.URL + " ..." + return func() tea.Msg { + return endpointActivationResult{ + endpoint: ep, + host: ep.URL, + err: fmt.Errorf("portal %s is not configured", ep.PortalID), } } - body, err := io.ReadAll(resp.Body) + } + if m.portalManager == nil { + return func() tea.Msg { + return endpointActivationResult{ + endpoint: ep, + host: ep.URL, + err: fmt.Errorf("portal manager is not configured"), + } + } + } + + ch := make(chan string, 32) + ctx, cancel := context.WithCancel(context.Background()) + m.portalLogCh = ch + m.portalCancel = cancel + m.preflightLabel = "Connecting portal " + portal.Name + " to " + ep.URL + " ..." + activate := func() tea.Msg { + defer cancel() + defer close(ch) + localURL, err := m.portalManager.Activate(ctx, portal, ep.URL, func(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + select { + case ch <- line: + default: + } + }) if err != nil { - return preflightResult{host: host, err: err} + return endpointActivationResult{endpoint: ep, host: ep.URL, err: err} } - var provs []config.ProviderInfo - if err := json.Unmarshal(body, &provs); err != nil { - return preflightResult{host: host, err: fmt.Errorf("could not parse providers response: %w", err)} + provs, err := fetchProviders(localURL) + return endpointActivationResult{endpoint: ep, host: localURL, providers: provs, err: err} + } + return tea.Batch(activate, waitPortalLog(ch)) +} + +func waitPortalLog(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return portalLogDoneMsg{} } - return preflightResult{host: host, providers: provs} + return portalLogMsg(line) + } +} + +func (m *model) quitCmd() tea.Cmd { + cancel := m.portalCancel + portalManager := m.portalManager + return func() tea.Msg { + if cancel != nil { + cancel() + } + if portalManager == nil { + return quitMsg{} + } + return quitMsg{Err: portalManager.Close()} } } @@ -139,18 +250,57 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.g.Providers = msg.providers m.preflightErr = "" m.forcedToEndpoint = false - // Ensure the active host is in the endpoint list and first. - _ = m.g.UpsertEndpoint(m.g.ApertureHost) m.step = stepMenu m.resetStack(m.rootMenu()) return m, tea.ClearScreen + case endpointActivationResult: + m.portalCancel = nil + if msg.err != nil { + m.preflightErr = msg.err.Error() + m.forcedToEndpoint = true + m.g.ApertureHost = msg.endpoint.URL + m.step = stepMenu + m.resetStack(m.endpointsMenu()) + return m, nil + } + m.g.ApertureHost = msg.host + m.g.Providers = msg.providers + m.preflightErr = "" + m.forcedToEndpoint = false + m.step = stepMenu + m.resetStack(m.rootMenu()) + return m, tea.ClearScreen + + case portalLogMsg: + m.portalLogs = append(m.portalLogs, string(msg)) + if len(m.portalLogs) > 12 { + m.portalLogs = m.portalLogs[len(m.portalLogs)-12:] + } + if m.portalLogCh != nil { + return m, waitPortalLog(m.portalLogCh) + } + return m, nil + + case portalLogDoneMsg: + m.portalLogCh = nil + return m, nil + + case quitMsg: + if msg.Err != nil { + m.errMsg = "Error shutting down portals: " + msg.Err.Error() + m.step = stepError + return m, nil + } + return m, tea.Quit + case menu.ExecDoneMsg: // A client's foreground launch has exited. Re-run preflight: the // user may have changed things outside the launcher while the // agent was running. m.popToRoot() m.step = stepPreflight + m.preflightLabel = "Checking " + m.g.ApertureHost + " ..." return m, runPreflight(m.g.ApertureHost) case menu.InstallDoneMsg: @@ -179,13 +329,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.step { case stepPreflight: if msg.String() == "ctrl+c" { - return m, tea.Quit + return m, m.quitCmd() } return m, nil case stepError: switch msg.String() { case "ctrl+c", "q": - return m, tea.Quit + return m, m.quitCmd() default: m.step = stepMenu return m, nil @@ -208,12 +358,12 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - return m, tea.Quit + return m, m.quitCmd() case "q": // "q" quits from the root only; on sub-menus it pops. if len(m.stack) <= 1 { - return m, tea.Quit + return m, m.quitCmd() } m.popOne() return m, tea.ClearScreen @@ -320,7 +470,7 @@ func (m *model) activate(idx int) (tea.Model, tea.Cmd) { func (m *model) applyResult(res menu.Result) (tea.Model, tea.Cmd) { switch { case res.Quit: - return m, tea.Quit + return m, m.quitCmd() case res.Pop: m.popOne() return m, tea.ClearScreen @@ -346,7 +496,7 @@ func (m *model) applyResult(res menu.Result) (tea.Model, tea.Cmd) { func (m *model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - return m, tea.Quit + return m, m.quitCmd() case "esc": m.step = stepMenu m.inputValue = "" @@ -380,7 +530,17 @@ func (m *model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) View() string { switch m.step { case stepPreflight: - return dotYellow + " Checking " + m.g.ApertureHost + " …\n" + label := m.preflightLabel + if label == "" { + label = "Checking " + m.g.ApertureHost + " ..." + } + var sb strings.Builder + sb.WriteString(dotYellow + " " + label + "\n") + for _, line := range m.portalLogs { + sb.WriteString(dimStyle.Render(" " + line)) + sb.WriteString("\n") + } + return sb.String() case stepError: var sb strings.Builder sb.WriteString(errorStyle.Render("Cannot launch")) @@ -662,6 +822,39 @@ func (m *model) resetStack(root *menu.Menu) { m.cursors = []int{0} } +func (m *model) refreshEndpointsMenu() { + m.refreshMenuByTitle(endpointsTitle, m.endpointsMenu()) +} + +func (m *model) refreshPortalsMenu() { + for i := range m.stack { + if m.stack[i].Title == "Choose a portal" { + m.stack[i] = m.endpointPortalMenu() + m.cursors[i] = 0 + } + } + m.refreshMenuByTitle("Portals", m.portalsMenu()) +} + +func (m *model) refreshMenuByTitle(title string, next *menu.Menu) { + for i := len(m.stack) - 1; i >= 0; i-- { + if m.stack[i].Title != title { + continue + } + m.stack = m.stack[:i+1] + m.cursors = m.cursors[:i+1] + m.stack[i] = next + m.cursors[i] = 0 + return + } + if len(m.stack) > 0 { + m.stack[len(m.stack)-1] = next + m.cursors[len(m.cursors)-1] = 0 + return + } + m.resetStack(next) +} + // --- Input step helpers --- // promptForInput sets up the single-line text input step. onSave is invoked diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 6669897..7ba465d 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -216,15 +216,44 @@ func TestSettingsMenu_ToggleYolo(t *testing.T) { m := &model{g: g, step: stepMenu} m.resetStack(m.settingsMenu()) - // YOLO is the 3rd item. - res := m.top().Items[2].Action() + idx := -1 + for i, it := range m.top().Items { + if strings.HasPrefix(it.Label, "YOLO mode:") { + idx = i + break + } + } + if idx == -1 { + t.Fatal("YOLO item not found") + } + res := m.top().Items[idx].Action() if !g.Settings.YoloMode { t.Error("YoloMode = false after toggle") } if res.Replace == nil { t.Fatal("toggle should replace menu in place") } - if !strings.Contains(res.Replace.Items[2].Label, "YOLO mode: on") { - t.Errorf("new label = %q", res.Replace.Items[2].Label) + if !strings.Contains(res.Replace.Items[idx].Label, "YOLO mode: on") { + t.Errorf("new label = %q", res.Replace.Items[idx].Label) + } +} + +func TestSettingsMenu_PortalsFirst(t *testing.T) { + m := &model{g: &config.Global{}, step: stepMenu} + menu := m.settingsMenu() + if len(menu.Items) == 0 || menu.Items[0].Label != "Portals" { + t.Fatalf("first settings item = %+v, want Portals", menu.Items) + } +} + +func TestEndpointLabel_ShowsPortal(t *testing.T) { + m := &model{g: &config.Global{ + Settings: config.Settings{ + Portals: []config.Portal{{ID: "portal-abcdef", Name: "Work"}}, + }, + }} + got := m.endpointLabel(config.Endpoint{URL: "http://ai", PortalID: "portal-abcdef"}) + if got != "http://ai via Work" { + t.Errorf("endpointLabel = %q", got) } } From f6b9484952024868d09eaa4b9851975b00ac2611 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Tue, 12 May 2026 00:56:17 +0000 Subject: [PATCH 2/7] Ensure portals close before exit --- cmd/aperture/main.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 65f6f4f..082d353 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -130,11 +130,18 @@ func main() { profiles.RegisterIfSupported() portalManager := portals.NewManager(g.Debug) - defer portalManager.Close() - p := tea.NewProgram(tui.NewModel(g, buildVersion, portalManager)) + + var exitCode int if _, err := p.Run(); err != nil { slog.Error("launcher error", "err", err) - os.Exit(1) + exitCode = 1 + } + if err := portalManager.Close(); err != nil { + slog.Error("shutting down portals", "err", err) + exitCode = 1 + } + if exitCode != 0 { + os.Exit(exitCode) } } From 99411b0466eb055d564353413b7bac4e6417f9ae Mon Sep 17 00:00:00 2001 From: Greg Wedow Date: Fri, 15 May 2026 09:04:22 -0400 Subject: [PATCH 3/7] tui: add setup guide for connection failures (#1) Show a diagnostic menu when preflight or endpoint activation fails, detecting Tailscale status to provide actionable guidance. Refactor manager_test.go and tailscale_test.go to table-driven subtests. --- internal/menu/menu.go | 8 +- internal/portals/manager_test.go | 214 +++++++++++++++++++++++-------- internal/tui/menus.go | 70 ++++++++-- internal/tui/tailscale.go | 66 ++++++++++ internal/tui/tailscale_test.go | 69 ++++++++++ internal/tui/tui.go | 15 ++- internal/tui/tui_test.go | 120 +++++++++++++++++ 7 files changed, 490 insertions(+), 72 deletions(-) create mode 100644 internal/tui/tailscale.go create mode 100644 internal/tui/tailscale_test.go diff --git a/internal/menu/menu.go b/internal/menu/menu.go index 3d1b135..9015cb2 100644 --- a/internal/menu/menu.go +++ b/internal/menu/menu.go @@ -49,8 +49,12 @@ type MenuItem struct { // Menu is a list of selectable items plus optional title and footer hint. type Menu struct { Title string - Items []MenuItem - Hint string + // Preamble is optional static text rendered (dimmed) between the title + // and the item list. Use it for informational paragraphs that are not + // selectable. + Preamble string + Items []MenuItem + Hint string // OnBack, when non-nil, overrides the default "pop stack one level" // behavior on Esc. Returning a nil tea.Cmd simply stays on this menu. OnBack func() tea.Cmd diff --git a/internal/portals/manager_test.go b/internal/portals/manager_test.go index dbbf629..b9d7e0e 100644 --- a/internal/portals/manager_test.go +++ b/internal/portals/manager_test.go @@ -33,70 +33,172 @@ func (n *fakeNode) Close() error { return nil } -func TestActivateStartsReverseProxy(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Host != "aperture.tailnet" { - t.Errorf("Host = %q, want aperture.tailnet", r.Host) - } - if r.URL.Path != "/api/providers" { - t.Errorf("path = %q, want /api/providers", r.URL.Path) - } - _, _ = w.Write([]byte(`[{"id":"anthropic"}]`)) - })) - defer backend.Close() - - var node *fakeNode - m := NewManager(false) - m.newNode = func(config.Portal, string, func(string, ...any), func(string, ...any)) tailnetNode { - node = &fakeNode{backendAddr: backend.Listener.Addr().String()} - return node - } +// activatedManager creates a Manager with a fake node wired to backend, +// activates the portal once, and returns everything tests need. +type activatedFixture struct { + manager *Manager + node *fakeNode + localURL string + logs []string +} - var logs []string - localURL, err := m.Activate(context.Background(), config.Portal{ID: "portal-abcdef", Name: "Work"}, "http://aperture.tailnet", func(line string) { - logs = append(logs, line) - }) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(localURL, "http://127.0.0.1:") { - t.Fatalf("localURL = %q, want localhost URL", localURL) +func activate(t *testing.T, backend *httptest.Server) activatedFixture { + t.Helper() + var f activatedFixture + f.manager = NewManager(false) + f.manager.newNode = func(_ config.Portal, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + f.node = &fakeNode{backendAddr: backend.Listener.Addr().String()} + return f.node } - resp, err := http.Get(localURL + "/api/providers") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + var err error + f.localURL, err = f.manager.Activate( + context.Background(), + config.Portal{ID: "portal-abcdef", Name: "Work"}, + "http://aperture.tailnet", + func(line string) { f.logs = append(f.logs, line) }, + ) if err != nil { t.Fatal(err) } - if string(body) != `[{"id":"anthropic"}]` { - t.Errorf("body = %s", body) - } - if node.up != 1 { - t.Errorf("Up called %d times, want 1", node.up) - } - if len(logs) == 0 { - t.Error("expected activation logs") - } + return f +} - localURL2, err := m.Activate(context.Background(), config.Portal{ID: "portal-abcdef", Name: "Work"}, "http://aperture.tailnet", nil) - if err != nil { - t.Fatal(err) - } - if localURL2 != localURL { - t.Errorf("reused localURL = %q, want %q", localURL2, localURL) - } - if node.up != 1 { - t.Errorf("Up called %d times after reuse, want 1", node.up) - } +func TestActivate(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "proxies requests to backend", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"id":"anthropic"}]`)) + })) + defer backend.Close() - if err := m.Close(); err != nil { - t.Fatal(err) + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/api/providers") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if got := string(body); got != `[{"id":"anthropic"}]` { + t.Errorf("body = %s, want %s", got, `[{"id":"anthropic"}]`) + } + }, + }, + { + name: "rewrites Host header to target", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != "aperture.tailnet" { + t.Errorf("Host = %q, want aperture.tailnet", r.Host) + } + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + }, + }, + { + name: "forwards request path", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/providers" { + t.Errorf("path = %q, want /api/providers", r.URL.Path) + } + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/api/providers") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + }, + }, + { + name: "returns localhost URL and calls Up once", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + if !strings.HasPrefix(f.localURL, "http://127.0.0.1:") { + t.Fatalf("localURL = %q, want http://127.0.0.1:... prefix", f.localURL) + } + if f.node.up != 1 { + t.Errorf("Up called %d times, want 1", f.node.up) + } + if len(f.logs) == 0 { + t.Error("expected activation logs") + } + }, + }, + { + name: "reuses existing portal without calling Up again", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + localURL2, err := f.manager.Activate( + context.Background(), + config.Portal{ID: "portal-abcdef", Name: "Work"}, + "http://aperture.tailnet", + nil, + ) + if err != nil { + t.Fatal(err) + } + if localURL2 != f.localURL { + t.Errorf("reused localURL = %q, want %q", localURL2, f.localURL) + } + if f.node.up != 1 { + t.Errorf("Up called %d times after reuse, want 1", f.node.up) + } + }, + }, + { + name: "Close shuts down node", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + + if err := f.manager.Close(); err != nil { + t.Fatal(err) + } + if !f.node.closed { + t.Error("node was not closed") + } + }, + }, } - if !node.closed { - t.Error("node was not closed") + + for _, tc := range tests { + t.Run(tc.name, tc.run) } } diff --git a/internal/tui/menus.go b/internal/tui/menus.go index a88f7ba..15422b0 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -13,8 +13,9 @@ import ( ) const ( - rootTitle = "Which editor do you want to use?" - endpointsTitle = "Aperture Endpoints" + rootTitle = "Which editor do you want to use?" + endpointsTitle = "Aperture Endpoints" + setupGuideTitle = "Getting Started" ) // rootMenu is the top-level client picker. It shows installed clients in @@ -224,18 +225,16 @@ func (m *model) endpointsMenu() *menu.Menu { }, }) - backHint := "Esc to go back" - if m.forcedToEndpoint { - backHint = "Esc to quit" - } - return &menu.Menu{ Title: endpointsTitle, Items: items, - Hint: "Enter to select · d to remove · a to add · " + backHint, + Hint: "Enter to select · d to remove · a to add · Esc to go back", OnBack: func() tea.Cmd { - if m.forcedToEndpoint { - return m.quitCmd() + if len(m.stack) <= 1 { + if m.forcedToEndpoint { + return m.quitCmd() + } + return nil } m.popOne() return tea.ClearScreen @@ -243,6 +242,57 @@ func (m *model) endpointsMenu() *menu.Menu { } } +// setupGuideMenu is shown when the preflight check fails. It diagnoses +// the user's Tailscale status and provides actionable guidance. +func (m *model) setupGuideMenu() *menu.Menu { + ts := checkTailscale() + + var preamble string + switch ts { + case tsNotInstalled: + preamble = "Aperture connects to your AI providers through Tailscale.\n\nTailscale is not installed.\nInstall it from: https://tailscale.com/download" + case tsNotRunning: + preamble = "Aperture connects to your AI providers through Tailscale.\n\nTailscale is installed but not running.\nStart Tailscale, then retry." + case tsNotConnected: + preamble = "Aperture connects to your AI providers through Tailscale.\n\nTailscale is not connected to a network.\nLog in with: tailscale up" + case tsConnected: + preamble = "Tailscale is connected.\n\nCould not reach Aperture at " + m.g.ApertureHost + ".\nEither:\n - set up an Aperture instance at https://aperture.tailscale.com/\n - or enter a different Aperture URL below" + } + + return &menu.Menu{ + Title: setupGuideTitle, + Preamble: preamble, + Items: []menu.MenuItem{ + { + Label: "Enter Aperture URL", + Action: func() menu.Result { + m.promptForInput("Aperture URL", "e.g. http://ai.example.com", func(v string) tea.Cmd { + v = strings.TrimSpace(v) + if !strings.Contains(v, "://") { + v = "http://" + v + } + _ = m.g.SetApertureHost(v) + return m.activateEndpointCmd(m.g.ActiveEndpoint()) + }) + return menu.Result{} + }, + }, + { + Label: "Retry connection", + Action: func() menu.Result { + return menu.Result{Cmd: m.activateEndpointCmd(m.g.ActiveEndpoint())} + }, + }, + { + Label: "Connection options", + Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, + }, + }, + Hint: "Enter to select · Esc to quit", + OnBack: func() tea.Cmd { return m.quitCmd() }, + } +} + func (m *model) addEndpointConnectionMenu() *menu.Menu { return &menu.Menu{ Title: "Endpoint Connection", diff --git a/internal/tui/tailscale.go b/internal/tui/tailscale.go new file mode 100644 index 0000000..47dea11 --- /dev/null +++ b/internal/tui/tailscale.go @@ -0,0 +1,66 @@ +package tui + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "runtime" + "time" +) + +type tailscaleStatus int + +const ( + tsNotInstalled tailscaleStatus = iota + tsNotRunning + tsNotConnected + tsConnected +) + +// checkTailscale probes the local Tailscale installation. Overridable for tests. +var checkTailscale = defaultCheckTailscale + +func defaultCheckTailscale() tailscaleStatus { + bin := findTailscaleBinary() + if bin == "" { + return tsNotInstalled + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "status", "--json").Output() + if err != nil { + return tsNotRunning + } + return parseTailscaleStatus(out) +} + +func findTailscaleBinary() string { + if p, err := exec.LookPath("tailscale"); err == nil { + return p + } + if runtime.GOOS == "darwin" { + const macApp = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + if _, err := os.Stat(macApp); err == nil { + return macApp + } + } + return "" +} + +func parseTailscaleStatus(data []byte) tailscaleStatus { + var status struct { + BackendState string `json:"BackendState"` + } + if err := json.Unmarshal(data, &status); err != nil { + return tsNotRunning + } + switch status.BackendState { + case "Running": + return tsConnected + case "NeedsLogin", "NeedsMachineAuth": + return tsNotConnected + default: + return tsNotRunning + } +} diff --git a/internal/tui/tailscale_test.go b/internal/tui/tailscale_test.go new file mode 100644 index 0000000..3233d09 --- /dev/null +++ b/internal/tui/tailscale_test.go @@ -0,0 +1,69 @@ +package tui + +import "testing" + +var statusName = map[tailscaleStatus]string{ + tsNotInstalled: "tsNotInstalled", + tsNotRunning: "tsNotRunning", + tsNotConnected: "tsNotConnected", + tsConnected: "tsConnected", +} + +func TestParseTailscaleStatus(t *testing.T) { + tests := []struct { + name string + input []byte + want tailscaleStatus + }{ + { + name: "Running", + input: []byte(`{"BackendState":"Running"}`), + want: tsConnected, + }, + { + name: "NeedsLogin", + input: []byte(`{"BackendState":"NeedsLogin"}`), + want: tsNotConnected, + }, + { + name: "NeedsMachineAuth", + input: []byte(`{"BackendState":"NeedsMachineAuth"}`), + want: tsNotConnected, + }, + { + name: "Stopped", + input: []byte(`{"BackendState":"Stopped"}`), + want: tsNotRunning, + }, + { + name: "Empty", + input: []byte(`{}`), + want: tsNotRunning, + }, + { + name: "InvalidJSON", + input: []byte(`not json`), + want: tsNotRunning, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseTailscaleStatus(tc.input) + if got != tc.want { + t.Errorf("got %s (%d), want %s (%d)", + statusName[got], got, statusName[tc.want], tc.want) + } + }) + } +} + +func TestStatusName(t *testing.T) { + // Verify the map covers all known constants. + for _, s := range []tailscaleStatus{tsNotInstalled, tsNotRunning, tsNotConnected, tsConnected} { + if _, ok := statusName[s]; !ok { + t.Errorf("statusName missing entry for %d", s) + } + } +} + diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 06340bc..c9b83ca 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -244,7 +244,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.preflightErr = msg.err.Error() m.forcedToEndpoint = true m.step = stepMenu - m.resetStack(m.endpointsMenu()) + m.resetStack(m.setupGuideMenu()) return m, nil } m.g.Providers = msg.providers @@ -261,7 +261,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.forcedToEndpoint = true m.g.ApertureHost = msg.endpoint.URL m.step = stepMenu - m.resetStack(m.endpointsMenu()) + m.resetStack(m.setupGuideMenu()) return m, nil } m.g.ApertureHost = msg.host @@ -579,6 +579,13 @@ func (m *model) viewMenu() string { sb.WriteString(titleStyle.Render(top.Title)) sb.WriteString("\n") } + if top.Preamble != "" { + for _, line := range strings.Split(top.Preamble, "\n") { + sb.WriteString(dimStyle.Render(" " + line)) + sb.WriteString("\n") + } + sb.WriteString("\n") + } cursor := m.cursor() tokens := assignTokens(top.Items) visible, twoCols, half := m.menuLayout(top) @@ -769,9 +776,9 @@ func (m *model) menuHeader(top *menu.Menu) string { } return header + "\n\n" } - if m.forcedToEndpoint && top.Title == endpointsTitle { + if m.forcedToEndpoint && (top.Title == endpointsTitle || top.Title == setupGuideTitle) { header := dotRed + " Could not reach " + m.g.ApertureHost + "\n" - if m.preflightErr != "" { + if m.preflightErr != "" && top.Title != setupGuideTitle { header += dimStyle.Render(" "+m.preflightErr) + "\n" } return header + "\n" diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7ba465d..e9e3ba4 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" @@ -246,6 +247,125 @@ func TestSettingsMenu_PortalsFirst(t *testing.T) { } } +// withFakeTailscale overrides checkTailscale for the duration of a test. +func withFakeTailscale(t *testing.T, status tailscaleStatus) { + t.Helper() + orig := checkTailscale + checkTailscale = func() tailscaleStatus { return status } + t.Cleanup(func() { checkTailscale = orig }) +} + +func TestSetupGuideMenu_TailscaleNotInstalled(t *testing.T) { + withFakeTailscale(t, tsNotInstalled) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + if guide.Title != setupGuideTitle { + t.Errorf("title = %q, want %q", guide.Title, setupGuideTitle) + } + if !strings.Contains(guide.Preamble, "tailscale.com/download") { + t.Error("preamble missing Tailscale download URL") + } + actionCount := 0 + for _, it := range guide.Items { + if it.Action != nil { + actionCount++ + } + } + if actionCount != 3 { + t.Errorf("actionable items = %d, want 3", actionCount) + } +} + +func TestSetupGuideMenu_TailscaleConnected(t *testing.T) { + withFakeTailscale(t, tsConnected) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + if !strings.Contains(guide.Preamble, "aperture.tailscale.com") { + t.Error("preamble missing Aperture provisioning URL") + } + if !strings.Contains(guide.Preamble, "Tailscale is connected") { + t.Error("preamble missing 'Tailscale is connected' message") + } +} + +func TestSetupGuideMenu_RetryAction(t *testing.T) { + withFakeTailscale(t, tsConnected) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + for _, it := range guide.Items { + if it.Label == "Retry connection" { + res := it.Action() + if res.Cmd == nil { + t.Error("Retry action returned nil Cmd") + } + return + } + } + t.Error("Retry connection item not found") +} + +func TestSetupGuideMenu_ConnectionOptionsAction(t *testing.T) { + withFakeTailscale(t, tsConnected) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + for _, it := range guide.Items { + if it.Label == "Connection options" { + res := it.Action() + if res.Next == nil || res.Next.Title != endpointsTitle { + t.Errorf("Connection options should push endpoints menu, got %+v", res.Next) + } + return + } + } + t.Error("Connection options item not found") +} + +func TestPreflightFailure_ShowsSetupGuide(t *testing.T) { + withFakeTailscale(t, tsNotInstalled) + withFakeClients(t, nil) + m := &model{ + g: &config.Global{ApertureHost: "http://ai"}, + step: stepPreflight, + } + m.Update(preflightResult{err: fmt.Errorf("connection refused")}) + if !m.forcedToEndpoint { + t.Error("forcedToEndpoint should be true") + } + if m.top() == nil || m.top().Title != setupGuideTitle { + title := "" + if m.top() != nil { + title = m.top().Title + } + t.Errorf("top menu title = %q, want %q", title, setupGuideTitle) + } +} + +func TestEndpointActivationFailure_ShowsSetupGuide(t *testing.T) { + withFakeTailscale(t, tsConnected) + withFakeClients(t, nil) + m := &model{ + g: &config.Global{ApertureHost: "http://ai"}, + } + m.Update(endpointActivationResult{ + endpoint: config.Endpoint{URL: "http://ai"}, + err: fmt.Errorf("timeout"), + }) + if !m.forcedToEndpoint { + t.Error("forcedToEndpoint should be true") + } + if m.top() == nil || m.top().Title != setupGuideTitle { + title := "" + if m.top() != nil { + title = m.top().Title + } + t.Errorf("top menu title = %q, want %q", title, setupGuideTitle) + } +} + func TestEndpointLabel_ShowsPortal(t *testing.T) { m := &model{g: &config.Global{ Settings: config.Settings{ From b55a0144b6aac33221da51ddf639aa42fed93ef0 Mon Sep 17 00:00:00 2001 From: Remy Guercio Date: Mon, 18 May 2026 21:32:07 -0500 Subject: [PATCH 4/7] cmd, internal: Updates portals to bridges. Signed-off-by: Remy Guercio --- cmd/aperture/main.go | 10 +- internal/{portals => bridges}/manager.go | 38 ++++---- internal/{portals => bridges}/manager_test.go | 12 +-- internal/config/global.go | 40 ++++---- internal/config/settings.go | 28 +++--- internal/config/state_test.go | 42 +++++---- internal/tui/menus.go | 58 ++++++------ internal/tui/tui.go | 92 +++++++++---------- internal/tui/tui_test.go | 12 +-- 9 files changed, 170 insertions(+), 162 deletions(-) rename internal/{portals => bridges}/manager.go (81%) rename internal/{portals => bridges}/manager_test.go (93%) diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 082d353..c396683 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -12,8 +12,8 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/bridges" "github.com/tailscale/aperture-cli/internal/config" - "github.com/tailscale/aperture-cli/internal/portals" "github.com/tailscale/aperture-cli/internal/profiles" "github.com/tailscale/aperture-cli/internal/tui" @@ -129,16 +129,16 @@ func main() { // Register Claude Desktop on supported platforms (darwin, windows). profiles.RegisterIfSupported() - portalManager := portals.NewManager(g.Debug) - p := tea.NewProgram(tui.NewModel(g, buildVersion, portalManager)) + bridgeManager := bridges.NewManager(g.Debug) + p := tea.NewProgram(tui.NewModel(g, buildVersion, bridgeManager)) var exitCode int if _, err := p.Run(); err != nil { slog.Error("launcher error", "err", err) exitCode = 1 } - if err := portalManager.Close(); err != nil { - slog.Error("shutting down portals", "err", err) + if err := bridgeManager.Close(); err != nil { + slog.Error("shutting down bridges", "err", err) exitCode = 1 } if exitCode != 0 { diff --git a/internal/portals/manager.go b/internal/bridges/manager.go similarity index 81% rename from internal/portals/manager.go rename to internal/bridges/manager.go index e90144d..2b6a705 100644 --- a/internal/portals/manager.go +++ b/internal/bridges/manager.go @@ -1,5 +1,5 @@ -// Package portals runs embedded tsnet reverse proxies for Aperture endpoints. -package portals +// Package bridges runs embedded tsnet reverse proxies for Aperture endpoints. +package bridges import ( "context" @@ -23,7 +23,7 @@ type Manager struct { debug bool nodes map[string]*nodeRuntime - newNode func(portal config.Portal, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode + newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode } type nodeRuntime struct { @@ -60,17 +60,17 @@ func (n *tsnetNode) Close() error { return n.server.Close() } -// NewManager returns a portal manager. When debug is true, verbose tsnet +// NewManager returns a bridge manager. When debug is true, verbose tsnet // backend logs are also emitted to the supplied activation log sink. func NewManager(debug bool) *Manager { m := &Manager{ debug: debug, nodes: make(map[string]*nodeRuntime), } - m.newNode = func(portal config.Portal, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { + m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { s := &tsnet.Server{ Dir: stateDir, - Hostname: portal.ID, + Hostname: bridge.ID, UserLogf: userLogf, } if debug { @@ -81,11 +81,11 @@ func NewManager(debug bool) *Manager { return m } -// Activate starts or reuses a portal reverse proxy for remoteURL and returns +// Activate starts or reuses a bridge reverse proxy for remoteURL and returns // the localhost URL clients should use. -func (m *Manager) Activate(ctx context.Context, portal config.Portal, remoteURL string, logf func(string)) (string, error) { +func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL string, logf func(string)) (string, error) { if m == nil { - return "", fmt.Errorf("portal manager is not configured") + return "", fmt.Errorf("bridge manager is not configured") } if logf == nil { logf = func(string) {} @@ -96,10 +96,10 @@ func (m *Manager) Activate(ctx context.Context, portal config.Portal, remoteURL } m.mu.Lock() - rt := m.nodes[portal.ID] + rt := m.nodes[bridge.ID] needUp := false if rt == nil { - stateDir, err := config.PortalStateDir(portal.ID) + stateDir, err := config.BridgeStateDir(bridge.ID) if err != nil { m.mu.Unlock() return "", err @@ -112,33 +112,33 @@ func (m *Manager) Activate(ctx context.Context, portal config.Portal, remoteURL logf(fmt.Sprintf(format, args...)) } } - node := m.newNode(portal, stateDir, userLogf, debugLogf) + node := m.newNode(bridge, stateDir, userLogf, debugLogf) rt = &nodeRuntime{ node: node, proxies: make(map[string]*proxyRuntime), } - m.nodes[portal.ID] = rt + m.nodes[bridge.ID] = rt needUp = true } m.mu.Unlock() if needUp { - logf("Starting portal " + portal.Name + " (" + portal.ID + ")") + logf("Starting bridge " + bridge.Name + " (" + bridge.ID + ")") if err := rt.node.Up(ctx); err != nil { m.mu.Lock() - if m.nodes[portal.ID] == rt { - delete(m.nodes, portal.ID) + if m.nodes[bridge.ID] == rt { + delete(m.nodes, bridge.ID) } m.mu.Unlock() return "", err } - logf("Portal connected.") + logf("Bridge connected.") } m.mu.Lock() defer m.mu.Unlock() - if m.nodes[portal.ID] != rt { - return "", fmt.Errorf("portal stopped before activation completed") + if m.nodes[bridge.ID] != rt { + return "", fmt.Errorf("bridge stopped before activation completed") } key := target.String() if proxy := rt.proxies[key]; proxy != nil { diff --git a/internal/portals/manager_test.go b/internal/bridges/manager_test.go similarity index 93% rename from internal/portals/manager_test.go rename to internal/bridges/manager_test.go index b9d7e0e..7b0fd5c 100644 --- a/internal/portals/manager_test.go +++ b/internal/bridges/manager_test.go @@ -1,4 +1,4 @@ -package portals +package bridges import ( "context" @@ -34,7 +34,7 @@ func (n *fakeNode) Close() error { } // activatedManager creates a Manager with a fake node wired to backend, -// activates the portal once, and returns everything tests need. +// activates the bridge once, and returns everything tests need. type activatedFixture struct { manager *Manager node *fakeNode @@ -46,7 +46,7 @@ func activate(t *testing.T, backend *httptest.Server) activatedFixture { t.Helper() var f activatedFixture f.manager = NewManager(false) - f.manager.newNode = func(_ config.Portal, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + f.manager.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { f.node = &fakeNode{backendAddr: backend.Listener.Addr().String()} return f.node } @@ -54,7 +54,7 @@ func activate(t *testing.T, backend *httptest.Server) activatedFixture { var err error f.localURL, err = f.manager.Activate( context.Background(), - config.Portal{ID: "portal-abcdef", Name: "Work"}, + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture.tailnet", func(line string) { f.logs = append(f.logs, line) }, ) @@ -155,7 +155,7 @@ func TestActivate(t *testing.T) { }, }, { - name: "reuses existing portal without calling Up again", + name: "reuses existing bridge without calling Up again", run: func(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer backend.Close() @@ -165,7 +165,7 @@ func TestActivate(t *testing.T) { localURL2, err := f.manager.Activate( context.Background(), - config.Portal{ID: "portal-abcdef", Name: "Work"}, + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture.tailnet", nil, ) diff --git a/internal/config/global.go b/internal/config/global.go index 065a36b..87585c7 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -59,7 +59,7 @@ func (g *Global) SetYolo(on bool) error { } // ActiveEndpoint returns the persisted endpoint currently selected by the -// user. The runtime ApertureHost may differ for portal endpoints because it +// user. The runtime ApertureHost may differ for bridge endpoints because it // points at the local reverse proxy. func (g *Global) ActiveEndpoint() Endpoint { if len(g.Settings.Endpoints) == 0 { @@ -70,7 +70,7 @@ func (g *Global) ActiveEndpoint() Endpoint { // SetActiveEndpoint rotates the endpoint to the front of the endpoint list // (adding it if missing), updates ApertureHost to the endpoint URL, and -// persists. Portal activation later rewrites ApertureHost to localhost. +// persists. Bridge activation later rewrites ApertureHost to localhost. func (g *Global) SetActiveEndpoint(ep Endpoint) error { g.ApertureHost = ep.URL eps := []Endpoint{ep} @@ -118,49 +118,49 @@ func (g *Global) RemoveEndpoint(idx int) error { return SaveSettings(g.Settings) } -// AddPortal creates, saves, and returns a portal with a generated stable ID. -func (g *Global) AddPortal(name string) (Portal, error) { +// AddBridge creates, saves, and returns a bridge with a generated stable ID. +func (g *Global) AddBridge(name string) (Bridge, error) { name = strings.TrimSpace(name) if name == "" { - return Portal{}, fmt.Errorf("portal name is empty") + return Bridge{}, fmt.Errorf("bridge name is empty") } - id, err := newPortalID(g.Settings.Portals) + id, err := newBridgeID(g.Settings.Bridges) if err != nil { - return Portal{}, err + return Bridge{}, err } - p := Portal{ID: id, Name: name} - g.Settings.Portals = append(g.Settings.Portals, p) + p := Bridge{ID: id, Name: name} + g.Settings.Bridges = append(g.Settings.Bridges, p) if err := SaveSettings(g.Settings); err != nil { - return Portal{}, err + return Bridge{}, err } return p, nil } -// RemovePortal deletes a portal if no endpoint still references it. -func (g *Global) RemovePortal(id string) error { +// RemoveBridge deletes a bridge if no endpoint still references it. +func (g *Global) RemoveBridge(id string) error { for _, ep := range g.Settings.Endpoints { - if ep.PortalID == id { - return fmt.Errorf("portal is used by endpoint %s", ep.URL) + if ep.BridgeID == id { + return fmt.Errorf("bridge is used by endpoint %s", ep.URL) } } - for i, p := range g.Settings.Portals { + for i, p := range g.Settings.Bridges { if p.ID != id { continue } - g.Settings.Portals = append(g.Settings.Portals[:i], g.Settings.Portals[i+1:]...) + g.Settings.Bridges = append(g.Settings.Bridges[:i], g.Settings.Bridges[i+1:]...) return SaveSettings(g.Settings) } return nil } -// Portal returns the configured portal with id. -func (g *Global) Portal(id string) (Portal, bool) { - for _, p := range g.Settings.Portals { +// Bridge returns the configured bridge with id. +func (g *Global) Bridge(id string) (Bridge, bool) { + for _, p := range g.Settings.Bridges { if p.ID == id { return p, true } } - return Portal{}, false + return Bridge{}, false } // RecordLaunch stores the launch record to disk and updates the in-memory copy. diff --git a/internal/config/settings.go b/internal/config/settings.go index 2974bdf..b113a55 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -21,20 +21,20 @@ const DefaultLocation = "http://ai" // Endpoint holds the URL and per-endpoint configuration for an Aperture proxy. type Endpoint struct { URL string `json:"url"` - PortalID string `json:"portalId,omitempty"` + BridgeID string `json:"bridgeId,omitempty"` } -// Portal is an embedded tsnet node used to reach Aperture without requiring +// Bridge is an embedded tsnet node used to reach Aperture without requiring // Tailscale to run on the host. -type Portal struct { +type Bridge struct { ID string `json:"id"` Name string `json:"name"` } // Settings holds persistent launcher configuration managed by the user. type Settings struct { - // Portals is the set of embedded tsnet nodes the user has configured. - Portals []Portal `json:"portals,omitempty"` + // Bridges is the set of embedded tsnet nodes the user has configured. + Bridges []Bridge `json:"bridges,omitempty"` // Endpoints is the ordered list of Aperture proxy endpoints. // The first entry is used as the active endpoint on startup. @@ -98,30 +98,30 @@ func defaultSettings() Settings { } } -// PortalStateDir returns the tsnet state directory for a portal ID. -func PortalStateDir(id string) (string, error) { +// BridgeStateDir returns the tsnet state directory for a bridge ID. +func BridgeStateDir(id string) (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", err } - suffix := strings.TrimPrefix(id, "portal-") + suffix := strings.TrimPrefix(id, "bridge-") if suffix == "" { - return "", fmt.Errorf("portal ID is empty") + return "", fmt.Errorf("bridge ID is empty") } - return filepath.Join(dir, "aperture", "portals", suffix), nil + return filepath.Join(dir, "aperture", "bridges", suffix), nil } func sameEndpoint(a, b Endpoint) bool { - return a.URL == b.URL && a.PortalID == b.PortalID + return a.URL == b.URL && a.BridgeID == b.BridgeID } -func newPortalID(existing []Portal) (string, error) { +func newBridgeID(existing []Bridge) (string, error) { for range 10 { var b [3]byte if _, err := rand.Read(b[:]); err != nil { return "", err } - id := "portal-" + hex.EncodeToString(b[:]) + id := "bridge-" + hex.EncodeToString(b[:]) found := false for _, p := range existing { if p.ID == id { @@ -133,5 +133,5 @@ func newPortalID(existing []Portal) (string, error) { return id, nil } } - return "", fmt.Errorf("could not generate a unique portal ID") + return "", fmt.Errorf("could not generate a unique bridge ID") } diff --git a/internal/config/state_test.go b/internal/config/state_test.go index 6e7ecf7..298cca5 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -42,7 +42,11 @@ func TestLaunchState_LegacyMigration(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) // Seed a launcher.json in the old shape that used lastProfileName. - dir := filepath.Join(tmp, ".config", "aperture") + cfgDir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(cfgDir, "aperture") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } @@ -75,12 +79,12 @@ func TestSettings_RoundTrip(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) want := config.Settings{ - Portals: []config.Portal{ - {ID: "portal-abcdef", Name: "Work"}, + Bridges: []config.Bridge{ + {ID: "bridge-abcdef", Name: "Work"}, }, Endpoints: []config.Endpoint{ {URL: "http://ai"}, - {URL: "http://aperture.example.com", PortalID: "portal-abcdef"}, + {URL: "http://aperture.example.com", BridgeID: "bridge-abcdef"}, }, YoloMode: true, } @@ -95,11 +99,11 @@ func TestSettings_RoundTrip(t *testing.T) { if len(got.Endpoints) != 2 || got.Endpoints[0].URL != "http://ai" { t.Errorf("endpoints = %+v", got.Endpoints) } - if len(got.Portals) != 1 || got.Portals[0].ID != "portal-abcdef" { - t.Errorf("portals = %+v", got.Portals) + if len(got.Bridges) != 1 || got.Bridges[0].ID != "bridge-abcdef" { + t.Errorf("bridges = %+v", got.Bridges) } - if got.Endpoints[1].PortalID != "portal-abcdef" { - t.Errorf("portal endpoint = %+v", got.Endpoints[1]) + if got.Endpoints[1].BridgeID != "bridge-abcdef" { + t.Errorf("bridge endpoint = %+v", got.Endpoints[1]) } if !got.YoloMode { t.Error("YoloMode = false, want true") @@ -134,7 +138,7 @@ func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { } } -func TestGlobal_SetActiveEndpoint_DistinguishesPortal(t *testing.T) { +func TestGlobal_SetActiveEndpoint_DistinguishesBridge(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) @@ -143,33 +147,37 @@ func TestGlobal_SetActiveEndpoint_DistinguishesPortal(t *testing.T) { Settings: config.Settings{ Endpoints: []config.Endpoint{ {URL: "http://ai"}, - {URL: "http://ai", PortalID: "portal-abcdef"}, + {URL: "http://ai", BridgeID: "bridge-abcdef"}, }, }, } - if err := g.SetActiveEndpoint(config.Endpoint{URL: "http://ai", PortalID: "portal-abcdef"}); err != nil { + if err := g.SetActiveEndpoint(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}); err != nil { t.Fatal(err) } - if g.Settings.Endpoints[0].PortalID != "portal-abcdef" { - t.Errorf("front endpoint = %+v, want portal endpoint", g.Settings.Endpoints[0]) + if g.Settings.Endpoints[0].BridgeID != "bridge-abcdef" { + t.Errorf("front endpoint = %+v, want bridge endpoint", g.Settings.Endpoints[0]) } if len(g.Settings.Endpoints) != 2 { t.Errorf("endpoints len = %d, want 2", len(g.Settings.Endpoints)) } } -func TestPortalStateDir(t *testing.T) { +func TestBridgeStateDir(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) - got, err := config.PortalStateDir("portal-abcdef") + got, err := config.BridgeStateDir("bridge-abcdef") + if err != nil { + t.Fatal(err) + } + cfgDir, err := os.UserConfigDir() if err != nil { t.Fatal(err) } - want := filepath.Join(tmp, ".config", "aperture", "portals", "abcdef") + want := filepath.Join(cfgDir, "aperture", "bridges", "abcdef") if got != want { - t.Errorf("PortalStateDir = %q, want %q", got, want) + t.Errorf("BridgeStateDir = %q, want %q", got, want) } } diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 15422b0..280d431 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -108,8 +108,8 @@ func (m *model) settingsMenu() *menu.Menu { Title: "Settings", Items: []menu.MenuItem{ { - Label: "Portals", - Action: func() menu.Result { return menu.Result{Next: m.portalsMenu()} }, + Label: "Bridges", + Action: func() menu.Result { return menu.Result{Next: m.bridgesMenu()} }, }, { Label: "Aperture Endpoints", @@ -131,14 +131,14 @@ func (m *model) settingsMenu() *menu.Menu { } } -func (m *model) portalsMenu() *menu.Menu { +func (m *model) bridgesMenu() *menu.Menu { items := []menu.MenuItem{ { - Label: "Portals connect Aperture through an embedded Tailscale node, so this host does not need tailscaled running.", + Label: "Bridges connect Aperture through an embedded Tailscale node, so this host does not need tailscaled running.", Disabled: true, }, } - for _, p := range m.g.Settings.Portals { + for _, p := range m.g.Settings.Bridges { p := p items = append(items, menu.MenuItem{ Label: p.Name, @@ -151,11 +151,11 @@ func (m *model) portalsMenu() *menu.Menu { Shortcut: "a", Hidden: true, Action: func() menu.Result { - m.promptForInput("Add Portal:", "Name", func(v string) tea.Cmd { - if _, err := m.g.AddPortal(v); err != nil { + m.promptForInput("Add Bridge:", "Name", func(v string) tea.Cmd { + if _, err := m.g.AddBridge(v); err != nil { return func() tea.Msg { return menu.SimpleDoneMsg{Err: err} } } - m.refreshPortalsMenu() + m.refreshBridgesMenu() return nil }) return menu.Result{} @@ -167,17 +167,17 @@ func (m *model) portalsMenu() *menu.Menu { Hidden: true, Action: func() menu.Result { idx := m.cursor() - 1 - if idx < 0 || idx >= len(m.g.Settings.Portals) { + if idx < 0 || idx >= len(m.g.Settings.Bridges) { return menu.Result{} } - if err := m.g.RemovePortal(m.g.Settings.Portals[idx].ID); err != nil { + if err := m.g.RemoveBridge(m.g.Settings.Bridges[idx].ID); err != nil { return errResult(err.Error()) } - return menu.Result{Replace: m.portalsMenu()} + return menu.Result{Replace: m.bridgesMenu()} }, }) return &menu.Menu{ - Title: "Portals", + Title: "Bridges", Items: items, Hint: "d to remove · a to add · Esc to go back", } @@ -309,40 +309,40 @@ func (m *model) addEndpointConnectionMenu() *menu.Menu { }, }, { - Label: "Portal", - Action: func() menu.Result { return menu.Result{Next: m.endpointPortalMenu()} }, + Label: "Bridge", + Action: func() menu.Result { return menu.Result{Next: m.endpointBridgeMenu()} }, }, }, Hint: "Enter to select · Esc to go back", } } -func (m *model) endpointPortalMenu() *menu.Menu { - if len(m.g.Settings.Portals) == 0 { +func (m *model) endpointBridgeMenu() *menu.Menu { + if len(m.g.Settings.Bridges) == 0 { return &menu.Menu{ - Title: "Choose a portal", + Title: "Choose a bridge", Items: []menu.MenuItem{ { - Label: "No portals configured.", + Label: "No bridges configured.", Disabled: true, }, { - Label: "Add Portal", - Action: func() menu.Result { return menu.Result{Next: m.portalsMenu()} }, + Label: "Add Bridge", + Action: func() menu.Result { return menu.Result{Next: m.bridgesMenu()} }, }, }, - Hint: "Enter to add a portal · Esc to go back", + Hint: "Enter to add a bridge · Esc to go back", } } - items := make([]menu.MenuItem, 0, len(m.g.Settings.Portals)) - for _, p := range m.g.Settings.Portals { + items := make([]menu.MenuItem, 0, len(m.g.Settings.Bridges)) + for _, p := range m.g.Settings.Bridges { p := p items = append(items, menu.MenuItem{ Label: p.Name, Description: p.ID, Action: func() menu.Result { - m.promptForInput("Add Portal Endpoint:", "URL", func(v string) tea.Cmd { - _ = m.g.UpsertEndpoint(config.Endpoint{URL: strings.TrimSpace(v), PortalID: p.ID}) + m.promptForInput("Add Bridge Endpoint:", "URL", func(v string) tea.Cmd { + _ = m.g.UpsertEndpoint(config.Endpoint{URL: strings.TrimSpace(v), BridgeID: p.ID}) m.refreshEndpointsMenu() return nil }) @@ -351,20 +351,20 @@ func (m *model) endpointPortalMenu() *menu.Menu { }) } return &menu.Menu{ - Title: "Choose a portal", + Title: "Choose a bridge", Items: items, Hint: "Enter to select · Esc to go back", } } func (m *model) endpointLabel(ep config.Endpoint) string { - if ep.PortalID == "" { + if ep.BridgeID == "" { return ep.URL + " (direct)" } - if p, ok := m.g.Portal(ep.PortalID); ok { + if p, ok := m.g.Bridge(ep.BridgeID); ok { return ep.URL + " via " + p.Name } - return ep.URL + " via " + ep.PortalID + return ep.URL + " via " + ep.BridgeID } // installAgentsMenu lists uninstalled clients and confirms/runs each install. diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c9b83ca..bf6e0c9 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -17,10 +17,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/tailscale/aperture-cli/internal/bridges" "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" - "github.com/tailscale/aperture-cli/internal/portals" ) type step int @@ -47,11 +47,11 @@ var ( // NewModel returns the TUI model. g holds the persisted launcher state // (settings, endpoints, last launch). buildVersion is shown at the bottom // of the client picker. -func NewModel(g *config.Global, buildVersion string, portalManager *portals.Manager) tea.Model { +func NewModel(g *config.Global, buildVersion string, bridgeManager *bridges.Manager) tea.Model { return &model{ g: g, buildVersion: buildVersion, - portalManager: portalManager, + bridgeManager: bridgeManager, step: stepPreflight, } } @@ -59,7 +59,7 @@ func NewModel(g *config.Global, buildVersion string, portalManager *portals.Mana type model struct { g *config.Global buildVersion string - portalManager *portals.Manager + bridgeManager *bridges.Manager step step @@ -86,9 +86,9 @@ type model struct { preflightErr string forcedToEndpoint bool // true when preflight failure dropped user on endpoints menu preflightLabel string - portalLogCh chan string - portalLogs []string - portalCancel context.CancelFunc + bridgeLogCh chan string + bridgeLogs []string + bridgeCancel context.CancelFunc } func (m *model) Init() tea.Cmd { @@ -109,8 +109,8 @@ type endpointActivationResult struct { err error } -type portalLogMsg string -type portalLogDoneMsg struct{} +type bridgeLogMsg string +type bridgeLogDoneMsg struct{} type quitMsg struct{ Err error } func runPreflight(host string) tea.Cmd { @@ -145,14 +145,14 @@ func fetchProviders(host string) ([]config.ProviderInfo, error) { func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { m.step = stepPreflight m.preflightErr = "" - m.portalLogs = nil - m.portalLogCh = nil - if m.portalCancel != nil { - m.portalCancel() - m.portalCancel = nil + m.bridgeLogs = nil + m.bridgeLogCh = nil + if m.bridgeCancel != nil { + m.bridgeCancel() + m.bridgeCancel = nil } - if ep.PortalID == "" { + if ep.BridgeID == "" { m.preflightLabel = "Checking " + ep.URL + " ..." return func() tea.Msg { provs, err := fetchProviders(ep.URL) @@ -160,36 +160,36 @@ func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { } } - portal, ok := m.g.Portal(ep.PortalID) + bridge, ok := m.g.Bridge(ep.BridgeID) if !ok { m.preflightLabel = "Checking " + ep.URL + " ..." return func() tea.Msg { return endpointActivationResult{ endpoint: ep, host: ep.URL, - err: fmt.Errorf("portal %s is not configured", ep.PortalID), + err: fmt.Errorf("bridge %s is not configured", ep.BridgeID), } } } - if m.portalManager == nil { + if m.bridgeManager == nil { return func() tea.Msg { return endpointActivationResult{ endpoint: ep, host: ep.URL, - err: fmt.Errorf("portal manager is not configured"), + err: fmt.Errorf("bridge manager is not configured"), } } } ch := make(chan string, 32) ctx, cancel := context.WithCancel(context.Background()) - m.portalLogCh = ch - m.portalCancel = cancel - m.preflightLabel = "Connecting portal " + portal.Name + " to " + ep.URL + " ..." + m.bridgeLogCh = ch + m.bridgeCancel = cancel + m.preflightLabel = "Connecting bridge " + bridge.Name + " to " + ep.URL + " ..." activate := func() tea.Msg { defer cancel() defer close(ch) - localURL, err := m.portalManager.Activate(ctx, portal, ep.URL, func(line string) { + localURL, err := m.bridgeManager.Activate(ctx, bridge, ep.URL, func(line string) { line = strings.TrimSpace(line) if line == "" { return @@ -205,30 +205,30 @@ func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { provs, err := fetchProviders(localURL) return endpointActivationResult{endpoint: ep, host: localURL, providers: provs, err: err} } - return tea.Batch(activate, waitPortalLog(ch)) + return tea.Batch(activate, waitBridgeLog(ch)) } -func waitPortalLog(ch <-chan string) tea.Cmd { +func waitBridgeLog(ch <-chan string) tea.Cmd { return func() tea.Msg { line, ok := <-ch if !ok { - return portalLogDoneMsg{} + return bridgeLogDoneMsg{} } - return portalLogMsg(line) + return bridgeLogMsg(line) } } func (m *model) quitCmd() tea.Cmd { - cancel := m.portalCancel - portalManager := m.portalManager + cancel := m.bridgeCancel + bridgeManager := m.bridgeManager return func() tea.Msg { if cancel != nil { cancel() } - if portalManager == nil { + if bridgeManager == nil { return quitMsg{} } - return quitMsg{Err: portalManager.Close()} + return quitMsg{Err: bridgeManager.Close()} } } @@ -255,7 +255,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen case endpointActivationResult: - m.portalCancel = nil + m.bridgeCancel = nil if msg.err != nil { m.preflightErr = msg.err.Error() m.forcedToEndpoint = true @@ -272,23 +272,23 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resetStack(m.rootMenu()) return m, tea.ClearScreen - case portalLogMsg: - m.portalLogs = append(m.portalLogs, string(msg)) - if len(m.portalLogs) > 12 { - m.portalLogs = m.portalLogs[len(m.portalLogs)-12:] + case bridgeLogMsg: + m.bridgeLogs = append(m.bridgeLogs, string(msg)) + if len(m.bridgeLogs) > 12 { + m.bridgeLogs = m.bridgeLogs[len(m.bridgeLogs)-12:] } - if m.portalLogCh != nil { - return m, waitPortalLog(m.portalLogCh) + if m.bridgeLogCh != nil { + return m, waitBridgeLog(m.bridgeLogCh) } return m, nil - case portalLogDoneMsg: - m.portalLogCh = nil + case bridgeLogDoneMsg: + m.bridgeLogCh = nil return m, nil case quitMsg: if msg.Err != nil { - m.errMsg = "Error shutting down portals: " + msg.Err.Error() + m.errMsg = "Error shutting down bridges: " + msg.Err.Error() m.step = stepError return m, nil } @@ -536,7 +536,7 @@ func (m *model) View() string { } var sb strings.Builder sb.WriteString(dotYellow + " " + label + "\n") - for _, line := range m.portalLogs { + for _, line := range m.bridgeLogs { sb.WriteString(dimStyle.Render(" " + line)) sb.WriteString("\n") } @@ -833,14 +833,14 @@ func (m *model) refreshEndpointsMenu() { m.refreshMenuByTitle(endpointsTitle, m.endpointsMenu()) } -func (m *model) refreshPortalsMenu() { +func (m *model) refreshBridgesMenu() { for i := range m.stack { - if m.stack[i].Title == "Choose a portal" { - m.stack[i] = m.endpointPortalMenu() + if m.stack[i].Title == "Choose a bridge" { + m.stack[i] = m.endpointBridgeMenu() m.cursors[i] = 0 } } - m.refreshMenuByTitle("Portals", m.portalsMenu()) + m.refreshMenuByTitle("Bridges", m.bridgesMenu()) } func (m *model) refreshMenuByTitle(title string, next *menu.Menu) { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e9e3ba4..62efca9 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -239,11 +239,11 @@ func TestSettingsMenu_ToggleYolo(t *testing.T) { } } -func TestSettingsMenu_PortalsFirst(t *testing.T) { +func TestSettingsMenu_BridgesFirst(t *testing.T) { m := &model{g: &config.Global{}, step: stepMenu} menu := m.settingsMenu() - if len(menu.Items) == 0 || menu.Items[0].Label != "Portals" { - t.Fatalf("first settings item = %+v, want Portals", menu.Items) + if len(menu.Items) == 0 || menu.Items[0].Label != "Bridges" { + t.Fatalf("first settings item = %+v, want Bridges", menu.Items) } } @@ -366,13 +366,13 @@ func TestEndpointActivationFailure_ShowsSetupGuide(t *testing.T) { } } -func TestEndpointLabel_ShowsPortal(t *testing.T) { +func TestEndpointLabel_ShowsBridge(t *testing.T) { m := &model{g: &config.Global{ Settings: config.Settings{ - Portals: []config.Portal{{ID: "portal-abcdef", Name: "Work"}}, + Bridges: []config.Bridge{{ID: "bridge-abcdef", Name: "Work"}}, }, }} - got := m.endpointLabel(config.Endpoint{URL: "http://ai", PortalID: "portal-abcdef"}) + got := m.endpointLabel(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}) if got != "http://ai via Work" { t.Errorf("endpointLabel = %q", got) } From 2722ae57fabe1ef56dd2cefe7b48187a1f6b2cba Mon Sep 17 00:00:00 2001 From: Greg Wedow Date: Tue, 19 May 2026 13:08:27 +0000 Subject: [PATCH 5/7] internal/clients/copilot: add GitHub Copilot CLI client Support Copilot CLI's BYOK mode to route through the Aperture gateway. Three backends: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, configured entirely via the COPILOT_PROVIDER_* env vars with COPILOT_OFFLINE=true to bypass GitHub auth. --- cmd/aperture/main.go | 1 + internal/clients/copilot/copilot.go | 281 +++++++++++++++++++++++ internal/clients/copilot/copilot_test.go | 129 +++++++++++ internal/clients/copilot/install.go | 17 ++ 4 files changed, 428 insertions(+) create mode 100644 internal/clients/copilot/copilot.go create mode 100644 internal/clients/copilot/copilot_test.go create mode 100644 internal/clients/copilot/install.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index c396683..cdc5327 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -20,6 +20,7 @@ import ( // Side-effect imports register each client with internal/clients. _ "github.com/tailscale/aperture-cli/internal/clients/claudecode" _ "github.com/tailscale/aperture-cli/internal/clients/codex" + _ "github.com/tailscale/aperture-cli/internal/clients/copilot" _ "github.com/tailscale/aperture-cli/internal/clients/gemini" _ "github.com/tailscale/aperture-cli/internal/clients/opencode" ) diff --git a/internal/clients/copilot/copilot.go b/internal/clients/copilot/copilot.go new file mode 100644 index 0000000..07f5531 --- /dev/null +++ b/internal/clients/copilot/copilot.go @@ -0,0 +1,281 @@ +// Package copilot is the GitHub Copilot CLI client. It supports three backend +// flavors — OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages — +// and configures routing entirely via environment variables (no config files). +package copilot + +import ( + "os/exec" + "slices" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +func init() { + clients.Register(&Client{}) +} + +// Client is the GitHub Copilot CLI client. +type Client struct{} + +const ( + name = "GitHub Copilot" + binaryName = "copilot" +) + +type backend struct { + id string + displayName string + compatKey string + providerType string + wireAPI string +} + +var backends = []backend{ + {id: "openai_chat", displayName: "OpenAI Chat Completions", compatKey: "openai_chat", providerType: "openai", wireAPI: "completions"}, + {id: "openai_responses", displayName: "OpenAI Responses", compatKey: "openai_responses", providerType: "openai", wireAPI: "responses"}, + {id: "anthropic", displayName: "Anthropic Messages", compatKey: "anthropic_messages", providerType: "anthropic"}, +} + +// Name implements clients.Client. +func (c *Client) Name() string { return name } + +// BinaryName implements clients.Client. +func (c *Client) BinaryName() string { return binaryName } + +// CommonPaths implements clients.Client. +func (c *Client) CommonPaths() []string { return commonBinaryPaths() } + +// IsInstalled implements clients.Client. +func (c *Client) IsInstalled() bool { + return clients.IsInstalled(binaryName, c.CommonPaths()) +} + +// Install implements clients.Client. +func (c *Client) Install(_ *config.Global) clients.InstallPlan { + return clients.InstallPlan{ + Hint: "npm install -g @github/copilot", + Run: func() (*exec.Cmd, error) { + return exec.Command("/bin/sh", "-c", "npm install -g @github/copilot"), nil + }, + } +} + +// Uninstall implements clients.Client. +func (c *Client) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{ + Hint: "npm uninstall -g @github/copilot", + Run: func() error { + return exec.Command("npm", "uninstall", "-g", "@github/copilot").Run() + }, + } +} + +// Menu implements clients.Client. +func (c *Client) Menu(g *config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: name, + Action: func() menu.Result { return c.providerStep(g) }, + } +} + +func (c *Client) providerStep(g *config.Global) menu.Result { + provs := compatibleProviders(g.Providers) + if len(provs) == 0 { + return errorResult("No providers support GitHub Copilot CLI.") + } + if len(provs) == 1 { + return c.backendStep(g, provs[0]) + } + items := make([]menu.MenuItem, 0, len(provs)) + for _, p := range provs { + items = append(items, menu.MenuItem{ + Label: p.DisplayName(), + Description: p.Description, + Action: func() menu.Result { return c.backendStep(g, p) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a provider for " + name + ":", + Items: items, + }} +} + +func (c *Client) backendStep(g *config.Global, p config.ProviderInfo) menu.Result { + bs := backendsFor(p) + if len(bs) == 0 { + return errorResult("No compatible backends for " + p.DisplayName() + ".") + } + if len(bs) == 1 { + return c.modelStep(g, p, bs[0]) + } + items := make([]menu.MenuItem, 0, len(bs)) + for _, b := range bs { + items = append(items, menu.MenuItem{ + Label: b.displayName, + Action: func() menu.Result { return c.modelStep(g, p, b) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a backend for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) modelStep(g *config.Global, p config.ProviderInfo, b backend) menu.Result { + models := fqnModels(p) + if len(models) <= 1 { + var m string + if len(models) == 1 { + m = models[0] + } + return c.launch(g, p, b, m) + } + items := make([]menu.MenuItem, 0, len(models)) + for _, m := range models { + items = append(items, menu.MenuItem{ + Label: m, + Action: func() menu.Result { return c.launch(g, p, b, m) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) launch(g *config.Global, p config.ProviderInfo, b backend, model string) menu.Result { + bin := clients.FindBinary(binaryName, c.CommonPaths()) + if bin == "" { + bin = binaryName + } + + env := buildEnv(g.ApertureHost, b, model) + + _ = g.RecordLaunch(config.LaunchState{ + LastClientName: name, + LastBackendType: b.id, + LastProviderID: p.ID, + LastModel: model, + }) + + cmd := clients.Launch(clients.LaunchSpec{ + Binary: bin, + Env: env, + Debug: g.Debug, + }) + return menu.Result{Cmd: cmd, PopOnDone: true} +} + +func buildEnv(apertureHost string, b backend, model string) map[string]string { + host := strings.TrimRight(apertureHost, "/") + env := map[string]string{ + "COPILOT_PROVIDER_TYPE": b.providerType, + "COPILOT_PROVIDER_API_KEY": "not-needed", + "COPILOT_OFFLINE": "true", + } + if b.providerType == "openai" { + env["COPILOT_PROVIDER_BASE_URL"] = host + "/v1" + } else { + env["COPILOT_PROVIDER_BASE_URL"] = host + } + if b.wireAPI != "" { + env["COPILOT_PROVIDER_WIRE_API"] = b.wireAPI + } + if model != "" { + env["COPILOT_MODEL"] = stripProviderPrefix(model) + } + return env +} + +// Replay implements clients.Client. +func (c *Client) Replay(g *config.Global) tea.Cmd { + if g.LastLaunch.LastClientName != name || !c.IsInstalled() { + return nil + } + prov, ok := g.Provider(g.LastLaunch.LastProviderID) + if !ok { + return nil + } + idx := slices.IndexFunc(backends, func(b backend) bool { + return b.id == g.LastLaunch.LastBackendType + }) + if idx < 0 { + return nil + } + b := backends[idx] + if !prov.Compatibility[b.compatKey] { + return nil + } + model := g.LastLaunch.LastModel + if model != "" && !slices.Contains(fqnModels(prov), model) { + return nil + } + res := c.launch(g, prov, b, model) + return res.Cmd +} + +// QuickSelectLabel implements clients.Client. +func (c *Client) QuickSelectLabel(g *config.Global) string { + prov, _ := g.Provider(g.LastLaunch.LastProviderID) + b := g.LastLaunch.LastBackendType + for _, bb := range backends { + if bb.id == b { + b = bb.displayName + break + } + } + label := name + " via " + prov.DisplayName() + " - " + b + if g.LastLaunch.LastModel != "" { + label += " - " + g.LastLaunch.LastModel + } + return label +} + +func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo { + var out []config.ProviderInfo + for _, p := range all { + if len(backendsFor(p)) > 0 { + out = append(out, p) + } + } + return out +} + +func backendsFor(p config.ProviderInfo) []backend { + var out []backend + for _, b := range backends { + if p.Compatibility[b.compatKey] { + out = append(out, b) + } + } + return out +} + +func fqnModels(p config.ProviderInfo) []string { + out := make([]string, len(p.Models)) + for i, m := range p.Models { + out[i] = p.ID + "/" + m + } + return out +} + +func stripProviderPrefix(fqn string) string { + if _, after, ok := strings.Cut(fqn, "/"); ok { + return after + } + return fqn +} + +func errorResult(msg string) menu.Result { + return menu.Result{Cmd: func() tea.Msg { + return menu.SimpleDoneMsg{Err: errString(msg)} + }} +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/clients/copilot/copilot_test.go b/internal/clients/copilot/copilot_test.go new file mode 100644 index 0000000..ec4f53f --- /dev/null +++ b/internal/clients/copilot/copilot_test.go @@ -0,0 +1,129 @@ +package copilot + +import ( + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const testHost = "http://ai.example.com" + +func TestBuildEnv_OpenAIChat(t *testing.T) { + b := backends[0] // openai_chat + env := buildEnv(testHost, b, "prov/gpt-5") + + want := map[string]string{ + "COPILOT_PROVIDER_BASE_URL": testHost + "/v1", + "COPILOT_PROVIDER_TYPE": "openai", + "COPILOT_PROVIDER_API_KEY": "not-needed", + "COPILOT_OFFLINE": "true", + "COPILOT_PROVIDER_WIRE_API": "completions", + "COPILOT_MODEL": "gpt-5", + } + for k, v := range want { + if env[k] != v { + t.Errorf("%s = %q, want %q", k, env[k], v) + } + } + if len(env) != len(want) { + t.Errorf("env has %d keys, want %d", len(env), len(want)) + } +} + +func TestBuildEnv_OpenAIResponses(t *testing.T) { + b := backends[1] // openai_responses + env := buildEnv(testHost, b, "") + + if env["COPILOT_PROVIDER_WIRE_API"] != "responses" { + t.Errorf("WIRE_API = %q, want responses", env["COPILOT_PROVIDER_WIRE_API"]) + } + if _, ok := env["COPILOT_MODEL"]; ok { + t.Error("COPILOT_MODEL should not be set when model is empty") + } + if len(env) != 5 { + t.Errorf("env has %d keys, want 5", len(env)) + } +} + +func TestBuildEnv_Anthropic(t *testing.T) { + b := backends[2] // anthropic + env := buildEnv(testHost, b, "prov/claude-sonnet-4") + + if env["COPILOT_PROVIDER_BASE_URL"] != testHost { + t.Errorf("BASE_URL = %q, want %q (no /v1 for anthropic)", env["COPILOT_PROVIDER_BASE_URL"], testHost) + } + if _, ok := env["COPILOT_PROVIDER_WIRE_API"]; ok { + t.Error("WIRE_API should not be set for anthropic") + } + if env["COPILOT_MODEL"] != "claude-sonnet-4" { + t.Errorf("COPILOT_MODEL = %q, want claude-sonnet-4", env["COPILOT_MODEL"]) + } + if len(env) != 5 { + t.Errorf("env has %d keys, want 5", len(env)) + } +} + +func TestBackendsFor(t *testing.T) { + t.Run("openai_both", func(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{ + "openai_chat": true, + "openai_responses": true, + }} + bs := backendsFor(p) + if len(bs) != 2 { + t.Errorf("backendsFor = %d, want 2", len(bs)) + } + }) + t.Run("anthropic_only", func(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"anthropic_messages": true}} + bs := backendsFor(p) + if len(bs) != 1 || bs[0].id != "anthropic" { + t.Errorf("backendsFor = %+v, want [anthropic]", bs) + } + }) + t.Run("bedrock_none", func(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"bedrock": true}} + bs := backendsFor(p) + if len(bs) != 0 { + t.Errorf("backendsFor = %+v, want empty", bs) + } + }) +} + +func TestCompatibleProviders(t *testing.T) { + provs := []config.ProviderInfo{ + {ID: "openai", Compatibility: map[string]bool{"openai_chat": true, "openai_responses": true}}, + {ID: "anthropic", Compatibility: map[string]bool{"anthropic_messages": true}}, + {ID: "bedrock", Compatibility: map[string]bool{"bedrock": true}}, + } + got := compatibleProviders(provs) + if len(got) != 2 { + t.Fatalf("compatibleProviders len = %d, want 2", len(got)) + } + if got[0].ID != "openai" || got[1].ID != "anthropic" { + t.Errorf("compatibleProviders = %v, want [openai, anthropic]", got) + } +} + +func TestFqnModels(t *testing.T) { + p := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5", "gpt-5-mini"}} + got := fqnModels(p) + want := []string{"openai/gpt-5", "openai/gpt-5-mini"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("fqnModels = %v, want %v", got, want) + } +} + +func TestStripProviderPrefix(t *testing.T) { + cases := map[string]string{ + "openai/gpt-5": "gpt-5", + "anthropic/claude-sonnet-4": "claude-sonnet-4", + "bare-model": "bare-model", + "provider/nested/model": "nested/model", + } + for in, want := range cases { + if got := stripProviderPrefix(in); got != want { + t.Errorf("stripProviderPrefix(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/clients/copilot/install.go b/internal/clients/copilot/install.go new file mode 100644 index 0000000..d0d8417 --- /dev/null +++ b/internal/clients/copilot/install.go @@ -0,0 +1,17 @@ +package copilot + +import ( + "os" + "path/filepath" +) + +func commonBinaryPaths() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{ + filepath.Join(home, ".local", "bin", "copilot"), + filepath.Join(home, ".npm-global", "bin", "copilot"), + } +} From f516f936f80a02b13f6f01b9c61d968b29bc8afc Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Tue, 19 May 2026 12:01:11 -0700 Subject: [PATCH 6/7] internal/bridges: add bridge.ID validation Validate the bridge ID fits the pattern: bridge-{hash} before joining the tailnet. This creates consistent hostnames like: aperture-cli-bridge-{hash} --- internal/bridges/manager.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index 2b6a705..1059cfe 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -70,7 +70,7 @@ func NewManager(debug bool) *Manager { m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { s := &tsnet.Server{ Dir: stateDir, - Hostname: bridge.ID, + Hostname: "aperture-cli-" + bridge.ID, UserLogf: userLogf, } if debug { @@ -87,6 +87,9 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL if m == nil { return "", fmt.Errorf("bridge manager is not configured") } + if err := validateBridgeID(bridge.ID); err != nil { + return "", err + } if logf == nil { logf = func(string) {} } @@ -181,6 +184,25 @@ func (m *Manager) Close() error { return errors.Join(errs...) } +// validateBridgeID rejects IDs that don't match the system-generated +// "bridge-" format, so a hand-edited config can't inject arbitrary +// content into the tailnet hostname. +func validateBridgeID(id string) error { + suffix, ok := strings.CutPrefix(id, "bridge-") + if !ok || suffix == "" { + return fmt.Errorf("invalid bridge ID %q", id) + } + if len(suffix) > 64 { + return fmt.Errorf("invalid bridge ID %q", id) + } + for _, r := range suffix { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return fmt.Errorf("invalid bridge ID %q", id) + } + } + return nil +} + func parseTarget(raw string) (*url.URL, error) { raw = strings.TrimSpace(raw) if raw == "" { From d2aee48805c21af17b81008760a33eb65fa339c0 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Tue, 19 May 2026 12:07:45 -0700 Subject: [PATCH 7/7] internal/tui: gofmt --- internal/tui/tailscale_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/tailscale_test.go b/internal/tui/tailscale_test.go index 3233d09..a3c83c2 100644 --- a/internal/tui/tailscale_test.go +++ b/internal/tui/tailscale_test.go @@ -66,4 +66,3 @@ func TestStatusName(t *testing.T) { } } } -