Skip to content

Commit

Permalink
[vnet][4] host DNS configuration (#41032)
Browse files Browse the repository at this point in the history
* [vnet] host DNS configuration

* minor edits

* always clean up
  • Loading branch information
nklaassen authored May 24, 2024
1 parent bfa3a0e commit 2f15817
Show file tree
Hide file tree
Showing 5 changed files with 364 additions and 127 deletions.
129 changes: 129 additions & 0 deletions lib/vnet/osconfig_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// 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 (
"bufio"
"context"
"log/slog"
"os"
"os/exec"
"path/filepath"

"github.com/gravitational/trace"
)

// configureOS configures the host OS according to [cfg]. It is safe to call repeatedly, and it is meant to be
// called with an empty [osConfig] to deconfigure anything necessary before exiting.
func configureOS(ctx context.Context, cfg *osConfig) error {
// There is no need to remove IPs or the IPv6 route, they will automatically be cleaned up when the
// process exits and the TUN is deleted.
if cfg.tunIPv6 != "" {
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)
}
}

if err := configureDNS(ctx, cfg.dnsAddr, cfg.dnsZones); err != nil {
return trace.Wrap(err, "configuring DNS")
}

return nil
}

const resolverFileComment = "# automatically installed by Teleport VNet"

var resolverPath = filepath.Join("/", "etc", "resolver")

func configureDNS(ctx context.Context, nameserver string, zones []string) error {
if len(nameserver) == 0 && len(zones) > 0 {
return trace.BadParameter("empty nameserver with non-empty zones")
}

slog.DebugContext(ctx, "Configuring DNS.", "nameserver", nameserver, "zones", zones)
if err := os.MkdirAll(resolverPath, os.FileMode(0755)); err != nil {
return trace.Wrap(err, "creating %s", resolverPath)
}

managedFiles, err := vnetManagedResolverFiles()
if err != nil {
return trace.Wrap(err, "finding VNet managed files in /etc/resolver")
}

// Always attempt to write or clean up all files below, even if encountering errors with one or more of
// them.
var allErrors []error

for _, zone := range zones {
fileName := filepath.Join(resolverPath, zone)
contents := resolverFileComment + "\nnameserver " + nameserver
if err := os.WriteFile(fileName, []byte(contents), 0644); err != nil {
allErrors = append(allErrors, trace.Wrap(err, "writing DNS configuration file %s", fileName))
} else {
// Successfully wrote this file, don't clean it up below.
delete(managedFiles, fileName)
}
}

// Delete stale files.
for fileName := range managedFiles {
if err := os.Remove(fileName); err != nil {
allErrors = append(allErrors, trace.Wrap(err, "deleting VNet managed file %s", fileName))
}
}

return trace.NewAggregate(allErrors...)
}

func vnetManagedResolverFiles() (map[string]struct{}, error) {
entries, err := os.ReadDir(resolverPath)
if err != nil {
return nil, trace.Wrap(err, "reading %s", resolverPath)
}

matchingFiles := make(map[string]struct{})
for _, entry := range entries {
if entry.IsDir() {
continue
}
filePath := filepath.Join(resolverPath, entry.Name())
file, err := os.Open(filePath)
if err != nil {
return nil, trace.Wrap(err, "opening %s", filePath)
}
defer file.Close()

scanner := bufio.NewScanner(file)
if scanner.Scan() {
if resolverFileComment == scanner.Text() {
matchingFiles[filePath] = struct{}{}
}
}
}
return matchingFiles, nil
}
206 changes: 166 additions & 40 deletions lib/vnet/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ import (
"context"
"log/slog"
"os"
"time"

"github.com/gravitational/trace"
"golang.org/x/sync/errgroup"
"golang.zx2c4.com/wireguard/tun"

"github.com/gravitational/teleport/api/profile"
"github.com/gravitational/teleport/api/types"
)

// Run is a blocking call to create and start Teleport VNet.
Expand All @@ -34,9 +39,16 @@ func Run(ctx context.Context, appProvider AppProvider) error {

dnsIPv6 := ipv6WithSuffix(ipv6Prefix, []byte{2})

tun, err := CreateAndSetupTUNDevice(ctx, ipv6Prefix.String())
if err != nil {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

tunCh, adminCommandErrCh := CreateAndSetupTUNDevice(ctx, ipv6Prefix.String(), dnsIPv6.String())

var tun TUNDevice
select {
case err := <-adminCommandErrCh:
return trace.Wrap(err)
case tun = <-tunCh:
}

appResolver := NewTCPAppResolver(appProvider)
Expand All @@ -51,62 +63,162 @@ func Run(ctx context.Context, appProvider AppProvider) error {
return trace.Wrap(err)
}

return trace.Wrap(manager.Run(ctx))
allErrors := make(chan error, 2)
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
// Make sure to cancel the context if manager.Run terminates for any reason.
defer cancel()
err := trace.Wrap(manager.Run(ctx), "running VNet manager")
allErrors <- err
return err
})
g.Go(func() error {
var adminCommandErr error
select {
case adminCommandErr = <-adminCommandErrCh:
// The admin command exited before the context was canceled, cancel everything and exit.
cancel()
case <-ctx.Done():
// The context has been canceled, the admin command should now exit.
adminCommandErr = <-adminCommandErrCh
}
adminCommandErr = trace.Wrap(adminCommandErr, "running admin subcommand")
allErrors <- adminCommandErr
return adminCommandErr
})
// Deliberately ignoring the error from g.Wait() to return an aggregate of all errors.
_ = g.Wait()
close(allErrors)
return trace.NewAggregateFromChannel(allErrors, context.Background())
}

// 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 {
// 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.
//
// It also handles host OS configuration that must run as root, and stays alive to keep the host configuration
// up to date. It will stay running until the socket at [socketPath] is deleting or encountering an
// unrecoverable error.
func AdminSubcommand(ctx context.Context, socketPath, ipv6Prefix, dnsAddr string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

tunCh, errCh := createAndSetupTUNDeviceAsRoot(ctx, ipv6Prefix, dnsAddr)
var tun tun.Device
select {
case tun = <-tunCh:
case err := <-errCh:
return trace.Wrap(err, "performing admin setup")
}
tunName, err := tun.Name()
if err != nil {
return trace.Wrap(err, "getting TUN name")
}
if err := sendTUNNameAndFd(socketPath, tunName, tun.File().Fd()); err != nil {
return trace.Wrap(err)
return trace.Wrap(err, "sending TUN over socket")
}

// Stay alive until we get an error on errCh, indicating that the osConfig loop exited.
// If the socket is deleted, indicating that the parent process exited, cancel the context and then wait
// for the osConfig loop to exit and send an err on errCh.
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if _, err := os.Stat(socketPath); err != nil {
slog.DebugContext(ctx, "failed to stat socket path, assuming parent exited")
cancel()
return trace.Wrap(<-errCh)
}
case err = <-errCh:
return trace.Wrap(err)
}
}
return nil
}

// CreateAndSetupTUNDevice returns a virtual network device and configures the host OS to use that device for
// CreateAndSetupTUNDevice creates 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 not already running as root, it will spawn a root process to handle the TUN creation and host
// configuration.
//
// After the TUN device is created, it will be sent on the result channel. Any error will be sent on the err
// channel. Always select on both the result channel and the err channel when waiting for a result.
//
// This will keep running until [ctx] is canceled or an unrecoverable error is encountered, in order to keep
// the host OS configuration up to date.
func CreateAndSetupTUNDevice(ctx context.Context, ipv6Prefix, dnsAddr string) (<-chan tun.Device, <-chan 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)
return createAndSetupTUNDeviceAsRoot(ctx, ipv6Prefix, dnsAddr)
} else {
device, name, err = createAndSetupTUNDeviceWithoutRoot(ctx, ipv6Prefix)
}
if err != nil {
return nil, trace.Wrap(err)
return createAndSetupTUNDeviceWithoutRoot(ctx, ipv6Prefix, dnsAddr)
}
slog.InfoContext(ctx, "Created TUN device.", "device", name)
return device, nil
}

func createAndSetupTUNDeviceAsRoot(ctx context.Context, ipv6Prefix string) (tun.Device, string, error) {
// createAndSetupTUNDeviceAsRoot creates a virtual network device and configures the host OS to use that device for
// VNet connections.
//
// After the TUN device is created, it will be sent on the result channel. Any error will be sent on the err
// channel. Always select on both the result channel and the err channel when waiting for a result.
//
// This will keep running until [ctx] is canceled or an unrecoverable error is encountered, in order to keep
// the host OS configuration up to date.
func createAndSetupTUNDeviceAsRoot(ctx context.Context, ipv6Prefix, dnsAddr string) (<-chan tun.Device, <-chan error) {
tunCh := make(chan tun.Device, 1)
errCh := make(chan error, 2)

tun, tunName, err := createTUNDevice(ctx)
if err != nil {
return nil, "", trace.Wrap(err)
errCh <- trace.Wrap(err, "creating TUN device")
return tunCh, errCh
}

tunIPv6 := ipv6Prefix + "1"
cfg := osConfig{
tunName: tunName,
tunIPv6: tunIPv6,
}
if err := configureOS(ctx, &cfg); err != nil {
return nil, "", trace.Wrap(err, "configuring OS")
}

return tun, tunName, nil
tunCh <- tun

go func() {
defer func() {
// Shutting down, deconfigure OS.
errCh <- trace.Wrap(configureOS(context.Background(), &osConfig{}))
}()

var err error
tunIPv6 := ipv6Prefix + "1"
cfg := osConfig{
tunName: tunName,
tunIPv6: tunIPv6,
dnsAddr: dnsAddr,
}
if cfg.dnsZones, err = dnsZones(); err != nil {
errCh <- trace.Wrap(err, "getting DNS zones")
return
}
if err := configureOS(ctx, &cfg); err != nil {
errCh <- trace.Wrap(err, "configuring OS")
return
}

// Re-check the DNS zones every 10 seconds, and configure the host OS appropriately.
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if cfg.dnsZones, err = dnsZones(); err != nil {
errCh <- trace.Wrap(err, "getting DNS zones")
return
}
if err := configureOS(ctx, &cfg); err != nil {
errCh <- trace.Wrap(err, "configuring OS")
return
}
case <-ctx.Done():
return
}
}
}()
return tunCh, errCh
}

func createTUNDevice(ctx context.Context) (tun.Device, string, error) {
Expand All @@ -123,6 +235,20 @@ func createTUNDevice(ctx context.Context) (tun.Device, string, error) {
}

type osConfig struct {
tunName string
tunIPv6 string
tunName string
tunIPv6 string
dnsAddr string
dnsZones []string
}

func dnsZones() ([]string, error) {
profileDir := profile.FullProfilePath(os.Getenv(types.HomeEnvVar))
profileNames, err := profile.ListProfileNames(profileDir)
if err != nil {
return nil, trace.Wrap(err, "listing profiles")
}
// profile names are Teleport proxy addresses.
// TODO(nklaassen): support leaf clusters and custom DNS zones.
// TODO(nklaassen): check if profiles are expired.
return profileNames, nil
}
Loading

0 comments on commit 2f15817

Please sign in to comment.