Skip to content

chore: add vpn-daemon run subcommand for windows #15526

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 18, 2024
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
1 change: 1 addition & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.expCmd(),
r.gitssh(),
r.support(),
r.vpnDaemon(),
r.vscodeSSH(),
r.workspaceAgent(),
}
Expand Down
21 changes: 21 additions & 0 deletions cli/vpndaemon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cli

import (
"github.com/coder/serpent"
)

func (r *RootCmd) vpnDaemon() *serpent.Command {
cmd := &serpent.Command{
Use: "vpn-daemon [subcommand]",
Short: "VPN daemon commands used by Coder Desktop.",
Hidden: true,
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.vpnDaemonRun(),
},
}

return cmd
}
24 changes: 24 additions & 0 deletions cli/vpndaemon_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build !windows

package cli

import (
"golang.org/x/xerrors"

"github.com/coder/serpent"
)

func (*RootCmd) vpnDaemonRun() *serpent.Command {
cmd := &serpent.Command{
Use: "run",
Short: "Run the VPN daemon on Windows.",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
),
Handler: func(_ *serpent.Invocation) error {
return xerrors.New("vpn-daemon subcommand is not supported on this platform")
},
}

return cmd
}
75 changes: 75 additions & 0 deletions cli/vpndaemon_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//go:build windows

package cli

import (
"golang.org/x/xerrors"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/vpn"
"github.com/coder/serpent"
)

func (r *RootCmd) vpnDaemonRun() *serpent.Command {
var (
rpcReadHandleInt int64
rpcWriteHandleInt int64
)

cmd := &serpent.Command{
Use: "run",
Short: "Run the VPN daemon on Windows.",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
),
Options: serpent.OptionSet{
{
Flag: "rpc-read-handle",
Env: "CODER_VPN_DAEMON_RPC_READ_HANDLE",
Description: "The handle for the pipe to read from the RPC connection.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly ambiguous about which process's perspective. Is the daemon reading (and therefore the caller of this command is writing) or vice versa?

Value: serpent.Int64Of(&rpcReadHandleInt),
Required: true,
},
{
Flag: "rpc-write-handle",
Env: "CODER_VPN_DAEMON_RPC_WRITE_HANDLE",
Description: "The handle for the pipe to write to the RPC connection.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto on ambiguity on which process is reading and which is writing

Value: serpent.Int64Of(&rpcWriteHandleInt),
Required: true,
},
},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
logger := inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelDebug)

if rpcReadHandleInt < 0 || rpcWriteHandleInt < 0 {
return xerrors.Errorf("rpc-read-handle (%v) and rpc-write-handle (%v) must be positive", rpcReadHandleInt, rpcWriteHandleInt)
}
if rpcReadHandleInt == rpcWriteHandleInt {
return xerrors.Errorf("rpc-read-handle (%v) and rpc-write-handle (%v) must be different", rpcReadHandleInt, rpcWriteHandleInt)
}

// We don't need to worry about duplicating the handles on Windows,
// which is different from Unix.
logger.Info(ctx, "opening bidirectional RPC pipe", slog.F("rpc_read_handle", rpcReadHandleInt), slog.F("rpc_write_handle", rpcWriteHandleInt))
pipe, err := vpn.NewBidirectionalPipe(uintptr(rpcReadHandleInt), uintptr(rpcWriteHandleInt))
if err != nil {
return xerrors.Errorf("create bidirectional RPC pipe: %w", err)
}
defer pipe.Close()

logger.Info(ctx, "starting tunnel")
tunnel, err := vpn.NewTunnel(ctx, logger, pipe)
if err != nil {
return xerrors.Errorf("create new tunnel for client: %w", err)
}
defer tunnel.Close()

<-ctx.Done()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this will actually work in practice, should we add a tunnel.Wait() or something instead to wait for the *vpn.Tunnel to encounter a permanent error?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way a clean shutdown should work is that the manager sends StopRequest, the tunnel replies StopResponse and closes the tunnel → manager pipe. We could also add a tunnel.Wait() that returns a channel that closes when the tunnel is fully shut down like this, so that we can also have the daemon process exit.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add a tunnel.Wait() in a follow-up PR tomorrow since tunnel is mostly unimplemented anyways

return nil
},
}

return cmd
}
93 changes: 93 additions & 0 deletions cli/vpndaemon_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//go:build windows

package cli_test

import (
"fmt"
"os"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/testutil"
)

func TestVPNDaemonRun(t *testing.T) {
t.Parallel()

t.Run("InvalidFlags", func(t *testing.T) {
t.Parallel()

cases := []struct {
Name string
Args []string
ErrorContains string
}{
{
Name: "NoReadHandle",
Args: []string{"--rpc-write-handle", "10"},
ErrorContains: "rpc-read-handle",
},
{
Name: "NoWriteHandle",
Args: []string{"--rpc-read-handle", "10"},
ErrorContains: "rpc-write-handle",
},
{
Name: "NegativeReadHandle",
Args: []string{"--rpc-read-handle", "-1", "--rpc-write-handle", "10"},
ErrorContains: "rpc-read-handle",
},
{
Name: "NegativeWriteHandle",
Args: []string{"--rpc-read-handle", "10", "--rpc-write-handle", "-1"},
ErrorContains: "rpc-write-handle",
},
{
Name: "SameHandles",
Args: []string{"--rpc-read-handle", "10", "--rpc-write-handle", "10"},
ErrorContains: "rpc-read-handle",
},
}

for _, c := range cases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
inv, _ := clitest.New(t, append([]string{"vpn-daemon", "run"}, c.Args...)...)
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, c.ErrorContains)
})
}
})

t.Run("StartsTunnel", func(t *testing.T) {
t.Parallel()

r1, w1, err := os.Pipe()
require.NoError(t, err)
defer r1.Close()
defer w1.Close()
r2, w2, err := os.Pipe()
require.NoError(t, err)
defer r2.Close()
defer w2.Close()

ctx := testutil.Context(t, testutil.WaitLong)
inv, _ := clitest.New(t, "vpn-daemon", "run", "--rpc-read-handle", fmt.Sprint(r1.Fd()), "--rpc-write-handle", fmt.Sprint(w2.Fd()))
waiter := clitest.StartWithWaiter(t, inv.WithContext(ctx))

// Send garbage which should cause the handshake to fail and the daemon
// to exit.
_, err = w1.Write([]byte("garbage"))
require.NoError(t, err)
waiter.Cancel()
err = waiter.Wait()
require.ErrorContains(t, err, "handshake failed")
})

// TODO: once the VPN tunnel functionality is implemented, add tests that
// actually try to instantiate a tunnel to a workspace
}
69 changes: 69 additions & 0 deletions vpn/pipe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package vpn

import (
"io"
"os"

"github.com/hashicorp/go-multierror"
"golang.org/x/xerrors"
)

// BidirectionalPipe combines a pair of files that can be used for bidirectional
// communication.
type BidirectionalPipe struct {
read *os.File
write *os.File
}

var _ io.ReadWriteCloser = BidirectionalPipe{}

// NewBidirectionalPipe creates a new BidirectionalPipe from the given file
// descriptors.
func NewBidirectionalPipe(readFd, writeFd uintptr) (BidirectionalPipe, error) {
read := os.NewFile(readFd, "pipe_read")
_, err := read.Stat()
if err != nil {
return BidirectionalPipe{}, xerrors.Errorf("stat pipe_read (fd=%v): %w", readFd, err)
}
write := os.NewFile(writeFd, "pipe_write")
_, err = write.Stat()
if err != nil {
return BidirectionalPipe{}, xerrors.Errorf("stat pipe_write (fd=%v): %w", writeFd, err)
}
return BidirectionalPipe{
read: read,
write: write,
}, nil
}

// Read implements io.Reader. Data is read from the read pipe.
func (b BidirectionalPipe) Read(p []byte) (int, error) {
n, err := b.read.Read(p)
if err != nil {
return n, xerrors.Errorf("read from pipe_read (fd=%v): %w", b.read.Fd(), err)
}
return n, nil
}

// Write implements io.Writer. Data is written to the write pipe.
func (b BidirectionalPipe) Write(p []byte) (n int, err error) {
n, err = b.write.Write(p)
if err != nil {
return n, xerrors.Errorf("write to pipe_write (fd=%v): %w", b.write.Fd(), err)
}
return n, nil
}

// Close implements io.Closer. Both the read and write pipes are closed.
func (b BidirectionalPipe) Close() error {
var err error
rErr := b.read.Close()
if rErr != nil {
err = multierror.Append(err, xerrors.Errorf("close pipe_read (fd=%v): %w", b.read.Fd(), rErr))
}
wErr := b.write.Close()
if err != nil {
err = multierror.Append(err, xerrors.Errorf("close pipe_write (fd=%v): %w", b.write.Fd(), wErr))
}
return err
}
Loading