omarchy-send/internal/discovery/probe_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

129 lines
4.7 KiB
Go

package discovery
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"omarchy-send/internal/protocol"
)
// TestProbeRegistersPeer drives Probe against a stub /register that behaves like
// a real peer: it records the caller and returns its own DeviceInfo. The https
// attempt fails against the plain-http test server and Probe falls back to http.
func TestProbeRegistersPeer(t *testing.T) {
peerInfo := protocol.DeviceInfo{Alias: "Remote", Fingerprint: "remote-fp", Port: 53317, Protocol: "http"}
var sawOurInfo bool
mux := http.NewServeMux()
mux.HandleFunc("/api/localsend/v2/register", func(w http.ResponseWriter, r *http.Request) {
var in protocol.DeviceInfo
if err := json.NewDecoder(r.Body).Decode(&in); err == nil && in.Fingerprint == "self-fp" {
sawOurInfo = true
}
_ = json.NewEncoder(w).Encode(peerInfo)
})
srv := httptest.NewServer(mux)
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://") // host:port
d := New(protocol.DeviceInfo{Fingerprint: "self-fp", Alias: "Self"})
if err := d.Probe(context.Background(), host); err != nil {
t.Fatalf("Probe failed: %v", err)
}
if !sawOurInfo {
t.Error("peer did not receive our device info in the register body")
}
peers := d.Snapshot()
if len(peers) != 1 || peers[0].Info.Fingerprint != "remote-fp" {
t.Fatalf("peer not recorded as expected: %+v", peers)
}
if wantIP := strings.Split(host, ":")[0]; peers[0].IP != wantIP {
t.Errorf("peer IP = %q, want %q (the host we dialed)", peers[0].IP, wantIP)
}
}
// TestFindPeerViaProbe covers the headless-send path for remote peers: a peer
// that multicast can't see (here: only reachable by unicast Probe) must still
// satisfy a FindPeer that is already waiting — Probe → NotePeer → PeerFound.
func TestFindPeerViaProbe(t *testing.T) {
peerInfo := protocol.DeviceInfo{Alias: "titan-box", Fingerprint: "titan-fp", Port: 53317, Protocol: "http"}
mux := http.NewServeMux()
mux.HandleFunc("/api/localsend/v2/register", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(peerInfo)
})
srv := httptest.NewServer(mux)
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
d := New(protocol.DeviceInfo{Fingerprint: "self-fp", Alias: "Self"})
// Probe concurrently, like watchRemotes does while FindPeer waits.
go func() {
if err := d.Probe(context.Background(), host); err != nil {
t.Errorf("Probe failed: %v", err)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
got, err := d.FindPeer(ctx, func(p Peer) bool {
return strings.EqualFold(strings.TrimSpace(p.Info.Alias), "titan-box")
})
if err != nil {
t.Fatalf("FindPeer did not see the probed peer: %v", err)
}
if got.Info.Fingerprint != "titan-fp" {
t.Errorf("fingerprint = %q, want titan-fp", got.Info.Fingerprint)
}
if wantIP := strings.Split(host, ":")[0]; got.IP != wantIP {
t.Errorf("peer IP = %q, want %q (the host probed)", got.IP, wantIP)
}
}
func TestProbeUnreachableErrors(t *testing.T) {
d := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
// 127.0.0.1:1 — nothing listening; both https and http should fail fast.
if err := d.Probe(context.Background(), "127.0.0.1:1"); err == nil {
t.Fatal("expected an error probing an unreachable host")
}
}
// 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)
}
if h, p := hostPort("100.64.0.2:9999"); h != "100.64.0.2" || p != 9999 {
t.Errorf("hostPort(host:port) = %q,%d; want 100.64.0.2,9999", h, p)
}
}