Skip to content
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

[vnet][1] setup TUN and IPv6 on MacOS #40893

Merged
merged 17 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 13 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
4 changes: 4 additions & 0 deletions constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,10 @@ const (
// until a domain name stops resolving. Its main use is to ensure no
// auth instances are still running the previous major version.
WaitSubCommand = "wait"

// VnetAdminSetupSubCommand is the sub-command tsh vnet uses to perform
// a setup as a privileged user.
VnetAdminSetupSubCommand = "vnet-admin-setup"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
VnetAdminSetupSubCommand = "vnet-admin-setup"
VNetAdminSetupSubCommand = "vnet-admin-setup"

To be consistent with nklaassen/vnet0.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ravicious and I have decided to capitalize as Vnet in code - otherwise JS gRPC codegen starts creating things called vNetXxxxx. Logs and errors still use VNet.

)

const (
Expand Down
122 changes: 122 additions & 0 deletions lib/vnet/setup.go
nklaassen marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

There is currently no DNS support, no app forwarding, no Teleport login of any kind, that will all come in following PRs. At this point the VNet process just logs that it has handled the connection and closes it immediately.

To make it more clear, at the moment all you can do is try to reach the IPv6 address directly, for example:

$ tsh vnet -d
2024-04-26T17:08:07+02:00 INFO  Spawning child process as root to create and setup TUN device vnet/setup_darwin.go:44
2024-04-26T17:08:12+02:00 INFO  Created TUN device. device:utun4 vnet/setup.go:83
2024-04-26T17:08:12+02:00 INFO [VNET]      Running Teleport VNet. ipv6:fd75:7ee9:a8d5:: trace_id:c9080ff4b2b4cd396cdb04c01d2c8a3f span_id:0d365c617eaf8431 vnet/vnet.go:226
2024-04-26T17:08:12+02:00 DEBU  Forwarding IP packets between OS and VNet. trace_id:c9080ff4b2b4cd396cdb04c01d2c8a3f span_id:0d365c617eaf8431 vnet/vnet.go:338

So I grab fd75:7ee9:a8d5:: and then:

$ curl -g -6 'http://[fd75:7ee9:a8d5::]:80'

Maybe there's an easier way to do this, but running that is what worked for me to trigger the following output from tsh vnet -d:

2024-04-26T17:08:47+02:00 DEBU [VNET]      Handling TCP connection. request:{80 fd75:7ee9:a8d5:: 63653 fd75:7ee9:a8d5::1} trace_id:c9080ff4b2b4cd396cdb04c01d2c8a3f span_id:0d365c617eaf8431 vnet/vnet.go:244
2024-04-26T17:08:47+02:00 DEBU [VNET]      No handler for address. request:{80 fd75:7ee9:a8d5:: 63653 fd75:7ee9:a8d5::1} addr:fd75:7ee9:a8d5:: trace_id:c9080ff4b2b4cd396cdb04c01d2c8a3f span_id:0d365c617eaf8431 vnet/vnet.go:249
2024-04-26T17:08:47+02:00 DEBU [VNET]      Finished handling TCP connection. request:{80 fd75:7ee9:a8d5:: 63653 fd75:7ee9:a8d5::1} trace_id:c9080ff4b2b4cd396cdb04c01d2c8a3f span_id:0d365c617eaf8431 vnet/vnet.go:251

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To be even more clear, even this no longer works, since I removed promiscuous mode in the previous PR this no longer handles packets destined for an IP address that hasn't been assigned yet, and outside of tests, no IPs are assigned yet. Things start actually working in #41031 where we assign an IP for DNS, and querying DNS for an app name assigns an IP to that app

Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Teleport
// Copyright (C) 2024 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package vnet

import (
"context"
"log/slog"
"os"

"github.com/gravitational/trace"
"golang.zx2c4.com/wireguard/tun"
)

// Run is a blocking call to create and start Teleport VNet.
func Run(ctx context.Context) error {
ibeckermayer marked this conversation as resolved.
Show resolved Hide resolved
ipv6Prefix, err := IPv6Prefix()
if err != nil {
return trace.Wrap(err)
}

tun, err := CreateAndSetupTUNDevice(ctx, ipv6Prefix.String())
if err != nil {
return trace.Wrap(err)
}

manager, err := NewManager(&Config{
TUNDevice: tun,
IPv6Prefix: ipv6Prefix,
})
if err != nil {
return trace.Wrap(err)
}

return trace.Wrap(manager.Run(ctx))
}

// AdminSubcommand is the tsh subcommand that should run as root that will
// create and setup a TUN device and pass the file descriptor for that device
// over the unix socket found at socketPath.
func AdminSubcommand(ctx context.Context, socketPath, ipv6Prefix string) error {
tun, tunName, err := createAndSetupTUNDeviceAsRoot(ctx, ipv6Prefix)
if err != nil {
return trace.Wrap(err, "performing admin setup")
}
if err := sendTUNNameAndFd(socketPath, tunName, tun.File().Fd()); err != nil {
return trace.Wrap(err)
}
return nil
}

// CreateAndSetupTUNDevice returns a virtual network device and configures the host OS to use that device for
// VNet connections.
func CreateAndSetupTUNDevice(ctx context.Context, ipv6Prefix string) (tun.Device, error) {
var (
device tun.Device
name string
err error
)
if os.Getuid() == 0 {
// We can get here if the user runs `tsh vnet` as root, but it is not in the expected path when
// started as a regular user. Typically we expect `tsh vnet` to be run as a non-root user, and for
// AdminSubcommand to directly call createAndSetupTUNDeviceAsRoot.
device, name, err = createAndSetupTUNDeviceAsRoot(ctx, ipv6Prefix)
} else {
device, name, err = createAndSetupTUNDeviceWithoutRoot(ctx, ipv6Prefix)
}
if err != nil {
return nil, trace.Wrap(err)
}
slog.InfoContext(ctx, "Created TUN device.", "device", name)
return device, nil
}

func createAndSetupTUNDeviceAsRoot(ctx context.Context, ipv6Prefix string) (tun.Device, string, error) {
ibeckermayer marked this conversation as resolved.
Show resolved Hide resolved
tun, tunName, err := createTUNDevice(ctx)
if err != nil {
return nil, "", trace.Wrap(err)
}

tunIPv6 := ipv6Prefix + "1"
nklaassen marked this conversation as resolved.
Show resolved Hide resolved
cfg := osConfig{
tunName: tunName,
tunIPv6: tunIPv6,
}
if err := configureOS(ctx, &cfg); err != nil {
return nil, "", trace.Wrap(err, "configuring OS")
}

return tun, tunName, nil
}

func createTUNDevice(ctx context.Context) (tun.Device, string, error) {
slog.DebugContext(ctx, "Creating TUN device.")
dev, err := tun.CreateTUN("utun", mtu)
if err != nil {
return nil, "", trace.Wrap(err, "creating TUN device")
}
name, err := dev.Name()
if err != nil {
return nil, "", trace.Wrap(err, "getting TUN device name")
}
return dev, name, nil
}

type osConfig struct {
tunName string
tunIPv6 string
}
245 changes: 245 additions & 0 deletions lib/vnet/setup_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// Teleport
// Copyright (C) 2024 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

//go:build darwin
// +build darwin

package vnet

import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/google/uuid"
"github.com/gravitational/trace"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/tun"

"github.com/gravitational/teleport"
)

const (
tunHandoverTimeout = time.Minute
)

func createAndSetupTUNDeviceWithoutRoot(ctx context.Context, ipv6Prefix string) (tun.Device, string, error) {
slog.InfoContext(ctx, "Spawning child process as root to create and setup TUN device")
socket, socketPath, err := createUnixSocket()
if err != nil {
return nil, "", trace.Wrap(err)
}

ctx, cancel := context.WithCancel(ctx)
defer cancel()

adminCommandErr := make(chan error, 1)
go func() {
adminCommandErr <- trace.Wrap(execAdminSubcommand(ctx, socketPath, ipv6Prefix))
}()

recvTunErr := make(chan error, 1)
var tunName string
var tunFd uintptr
go func() {
tunName, tunFd, err = recvTUNNameAndFd(ctx, socket)
recvTunErr <- trace.Wrap(err, "receiving TUN name and file descriptor")
}()

loop:
for {
select {
case err := <-adminCommandErr:
if err != nil {
return nil, "", trace.Wrap(err)
}
case err := <-recvTunErr:
if err != nil {
return nil, "", trace.Wrap(err)
}
break loop
}
}

tunDevice, err := tun.CreateTUNFromFile(os.NewFile(tunFd, ""), 0)
if err != nil {
return nil, "", trace.Wrap(err, "creating TUN device from file descriptor")
}

return tunDevice, tunName, nil
}

func execAdminSubcommand(ctx context.Context, socketPath, ipv6Prefix string) error {
executableName, err := os.Executable()
if err != nil {
return trace.Wrap(err, "getting executable path")
}

appleScript := fmt.Sprintf(`
set executableName to "%s"
set socketPath to "%s"
set ipv6Prefix to "%s"
do shell script quoted form of executableName & `+
`" %s --socket " & quoted form of socketPath & `+
`" --ipv6-prefix " & quoted form of ipv6Prefix `+
`with prompt "VNet wants to set up a virtual network device" with administrator privileges`,
executableName, socketPath, ipv6Prefix, teleport.VnetAdminSetupSubCommand)

// The context we pass here has effect only on the password prompt being shown. Once osascript spawns the
// privileged process, canceling the context (and thus killing osascript) has no effect on the privileged
// process.
cmd := exec.CommandContext(ctx, "osascript", "-e", appleScript)
var stderr strings.Builder
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
stderr := stderr.String()

// When the user closes the prompt for administrator privileges, the -128 error is returned.
// https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/reference/ASLR_error_codes.html#//apple_ref/doc/uid/TP40000983-CH220-SW2
if strings.Contains(stderr, "-128") {
return trace.Errorf("password prompt closed by user")
}
return trace.Wrap(exitError, "admin subcommand exited, stderr: %s", stderr)
}
return trace.Wrap(err)
}
return nil
}

func createUnixSocket() (*net.UnixListener, string, error) {
socketPath := filepath.Join(os.TempDir(), "vnet"+uuid.NewString()+".sock")
socketAddr := &net.UnixAddr{Name: socketPath, Net: "unix"}
l, err := net.ListenUnix(socketAddr.Net, socketAddr)
if err != nil {
return nil, "", trace.Wrap(err, "creating unix socket")
}
if err := os.Chmod(socketPath, 0o600); err != nil {
return nil, "", trace.Wrap(err, "setting permissions on unix socket")
}
return l, socketPath, nil
}

// sendTUNNameAndFd sends the name of the TUN device and its open file descriptor over a unix socket, meant
// for passing the TUN from the root process which must create it to the user process.
func sendTUNNameAndFd(socketPath, tunName string, fd uintptr) error {
socketAddr := &net.UnixAddr{Name: socketPath, Net: "unix"}
conn, err := net.DialUnix(socketAddr.Net, nil /*laddr*/, socketAddr)
if err != nil {
return trace.Wrap(err)
}
defer conn.Close()

err = conn.SetDeadline(time.Now().Add(tunHandoverTimeout))
if err != nil {
return trace.Wrap(err)
}

// Write the device name as the main message and pass the file desciptor as out-of-band data.
rights := unix.UnixRights(int(fd))
if _, _, err := conn.WriteMsgUnix([]byte(tunName), rights, socketAddr); err != nil {
return trace.Wrap(err, "writing to unix conn")
}
return nil
}

// recvTUNNameAndFd receives the name of a TUN device and its open file descriptor over a unix socket, meant
// for passing the TUN from the root process which must create it to the user process.
func recvTUNNameAndFd(ctx context.Context, socket *net.UnixListener) (string, uintptr, error) {
ctx, cancel := context.WithTimeout(ctx, tunHandoverTimeout)
defer cancel()
deadline, _ := ctx.Deadline()

err := socket.SetDeadline(deadline)
if err != nil {
return "", 0, trace.Wrap(err)
}
go func() {
<-ctx.Done()
socket.Close()
}()

conn, err := socket.AcceptUnix()
if err != nil {
return "", 0, trace.Wrap(err)
}
go func() {
// Close the connection early to unblock reads if the context is canceled.
<-ctx.Done()
conn.Close()
}()

msg := make([]byte, 128)
oob := make([]byte, unix.CmsgSpace(4)) // Fd is 4 bytes
n, oobn, _, _, err := conn.ReadMsgUnix(msg, oob)
if err != nil {
return "", 0, trace.Wrap(err, "reading from unix conn")
}

// Parse the device name from the main message.
if n == 0 {
return "", 0, trace.Errorf("failed to read msg from unix conn")
}
if oobn != len(oob) {
return "", 0, trace.Errorf("failed to read out-of-band data from unix conn")
}
tunName := string(msg[:n])

// Parse the file descriptor from the out-of-band data.
scm, err := unix.ParseSocketControlMessage(oob)
if err != nil {
return "", 0, trace.Wrap(err, "parsing socket control message")
}
if len(scm) != 1 {
return "", 0, trace.BadParameter("expect 1 socket control message, got %d", len(scm))
}
fds, err := unix.ParseUnixRights(&scm[0])
if err != nil {
return "", 0, trace.Wrap(err, "parsing file descriptors")
}
if len(fds) != 1 {
return "", 0, trace.BadParameter("expected 1 file descriptor, got %d", len(fds))
}
fd := uintptr(fds[0])

return tunName, fd, nil
}

func configureOS(ctx context.Context, cfg *osConfig) error {
ibeckermayer marked this conversation as resolved.
Show resolved Hide resolved
if cfg.tunIPv6 != "" && cfg.tunName != "" {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider inverting the condition here so that the return nil is indented and the body that does most of the work is not in a conditional.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

slog.InfoContext(ctx, "Setting IPv6 address for the TUN device.", "device", cfg.tunName, "address", cfg.tunIPv6)
cmd := exec.CommandContext(ctx, "ifconfig", cfg.tunName, "inet6", cfg.tunIPv6, "prefixlen", "64")
if err := cmd.Run(); err != nil {
return trace.Wrap(err, "running %v", cmd.Args)
}

slog.InfoContext(ctx, "Setting an IPv6 route for the VNet.")
cmd = exec.CommandContext(ctx, "route", "add", "-inet6", cfg.tunIPv6, "-prefixlen", "64", "-interface", cfg.tunName)
if err := cmd.Run(); err != nil {
return trace.Wrap(err, "running %v", cmd.Args)
}
}
return nil
}
Loading
Loading