omarchy-send/internal/tsproxy/tsproxy_test.go
28allday 7d6e339dcb Route tailnet traffic via tailscaled's SOCKS5 proxy on userspace boxes
Under tailscaled --tun=userspace-networking (no TUN device — the default
in unprivileged containers), processes cannot dial tailnet addresses at
all: inbound connections are proxied to loopback by tailscaled, but
outbound 100.64.0.0/10 dials have no route. omarchy-send could receive
on such boxes but never send, probe, or discover over the tailnet.

New internal/tsproxy auto-detects this: when no local interface carries
a tailnet address but a SOCKS5 proxy answers at localhost:1055 (the
conventional --socks5-server address), tailnet-bound connections route
through it. LAN traffic is never proxied; explicit HTTPS_PROXY/
HTTP_PROXY/NO_PROXY win over detection, restoring the env-var support
the custom transports had silently dropped.

Also guard NotePeer against the loopback trap the same proxying causes:
inbound registers all appear to come from 127.0.0.1, and recording that
over a peer's routable address made a TUI "send" loop back to our own
receiver while looking delivered.

Live-verified from an omaterm container (userspace tailscaled): headless
send discovered and messaged a tailnet peer with no env vars set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:29:14 +01:00

96 lines
2.9 KiB
Go

package tsproxy
import (
"net/http"
"net/url"
"testing"
"time"
)
func TestIsTailnetHost(t *testing.T) {
cases := []struct {
host string
want bool
}{
{"100.90.62.102", true}, // tailnet (CGNAT range)
{"100.64.0.1", true}, // range start
{"100.127.255.254", true} /* range end */, {"100.128.0.1", false}, // just past the /10
{"192.168.1.46", false}, // LAN
{"127.0.0.1", false}, // loopback
{"colossus", false}, // hostname, not an IP
{"", false},
}
for _, c := range cases {
if got := isTailnetHost(c.host); got != c.want {
t.Errorf("isTailnetHost(%q) = %v, want %v", c.host, got, c.want)
}
}
}
// stubEnvProxy replaces the env-var proxy lookup for a test. The stdlib's
// ProxyFromEnvironment caches the environment process-wide on first use, so
// t.Setenv can't drive these tests reliably.
func stubEnvProxy(t *testing.T, u *url.URL) {
t.Helper()
orig := envProxy
envProxy = func(*http.Request) (*url.URL, error) { return u, nil }
t.Cleanup(func() { envProxy = orig })
}
// Explicit proxy env vars must win over auto-detection, like the default
// transport.
func TestProxyFuncEnvOverride(t *testing.T) {
stubEnvProxy(t, &url.URL{Scheme: "socks5", Host: "127.0.0.1:9999"})
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr}) // detection would say 1055
req, _ := http.NewRequest(http.MethodGet, "https://100.90.62.102:53317/info", nil)
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u == nil || u.Host != "127.0.0.1:9999" {
t.Errorf("proxy = %v, want explicit socks5://127.0.0.1:9999", u)
}
}
// Non-tailnet destinations never get the auto-detected proxy.
func TestProxyFuncNonTailnetDirect(t *testing.T) {
stubEnvProxy(t, nil)
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr})
req, _ := http.NewRequest(http.MethodGet, "https://192.168.1.46:53317/info", nil)
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u != nil {
t.Errorf("proxy = %v, want nil (direct) for a LAN destination", u)
}
}
// A tailnet destination uses the cached detection result; with the cache
// primed to "proxy present" the URL comes back, and direct otherwise.
func TestProxyFuncTailnetUsesDetection(t *testing.T) {
stubEnvProxy(t, nil)
req, _ := http.NewRequest(http.MethodGet, "https://100.90.62.102:53317/info", nil)
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr})
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u == nil || u.Scheme != "socks5" || u.Host != conventionalAddr {
t.Errorf("proxy = %v, want socks5://%s", u, conventionalAddr)
}
prime(nil)
if u, _ := ProxyFunc(req); u != nil {
t.Errorf("proxy = %v, want nil when no proxy detected", u)
}
}
// prime seeds the detection cache for tests.
func prime(u *url.URL) {
mu.Lock()
defer mu.Unlock()
checked = time.Now()
cached = u
}