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<