Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,14 +411,24 @@ 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
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.

Expand Down Expand Up @@ -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
Expand Down
71 changes: 55 additions & 16 deletions internal/cli/tun.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down
95 changes: 95 additions & 0 deletions internal/cli/tun_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
}

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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("<html>captive portal</html>"); err == nil {
t.Error("parseTrace accepted a page with no address in it")
}
}
69 changes: 69 additions & 0 deletions internal/cli/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
//
Expand Down
Loading