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>
This commit is contained in:
28allday 2026-06-06 13:29:14 +01:00
parent ff67bd6a71
commit 7d6e339dcb
6 changed files with 276 additions and 1 deletions

View file

@ -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 IP with nothing else to configure. Sending and receiving both work, because the
probe is a two-way handshake (each side learns the other). 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 #### Public-IP boxes: firewall the port
The receiver binds **all interfaces**, so on a box with a public IP, port `53317` The receiver binds **all interfaces**, so on a box with a public IP, port `53317`

View file

@ -27,6 +27,7 @@ import (
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/transfer" "omarchy-send/internal/transfer"
"omarchy-send/internal/tsproxy"
) )
// errOpen wraps a failure to open a source file, so the send loop can skip just // 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. // and waiting for response headers after the body is sent.
Timeout: 0, Timeout: 0,
Transport: &http.Transport{ 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}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,

View file

@ -18,6 +18,7 @@ import (
"omarchy-send/internal/dbg" "omarchy-send/internal/dbg"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/tsproxy"
) )
// EventKind distinguishes peer lifecycle events. // EventKind distinguishes peer lifecycle events.
@ -73,6 +74,11 @@ func New(self protocol.DeviceInfo) *Discoverer {
client: &http.Client{ client: &http.Client{
Timeout: 3 * time.Second, Timeout: 3 * time.Second,
Transport: &http.Transport{ 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}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}, },
}, },
@ -338,6 +344,12 @@ func (d *Discoverer) Probe(ctx context.Context, host string) error {
return lastErr 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. // 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. // It handles bare IPv6 by requiring the [::]:port form for a custom port.
func hostPort(host string) (string, int) { func hostPort(host string) (string, int) {
@ -361,7 +373,13 @@ func (d *Discoverer) NotePeer(info protocol.DeviceInfo, ip string) {
d.mu.Lock() d.mu.Lock()
prev, existed := d.peers[info.Fingerprint] 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.peers[info.Fingerprint] = peer
d.mu.Unlock() d.mu.Unlock()

View file

@ -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) { func TestHostPortDefaults(t *testing.T) {
if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort { if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort {
t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort) t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort)

111
internal/tsproxy/tsproxy.go Normal file
View file

@ -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
}

View file

@ -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
}