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
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,18 @@ vpncli sync
`sync` treats the provider as the source of truth. Rows for servers that no
longer exist are dropped, drifted addresses and statuses are corrected, and
servers tagged `vpncli` that local state has never seen are adopted - which is
how a second machine, or a run that died mid-provision, is picked back up.
how a run that died mid-provision is picked back up, and how a second machine
gets to see, rotate and destroy what the first one made.
Untagged servers are left alone, because that listing covers the whole account.

Adopting a server is not the same as being able to connect to it. Its REALITY
keys were generated by the machine that bootstrapped it and live only in that
machine's state file, so on any other machine an adopted server has no keys.
`vpncli server bootstrap` would give it some, but they are new keys and they
replace the old ones, which cuts off every client the first machine set up.
To use a server from two machines, connect from the one that made it and
import the link on the other.

Create a server from those answers:

```sh
Expand Down Expand Up @@ -396,10 +405,20 @@ when you interrupt it, so there is no file or process left to remember.

```sh
vpncli tun up 3 --detach
vpncli tun status # up through vpncli-ams3-0a910d (203.0.113.10, ams3) for 8m
vpncli tun status
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
```

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.

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 @@ -514,7 +533,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, and since when
vpncli tun status whether the tunnel is up, since when, and on which interface
vpncli tun down stop it

vpncli sync reconcile local state against the provider
Expand Down
8 changes: 7 additions & 1 deletion internal/cli/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ Requires DIGITALOCEAN_TOKEN or DIGITALOCEAN_ACCESS_TOKEN to be set.`,
if err != nil {
return fmt.Errorf("%q is not a server id: `vpncli server list` shows them", args[0])
}
return runDestroy(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), openProvider, id, yes)
if err := runDestroy(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), openProvider, id, yes); err != nil {
return err
}
if t, err := newTunnel(); err == nil {
noteDeadTunnel(cmd.Context(), cmd.OutOrStdout(), t, id)
}
return nil
},
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ wait: a server that exists but is not in state is invisible and still billed.
So an interrupted wait leaves something ` + "`vpncli server destroy`" + ` can clean up, and
` + "`vpncli sync`" + ` picks it up on any machine.

The server comes up as a stock OS image with the configured SSH key installed.
Installing Xray-core and the REALITY camouflage is v0.8.0.
Once it has booted, the bootstrap installs Xray-core and the REALITY
camouflage over SSH. If that fails halfway, ` + "`vpncli server bootstrap`" + ` finishes it.

Requires DIGITALOCEAN_TOKEN or DIGITALOCEAN_ACCESS_TOKEN to be set.`,
Args: cobra.NoArgs,
Expand Down
10 changes: 8 additions & 2 deletions internal/cli/rotate.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,14 @@ Requires DIGITALOCEAN_TOKEN or DIGITALOCEAN_ACCESS_TOKEN to be set.`,
if err != nil {
return fmt.Errorf("%q is not a server id: `vpncli server list` shows them", args[0])
}
return runRotate(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(),
openProvider, dialSSH, reality.Check, id, yes)
if err := runRotate(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(),
openProvider, dialSSH, reality.Check, id, yes); err != nil {
return err
}
if t, err := newTunnel(); err == nil {
noteDeadTunnel(cmd.Context(), cmd.OutOrStdout(), t, id)
}
return nil
},
}

Expand Down
69 changes: 64 additions & 5 deletions internal/cli/tun.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"strconv"
"strings"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -102,11 +103,15 @@ func newTunStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Say whether the tunnel is up",
Long: `Report whether a tunnel is running, through which server, and since when.

It reads local state and one process, so it is instant and works offline. It
does not check that traffic is actually flowing - for that, ask something on
the internet where it thinks you are.`,
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.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
t, err := newTunnel()
Expand Down Expand Up @@ -217,6 +222,14 @@ func runTunStatus(ctx context.Context, out io.Writer, t *tunnel) error {
return err
}

if err := printTunServer(ctx, out, t, running); err != nil {
return err
}
return printTunInterface(out, t)
}

// printTunServer says which server the tunnel goes through, and since when.
func printTunServer(ctx context.Context, out io.Writer, t *tunnel, running record) error {
if running.Server == 0 {
// Running, but not started by this command - so there is nothing to
// say about which server or since when.
Expand Down Expand Up @@ -244,6 +257,52 @@ func runTunStatus(ctx context.Context, out io.Writer, t *tunnel) error {
return nil
}

// printTunInterface describes the interface sing-box created. A running
// sing-box with no interface is the one failure status can actually see: it
// was started without root, and is routing nothing.
func printTunInterface(out io.Writer, t *tunnel) error {
l, err := t.run.Interface(client.TunAddr())
if errors.Is(err, ErrNoInterface) {
fmt.Fprintf(out, "but no interface has %s on it, so nothing is being routed: sing-box needs root to create one\n",
client.TunAddr())
return nil
}
if err != nil {
return err
}

how := "up"
if !l.Up {
how = "down, so nothing is being routed"
}
fmt.Fprintf(out, "interface %s: %s, mtu %d, %s\n", l.Name, strings.Join(l.Addrs, ", "), l.MTU, how)
return nil
}

// noteDeadTunnel says so when the tunnel on this machine runs through a server
// that is no longer in state. It stays up and carries nothing, which looks
// exactly like the network having gone. Best effort: a check that fails
// should not turn a destroy that worked into an error.
func noteDeadTunnel(ctx context.Context, out io.Writer, t *tunnel, id int64) {
_, running, err := t.running(ctx)
if err != nil || running.Server != id {
return
}

store, err := openStore()
if err != nil {
return
}
defer store.Close()

if _, err := store.Get(ctx, id); !errors.Is(err, state.ErrNotFound) {
return
}

fmt.Fprintf(out, "The tunnel on this machine still goes through it and carries nothing: "+
"`vpncli tun down`, then `vpncli tun up`.\n")
}

// chooseServer resolves the id a tunnel is for. With none given it takes the
// most recently configured server, which after a provision or a rotation is
// the one meant.
Expand Down
116 changes: 115 additions & 1 deletion internal/cli/tun_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"io"
"net/netip"
"os"
"strings"
"testing"
Expand All @@ -30,10 +31,18 @@ type fakeRunner struct {
// version is what the client prints; versionErr is it failing to answer.
version string
versionErr error

// link is the interface the tunnel's address is on; linkErr is there
// being none.
link link
linkErr error
}

func newFakeRunner() *fakeRunner {
return &fakeRunner{version: "sing-box version 1.14.0\n\nEnvironment: go1.26.7 darwin/arm64\n"}
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"}},
}
}

func (f *fakeRunner) Version(context.Context) (string, error) {
Expand Down Expand Up @@ -63,6 +72,8 @@ func (f *fakeRunner) Start(_ context.Context, _ io.Writer, name string, args ...
return nil
}

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 }

func (f *fakeRunner) Stop(_ context.Context, _ io.Writer, pids []int) error {
Expand Down Expand Up @@ -530,3 +541,106 @@ func TestOlder(t *testing.T) {
}
}
}

func TestNoteDeadTunnel(t *testing.T) {
for _, tc := range []struct {
name string
through int64
gone int64
want bool
}{
{"through the destroyed server", 99, 99, true},
{"through a server still in state", 1, 1, false},
{"through some other server", 1, 99, false},
} {
t.Run(tc.name, func(t *testing.T) {
tn, f := tunneling(t)
if err := tn.save(record{Server: tc.through, Started: time.Now()}); err != nil {
t.Fatalf("save: %v", err)
}
f.live = []int{4242}

var out bytes.Buffer
noteDeadTunnel(context.Background(), &out, tn, tc.gone)
if got := strings.Contains(out.String(), "vpncli tun down"); got != tc.want {
t.Errorf("note = %q, want a note: %v", out.String(), tc.want)
}
})
}
}

func TestNoteDeadTunnelWithNothingRunning(t *testing.T) {
tn, _ := tunneling(t)

var out bytes.Buffer
noteDeadTunnel(context.Background(), &out, tn, 99)
if out.Len() != 0 {
t.Errorf("note = %q, want nothing", out.String())
}
}

func TestTunStatusNamesTheInterface(t *testing.T) {
tn, _ := tunneling(t)
if _, err := up(t, tn, 1, true); err != nil {
t.Fatalf("tun up: %v", err)
}

var out bytes.Buffer
if err := runTunStatus(context.Background(), &out, tn); err != nil {
t.Fatalf("tun status: %v", err)
}
want := "interface utun9: 172.19.0.1/30, mtu 9000, up"
if !strings.Contains(out.String(), want) {
t.Errorf("status = %q, want it to say %q", out.String(), want)
}
}

// sing-box run without root starts, logs, and creates nothing. That is the
// one failure status can see without sending traffic anywhere.
func TestTunStatusWithNoInterface(t *testing.T) {
tn, f := tunneling(t)
if _, err := up(t, tn, 1, true); err != nil {
t.Fatalf("tun up: %v", err)
}
f.linkErr = ErrNoInterface

var out bytes.Buffer
if err := runTunStatus(context.Background(), &out, tn); err != nil {
t.Fatalf("tun status: %v", err)
}
if !strings.Contains(out.String(), "nothing is being routed") {
t.Errorf("status = %q, want it to say nothing is routed", out.String())
}
}

func TestTunStatusWithTheInterfaceDown(t *testing.T) {
tn, f := tunneling(t)
if _, err := up(t, tn, 1, true); err != nil {
t.Fatalf("tun up: %v", err)
}
f.link.Up = false

var out bytes.Buffer
if err := runTunStatus(context.Background(), &out, tn); err != nil {
t.Fatalf("tun status: %v", err)
}
if !strings.Contains(out.String(), "utun9") || !strings.Contains(out.String(), "down, so nothing") {
t.Errorf("status = %q, want the interface reported as down", out.String())
}
}

// The status reads the real interface table, so it has to find whatever the
// tunnel address is on. Loopback is on every machine.
func TestSystemInterfaceFindsAnAddress(t *testing.T) {
l, err := system{}.Interface(netip.MustParseAddr("127.0.0.1"))
if err != nil {
t.Fatalf("Interface: %v", err)
}
if l.Name == "" || !l.Up {
t.Errorf("link = %+v, want the loopback interface, up", l)
}

if _, err := (system{}).Interface(netip.MustParseAddr("192.0.2.255")); !errors.Is(err, ErrNoInterface) {
t.Errorf("err = %v, want ErrNoInterface for an address nothing has", err)
}
}
Loading
Loading