diff --git a/go.mod b/go.mod index 901c3b3b7d1dc..0d8d874230f46 100644 --- a/go.mod +++ b/go.mod @@ -133,6 +133,7 @@ require ( github.com/go-webauthn/webauthn v0.11.2 github.com/gobwas/ws v1.4.0 github.com/gocql/gocql v1.7.0 + github.com/godbus/dbus/v5 v5.2.2 github.com/gofrs/flock v0.13.0 github.com/gogo/protobuf v1.3.2 // replaced github.com/golang-jwt/jwt/v4 v4.5.2 @@ -428,7 +429,6 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect diff --git a/lib/vnet/admin_process_darwin.go b/lib/vnet/admin_process_darwin.go index 9a7b789657a50..09c6152e909a1 100644 --- a/lib/vnet/admin_process_darwin.go +++ b/lib/vnet/admin_process_darwin.go @@ -18,18 +18,16 @@ package vnet import ( "context" - "errors" - "os" - "time" "github.com/gravitational/trace" - "golang.org/x/sync/errgroup" - "golang.zx2c4.com/wireguard/tun" - vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/vnet/daemon" ) +const ( + tunInterfaceName = "utun" +) + // RunDarwinAdminProcess must run as root. It creates and sets up a TUN device // and passes the file descriptor for that device over the unix socket found at // config.socketPath. @@ -54,101 +52,5 @@ func RunDarwinAdminProcess(ctx context.Context, config daemon.Config) error { } defer clt.close() - tun, tunName, err := createTUNDevice(ctx) - if err != nil { - return trace.Wrap(err) - } - defer tun.Close() - - networkStackConfig, err := newNetworkStackConfig(ctx, tun, clt) - if err != nil { - return trace.Wrap(err, "creating network stack config") - } - networkStack, err := newNetworkStack(networkStackConfig) - if err != nil { - return trace.Wrap(err, "creating network stack") - } - - if err := clt.ReportNetworkStackInfo(ctx, &vnetv1.NetworkStackInfo{ - InterfaceName: tunName, - Ipv6Prefix: networkStackConfig.ipv6Prefix.String(), - }); err != nil { - return trace.Wrap(err, "reporting network stack info to client application") - } - - osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ - clt: clt, - tunName: tunName, - ipv6Prefix: networkStackConfig.ipv6Prefix.String(), - dnsIPv6: networkStackConfig.dnsIPv6.String(), - addDNSAddress: networkStack.addDNSAddress, - }) - if err != nil { - return trace.Wrap(err, "creating OS config provider") - } - osConfigurator := newOSConfigurator(osConfigProvider) - - g, ctx := errgroup.WithContext(ctx) - g.Go(func() error { - defer log.InfoContext(ctx, "Network stack terminated.") - if err := networkStack.run(ctx); err != nil { - return trace.Wrap(err, "running network stack") - } - return errors.New("network stack terminated") - }) - g.Go(func() error { - defer log.InfoContext(ctx, "OS configuration loop exited.") - if err := osConfigurator.runOSConfigurationLoop(ctx); err != nil { - return trace.Wrap(err, "running OS configuration loop") - } - return errors.New("OS configuration loop terminated") - }) - g.Go(func() error { - defer log.InfoContext(ctx, "Ping loop exited.") - tick := time.Tick(time.Second) - for { - select { - case <-tick: - if err := clt.Ping(ctx); err != nil { - return trace.Wrap(err, "failed to ping client application, it may have exited, shutting down") - } - case <-ctx.Done(): - return ctx.Err() - } - } - }) - - done := make(chan error) - go func() { - done <- g.Wait() - }() - - select { - case err := <-done: - return trace.Wrap(err, "running VNet admin process") - case <-ctx.Done(): - } - - select { - case err := <-done: - // network stack exited cleanly within timeout - return trace.Wrap(err, "running VNet admin process") - case <-time.After(10 * time.Second): - log.ErrorContext(ctx, "VNet admin process did not exit within 10 seconds, forcing shutdown.") - os.Exit(1) - return nil - } -} - -func createTUNDevice(ctx context.Context) (tun.Device, string, error) { - log.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 + return runUnixAdminProcess(ctx, clt, tunInterfaceName) } diff --git a/lib/vnet/admin_process_linux.go b/lib/vnet/admin_process_linux.go new file mode 100644 index 0000000000000..5679d962be0e0 --- /dev/null +++ b/lib/vnet/admin_process_linux.go @@ -0,0 +1,47 @@ +// 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 . + +package vnet + +import ( + "context" + + "github.com/gravitational/trace" +) + +const ( + tunInterfaceName = "TeleportVNet" +) + +// LinuxAdminProcessConfig configures RunLinuxAdminProcess. +type LinuxAdminProcessConfig struct { + // ClientApplicationServiceSocketPath is the unix socket path of the client + // application service. + ClientApplicationServiceSocketPath string +} + +// RunLinuxAdminProcess must run as root. +func RunLinuxAdminProcess(ctx context.Context, config LinuxAdminProcessConfig) error { + log.InfoContext(ctx, "Running VNet admin process") + + clt, err := newUnixClientApplicationServiceClient(ctx, config.ClientApplicationServiceSocketPath) + if err != nil { + return trace.Wrap(err, "creating user process client") + } + defer clt.close() + + return runUnixAdminProcess(ctx, clt, tunInterfaceName) +} diff --git a/lib/vnet/admin_process_unix.go b/lib/vnet/admin_process_unix.go new file mode 100644 index 0000000000000..2173be88e7ed5 --- /dev/null +++ b/lib/vnet/admin_process_unix.go @@ -0,0 +1,133 @@ +// Teleport +// Copyright (C) 2025 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 . + +//go:build darwin || linux + +package vnet + +import ( + "context" + "errors" + "os" + "time" + + "github.com/gravitational/trace" + "golang.org/x/sync/errgroup" + "golang.zx2c4.com/wireguard/tun" + + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" +) + +func runUnixAdminProcess(ctx context.Context, clt *clientApplicationServiceClient, tunInterfaceName string) error { + tun, tunName, err := createTUNDevice(ctx, tunInterfaceName) + if err != nil { + return trace.Wrap(err) + } + defer tun.Close() + + networkStackConfig, err := newNetworkStackConfig(ctx, tun, clt) + if err != nil { + return trace.Wrap(err, "creating network stack config") + } + networkStack, err := newNetworkStack(networkStackConfig) + if err != nil { + return trace.Wrap(err, "creating network stack") + } + + if err := clt.ReportNetworkStackInfo(ctx, &vnetv1.NetworkStackInfo{ + InterfaceName: tunName, + Ipv6Prefix: networkStackConfig.ipv6Prefix.String(), + }); err != nil { + return trace.Wrap(err, "reporting network stack info to client application") + } + + osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ + clt: clt, + tunName: tunName, + ipv6Prefix: networkStackConfig.ipv6Prefix.String(), + dnsIPv6: networkStackConfig.dnsIPv6.String(), + addDNSAddress: networkStack.addDNSAddress, + }) + if err != nil { + return trace.Wrap(err, "creating OS config provider") + } + osConfigurator := newOSConfigurator(osConfigProvider) + + g, ctx := errgroup.WithContext(ctx) + g.Go(func() error { + defer log.InfoContext(ctx, "Network stack terminated.") + if err := networkStack.run(ctx); err != nil { + return trace.Wrap(err, "running network stack") + } + return errors.New("network stack terminated") + }) + g.Go(func() error { + defer log.InfoContext(ctx, "OS configuration loop exited.") + if err := osConfigurator.runOSConfigurationLoop(ctx); err != nil { + return trace.Wrap(err, "running OS configuration loop") + } + return errors.New("OS configuration loop terminated") + }) + g.Go(func() error { + defer log.InfoContext(ctx, "Ping loop exited.") + tick := time.Tick(time.Second) + for { + select { + case <-tick: + if err := clt.Ping(ctx); err != nil { + return trace.Wrap(err, "failed to ping client application, it may have exited, shutting down") + } + case <-ctx.Done(): + return ctx.Err() + } + } + }) + + done := make(chan error) + go func() { + done <- g.Wait() + }() + + select { + case err := <-done: + return trace.Wrap(err, "running VNet admin process") + case <-ctx.Done(): + } + + select { + case err := <-done: + // network stack exited cleanly within timeout + return trace.Wrap(err, "running VNet admin process") + case <-time.After(10 * time.Second): + log.ErrorContext(ctx, "VNet admin process did not exit within 10 seconds, forcing shutdown.") + os.Exit(1) + return nil + } +} + +func createTUNDevice(ctx context.Context, interfaceName string) (tun.Device, string, error) { + log.DebugContext(ctx, "Creating TUN device.") + dev, err := tun.CreateTUN(interfaceName, mtu) + if err != nil { + return nil, "", trace.Wrap(err, "creating TUN device") + } + name, err := dev.Name() + if err != nil { + dev.Close() + return nil, "", trace.Wrap(err, "getting TUN device name") + } + return dev, name, nil +} diff --git a/lib/vnet/client_application_service_client.go b/lib/vnet/client_application_service_client.go index ffee0471ba349..8028a5482e205 100644 --- a/lib/vnet/client_application_service_client.go +++ b/lib/vnet/client_application_service_client.go @@ -40,6 +40,8 @@ type clientApplicationServiceClient struct { conn *grpc.ClientConn } +// newClientApplicationServiceClient creates a gRPC client over a TCP +// socket using mTLS credentials. func newClientApplicationServiceClient(ctx context.Context, creds *credentials, addr string) (*clientApplicationServiceClient, error) { tlsConfig, err := creds.clientTLSConfig() if err != nil { diff --git a/lib/vnet/client_application_service_client_linux.go b/lib/vnet/client_application_service_client_linux.go new file mode 100644 index 0000000000000..ba72350878339 --- /dev/null +++ b/lib/vnet/client_application_service_client_linux.go @@ -0,0 +1,49 @@ +// Teleport +// Copyright (C) 2026 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 . + +package vnet + +import ( + "context" + "fmt" + + "github.com/gravitational/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/gravitational/teleport/api/utils/grpc/interceptors" + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" +) + +// newUnixClientApplicationServiceClient creates a gRPC client over a Unix +// socket without TLS. +func newUnixClientApplicationServiceClient(_ context.Context, socketPath string) (*clientApplicationServiceClient, error) { + if socketPath == "" { + return nil, trace.BadParameter("missing unix socket path") + } + conn, err := grpc.NewClient(fmt.Sprintf("unix://%s", socketPath), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(interceptors.GRPCClientUnaryErrorInterceptor), + grpc.WithStreamInterceptor(interceptors.GRPCClientStreamErrorInterceptor), + ) + if err != nil { + return nil, trace.Wrap(err, "creating user process gRPC client") + } + return &clientApplicationServiceClient{ + clt: vnetv1.NewClientApplicationServiceClient(conn), + conn: conn, + }, nil +} diff --git a/lib/vnet/dbus_client_linux.go b/lib/vnet/dbus_client_linux.go new file mode 100644 index 0000000000000..c7912169f7308 --- /dev/null +++ b/lib/vnet/dbus_client_linux.go @@ -0,0 +1,71 @@ +// Teleport +// Copyright (C) 2026 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 . + +package vnet + +import ( + "context" + + "github.com/godbus/dbus/v5" + "github.com/gravitational/trace" +) + +// startService is called from the normal user process to start +// the privileged VNet daemon. It connects to the system D-Bus +// and calls the corresponding Start method, exposed on the VNet +// D-Bus interface, passing the client service unix socket path. +func startService(ctx context.Context, cfg LinuxAdminProcessConfig) error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return trace.NotFound("system D-Bus is unavailable: %v", err) + } + defer conn.Close() + + // basically this corresponds to calling something like + // `busctl --system call org.teleport.vnet1 /org/teleport/vnet1 org.teleport.vnet1.Daemon Start s ""` + // each D-Bus service owns a well-known name you refer to, then you specify an + // object path. object path is for granularity (a service can expose + // multiple objects, but it rarely used, so in our case it is the same as the name but + // slash-separated). then you call a method on a specific interface. the interface + // is implemented by some object, for vnet it’s the dbusDaemon struct. the D-Bus + // interface exposes the same methods as dbusDaemon, so we can call them over + // D-Bus. + obj := conn.Object(vnetDBusServiceName, dbus.ObjectPath(vnetDBusObjectPath)) + call := obj.CallWithContext(ctx, vnetDBusStartMethod, 0, cfg.ClientApplicationServiceSocketPath) + if call.Err != nil { + return trace.Wrap(call.Err, "calling D-Bus Start") + } + return nil +} + +// stopService is called from the normal user process to stop +// the privileged VNet daemon. It connects to the system D-Bus +// and calls the corresponding Stop method, exposed on the VNet +// D-Bus interface. +func stopService(ctx context.Context) error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return trace.NotFound("system D-Bus is unavailable: %v", err) + } + defer conn.Close() + + obj := conn.Object(vnetDBusServiceName, dbus.ObjectPath(vnetDBusObjectPath)) + call := obj.CallWithContext(ctx, vnetDBusStopMethod, 0) + if call.Err != nil { + return trace.Wrap(call.Err, "calling D-Bus Stop") + } + return nil +} diff --git a/lib/vnet/dbus_linux.go b/lib/vnet/dbus_linux.go new file mode 100644 index 0000000000000..678bdf3301ea9 --- /dev/null +++ b/lib/vnet/dbus_linux.go @@ -0,0 +1,46 @@ +// Teleport +// Copyright (C) 2026 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 . + +package vnet + +// VNet's D-Bus daemon claims the org.teleport.vnet1 service name +// and exposes the org.teleport.vnet1.Daemon interface, which has Start +// and Stop methods. For it to work the system must include: +// - /usr/share/dbus-1/system.d/org.teleport.vnet1.conf to allow the daemon to +// claim the org.teleport.vnet1 name on the system bus. +// - /usr/share/dbus-1/system-services/org.teleport.vnet1.service to enable +// D-Bus activation of the daemon. +// - /usr/lib/systemd/system/teleport-vnet.service to manage the daemon +// lifecycle under systemd. +// - /usr/share/polkit-1/actions/org.teleport.vnet1.policy to define who can +// perform the org.teleport.vnet1.manage-daemon action (and therefore call +// Start and Stop methods). +// +// The daemon is managed by systemd. we don’t use systemd directly +// because `systemctl start` takes only unit names and doesn’t let us pass +// per start args. the closest options are template units or environment +// file, but those are clunky so we wrap the admin process in a D-Bus daemon +// and expose Start with explicit parameters. +const ( + vnetDBusServiceName = "org.teleport.vnet1" + vnetDBusObjectPath = "/org/teleport/vnet1" + vnetDBusInterface = "org.teleport.vnet1.Daemon" + vnetDBusStartMethod = vnetDBusInterface + ".Start" + vnetDBusStopMethod = vnetDBusInterface + ".Stop" + // vnetPolkitAction must match the action ID defined in the polkit policy file. + vnetPolkitAction = "org.teleport.vnet1.manage-daemon" + vnetSystemdUnitName = "teleport-vnet.service" +) diff --git a/lib/vnet/dbus_service_linux.go b/lib/vnet/dbus_service_linux.go new file mode 100644 index 0000000000000..9f579d3ebe340 --- /dev/null +++ b/lib/vnet/dbus_service_linux.go @@ -0,0 +1,263 @@ +// Teleport +// Copyright (C) 2026 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 . + +package vnet + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/vnet/polkit" +) + +const polkitAuthorizationTimeout = 30 * time.Second + +// introspectNode describes the exported D-Bus API. Update it if any method +// signature is changed or new methods are added. +// +// D-Bus is strict about method signatures. If a client sends a different number +// or type of arguments than the service expects, the call fails with an error. +// There is no built-in forward or backward compatibility for method arguments. +// +// In practice this is unlikely to be a concern because the systemd unit +// typically runs the same tsh binary as the client, so both sides are expected +// to be the same version. +var introspectNode = &introspect.Node{ + Name: vnetDBusObjectPath, + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + { + Name: vnetDBusInterface, + Methods: []introspect.Method{ + { + Name: "Start", + Args: []introspect.Arg{ + {Name: "socketPath", Type: "s", Direction: "in"}, + }, + }, + {Name: "Stop"}, + }, + }, + }, +} + +// RunLinuxDBusService runs the privileged VNet D-Bus daemon on the system bus. +// It claims the VNet service name and exports the VNet interface that +// exposes Start and Stop methods that normal client processes can call via +// system D-Bus. The daemon blocks until the context is canceled. +func RunLinuxDBusService(ctx context.Context) error { + daemon, err := newDBusDaemon() + if err != nil { + return trace.Wrap(err) + } + + stop := context.AfterFunc(ctx, daemon.Close) + defer stop() + + return trace.Wrap(daemon.Wait()) +} + +func newDBusDaemon() (_ *dbusDaemon, err error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, trace.Wrap(err, "connecting to system D-Bus") + } + ctx, cancel := context.WithCancel(context.Background()) + defer func() { + if err != nil { + cancel() + _ = conn.Close() + } + }() + + daemon := &dbusDaemon{ + conn: conn, + done: make(chan error, 1), + startAdminProcess: func(socketPath string) error { + return trace.Wrap(RunLinuxAdminProcess(ctx, LinuxAdminProcessConfig{ + ClientApplicationServiceSocketPath: socketPath, + })) + }, + cancelAdminProcess: cancel, + } + + if err := conn.Export(daemon, dbus.ObjectPath(vnetDBusObjectPath), vnetDBusInterface); err != nil { + return nil, trace.Wrap(err, "exporting D-Bus object") + } + if err := conn.Export( + introspect.NewIntrospectable(introspectNode), + dbus.ObjectPath(vnetDBusObjectPath), + "org.freedesktop.DBus.Introspectable", + ); err != nil { + return nil, trace.Wrap(err, "exporting D-Bus introspection") + } + + reply, err := conn.RequestName(vnetDBusServiceName, dbus.NameFlagDoNotQueue) + if err != nil { + return nil, trace.Wrap(err, "requesting D-Bus name") + } + if reply != dbus.RequestNameReplyPrimaryOwner { + return nil, trace.Errorf("D-Bus name %s is already owned", vnetDBusServiceName) + } + + log.InfoContext(context.Background(), "Acquired D-Bus name", "name", vnetDBusServiceName) + return daemon, nil +} + +type dbusDaemon struct { + mu sync.Mutex + conn *dbus.Conn + started bool + closing bool + done chan error // buffered 1; receives the admin process error or nil + + startAdminProcess func(socketPath string) error + cancelAdminProcess context.CancelFunc +} + +func (d *dbusDaemon) Close() { + d.mu.Lock() + if d.closing { + d.mu.Unlock() + return + } + d.closing = true + started := d.started + d.mu.Unlock() + + d.cancelAdminProcess() + _ = d.conn.Close() + + // If no admin process goroutine was started, unblock Wait. + if !started { + d.done <- nil + } +} + +func (d *dbusDaemon) Wait() error { + err := <-d.done + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} + +// Start starts actual VNet admin process with passed unix socket path. +// It uses polkit to authorize the calling D-Bus sender. +// It returns an error if the admin process has already been started. +func (d *dbusDaemon) Start(socketPath string, sender dbus.Sender) *dbus.Error { + uid, err := d.authorize(sender) + if err != nil { + return dbus.MakeFailedError(trace.Wrap(err, "authorization failed")) + } + + d.mu.Lock() + defer d.mu.Unlock() + if d.closing { + return dbus.MakeFailedError(trace.Errorf("VNet D-Bus daemon is shutting down")) + } + if d.started { + return dbus.MakeFailedError(trace.Errorf("VNet admin process already started")) + } + d.started = true + log.InfoContext(context.Background(), "Starting VNet admin process", "uid", uid) + + go func() { + err := d.startAdminProcess(socketPath) + // TODO(tangyatsu): D-Bus supports signals, we might want to emit a signal when the admin process exits. + if err != nil && !errors.Is(err, context.Canceled) { + log.ErrorContext(context.Background(), "VNet admin process exited with error", "error", err) + } else { + log.InfoContext(context.Background(), "VNet admin process exited") + } + d.done <- err + d.Close() + }() + + return nil +} + +// Stop stops actual VNet admin process and exits the daemon. +// It uses polkit to authorize the calling D-Bus sender. +func (d *dbusDaemon) Stop(sender dbus.Sender) *dbus.Error { + uid, err := d.authorize(sender) + if err != nil { + return dbus.MakeFailedError(trace.Wrap(err, "authorization failed")) + } + // We intentionally do not reset started here to avoid a race with Start + // while the process is exiting. A new Start is allowed only after + // a new daemon instance is started. + // + // D-Bus activation can start the daemon on any method call. We allow + // Stop before Start so the service can exit immediately instead of idling + // waiting for a Start call that may never come. + log.InfoContext(context.Background(), "Stopping VNet admin process", "uid", uid) + d.Close() + return nil +} + +// authorize checks polkit authorization for the calling D-Bus sender and +// returns the sender UID. +func (d *dbusDaemon) authorize(sender dbus.Sender) (uint32, error) { + uid, err := d.lookupSenderUID(sender) + if err != nil { + return 0, trace.Wrap(err, "looking up D-Bus sender UID") + } + if uid == 0 { + // Always allow root to start the daemon. + return uid, nil + } + + authCtx, cancel := context.WithTimeout(d.conn.Context(), polkitAuthorizationTimeout) + defer cancel() + + subject := polkit.NewSystemBusNameSubject(string(sender)) + result, err := polkit.CheckAuthorization( + authCtx, + d.conn, + subject, + vnetPolkitAction, + map[string]string{}, + true, + "", + ) + if err != nil { + return 0, err + } + if !result.Authorized { + if result.Challenge { + return 0, trace.AccessDenied("polkit authentication required") + } + return 0, trace.AccessDenied("polkit authorization denied") + } + return uid, nil +} + +func (d *dbusDaemon) lookupSenderUID(sender dbus.Sender) (uint32, error) { + var uid uint32 + if err := d.conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus"). + Call("org.freedesktop.DBus.GetConnectionUnixUser", 0, string(sender)). + Store(&uid); err != nil { + return 0, trace.Wrap(err, "querying D-Bus sender UID") + } + return uid, nil +} diff --git a/lib/vnet/diag/routeconflict_other.go b/lib/vnet/diag/routeconflict_other.go index 5ca8360dabd60..8bdfd68af76f4 100644 --- a/lib/vnet/diag/routeconflict_other.go +++ b/lib/vnet/diag/routeconflict_other.go @@ -25,6 +25,8 @@ import ( "github.com/gravitational/trace" ) +// TODO(tangyatsu): linux diagnostics + func (n *NetInterfaces) interfaceApp(ctx context.Context, ifaceName string) (string, error) { return "", trace.NotImplemented("InterfaceApp is not implemented") } diff --git a/lib/vnet/dns/dns.go b/lib/vnet/dns/dns.go index fac646f231256..700b0274cc126 100644 --- a/lib/vnet/dns/dns.go +++ b/lib/vnet/dns/dns.go @@ -29,8 +29,6 @@ import ( "golang.org/x/net/dns/dnsmessage" "golang.org/x/sync/errgroup" "gvisor.dev/gvisor/pkg/tcpip" - - "github.com/gravitational/teleport" ) const ( @@ -88,7 +86,7 @@ type Server struct { // NewServer returns a DNS server that handles the details of the DNS protocol and asks [resolver] to answer // DNS questions. If [resolver] has no answer, requests will be forwarded to the upstream nameservers provided // by [upstreamNameserverSource]. -func NewServer(resolver Resolver, upstreamNameserverSource UpstreamNameserverSource) (*Server, error) { +func NewServer(resolver Resolver, upstreamNameserverSource UpstreamNameserverSource, logger *slog.Logger) (*Server, error) { return &Server{ resolver: resolver, upstreamNameserverSource: upstreamNameserverSource, @@ -98,7 +96,7 @@ func NewServer(resolver Resolver, upstreamNameserverSource UpstreamNameserverSou return &buf }, }, - slog: slog.With(teleport.ComponentKey, teleport.Component("vnet", "dns")), + slog: logger, }, nil } diff --git a/lib/vnet/dns/dns_test.go b/lib/vnet/dns/dns_test.go index 9d981c977dd03..c061330759963 100644 --- a/lib/vnet/dns/dns_test.go +++ b/lib/vnet/dns/dns_test.go @@ -59,7 +59,7 @@ func TestServer(t *testing.T) { // Create two upstream nameservers that are able to resolve A and AAAA records for all names. var upstreamAddrs []string for i := 0; i < 2; i++ { - upstreamServer, err := NewServer(staticResolver, noUpstreams) + upstreamServer, err := NewServer(staticResolver, noUpstreams, slog.Default()) require.NoError(t, err) conn, err := net.ListenUDP("udp", udpLocalhost) require.NoError(t, err) @@ -108,7 +108,7 @@ func TestServer(t *testing.T) { }, } upstreams := &stubUpstreamNamservers{nameservers: upstreamAddrs} - server, err := NewServer(resolver, upstreams) + server, err := NewServer(resolver, upstreams, slog.Default()) require.NoError(t, err) conn, err := net.ListenUDP("udp", udpLocalhost) diff --git a/lib/vnet/dns/osnameservers.go b/lib/vnet/dns/osnameservers.go index f8678e6652879..79e1c60a684ff 100644 --- a/lib/vnet/dns/osnameservers.go +++ b/lib/vnet/dns/osnameservers.go @@ -18,6 +18,7 @@ package dns import ( "context" + "log/slog" "net/netip" "time" @@ -31,10 +32,11 @@ import ( // these nameservers. type OSUpstreamNameserverSource struct { ttlCache *utils.FnCache + slog *slog.Logger } // NewOSUpstreamNameserverSource returns a new *OSUpstreamNameserverSource. -func NewOSUpstreamNameserverSource() (*OSUpstreamNameserverSource, error) { +func NewOSUpstreamNameserverSource(logger *slog.Logger) (*OSUpstreamNameserverSource, error) { ttlCache, err := utils.NewFnCache(utils.FnCacheConfig{ TTL: 10 * time.Second, }) @@ -43,19 +45,21 @@ func NewOSUpstreamNameserverSource() (*OSUpstreamNameserverSource, error) { } return &OSUpstreamNameserverSource{ ttlCache: ttlCache, + slog: logger, }, nil } // UpstreamNameservers returns a cached view of the host OS's current default // nameservers. func (s *OSUpstreamNameserverSource) UpstreamNameservers(ctx context.Context) ([]string, error) { - return utils.FnCacheGet(ctx, s.ttlCache, 0, loadUpstreamNameservers) + return utils.FnCacheGet(ctx, s.ttlCache, 0, s.loadUpstreamNameservers) } -func loadUpstreamNameservers(ctx context.Context) ([]string, error) { - return platformLoadUpstreamNameservers(ctx) +func (s *OSUpstreamNameserverSource) loadUpstreamNameservers(ctx context.Context) ([]string, error) { + return platformLoadUpstreamNameservers(ctx, s.slog) } -func withDNSPort(addr netip.Addr) string { +// AddrWithDNSPort returns addr with DNS port 53. +func AddrWithDNSPort(addr netip.Addr) string { return netip.AddrPortFrom(addr, 53).String() } diff --git a/lib/vnet/dns/osnameservers_darwin.go b/lib/vnet/dns/osnameservers_darwin.go index 8f765ad4a9701..945c85bb017f9 100644 --- a/lib/vnet/dns/osnameservers_darwin.go +++ b/lib/vnet/dns/osnameservers_darwin.go @@ -37,7 +37,7 @@ const ( // with the current default nameservers as configured for the OS, and it is the // easiest place to read them. Eventually we should probably use a better // method, but for now this works. -func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) { +func platformLoadUpstreamNameservers(ctx context.Context, slog *slog.Logger) ([]string, error) { f, err := os.Open(confFilePath) if err != nil { return nil, trace.Wrap(err, "opening %s", confFilePath) @@ -63,7 +63,7 @@ func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) { continue } - nameservers = append(nameservers, withDNSPort(ip)) + nameservers = append(nameservers, AddrWithDNSPort(ip)) } slog.DebugContext(ctx, "Loaded host upstream nameservers.", "nameservers", nameservers, "config_file", confFilePath) diff --git a/lib/vnet/dns/osnameservers_darwin_test.go b/lib/vnet/dns/osnameservers_darwin_test.go index c33df777bef67..b19ca51293e26 100644 --- a/lib/vnet/dns/osnameservers_darwin_test.go +++ b/lib/vnet/dns/osnameservers_darwin_test.go @@ -20,6 +20,7 @@ package dns import ( "context" + "log/slog" "net" "testing" @@ -39,9 +40,9 @@ func TestOSUpstreamNameservers(t *testing.T) { t.Cleanup(cancel) resolver := &stubResolver{} - upstreams, err := NewOSUpstreamNameserverSource() + upstreams, err := NewOSUpstreamNameserverSource(slog.Default()) require.NoError(t, err) - server, err := NewServer(resolver, upstreams) + server, err := NewServer(resolver, upstreams, slog.Default()) require.NoError(t, err) conn, err := net.ListenUDP("udp", udpLocalhost) diff --git a/lib/vnet/dns/osnameservers_linux.go b/lib/vnet/dns/osnameservers_linux.go new file mode 100644 index 0000000000000..fe7c3ef478af8 --- /dev/null +++ b/lib/vnet/dns/osnameservers_linux.go @@ -0,0 +1,75 @@ +// Teleport +// Copyright (C) 2026 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 . + +package dns + +import ( + "context" + "log/slog" + "net" + "net/netip" + + "github.com/godbus/dbus/v5" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/vnet/systemdresolved" +) + +// platformLoadUpstreamNameservers returns the list of DNS upstreams configured in systemd-resolved. +func platformLoadUpstreamNameservers(ctx context.Context, slog *slog.Logger) ([]string, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, trace.NotFound("system D-Bus is unavailable: %v", err) + } + defer conn.Close() + if err := systemdresolved.CheckAvailability(ctx, conn); err != nil { + return nil, err + } + + dns, err := systemdresolved.LoadConfiguredDNSServers(ctx, conn) + if err != nil { + return nil, err + } + + nameservers := make([]string, 0, len(dns)) + for _, entry := range dns { + addr, ok := netip.AddrFromSlice(entry.Address) + if !ok { + slog.DebugContext(ctx, "Skipping invalid DNS server address", "address_bytes", entry.Address) + continue + } + if addr.IsUnspecified() || addr.IsLoopback() { + continue + } + // Link-local IPv6 addresses require a scope to be routable. + if addr.Is6() && addr.IsLinkLocalUnicast() { + if entry.InterfaceIndex == 0 { + slog.DebugContext(ctx, "Skipping link-local DNS server without interface index", "address", addr.String()) + continue + } + iface, err := net.InterfaceByIndex(int(entry.InterfaceIndex)) + if err != nil { + slog.DebugContext(ctx, "Skipping link-local DNS server with unknown interface", "address", addr.String(), "interface_index", entry.InterfaceIndex, "error", err) + continue + } + addr = addr.WithZone(iface.Name) + } + nameservers = append(nameservers, AddrWithDNSPort(addr)) + } + + slog.DebugContext(ctx, "Loaded host upstream nameservers", "nameservers", nameservers) + return nameservers, nil +} diff --git a/lib/vnet/dns/osnameservers_other.go b/lib/vnet/dns/osnameservers_other.go index 3cc9d156f0d6a..9dfdc28c8b9ad 100644 --- a/lib/vnet/dns/osnameservers_other.go +++ b/lib/vnet/dns/osnameservers_other.go @@ -14,12 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//go:build !darwin && !windows +//go:build !darwin && !windows && !linux package dns import ( "context" + "log/slog" "runtime" "github.com/gravitational/trace" @@ -28,11 +29,8 @@ import ( var ( // vnetNotImplemented is an error indicating that VNet is not implemented on the host OS. vnetNotImplemented = &trace.NotImplementedError{Message: "VNet is not implemented on " + runtime.GOOS} - - // Satisfy unused linter. - _ = withDNSPort ) -func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) { +func platformLoadUpstreamNameservers(ctx context.Context, slog *slog.Logger) ([]string, error) { return nil, trace.Wrap(vnetNotImplemented) } diff --git a/lib/vnet/dns/osnameservers_windows.go b/lib/vnet/dns/osnameservers_windows.go index 2db2063761bd9..7040ec77392ec 100644 --- a/lib/vnet/dns/osnameservers_windows.go +++ b/lib/vnet/dns/osnameservers_windows.go @@ -30,7 +30,7 @@ import ( // platformLoadUpstreamNameservers attempts to find the default DNS nameservers // that VNet should forward unmatched queries to. To do this, it finds the // nameservers configured for each interface and sorts by the interface metric. -func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) { +func platformLoadUpstreamNameservers(ctx context.Context, slog *slog.Logger) ([]string, error) { interfaces, err := winipcfg.GetIPInterfaceTable(windows.AF_INET) if err != nil { return nil, trace.Wrap(err, "looking up local network interfaces") @@ -48,7 +48,7 @@ func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) { if ignoreUpstreamNameserver(ifaceNameserver) { continue } - nameservers = append(nameservers, withDNSPort(ifaceNameserver)) + nameservers = append(nameservers, AddrWithDNSPort(ifaceNameserver)) } } slog.DebugContext(ctx, "Loaded host upstream nameservers", "nameservers", nameservers) diff --git a/lib/vnet/escalate_linux.go b/lib/vnet/escalate_linux.go new file mode 100644 index 0000000000000..8d633f0713c42 --- /dev/null +++ b/lib/vnet/escalate_linux.go @@ -0,0 +1,199 @@ +// Teleport +// Copyright (C) 2026 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 . + +package vnet + +import ( + "context" + "os" + "os/exec" + "slices" + "time" + + systemddbus "github.com/coreos/go-systemd/v22/dbus" + "github.com/godbus/dbus/v5" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" +) + +const ( + terminateTimeout = 30 * time.Second +) + +// systemdUnitActiveState is the ActiveState property of a systemd unit. +// The values and descriptions below are copied from the official +// org.freedesktop.systemd1 D-Bus interface documentation. +type systemdUnitState string + +const ( + // systemdUnitActive means started, bound, plugged in, …, depending on the unit type. + systemdUnitActive systemdUnitState = "active" + // systemdUnitInactive means stopped, unbound, unplugged, …, depending on the unit type. + systemdUnitInactive systemdUnitState = "inactive" + // systemdUnitFailed means similar to inactive, but the unit failed in some way (process returned error code on exit, crashed, an operation timed out, or after too many restarts). + systemdUnitFailed systemdUnitState = "failed" + // systemdUnitActivating means changing from inactive to active. + systemdUnitActivating systemdUnitState = "activating" +) + +func execAdminProcess(ctx context.Context, cfg LinuxAdminProcessConfig) error { + if err := checkDBusServiceAvailability(ctx); err != nil { + if os.Geteuid() == 0 { + log.WarnContext(ctx, "VNet daemon not available via D-Bus, running daemon as a child process", "error", err) + return trace.Wrap(runAdminSubcommand(ctx, cfg)) + } else { + return trace.Wrap(err) + } + } + + return trace.Wrap(runService(ctx, cfg)) +} + +func runService(ctx context.Context, cfg LinuxAdminProcessConfig) error { + err := startService(ctx, cfg) + if err != nil { + return trace.Wrap(err) + } + log.InfoContext(ctx, "Started systemd service", "service", vnetSystemdUnitName) + + conn, err := systemddbus.NewWithContext(ctx) + if err != nil { + return trace.NotFound("systemd D-Bus is unavailable: %v", err) + } + defer conn.Close() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + stopCtx, stopCancel := context.WithTimeout(context.Background(), terminateTimeout) + defer stopCancel() + log.InfoContext(stopCtx, "Context canceled, stopping systemd service") + if err := stopService(stopCtx); err != nil { + return trace.Wrap(err, "sending stop request to systemd service %s", vnetSystemdUnitName) + } + err := waitForServiceStop(stopCtx, vnetSystemdUnitName) + if err != nil { + return trace.Wrap(err, "systemd service %s failed to stop with %v", vnetSystemdUnitName, terminateTimeout) + } + log.InfoContext(stopCtx, "Successfully stopped systemd service") + return nil + case <-ticker.C: + state, err := getSystemdUnitState(ctx, conn, vnetSystemdUnitName) + if err != nil { + return trace.Wrap(err, "querying systemd service %s", vnetSystemdUnitName) + } + if state != systemdUnitActive && state != systemdUnitActivating { + return trace.Errorf("service stopped running prematurely, status: %s", state) + } + } + } +} + +func waitForServiceStop(ctx context.Context, unit string) error { + // Open a fresh connection here because the main loop's connection is bound to + // the original context and may be closed when that context is canceled. + conn, err := systemddbus.NewWithContext(ctx) + if err != nil { + return trace.NotFound("systemd D-Bus is unavailable: %v", err) + } + defer conn.Close() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + state, err := getSystemdUnitState(ctx, conn, unit) + if err != nil { + return trace.Wrap(err, "querying systemd service %s", unit) + } + if state == systemdUnitInactive || state == systemdUnitFailed { + return nil + } + } + } +} + +func getSystemdUnitState(ctx context.Context, conn *systemddbus.Conn, unit string) (systemdUnitState, error) { + props, err := conn.GetUnitPropertiesContext(ctx, unit) + if err != nil { + return "", err + } + state, ok := props["ActiveState"].(string) + if !ok || state == "" { + return "", trace.Errorf("systemd ActiveState is missing for %s", unit) + } + return systemdUnitState(state), nil +} + +func checkDBusServiceAvailability(ctx context.Context) error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return trace.Wrap(err, "system D-Bus is unavailable") + } + defer conn.Close() + + // Check if the service is already running. + var hasOwner bool + err = conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus"). + CallWithContext(ctx, "org.freedesktop.DBus.NameHasOwner", 0, vnetDBusServiceName). + Store(&hasOwner) + if err != nil { + return trace.Wrap(err, "checking D-Bus service owner") + } + if hasOwner { + return nil + } + + // If it is not running, check that it can be activated via D-Bus. + var services []string + err = conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus"). + CallWithContext(ctx, "org.freedesktop.DBus.ListActivatableNames", 0). + Store(&services) + if err != nil { + return trace.Wrap(err, "listing activatable D-Bus names") + } + + if slices.Contains(services, vnetDBusServiceName) { + return nil + } else { + return trace.Errorf("D-Bus service %s is not available", vnetDBusServiceName) + } + // TODO(tangyatsu): Maybe also check the systemd unit file exists. D-Bus can report a name + // as activatable even if the corresponding systemd unit is missing. +} + +func runAdminSubcommand(ctx context.Context, cfg LinuxAdminProcessConfig) error { + executableName, err := os.Executable() + if err != nil { + return trace.Wrap(err, "getting executable path") + } + + cmd := exec.CommandContext(ctx, executableName, "-d", + teleport.VnetAdminSetupSubCommand, + "--socket", cfg.ClientApplicationServiceSocketPath, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return trace.Wrap(cmd.Run(), "running %s", teleport.VnetAdminSetupSubCommand) +} diff --git a/lib/vnet/network_stack.go b/lib/vnet/network_stack.go index b2bf30d67d9fa..207af902b47d1 100644 --- a/lib/vnet/network_stack.go +++ b/lib/vnet/network_stack.go @@ -21,6 +21,7 @@ import ( "errors" "log/slog" "net" + "net/netip" "os" "sync" @@ -49,6 +50,7 @@ var log = logutils.NewPackageLogger(teleport.ComponentKey, logComponent) const ( logComponent = "vnet" + dnsLogComponent = "dns" nicID = 1 mtu = 1500 tcpReceiveBufferSize = 0 // 0 means a default will be used. @@ -161,6 +163,8 @@ type networkStack struct { // dnsServer is the VNet's local DNS server that can handle UDP DNS // requests. dnsServer *dns.Server + // upstreamFilter removes VNet DNS addresses from upstream nameserver lists. + upstreamFilter *filteredUpstreamSource // tcpHandlerResolver resolves FQDNs to a TCP handler that will be used to handle all future TCP // connections to IP addresses that will be assigned to that FQDN. @@ -183,6 +187,55 @@ type networkStack struct { slog *slog.Logger } +type filteredUpstreamSource struct { + base dns.UpstreamNameserverSource + mu sync.RWMutex + exclude map[string]struct{} + slog *slog.Logger +} + +// filteredUpstreamSource wraps an upstream source and excludes addresses added via AddExclude. +// It is mainly used to filter VNet's own DNS addresses. +func newFilteredUpstreamSource(base dns.UpstreamNameserverSource, slog *slog.Logger) *filteredUpstreamSource { + return &filteredUpstreamSource{ + base: base, + exclude: make(map[string]struct{}), + slog: slog, + } +} + +func (f *filteredUpstreamSource) AddExclude(addr string) { + if addr == "" { + return + } + f.mu.Lock() + defer f.mu.Unlock() + f.exclude[addr] = struct{}{} +} + +func (f *filteredUpstreamSource) UpstreamNameservers(ctx context.Context) ([]string, error) { + nameservers, err := f.base.UpstreamNameservers(ctx) + if err != nil { + return nil, err + } + f.slog.DebugContext(ctx, "Loaded upstream nameservers (pre-filter)", "nameservers", nameservers) + f.mu.RLock() + defer f.mu.RUnlock() + if len(f.exclude) == 0 { + f.slog.DebugContext(ctx, "Loaded upstream nameservers (post-filter)", "nameservers", nameservers) + return nameservers, nil + } + filtered := make([]string, 0, len(nameservers)) + for _, nameserver := range nameservers { + if _, ok := f.exclude[nameserver]; ok { + continue + } + filtered = append(filtered, nameserver) + } + f.slog.DebugContext(ctx, "Loaded upstream nameservers (post-filter)", "nameservers", filtered) + return filtered, nil +} + type state struct { // mu is a single mutex that protects the whole state struct. This could be optimized as necessary. mu sync.RWMutex @@ -215,7 +268,8 @@ func newNetworkStack(cfg *networkStackConfig) (*networkStack, error) { if err := cfg.checkAndSetDefaults(); err != nil { return nil, trace.Wrap(err) } - slog := slog.With(teleport.ComponentKey, logComponent) + logger := slog.With(teleport.ComponentKey, logComponent) + dnsLogger := slog.With(teleport.ComponentKey, teleport.Component(logComponent, dnsLogComponent)) stack, linkEndpoint, err := createStack() if err != nil { @@ -234,17 +288,26 @@ func newNetworkStack(cfg *networkStackConfig) (*networkStack, error) { tcpHandlerResolver: cfg.tcpHandlerResolver, destroyed: make(chan struct{}), state: newState(), - slog: slog, + slog: logger, } upstreamNameserverSource := cfg.upstreamNameserverSource if upstreamNameserverSource == nil { - upstreamNameserverSource, err = dns.NewOSUpstreamNameserverSource() + upstreamNameserverSource, err = dns.NewOSUpstreamNameserverSource(dnsLogger) if err != nil { return nil, trace.Wrap(err) } } - dnsServer, err := dns.NewServer(ns, upstreamNameserverSource) + upstreamFilter := newFilteredUpstreamSource(upstreamNameserverSource, dnsLogger) + ns.upstreamFilter = upstreamFilter + if cfg.dnsIPv6 != (tcpip.Address{}) { + addr, ok := netip.AddrFromSlice(cfg.dnsIPv6.AsSlice()) + if !ok { + return nil, trace.Errorf("error parsing IPv6 DNS address %v", cfg.dnsIPv6) + } + upstreamFilter.AddExclude(dns.AddrWithDNSPort(addr)) + } + dnsServer, err := dns.NewServer(ns, upstreamFilter, dnsLogger) if err != nil { return nil, trace.Wrap(err) } @@ -260,7 +323,7 @@ func newNetworkStack(cfg *networkStackConfig) (*networkStack, error) { if err := ns.assignUDPHandler(cfg.dnsIPv6, dnsServer); err != nil { return nil, trace.Wrap(err) } - slog.DebugContext(context.Background(), "Serving DNS on IPv6.", "dns_addr", cfg.dnsIPv6) + logger.DebugContext(context.Background(), "Serving DNS on IPv6.", "dns_addr", cfg.dnsIPv6) } return ns, nil @@ -556,6 +619,13 @@ func (ns *networkStack) assignUDPHandler(addr tcpip.Address, handler udpHandler) // addDNSAddress adds a DNS handler at the given IP. func (ns *networkStack) addDNSAddress(ip net.IP) error { slog.DebugContext(context.Background(), "Serving DNS on IPv4.", "dns_addr", ip.String()) + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return trace.Errorf("error parsing IPv4 DNS address %s", ip.String()) + } + if ns.upstreamFilter != nil { + ns.upstreamFilter.AddExclude(dns.AddrWithDNSPort(addr)) + } return trace.Wrap(ns.assignUDPHandler(tcpip.AddrFromSlice(ip), ns.dnsServer), "adding UDP handler at %s", ip.String()) } diff --git a/lib/vnet/osconfig_linux.go b/lib/vnet/osconfig_linux.go new file mode 100644 index 0000000000000..d02df9e491d02 --- /dev/null +++ b/lib/vnet/osconfig_linux.go @@ -0,0 +1,186 @@ +// 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 . + +package vnet + +import ( + "context" + "errors" + "net" + "slices" + "strings" + + "github.com/godbus/dbus/v5" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/vnet/systemdresolved" +) + +type platformOSConfigState struct { + configuredIPv6 bool + configuredIPv4 bool + configuredCidrRanges []string + configuredNameserver bool + configuredDNSZones []string + broughtUpInterface bool + tunName string +} + +// platformConfigureOS configures the host OS according to cfg. +func platformConfigureOS(ctx context.Context, cfg *osConfig, state *platformOSConfigState) error { + // TODO(tangyatsu): use github.com/vishvananda/netlink to set up IPs and routes? + if cfg.tunIPv6 != "" && !state.configuredIPv6 { + log.InfoContext(ctx, "Setting IPv6 address for the TUN device.", "device", cfg.tunName, "address", cfg.tunIPv6) + addrWithPrefix := cfg.tunIPv6 + "/64" + if err := runCommand(ctx, + "ip", "addr", "add", addrWithPrefix, "dev", cfg.tunName, + ); err != nil { + return trace.Wrap(err) + } + state.configuredIPv6 = true + } + if cfg.tunIPv4 != "" && !state.configuredIPv4 { + log.InfoContext(ctx, "Setting IPv4 address for the TUN device.", + "device", cfg.tunName, "address", cfg.tunIPv4) + if err := runCommand(ctx, + "ip", "addr", "add", cfg.tunIPv4, "dev", cfg.tunName, + ); err != nil { + return trace.Wrap(err) + } + state.configuredIPv4 = true + } + if err := configureDNS(ctx, cfg, state); err != nil { + return trace.Wrap(err, "configuring DNS") + } + if (state.configuredIPv4 || state.configuredIPv6) && state.configuredNameserver && !state.broughtUpInterface { + log.InfoContext(ctx, "Bringing up the VNet interface", "device", cfg.tunName) + if err := runCommand(ctx, + "ip", "link", "set", cfg.tunName, "up", + ); err != nil { + return trace.Wrap(err) + } + state.broughtUpInterface = true + } + if cfg.tunIPv4 != "" && state.configuredIPv4 && state.broughtUpInterface { + for _, cidrRange := range cfg.cidrRanges { + if slices.Contains(state.configuredCidrRanges, cidrRange) { + continue + } + log.InfoContext(ctx, "Setting an IPv4 route", "netmask", cidrRange) + if err := runCommand(ctx, + "ip", "route", "add", cidrRange, "dev", cfg.tunName, + ); err != nil { + return trace.Wrap(err) + } + state.configuredCidrRanges = append(state.configuredCidrRanges, cidrRange) + } + } + return nil +} + +func shouldReconfigureDNSZones(cfg *osConfig, state *platformOSConfigState) bool { + return !utils.ContainSameUniqueElements(cfg.dnsZones, state.configuredDNSZones) +} + +func configureDNS(ctx context.Context, cfg *osConfig, state *platformOSConfigState) error { + // systemd-resolved stores DNS settings per network link. For VNet + // we configure DNS on the TUN link. The TUN is ephemeral, when + // the admin process exits and the TUN interface is deleted, + // systemd-resolved deletes the link and its DNS configuration. + // So we don't need additional DNS cleanup on restart + if cfg.tunName != "" { + state.tunName = cfg.tunName + } + if len(cfg.dnsAddrs) == 0 && len(cfg.dnsZones) > 0 { + return trace.BadParameter("empty nameserver with non-empty zones") + } + if len(cfg.dnsAddrs) > 0 && cfg.tunName == "" { + return trace.BadParameter("empty TUN interface name with non-empty nameserver") + } + deconfigure := len(cfg.dnsAddrs) == 0 && len(cfg.dnsZones) == 0 + + conn, err := dbus.ConnectSystemBus() + if err != nil { + return trace.NotFound("system D-Bus is unavailable: %v", err) + } + defer conn.Close() + if err := systemdresolved.CheckAvailability(ctx, conn); err != nil { + return err + } + + if shouldReconfigureDNSZones(cfg, state) { + iface, err := net.InterfaceByName(state.tunName) + if err != nil { + if deconfigure && isNoSuchNetworkInterfaceError(err) { + // During shutdown the TUN can disappear before deconfigure runs. + // In that case there is no link left to clear. + log.DebugContext(ctx, "Skipping DNS deconfiguration because TUN interface is unavailable.", "device", state.tunName) + return nil + } + return trace.Wrap(err, "looking up interface %s", state.tunName) + } + log.InfoContext(ctx, "Configuring DNS zones", "zones", cfg.dnsZones) + domains := make([]systemdresolved.Domain, 0, len(cfg.dnsZones)) + for _, dnsZone := range cfg.dnsZones { + domains = append(domains, systemdresolved.Domain{ + Domain: dnsZone, + RoutingOnly: true, + }) + } + // Equivalent to: resolvectl domain ~ ~ ... + if err := systemdresolved.SetLinkDomains(ctx, conn, int32(iface.Index), domains); err != nil { + return err + } + state.configuredDNSZones = cfg.dnsZones + } + + if len(cfg.dnsAddrs) > 0 && state.tunName != "" && !state.configuredNameserver { + iface, err := net.InterfaceByName(state.tunName) + if err != nil { + return trace.Wrap(err, "looking up interface %s", state.tunName) + } + addresses := make([]systemdresolved.DNSAddress, 0, len(cfg.dnsAddrs)) + for _, addr := range cfg.dnsAddrs { + address, err := systemdresolved.DNSAddressForIP(addr) + if err != nil { + return trace.Wrap(err, "parsing DNS nameserver %q", addr) + } + addresses = append(addresses, address) + } + log.InfoContext(ctx, "Configuring DNS nameserver", "nameservers", cfg.dnsAddrs) + // Equivalent to: resolvectl default-route false + if err := systemdresolved.SetLinkDefaultRoute(ctx, conn, int32(iface.Index), false); err != nil { + return err + } + + // Equivalent to: resolvectl dns ... + if err := systemdresolved.SetLinkDNS(ctx, conn, int32(iface.Index), addresses); err != nil { + return err + } + state.configuredNameserver = true + } + + return nil +} + +func isNoSuchNetworkInterfaceError(err error) bool { + var opErr *net.OpError + if errors.As(err, &opErr) { + return strings.Contains(opErr.Err.Error(), "no such network interface") + } + return false +} diff --git a/lib/vnet/polkit/polkit.go b/lib/vnet/polkit/polkit.go new file mode 100644 index 0000000000000..bd88174ad6825 --- /dev/null +++ b/lib/vnet/polkit/polkit.go @@ -0,0 +1,89 @@ +// Teleport +// Copyright (C) 2026 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 . + +package polkit + +import ( + "context" + + "github.com/godbus/dbus/v5" + "github.com/gravitational/trace" +) + +const ( + AuthorityServiceName = "org.freedesktop.PolicyKit1" + AuthorityObjectPath = "/org/freedesktop/PolicyKit1/Authority" + AuthorityInterface = "org.freedesktop.PolicyKit1.Authority" + + CheckAuthorizationMethod = AuthorityInterface + ".CheckAuthorization" + + CheckAuthorizationFlagNone = uint32(0x00000000) + CheckAuthorizationFlagAllowUserInteraction = uint32(0x00000001) + + SubjectKindSystemBusName = "system-bus-name" + SubjectDetailNameKey = "name" +) + +// Subject describes the entity being authorized. +type Subject struct { + // Kind is the subject kind, e.g. "system-bus-name". + Kind string + // Details are subject kind specific key/value pairs. + Details map[string]dbus.Variant +} + +// AuthorizationResult is the result of CheckAuthorization. +type AuthorizationResult struct { + // Authorized is true if the subject is authorized for the action. + Authorized bool + // Challenge is true if the subject could be authorized after authentication. + Challenge bool + // Details contains extra result information. + Details map[string]string +} + +// NewSystemBusNameSubject returns a polkit subject for a system bus name. +func NewSystemBusNameSubject(name string) Subject { + return Subject{ + Kind: SubjectKindSystemBusName, + Details: map[string]dbus.Variant{ + SubjectDetailNameKey: dbus.MakeVariant(name), + }, + } +} + +// CheckAuthorization calls polkit's CheckAuthorization method. +func CheckAuthorization( + ctx context.Context, + conn *dbus.Conn, + subject Subject, + actionID string, + details map[string]string, + allowUserInteraction bool, + cancellationID string, +) (AuthorizationResult, error) { + flags := CheckAuthorizationFlagNone + if allowUserInteraction { + flags = CheckAuthorizationFlagAllowUserInteraction + } + var result AuthorizationResult + if err := conn.Object(AuthorityServiceName, dbus.ObjectPath(AuthorityObjectPath)). + CallWithContext(ctx, CheckAuthorizationMethod, 0, subject, actionID, details, flags, cancellationID). + Store(&result); err != nil { + return AuthorizationResult{}, trace.Wrap(err, "checking polkit authorization") + } + return result, nil +} diff --git a/lib/vnet/systemdresolved/dbus.go b/lib/vnet/systemdresolved/dbus.go new file mode 100644 index 0000000000000..72c0e996aa007 --- /dev/null +++ b/lib/vnet/systemdresolved/dbus.go @@ -0,0 +1,159 @@ +// Teleport +// Copyright (C) 2026 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 . + +package systemdresolved + +import ( + "context" + "net" + "syscall" + + "github.com/godbus/dbus/v5" + "github.com/gravitational/trace" +) + +const ( + DBusService = "org.freedesktop.DBus" + DBusObjectPath = "/org/freedesktop/DBus" + DBusNameHasOwner = "org.freedesktop.DBus.NameHasOwner" + DBusPropertiesGet = "org.freedesktop.DBus.Properties.Get" + + Service = "org.freedesktop.resolve1" + ObjectPath = "/org/freedesktop/resolve1" + Manager = "org.freedesktop.resolve1.Manager" + + SetLinkDNSMethod = Manager + ".SetLinkDNS" + SetDomainsMethod = Manager + ".SetLinkDomains" + SetDefaultRouteMethod = Manager + ".SetLinkDefaultRoute" + + DNSProperty = "DNS" +) + +// DNSAddress is the systemd-resolved representation of a DNS address. +type DNSAddress struct { + Family int32 + Address []byte +} + +// Domain is the systemd-resolved representation of a DNS domain. +type Domain struct { + Domain string + RoutingOnly bool +} + +// DNS is a single DNS server entry from systemd-resolved's DNS property. +type DNS struct { + InterfaceIndex int32 + Family int32 + Address []byte +} + +// CheckAvailability returns an error if systemd-resolved is unavailable on D-Bus. +func CheckAvailability(ctx context.Context, conn *dbus.Conn) error { + var hasOwner bool + err := conn.Object(DBusService, dbus.ObjectPath(DBusObjectPath)). + CallWithContext(ctx, DBusNameHasOwner, 0, Service). + Store(&hasOwner) + if err != nil { + return trace.Wrap(err, "checking systemd-resolved D-Bus service owner") + } + if hasOwner { + return nil + } + return trace.Errorf( + "systemd-resolved is not running (D-Bus service %s has no owner).\n"+ + "you can enable it with:\n"+ + " sudo systemctl enable --now systemd-resolved\n", + Service, + ) +} + +// Object returns the systemd-resolved D-Bus object. +func Object(conn *dbus.Conn) dbus.BusObject { + return conn.Object(Service, dbus.ObjectPath(ObjectPath)) +} + +// LoadConfiguredDNSServers returns DNS servers currently configured in systemd-resolved. +func LoadConfiguredDNSServers(ctx context.Context, conn *dbus.Conn) ([]DNS, error) { + return loadDNSProperty(ctx, conn) +} + +// loadDNSProperty loads the systemd-resolved DNS property via D-Bus. +func loadDNSProperty(ctx context.Context, conn *dbus.Conn) ([]DNS, error) { + call := Object(conn).CallWithContext(ctx, DBusPropertiesGet, 0, Manager, DNSProperty) + if call.Err != nil { + return nil, trace.Wrap(call.Err, "getting systemd-resolved property %s", DNSProperty) + } + + var variant dbus.Variant + if err := call.Store(&variant); err != nil { + return nil, trace.Wrap(err, "decoding systemd-resolved property %s", DNSProperty) + } + + var dns []DNS + if err := dbus.Store([]any{variant.Value()}, &dns); err != nil { + return nil, trace.Wrap(err, "decoding systemd-resolved property %s", DNSProperty) + } + return dns, nil +} + +// SetLinkDomains configures per-link DNS domains. +func SetLinkDomains(ctx context.Context, conn *dbus.Conn, ifaceIndex int32, domains []Domain) error { + call := Object(conn).CallWithContext(ctx, SetDomainsMethod, 0, ifaceIndex, domains) + if call.Err != nil { + return trace.Wrap(call.Err, "setting systemd-resolved link domains") + } + return nil +} + +// SetLinkDefaultRoute configures whether this link is the default DNS route. +func SetLinkDefaultRoute(ctx context.Context, conn *dbus.Conn, ifaceIndex int32, enabled bool) error { + call := Object(conn).CallWithContext(ctx, SetDefaultRouteMethod, 0, ifaceIndex, enabled) + if call.Err != nil { + return trace.Wrap(call.Err, "setting systemd-resolved link default route") + } + return nil +} + +// SetLinkDNS configures per-link DNS servers. +func SetLinkDNS(ctx context.Context, conn *dbus.Conn, ifaceIndex int32, addresses []DNSAddress) error { + call := Object(conn).CallWithContext(ctx, SetLinkDNSMethod, 0, ifaceIndex, addresses) + if call.Err != nil { + return trace.Wrap(call.Err, "setting systemd-resolved link DNS") + } + return nil +} + +// DNSAddressForIP converts an IP address into a systemd-resolved DNSAddress. +func DNSAddressForIP(raw string) (DNSAddress, error) { + ip := net.ParseIP(raw) + if ip == nil { + return DNSAddress{}, trace.BadParameter("invalid IP address: %s", raw) + } + if ip4 := ip.To4(); ip4 != nil { + return DNSAddress{ + Family: syscall.AF_INET, + Address: []byte(ip4), + }, nil + } + if ip16 := ip.To16(); ip16 != nil { + return DNSAddress{ + Family: syscall.AF_INET6, + Address: []byte(ip16), + }, nil + } + return DNSAddress{}, trace.Errorf("unsupported IP address") +} diff --git a/lib/vnet/unsupported_os.go b/lib/vnet/unsupported_os.go index a003fa6c4f4c3..021714d62a5b7 100644 --- a/lib/vnet/unsupported_os.go +++ b/lib/vnet/unsupported_os.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//go:build !darwin && !windows +//go:build !darwin && !windows && !linux package vnet diff --git a/lib/vnet/user_process_linux.go b/lib/vnet/user_process_linux.go new file mode 100644 index 0000000000000..51fd5efb88c1e --- /dev/null +++ b/lib/vnet/user_process_linux.go @@ -0,0 +1,161 @@ +// 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 . + +package vnet + +import ( + "context" + "net" + "os" + "path/filepath" + + "github.com/gravitational/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + + "github.com/gravitational/teleport/api/utils/grpc/interceptors" + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" + "github.com/gravitational/teleport/lib/uds" +) + +const clientApplicationServiceSocketName = "vnet.sock" + +// runPlatformUserProcess launches a daemon in the background that will handle +// all networking and OS configuration. The user process exposes a gRPC +// interface that the daemon uses to query application names and get user +// certificates for apps. If successful it sets p.processManager and +// p.networkStackInfo. +func (p *UserProcess) runPlatformUserProcess(processCtx context.Context) error { + // Prefer XDG_RUNTIME_DIR for runtime sockets. + // Paths must be absolute, ignore relative values. + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" || !filepath.IsAbs(runtimeDir) { + runtimeDir = os.TempDir() + } + socketDir, err := os.MkdirTemp(runtimeDir, "vnet_service") + if err != nil { + return trace.Wrap(err, "creating temp dir for service socket") + } + + listener, socketPath, err := listenUnixSocket(socketDir) + if err != nil { + if removeErr := os.RemoveAll(socketDir); removeErr != nil { + log.ErrorContext(processCtx, "Failed to remove service socket directory", "error", removeErr) + } + return trace.Wrap(err, "listening on unix socket") + } + // grpcServer.Serve takes ownership of (and closes) the listener. + grpcServer := grpc.NewServer( + grpc.Creds(uds.NewTransportCredentials(insecure.NewCredentials())), + grpc.ChainUnaryInterceptor( + rootOnlyUnixSocketUnaryInterceptor, + interceptors.GRPCServerUnaryErrorInterceptor, + ), + grpc.ChainStreamInterceptor( + rootOnlyUnixSocketStreamInterceptor, + interceptors.GRPCServerStreamErrorInterceptor, + ), + ) + vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService) + + p.processManager.AddCriticalBackgroundTask("admin process", func() error { + defer func() { + // Delete vnet socket after the service terminates. + if err := os.RemoveAll(socketDir); err != nil { + log.ErrorContext(processCtx, "Failed to remove service socket directory", "error", err) + } + }() + return trace.Wrap(execAdminProcess(processCtx, LinuxAdminProcessConfig{ + ClientApplicationServiceSocketPath: socketPath, + })) + }) + p.processManager.AddCriticalBackgroundTask("gRPC service", func() error { + log.InfoContext(processCtx, "Starting gRPC service", + "socket", socketPath) + return trace.Wrap(grpcServer.Serve(listener), + "serving VNet user process gRPC service") + }) + p.processManager.AddCriticalBackgroundTask("gRPC server closer", func() error { + // grpcServer.Serve does not stop on its own when processCtx is done, so + // this task waits for processCtx and then explicitly stops grpcServer. + <-processCtx.Done() + grpcServer.GracefulStop() + return nil + }) + + select { + case nsi := <-p.clientApplicationService.networkStackInfo: + p.networkStackInfo = nsi + return nil + case <-processCtx.Done(): + return trace.Wrap(p.processManager.Wait(), "process manager exited before network stack info was received") + } +} + +func listenUnixSocket(dir string) (net.Listener, string, error) { + socketPath := filepath.Join(dir, clientApplicationServiceSocketName) + listener, err := net.Listen("unix", socketPath) + if err != nil { + return nil, "", trace.Wrap(err, "creating unix socket listener") + } + if err := os.Chmod(socketPath, 0600); err != nil { + _ = listener.Close() + return nil, "", trace.Wrap(err, "chmod unix socket %s", socketPath) + } + return listener, socketPath, nil +} + +func rootOnlyUnixSocketUnaryInterceptor( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (any, error) { + if err := requireRootUnixSocketPeer(ctx); err != nil { + return nil, trace.Wrap(err, "validating unix socket peer for unary call %s", info.FullMethod) + } + return handler(ctx, req) +} + +func rootOnlyUnixSocketStreamInterceptor( + srv any, + ss grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, +) error { + if err := requireRootUnixSocketPeer(ss.Context()); err != nil { + return trace.Wrap(err, "validating unix socket peer for stream call %s", info.FullMethod) + } + return handler(srv, ss) +} + +func requireRootUnixSocketPeer(ctx context.Context) error { + p, ok := peer.FromContext(ctx) + if !ok { + return status.Error(codes.Unauthenticated, "gRPC peer not found in context") + } + authInfo, ok := p.AuthInfo.(uds.AuthInfo) + if !ok || authInfo.Creds == nil { + return status.Error(codes.Unauthenticated, "missing unix socket peer credentials") + } + if authInfo.Creds.UID != 0 { + return status.Errorf(codes.PermissionDenied, "unix socket peer uid %d is not root", authInfo.Creds.UID) + } + return nil +} diff --git a/tool/tsh/common/vnet_daemon_linux.go b/tool/tsh/common/vnet_daemon_linux.go new file mode 100644 index 0000000000000..1acad351dae83 --- /dev/null +++ b/tool/tsh/common/vnet_daemon_linux.go @@ -0,0 +1,56 @@ +// 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 . + +//go:build linux + +package common + +import ( + "log/slog" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/vnet" +) + +const ( + // On linux the command must match the command provided in the systemd unit file. + vnetDaemonSubCommand = "vnet-daemon" +) + +// vnetDaemonCommand implements the vnet-daemon subcommand to run the VNet D-Bus daemon. +type vnetDaemonCommand struct { + *kingpin.CmdClause +} + +func newPlatformVnetDaemonCommand(app *kingpin.Application) *vnetDaemonCommand { + return &vnetDaemonCommand{ + CmdClause: app.Command(vnetDaemonSubCommand, "Start the VNet D-Bus daemon.").Hidden(), + } +} + +func (c *vnetDaemonCommand) run(cf *CLIConf) error { + level := slog.LevelInfo + if cf.Debug { + level = slog.LevelDebug + } + if _, err := utils.InitLogger(utils.LoggingForDaemon, level); err != nil { + return trace.Wrap(err, "initializing logger") + } + return trace.Wrap(vnet.RunLinuxDBusService(cf.Context)) +} diff --git a/tool/tsh/common/vnet_linux.go b/tool/tsh/common/vnet_linux.go new file mode 100644 index 0000000000000..9c34117ea93cd --- /dev/null +++ b/tool/tsh/common/vnet_linux.go @@ -0,0 +1,66 @@ +// Teleport +// Copyright (C) 2025 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 . + +package common + +import ( + "context" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" + "github.com/gravitational/teleport/lib/vnet" +) + +// vnetAdminSetupCommand is the fallback command ran as root when there is no +// available vnet daemon on system. +type vnetAdminSetupCommand struct { + *kingpin.CmdClause + cfg vnet.LinuxAdminProcessConfig +} + +func (c *vnetAdminSetupCommand) run(clf *CLIConf) error { + return trace.Wrap(vnet.RunLinuxAdminProcess(clf.Context, c.cfg)) +} + +func newPlatformVnetAdminSetupCommand(app *kingpin.Application) *vnetAdminSetupCommand { + cmd := &vnetAdminSetupCommand{ + CmdClause: app.Command(teleport.VnetAdminSetupSubCommand, "Start the VNet admin subprocess.").Hidden(), + } + cmd.Flag("socket", "Client application service unix socket path.").Required().StringVar(&cmd.cfg.ClientApplicationServiceSocketPath) + return cmd +} + +// The vnet-service command is only supported on windows. +func newPlatformVnetServiceCommand(app *kingpin.Application) vnetCommandNotSupported { + return vnetCommandNotSupported{} +} + +// The vnet-install-service command is only supported on windows. +func newPlatformVnetInstallServiceCommand(app *kingpin.Application) vnetCommandNotSupported { + return vnetCommandNotSupported{} +} + +// The vnet-uninstall-service command is only supported on windows. +func newPlatformVnetUninstallServiceCommand(app *kingpin.Application) vnetCommandNotSupported { + return vnetCommandNotSupported{} +} + +func runVnetDiagnostics(ctx context.Context, nsi *vnetv1.NetworkStackInfo) error { + return trace.NotImplemented("diagnostics not implemented") +} diff --git a/tool/tsh/common/vnet_nodaemon.go b/tool/tsh/common/vnet_nodaemon.go index c3e0917e046dd..ee536be15dfc3 100644 --- a/tool/tsh/common/vnet_nodaemon.go +++ b/tool/tsh/common/vnet_nodaemon.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//go:build !vnetdaemon || !darwin +//go:build (!vnetdaemon || !darwin) && !linux package common diff --git a/tool/tsh/common/vnet_other.go b/tool/tsh/common/vnet_other.go index 60198a9ca9d84..c44d35cdf7c7e 100644 --- a/tool/tsh/common/vnet_other.go +++ b/tool/tsh/common/vnet_other.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//go:build !darwin && !windows +//go:build !darwin && !windows && !linux package common diff --git a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx index 9b59d2a654d4f..481cacedd47a7 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx @@ -114,6 +114,7 @@ const VnetConnectionItemBase = forwardRef< diagnosticsAttempt, getDisabledDiagnosticsReason, showDiagWarningIndicator, + isDiagSupported, } = useVnetContext(); const { close: closeConnectionsPanel } = useConnectionsContext(); const rootClusterUri = useStoreSelector( @@ -258,16 +259,18 @@ const VnetConnectionItemBase = forwardRef< )} - { - e.stopPropagation(); - props.runDiagnosticsFromVnetPanel(); - }} - > - - + {isDiagSupported && ( + { + e.stopPropagation(); + props.runDiagnosticsFromVnetPanel(); + }} + > + + + )} )} diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx index fa6abef75c780..f50a1ddfaf4be 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx @@ -36,6 +36,7 @@ import { useVnetContext, VnetContextProvider } from './vnetContext'; import { VnetSliderStep as Component } from './VnetSliderStep'; type StoryProps = { + platform: 'darwin' | 'win32' | 'linux'; startVnet: 'success' | 'error' | 'processing'; autoStart: boolean; appDnsZones: string[]; @@ -53,6 +54,7 @@ type StoryProps = { }; const defaultArgs: StoryProps = { + platform: 'darwin', startVnet: 'success', autoStart: true, appDnsZones: ['teleport.example.com', 'company.test'], @@ -78,6 +80,10 @@ const meta: Meta = { }, ], argTypes: { + platform: { + control: { type: 'inline-radio' }, + options: ['darwin', 'win32', 'linux'], + }, startVnet: { control: { type: 'inline-radio' }, options: ['success', 'error', 'processing'], @@ -115,7 +121,7 @@ const meta: Meta = { export default meta; function VnetSliderStep(props: StoryProps) { - const appContext = new MockAppContext(); + const appContext = new MockAppContext({ platform: props.platform }); if (props.isWorkspacePresent) { appContext.addRootCluster(makeRootCluster()); diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx index d0130ddd155e8..4a97ef64baa01 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -55,6 +55,10 @@ export type VnetContext = { * Describes whether the given OS can run VNet. */ isSupported: boolean; + /** + * Describes whether the given OS can run VNet diagnostics. + */ + isDiagSupported: boolean; status: VnetStatus; start: () => Promise<[void, Error]>; startAttempt: Attempt; @@ -167,7 +171,9 @@ export const VnetContextProvider: FC< () => mainProcessClient.getRuntimeSettings().platform, [mainProcessClient] ); - const isSupported = platform === 'darwin' || platform === 'win32'; + const isSupported = + platform === 'darwin' || platform === 'win32' || platform === 'linux'; + const isDiagSupported = platform === 'darwin' || platform === 'win32'; const [startAttempt, start] = useAsync( useCallback(async () => { @@ -459,6 +465,10 @@ export const VnetContextProvider: FC< useEffect( function periodicallyRunDiagnostics() { + if (!isDiagSupported) { + return; + } + if (status.value !== 'running') { return; } @@ -480,6 +490,7 @@ export const VnetContextProvider: FC< }; }, [ + isDiagSupported, diagnosticsIntervalMs, runDiagnosticsAndShowNotification, status.value, @@ -515,6 +526,7 @@ export const VnetContextProvider: FC<