Skip to content

Commit c4f30eb

Browse files
committed
chore: add vpn-daemon run subcommand for windows
`coder vpn-daemon run` will instantiate a RPC connection with the specified pipe handles and communicate with the (yet to be implemented) parent process. The tests don't ensure that the tunnel is actually usable yet as the tunnel functionality isn't implemented, but it does make sure that the tunnel tries to read from the RPC pipe.
1 parent f1cb3a5 commit c4f30eb

File tree

5 files changed

+296
-0
lines changed

5 files changed

+296
-0
lines changed

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
125125
r.expCmd(),
126126
r.gitssh(),
127127
r.support(),
128+
r.vpnDaemon(),
128129
r.vscodeSSH(),
129130
r.workspaceAgent(),
130131
}

cli/vpndaemon.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package cli
2+
3+
import (
4+
"github.com/coder/serpent"
5+
)
6+
7+
func (r *RootCmd) vpnDaemon() *serpent.Command {
8+
cmd := &serpent.Command{
9+
Use: "vpn-daemon [subcommand]",
10+
Short: "VPN daemon commands used by Coder Desktop.",
11+
Hidden: true,
12+
Handler: func(inv *serpent.Invocation) error {
13+
return inv.Command.HelpHandler(inv)
14+
},
15+
Children: []*serpent.Command{
16+
r.vpnDaemonRun(),
17+
},
18+
}
19+
20+
return cmd
21+
}

cli/vpndaemon_other.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build !windows
2+
3+
package cli
4+
5+
import (
6+
"golang.org/x/xerrors"
7+
8+
"github.com/coder/serpent"
9+
)
10+
11+
func (*RootCmd) vpnDaemonRun() *serpent.Command {
12+
cmd := &serpent.Command{
13+
Use: "run",
14+
Short: "Run the VPN daemon on Windows.",
15+
Middleware: serpent.Chain(
16+
serpent.RequireNArgs(0),
17+
),
18+
Handler: func(_ *serpent.Invocation) error {
19+
return xerrors.New("vpn-daemon subcommand is not supported on this platform")
20+
},
21+
}
22+
23+
return cmd
24+
}

cli/vpndaemon_windows.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//go:build windows
2+
3+
package cli
4+
5+
import (
6+
"io"
7+
"os"
8+
9+
"cdr.dev/slog"
10+
"golang.org/x/xerrors"
11+
12+
"cdr.dev/slog/sloggers/sloghuman"
13+
"github.com/coder/coder/v2/vpn"
14+
"github.com/coder/serpent"
15+
)
16+
17+
func (r *RootCmd) vpnDaemonRun() *serpent.Command {
18+
var (
19+
rpcReadHandleInt int64
20+
rpcWriteHandleInt int64
21+
logPath string
22+
)
23+
24+
cmd := &serpent.Command{
25+
Use: "run",
26+
Short: "Run the VPN daemon on Windows.",
27+
Middleware: serpent.Chain(
28+
serpent.RequireNArgs(0),
29+
),
30+
Options: serpent.OptionSet{
31+
{
32+
Flag: "rpc-read-handle",
33+
Env: "CODER_VPN_DAEMON_RPC_READ_HANDLE",
34+
Description: "The handle for the pipe to read from the RPC connection.",
35+
Value: serpent.Int64Of(&rpcReadHandleInt),
36+
Required: true,
37+
},
38+
{
39+
Flag: "rpc-write-handle",
40+
Env: "CODER_VPN_DAEMON_RPC_WRITE_HANDLE",
41+
Description: "The handle for the pipe to write to the RPC connection.",
42+
Value: serpent.Int64Of(&rpcWriteHandleInt),
43+
Required: true,
44+
},
45+
{
46+
Flag: "log-path",
47+
Env: "CODER_VPN_DAEMON_LOG_PATH",
48+
Description: "The path to the log file to write to.",
49+
Value: serpent.StringOf(&logPath),
50+
Required: false, // logs will also be written to stderr
51+
},
52+
},
53+
Handler: func(inv *serpent.Invocation) error {
54+
ctx := inv.Context()
55+
56+
if rpcReadHandleInt < 0 || rpcWriteHandleInt < 0 {
57+
return xerrors.Errorf("rpc-read-handle (%v) and rpc-write-handle (%v) must be positive", rpcReadHandleInt, rpcWriteHandleInt)
58+
}
59+
if rpcReadHandleInt == rpcWriteHandleInt {
60+
return xerrors.Errorf("rpc-read-handle (%v) and rpc-write-handle (%v) must be different", rpcReadHandleInt, rpcWriteHandleInt)
61+
}
62+
63+
logger := inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelDebug)
64+
if logPath != "" {
65+
f, err := os.Create(logPath)
66+
if err != nil {
67+
return xerrors.Errorf("create log file: %w", err)
68+
}
69+
defer f.Close()
70+
logger = logger.AppendSinks(sloghuman.Sink(f))
71+
}
72+
73+
logger.Info(ctx, "opening bidirectional RPC pipe", slog.F("rpc_read_handle", rpcReadHandleInt), slog.F("rpc_write_handle", rpcWriteHandleInt))
74+
pipe, err := newBidiPipe(uintptr(rpcReadHandleInt), uintptr(rpcWriteHandleInt))
75+
if err != nil {
76+
return xerrors.Errorf("create bidirectional RPC pipe: %w", err)
77+
}
78+
defer pipe.Close()
79+
80+
logger.Info(ctx, "starting tunnel")
81+
tunnel, err := vpn.NewTunnel(ctx, logger, pipe)
82+
if err != nil {
83+
return xerrors.Errorf("create new tunnel for client: %w", err)
84+
}
85+
defer tunnel.Close()
86+
87+
<-ctx.Done()
88+
return nil
89+
},
90+
}
91+
92+
return cmd
93+
}
94+
95+
type bidiPipe struct {
96+
read *os.File
97+
write *os.File
98+
}
99+
100+
var _ io.ReadWriteCloser = bidiPipe{}
101+
102+
func newBidiPipe(readHandle, writeHandle uintptr) (bidiPipe, error) {
103+
read := os.NewFile(readHandle, "rpc_read")
104+
_, err := read.Stat()
105+
if err != nil {
106+
return bidiPipe{}, xerrors.Errorf("stat rpc_read pipe (handle=%v): %w", readHandle, err)
107+
}
108+
write := os.NewFile(writeHandle, "rpc_write")
109+
_, err = write.Stat()
110+
if err != nil {
111+
return bidiPipe{}, xerrors.Errorf("stat rpc_write pipe (handle=%v): %w", writeHandle, err)
112+
}
113+
return bidiPipe{
114+
read: read,
115+
write: write,
116+
}, nil
117+
}
118+
119+
// Read implements io.Reader. Data is read from the read pipe.
120+
func (b bidiPipe) Read(p []byte) (int, error) {
121+
n, err := b.read.Read(p)
122+
if err != nil {
123+
return n, xerrors.Errorf("read from rpc_read pipe (handle=%v): %w", b.read.Fd(), err)
124+
}
125+
return n, nil
126+
}
127+
128+
// Write implements io.Writer. Data is written to the write pipe.
129+
func (b bidiPipe) Write(p []byte) (n int, err error) {
130+
n, err = b.write.Write(p)
131+
if err != nil {
132+
return n, xerrors.Errorf("write to rpc_write pipe (handle=%v): %w", b.write.Fd(), err)
133+
}
134+
return n, nil
135+
}
136+
137+
// Close implements io.Closer. Both the read and write pipes are closed.
138+
func (b bidiPipe) Close() error {
139+
err := b.read.Close()
140+
if err != nil {
141+
return xerrors.Errorf("close rpc_read pipe (handle=%v): %w", b.read.Fd(), err)
142+
}
143+
err = b.write.Close()
144+
if err != nil {
145+
return xerrors.Errorf("close rpc_write pipe (handle=%v): %w", b.write.Fd(), err)
146+
}
147+
return nil
148+
}

cli/vpndaemon_windows_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//go:build windows
2+
3+
package cli_test
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/coder/coder/v2/cli/clitest"
14+
"github.com/coder/coder/v2/testutil"
15+
)
16+
17+
func TestVPNDaemonRun(t *testing.T) {
18+
t.Parallel()
19+
20+
t.Run("InvalidFlags", func(t *testing.T) {
21+
t.Parallel()
22+
23+
cases := []struct {
24+
Name string
25+
Args []string
26+
ErrorContains string
27+
}{
28+
{
29+
Name: "NoReadHandle",
30+
Args: []string{"--rpc-write-handle", "10"},
31+
ErrorContains: "rpc-read-handle",
32+
},
33+
{
34+
Name: "NoWriteHandle",
35+
Args: []string{"--rpc-read-handle", "10"},
36+
ErrorContains: "rpc-write-handle",
37+
},
38+
{
39+
Name: "NegativeReadHandle",
40+
Args: []string{"--rpc-read-handle", "-1", "--rpc-write-handle", "10"},
41+
ErrorContains: "rpc-read-handle",
42+
},
43+
{
44+
Name: "NegativeWriteHandle",
45+
Args: []string{"--rpc-read-handle", "10", "--rpc-write-handle", "-1"},
46+
ErrorContains: "rpc-write-handle",
47+
},
48+
{
49+
Name: "SameHandles",
50+
Args: []string{"--rpc-read-handle", "10", "--rpc-write-handle", "10"},
51+
ErrorContains: "rpc-read-handle",
52+
},
53+
}
54+
55+
for _, c := range cases {
56+
c := c
57+
t.Run(c.Name, func(t *testing.T) {
58+
t.Parallel()
59+
ctx := testutil.Context(t, testutil.WaitLong)
60+
inv, _ := clitest.New(t, append([]string{"vpn-daemon", "run"}, c.Args...)...)
61+
err := inv.WithContext(ctx).Run()
62+
require.ErrorContains(t, err, c.ErrorContains)
63+
})
64+
}
65+
})
66+
67+
t.Run("LogFile", func(t *testing.T) {
68+
t.Parallel()
69+
70+
r1, w1, err := os.Pipe()
71+
require.NoError(t, err)
72+
defer r1.Close()
73+
defer w1.Close()
74+
r2, w2, err := os.Pipe()
75+
require.NoError(t, err)
76+
defer r2.Close()
77+
defer w2.Close()
78+
79+
logDir := t.TempDir()
80+
logPath := filepath.Join(logDir, "coder-daemon.log")
81+
82+
ctx := testutil.Context(t, testutil.WaitLong)
83+
inv, _ := clitest.New(t, "vpn-daemon", "run", "--rpc-read-handle", fmt.Sprint(r1.Fd()), "--rpc-write-handle", fmt.Sprint(w2.Fd()), "--log-path", logPath)
84+
waiter := clitest.StartWithWaiter(t, inv.WithContext(ctx))
85+
86+
// Send garbage which should cause the handshake to fail and the daemon
87+
// to exit.
88+
_, err = w1.Write([]byte("garbage"))
89+
require.NoError(t, err)
90+
waiter.Cancel()
91+
err = waiter.Wait()
92+
require.ErrorContains(t, err, "handshake failed")
93+
94+
// Check that the log file was created and is not empty.
95+
stat, err := os.Stat(logPath)
96+
require.NoError(t, err)
97+
require.Greater(t, stat.Size(), int64(0))
98+
})
99+
100+
// TODO: once the VPN tunnel functionality is implemented, add tests that
101+
// actually try to instantiate a tunnel to a workspace
102+
}

0 commit comments

Comments
 (0)