diff --git a/README.md b/README.md index 113ac29..f1cd1f9 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,25 @@ The receiver already listens on all interfaces, so it's reachable at its Tailsca IP with nothing else to configure. Sending and receiving both work, because the probe is a two-way handshake (each side learns the other). +#### Containers / userspace-networking tailscaled + +When tailscaled runs with `--tun=userspace-networking` (typical in unprivileged +containers — no TUN device), processes cannot dial tailnet addresses directly; +outbound tailnet traffic only works through tailscaled's built-in SOCKS5 proxy. +omarchy-send handles this automatically: when the box has no tailnet address on +a local interface but a proxy answers at `localhost:1055`, connections to +`100.64.0.0/10` destinations are routed through it — LAN traffic is never +proxied, and explicit `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables override +the auto-detection. The one thing the box must provide is the proxy itself: + +```sh +tailscaled --tun=userspace-networking --socks5-server=localhost:1055 … +``` + +Note that inbound connections on such boxes appear to come from `127.0.0.1` +(tailscaled re-dials loopback); omarchy-send keeps a peer's routable address +rather than letting those registers overwrite it. + #### Public-IP boxes: firewall the port The receiver binds **all interfaces**, so on a box with a public IP, port `53317` diff --git a/internal/client/client.go b/internal/client/client.go index ac638a7..fd3fc0d 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -27,6 +27,7 @@ import ( "omarchy-send/internal/discovery" "omarchy-send/internal/protocol" "omarchy-send/internal/transfer" + "omarchy-send/internal/tsproxy" ) // errOpen wraps a failure to open a source file, so the send loop can skip just @@ -60,6 +61,10 @@ func New(self protocol.DeviceInfo) *Sender { // and waiting for response headers after the body is sent. Timeout: 0, Transport: &http.Transport{ + // Proxy env vars + tailnet SOCKS5 auto-detection (see + // discovery) so transfers also work from userspace-networking + // Tailscale boxes. + Proxy: tsproxy.ProxyFunc, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, TLSHandshakeTimeout: 10 * time.Second, diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index b419ca4..fc46689 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -18,6 +18,7 @@ import ( "omarchy-send/internal/dbg" "omarchy-send/internal/protocol" + "omarchy-send/internal/tsproxy" ) // EventKind distinguishes peer lifecycle events. @@ -73,6 +74,11 @@ func New(self protocol.DeviceInfo) *Discoverer { client: &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ + // Proxy env vars are honoured like the default transport, and + // tailnet destinations are auto-routed through the local + // tailscaled SOCKS5 proxy on userspace-networking boxes (no + // TUN), where direct outbound tailnet dials cannot work. + Proxy: tsproxy.ProxyFunc, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, }, @@ -338,6 +344,12 @@ func (d *Discoverer) Probe(ctx context.Context, host string) error { return lastErr } +// isLoopback reports whether ip parses as a loopback address (e.g. 127.0.0.1). +func isLoopback(ip string) bool { + p := net.ParseIP(ip) + return p != nil && p.IsLoopback() +} + // hostPort splits an optional :port off host, defaulting to the LocalSend port. // It handles bare IPv6 by requiring the [::]:port form for a custom port. func hostPort(host string) (string, int) { @@ -361,7 +373,13 @@ func (d *Discoverer) NotePeer(info protocol.DeviceInfo, ip string) { d.mu.Lock() prev, existed := d.peers[info.Fingerprint] - changed := !existed || prev.IP != ip || prev.Info.Alias != info.Alias + // Never downgrade a routable address to loopback: behind a + // userspace-networking tailscaled, inbound registers all appear to come + // from 127.0.0.1 — recording that would make us "reply" to ourselves. + if existed && isLoopback(ip) && !isLoopback(prev.IP) { + peer.IP = prev.IP + } + changed := !existed || prev.IP != peer.IP || prev.Info.Alias != info.Alias d.peers[info.Fingerprint] = peer d.mu.Unlock() diff --git a/internal/discovery/probe_test.go b/internal/discovery/probe_test.go index d7e239b..043a1f9 100644 --- a/internal/discovery/probe_test.go +++ b/internal/discovery/probe_test.go @@ -93,6 +93,32 @@ func TestProbeUnreachableErrors(t *testing.T) { } } +// A register arriving via a userspace-networking tailscaled proxy appears to +// come from 127.0.0.1; that must not overwrite a peer's known routable address +// (sending to it would loop back to our own receiver). +func TestNotePeerKeepsRoutableOverLoopback(t *testing.T) { + d := New(protocol.DeviceInfo{Fingerprint: "self-fp"}) + info := protocol.DeviceInfo{Alias: "gav", Fingerprint: "gav-fp", Port: 53317} + + d.NotePeer(info, "100.91.41.111") + d.NotePeer(info, "127.0.0.1") // inbound register through the local proxy + if got := d.Snapshot()[0].IP; got != "100.91.41.111" { + t.Errorf("IP downgraded to %q, want 100.91.41.111 kept", got) + } + + // A first sight at loopback is still recorded (nothing better known)… + d2 := New(protocol.DeviceInfo{Fingerprint: "self-fp"}) + d2.NotePeer(info, "127.0.0.1") + if got := d2.Snapshot()[0].IP; got != "127.0.0.1" { + t.Errorf("first-sight IP = %q, want 127.0.0.1", got) + } + // …and upgrades to the routable address as soon as one is learned. + d2.NotePeer(info, "100.91.41.111") + if got := d2.Snapshot()[0].IP; got != "100.91.41.111" { + t.Errorf("IP = %q, want upgrade to 100.91.41.111", got) + } +} + func TestHostPortDefaults(t *testing.T) { if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort { t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort) diff --git a/internal/tsproxy/tsproxy.go b/internal/tsproxy/tsproxy.go new file mode 100644 index 0000000..46003a7 --- /dev/null +++ b/internal/tsproxy/tsproxy.go @@ -0,0 +1,111 @@ +// Package tsproxy routes tailnet-bound HTTP connections through the local +// tailscaled SOCKS5 proxy when — and only when — the box needs it. On a box +// whose tailscaled runs with --tun=userspace-networking (e.g. an unprivileged +// container), there is no TUN interface, so ordinary outbound dials to +// 100.64.0.0/10 addresses cannot route; tailscaled's --socks5-server is the +// only outbound path. On a normal box the tailnet address is a local interface +// address and no proxy is involved. +package tsproxy + +import ( + "net" + "net/http" + "net/url" + "sync" + "time" + + "omarchy-send/internal/dbg" +) + +// conventionalAddr is where tailscaled's SOCKS5 proxy conventionally listens +// (--socks5-server=localhost:1055, the address used throughout Tailscale's +// userspace-networking docs). An explicit HTTPS_PROXY/HTTP_PROXY overrides it. +const conventionalAddr = "127.0.0.1:1055" + +// detectTTL bounds how long a detection result is trusted, so a tailscaled +// (re)started after us is picked up without restarting omarchy-send. +const detectTTL = 30 * time.Second + +// tailnetCIDR is Tailscale's CGNAT range; every tailnet IPv4 falls in it. +var tailnetCIDR = func() *net.IPNet { + _, n, _ := net.ParseCIDR("100.64.0.0/10") + return n +}() + +// envProxy resolves the proxy environment variables; a seam for tests (the +// stdlib caches the env process-wide on first use). +var envProxy = http.ProxyFromEnvironment + +// ProxyFunc is an http.Transport.Proxy implementation. Explicit proxy +// environment variables (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) always win, like the +// default transport; otherwise requests to tailnet addresses are routed via +// the local tailscaled SOCKS5 proxy when the box can't dial them directly. +func ProxyFunc(req *http.Request) (*url.URL, error) { + if u, err := envProxy(req); err != nil || u != nil { + return u, err + } + if isTailnetHost(req.URL.Hostname()) { + return detect(), nil + } + return nil, nil +} + +// isTailnetHost reports whether host is an IP in the Tailscale CGNAT range. +func isTailnetHost(host string) bool { + ip := net.ParseIP(host) + return ip != nil && tailnetCIDR.Contains(ip) +} + +var ( + mu sync.Mutex + checked time.Time + cached *url.URL // non-nil when tailnet dials must go through the proxy +) + +// detect decides (with a short cache) whether tailnet destinations need the +// local SOCKS5 proxy: only when no local interface carries a tailnet address +// (i.e. userspace networking — a TUN box can dial directly) AND something is +// listening at the conventional proxy address. +func detect() *url.URL { + mu.Lock() + defer mu.Unlock() + if time.Since(checked) < detectTTL { + return cached + } + checked = time.Now() + prev := cached + cached = nil + if !hasLocalTailnetAddr() && listening(conventionalAddr) { + cached = &url.URL{Scheme: "socks5", Host: conventionalAddr} + } + if (cached == nil) != (prev == nil) { + dbg.Logf("tsproxy: tailnet SOCKS5 proxy at %s: %v", conventionalAddr, cached != nil) + } + return cached +} + +// hasLocalTailnetAddr reports whether any local interface has a tailnet +// address — true on kernel-TUN tailscale boxes, false under userspace +// networking (the box's own tailnet IP is not a local address there). +func hasLocalTailnetAddr() bool { + addrs, err := net.InterfaceAddrs() + if err != nil { + return false + } + for _, a := range addrs { + if ipn, ok := a.(*net.IPNet); ok && tailnetCIDR.Contains(ipn.IP) { + return true + } + } + return false +} + +// listening reports whether a TCP listener answers at addr. +func listening(addr string) bool { + c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond) + if err != nil { + return false + } + _ = c.Close() + return true +} diff --git a/internal/tsproxy/tsproxy_test.go b/internal/tsproxy/tsproxy_test.go new file mode 100644 index 0000000..bfa3ef1 --- /dev/null +++ b/internal/tsproxy/tsproxy_test.go @@ -0,0 +1,96 @@ +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 +}