diff --git a/README.md b/README.md index f403f83..ab96d8a 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,8 @@ vpncli tun down ``` up through vpncli-ams3-0a910d (203.0.113.10, ams3) for 8m -interface utun5: 172.19.0.1/30, fdfe:dcba:9876::1/126, mtu 9000, up +interface utun5: 172.19.0.1/30, fdfe:dcba:9876::1/126, mtu 65535, up +exit 203.0.113.10 in NL, the server's address ``` The interface is found by the tunnel's own address, because its name is the @@ -419,6 +420,15 @@ system's choice. A sing-box running with no interface carrying that address is reported as routing nothing - which is what one started without root looks like, and otherwise looks exactly like a tunnel that works. +The exit line is where the internet thinks you are. Status asks Cloudflare's +trace endpoint, by IP so no DNS lookup goes anywhere, which address and country +the request arrived from, and checks the address against the server's. A +process and an interface only show that a tunnel exists; this shows it carrying +traffic. An exit that is not the server is a tunnel that is up and routing +nothing, and a lookup that times out is one that cannot reach the internet at +all. It is the one request status makes, so it takes a round trip rather than +being instant. + With no id it takes the most recently configured server, which after a provision or a rotation is the one you meant. @@ -533,7 +543,7 @@ vpncli server rotate 3 replace one with a fresh server vpncli server destroy 3 delete one and forget it vpncli tun up 3 route this machine through a server -vpncli tun status whether the tunnel is up, since when, and on which interface +vpncli tun status whether the tunnel is up, on which interface, and the exit country vpncli tun down stop it vpncli sync reconcile local state against the provider diff --git a/internal/cli/tun.go b/internal/cli/tun.go index ddf201c..e15cb78 100644 --- a/internal/cli/tun.go +++ b/internal/cli/tun.go @@ -104,14 +104,17 @@ func newTunStatusCommand() *cobra.Command { Use: "status", Short: "Say whether the tunnel is up", Long: `Report whether a tunnel is running, through which server, and since when, -and the network interface it created: its name, addresses, MTU and whether it -is up. - -It reads local state, the process list and the interface table, so it is -instant and works offline. A sing-box with no interface is reported as routing -nothing, which is what one started without root does. It does not check that -traffic is actually flowing - for that, ask something on the internet where it -thinks you are.`, +the network interface it created, and where traffic actually leaves from. + +The interface line gives its name, addresses, MTU and whether it is up. A +sing-box with no interface is reported as routing nothing, which is what one +started without root does. + +The exit line asks Cloudflare (` + exitTraceURL + `) which address +and country a request arrives from, and checks it against the server's. That +is the one request status makes, and it goes through the tunnel like everything +else; an exit that is not the server means the tunnel is up and carrying +nothing.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { t, err := newTunnel() @@ -222,24 +225,30 @@ func runTunStatus(ctx context.Context, out io.Writer, t *tunnel) error { return err } - if err := printTunServer(ctx, out, t, running); err != nil { + serverIP, err := printTunServer(ctx, out, t, running) + if err != nil { + return err + } + if err := printTunInterface(out, t); err != nil { return err } - return printTunInterface(out, t) + printTunExit(ctx, out, t, serverIP) + return nil } -// printTunServer says which server the tunnel goes through, and since when. -func printTunServer(ctx context.Context, out io.Writer, t *tunnel, running record) error { +// printTunServer says which server the tunnel goes through, and since when, +// and returns its address, or nothing when that is not known. +func printTunServer(ctx context.Context, out io.Writer, t *tunnel, running record) (string, error) { if running.Server == 0 { // Running, but not started by this command - so there is nothing to // say about which server or since when. fmt.Fprintf(out, "up, from a tunnel this command did not start (%s)\n", t.configPath()) - return nil + return "", nil } store, err := openStore() if err != nil { - return err + return "", err } defer store.Close() @@ -249,12 +258,42 @@ func printTunServer(ctx context.Context, out io.Writer, t *tunnel, running recor // is what destroying one from another terminal looks like. fmt.Fprintf(out, "up through server %d, which is no longer in local state, for %s\n", running.Server, took(time.Since(running.Started))) - return nil + return "", nil } fmt.Fprintf(out, "up through %s (%s, %s) for %s\n", srv.Name, srv.IPv4, srv.Region, took(time.Since(running.Started))) - return nil + return srv.IPv4, nil +} + +// printTunExit says where the internet thinks this machine is. It is the one +// line that shows traffic actually going through the tunnel rather than a +// process and an interface that merely exist: an exit address that is not the +// server's is a tunnel that is up and carrying nothing. +// +// A lookup that fails is reported rather than returned. The rest of the status +// is still true, and a tunnel that cannot reach the internet is worth seeing +// next to it, not instead of it. +func printTunExit(ctx context.Context, out io.Writer, t *tunnel, serverIP string) { + e, err := t.run.Locate(ctx) + if err != nil { + fmt.Fprintf(out, "exit unknown: %v\n", err) + return + } + + where := e.IP + if e.Country != "" { + where += " in " + e.Country + } + switch { + case serverIP == "": + fmt.Fprintf(out, "exit %s\n", where) + case e.IP == serverIP: + fmt.Fprintf(out, "exit %s, the server's address\n", where) + default: + fmt.Fprintf(out, "exit %s, which is not the server (%s): traffic is not going through the tunnel\n", + where, serverIP) + } } // printTunInterface describes the interface sing-box created. A running diff --git a/internal/cli/tun_test.go b/internal/cli/tun_test.go index b85c16b..b9c9653 100644 --- a/internal/cli/tun_test.go +++ b/internal/cli/tun_test.go @@ -36,12 +36,17 @@ type fakeRunner struct { // being none. link link linkErr error + + // exit is where traffic arrives from; exitErr is the lookup failing. + exit exit + exitErr error } func newFakeRunner() *fakeRunner { return &fakeRunner{ version: "sing-box version 1.14.0\n\nEnvironment: go1.26.7 darwin/arm64\n", link: link{Name: "utun9", MTU: 9000, Up: true, Addrs: []string{"172.19.0.1/30"}}, + exit: exit{IP: "203.0.113.10", Country: "DE"}, } } @@ -72,6 +77,8 @@ func (f *fakeRunner) Start(_ context.Context, _ io.Writer, name string, args ... return nil } +func (f *fakeRunner) Locate(context.Context) (exit, error) { return f.exit, f.exitErr } + func (f *fakeRunner) Interface(netip.Addr) (link, error) { return f.link, f.linkErr } func (f *fakeRunner) Matching(context.Context, string) ([]int, error) { return f.live, nil } @@ -644,3 +651,91 @@ func TestSystemInterfaceFindsAnAddress(t *testing.T) { t.Errorf("err = %v, want ErrNoInterface for an address nothing has", err) } } + +func tunStatus(t *testing.T, tn *tunnel) string { + t.Helper() + + var out bytes.Buffer + if err := runTunStatus(context.Background(), &out, tn); err != nil { + t.Fatalf("tun status: %v", err) + } + return out.String() +} + +func TestTunStatusSaysWhereTrafficLeaves(t *testing.T) { + tn, _ := tunneling(t) + if _, err := up(t, tn, 1, true); err != nil { + t.Fatalf("tun up: %v", err) + } + + want := "exit 203.0.113.10 in DE, the server's address" + if got := tunStatus(t, tn); !strings.Contains(got, want) { + t.Errorf("status = %q, want it to say %q", got, want) + } +} + +// Up, with an interface, and leaving from somewhere else: the tunnel is +// carrying nothing, and only asking the outside can tell. +func TestTunStatusCatchesTrafficNotInTheTunnel(t *testing.T) { + tn, f := tunneling(t) + if _, err := up(t, tn, 1, true); err != nil { + t.Fatalf("tun up: %v", err) + } + f.exit = exit{IP: "198.51.100.7", Country: "US"} + + got := tunStatus(t, tn) + for _, want := range []string{"198.51.100.7 in US", "not going through the tunnel"} { + if !strings.Contains(got, want) { + t.Errorf("status = %q, want it to mention %q", got, want) + } + } +} + +// A failed lookup is part of the status, not the end of it. +func TestTunStatusWhenTheExitCannotBeFound(t *testing.T) { + tn, f := tunneling(t) + if _, err := up(t, tn, 1, true); err != nil { + t.Fatalf("tun up: %v", err) + } + f.exitErr = errors.New("context deadline exceeded") + + got := tunStatus(t, tn) + for _, want := range []string{"up through", "interface utun9", "exit unknown: context deadline exceeded"} { + if !strings.Contains(got, want) { + t.Errorf("status = %q, want it to mention %q", got, want) + } + } +} + +// A tunnel started by hand has no server to compare against, so the exit is +// reported as it is. +func TestTunStatusExitWithoutAServer(t *testing.T) { + tn, f := tunneling(t) + f.live = []int{9001} + + got := tunStatus(t, tn) + if !strings.Contains(got, "exit 203.0.113.10 in DE\n") { + t.Errorf("status = %q, want the exit with nothing to compare it to", got) + } +} + +func TestParseTrace(t *testing.T) { + trace := "fl=522f106\nh=1.1.1.1\nip=209.38.105.249\nts=1790991942.000\ncolo=AMS\nloc=NL\ntls=TLSv1.3\n" + + got, err := parseTrace(trace) + if err != nil { + t.Fatalf("parseTrace: %v", err) + } + if want := (exit{IP: "209.38.105.249", Country: "NL"}); got != want { + t.Errorf("parseTrace = %+v, want %+v", got, want) + } + + got, err = parseTrace("ip=192.0.2.1\nloc=XX\n") + if err != nil || got.Country != "" { + t.Errorf("parseTrace with loc=XX = %+v, %v, want no country", got, err) + } + + if _, err := parseTrace("captive portal"); err == nil { + t.Error("parseTrace accepted a page with no address in it") + } +} diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index e4e1307..897950f 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "net/http" "net/netip" "os" "os/exec" @@ -65,6 +66,26 @@ type runner interface { // Interface finds the network interface carrying addr, or reports // ErrNoInterface. Interface(addr netip.Addr) (link, error) + // Locate asks the internet where this machine's traffic arrives from. + Locate(ctx context.Context) (exit, error) +} + +// exitTraceURL is asked where traffic leaves from. Cloudflare's trace answers +// with the address a request arrived from and the country that address is in, +// needs no account, and is reached by IP: there is no lookup to leak, and the +// answer is the IPv4 address, which is the one the server is recorded by. +const exitTraceURL = "https://1.1.1.1/cdn-cgi/trace" + +// exitTimeout bounds the lookup. Through a tunnel to another continent a +// round trip is a quarter of a second, so this only runs out on one that is +// not carrying anything. +const exitTimeout = 5 * time.Second + +// exit is where traffic arrives from, as the internet sees it. +type exit struct { + IP string + // Country is the ISO 3166 code, "NL", or empty when it is not known. + Country string } // ErrNoInterface is returned when no interface carries the tunnel's address. @@ -304,6 +325,54 @@ func (system) Interface(addr netip.Addr) (link, error) { return link{}, ErrNoInterface } +func (system) Locate(ctx context.Context) (exit, error) { + ctx, cancel := context.WithTimeout(ctx, exitTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, exitTraceURL, nil) + if err != nil { + return exit{}, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return exit{}, fmt.Errorf("asking %s: %w", exitTraceURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return exit{}, fmt.Errorf("asking %s: %s", exitTraceURL, resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return exit{}, fmt.Errorf("reading %s: %w", exitTraceURL, err) + } + return parseTrace(string(body)) +} + +// parseTrace reads Cloudflare's trace, which is key=value lines. +func parseTrace(body string) (exit, error) { + var e exit + for line := range strings.SplitSeq(body, "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch key { + case "ip": + e.IP = value + case "loc": + // XX is Cloudflare for not knowing. + if value != "XX" { + e.Country = value + } + } + } + if e.IP == "" { + return exit{}, fmt.Errorf("%s did not say where the request came from", exitTraceURL) + } + return e, nil +} + // usable checks that the client is installed and new enough to read what this // generates. //