diff --git a/lib/srv/app/azure/handler_test.go b/lib/srv/app/azure/handler_test.go
index cead5740df5f5..f8fec00bdbfb7 100644
--- a/lib/srv/app/azure/handler_test.go
+++ b/lib/srv/app/azure/handler_test.go
@@ -47,9 +47,7 @@ func TestForwarder_getToken(t *testing.T) {
checkErr require.ErrorAssertionFunc
}
- var tests []testCase
-
- tests = []testCase{
+ tests := []testCase{
{
name: "base case",
getTokenContext: context.Background(),
diff --git a/lib/vnet/admin_process_linux.go b/lib/vnet/admin_process_linux.go
new file mode 100644
index 0000000000000..be21d25e6aa60
--- /dev/null
+++ b/lib/vnet/admin_process_linux.go
@@ -0,0 +1,127 @@
+// 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"
+ "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"
+)
+
+type LinuxAdminProcessConfig struct {
+ ClientApplicationServiceAddr string
+ ServiceCredentialPath string
+}
+
+// RunLinuxAdminProcess must run as root.
+func RunLinuxAdminProcess(ctx context.Context, config LinuxAdminProcessConfig) error {
+ log.InfoContext(ctx, "Running VNet admin process")
+
+ serviceCreds, err := readCredentials(config.ServiceCredentialPath)
+ if err != nil {
+ return trace.Wrap(err, "reading service IPC credentials")
+ }
+ clt, err := newClientApplicationServiceClient(ctx, serviceCreds, config.ClientApplicationServiceAddr)
+ if err != nil {
+ return trace.Wrap(err, "creating user process client")
+ }
+ defer clt.close()
+
+ tun, err := tun.CreateTUN("TeleportVNet", mtu)
+ if err != nil {
+ return trace.Wrap(err, "creating TUN device")
+ }
+ defer tun.Close()
+ tunName, err := tun.Name()
+ if err != nil {
+ return trace.Wrap(err, "getting TUN device name")
+ }
+
+ 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 := newRemoteOSConfigProvider(
+ clt,
+ tunName,
+ networkStackConfig.ipv6Prefix.String(),
+ networkStackConfig.dnsIPv6.String(),
+ )
+ if err != nil {
+ return trace.Wrap(err, "creating OS config provider")
+ }
+ osConfigurator := newOSConfigurator(osConfigProvider)
+
+ g, ctx := errgroup.WithContext(ctx)
+ g.Go(func() error {
+ if err := networkStack.run(ctx); err != nil {
+ return trace.Wrap(err, "running network stack")
+ }
+ return errors.New("network stack terminated")
+ })
+ g.Go(func() error {
+ 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 {
+ 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()
+ }
+ }
+ })
+ return trace.Wrap(g.Wait(), "running VNet admin process")
+}
+
+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
+}
diff --git a/lib/vnet/diag/routeconflict_other.go b/lib/vnet/diag/routeconflict_other.go
index ce0acdaca1553..bb8cf0a42d952 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: 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/osnameservers.go b/lib/vnet/dns/osnameservers.go
index f8678e6652879..a53a487e9b62f 100644
--- a/lib/vnet/dns/osnameservers.go
+++ b/lib/vnet/dns/osnameservers.go
@@ -24,17 +24,19 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/lib/utils/slices"
)
// OSUpstreamNameserverSource provides the list of upstream DNS nameservers
// configured in the OS. The VNet DNS resolver will forward unhandles queries to
// these nameservers.
type OSUpstreamNameserverSource struct {
- ttlCache *utils.FnCache
+ ttlCache *utils.FnCache
+ localAddr netip.Addr
}
// NewOSUpstreamNameserverSource returns a new *OSUpstreamNameserverSource.
-func NewOSUpstreamNameserverSource() (*OSUpstreamNameserverSource, error) {
+func NewOSUpstreamNameserverSource(localAddr netip.Addr) (*OSUpstreamNameserverSource, error) {
ttlCache, err := utils.NewFnCache(utils.FnCacheConfig{
TTL: 10 * time.Second,
})
@@ -42,18 +44,28 @@ func NewOSUpstreamNameserverSource() (*OSUpstreamNameserverSource, error) {
return nil, trace.Wrap(err)
}
return &OSUpstreamNameserverSource{
- ttlCache: ttlCache,
+ ttlCache: ttlCache,
+ localAddr: localAddr,
}, 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) {
+ allNameservers, err := platformLoadUpstreamNameservers(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return slices.FilterMapUnique(allNameservers, func(addr netip.Addr) (string, bool) {
+ if addr.Compare(s.localAddr) == 0 {
+ return "", false
+ }
+ return withDNSPort(addr), true
+ }), nil
}
func withDNSPort(addr netip.Addr) string {
diff --git a/lib/vnet/dns/osnameservers_other.go b/lib/vnet/dns/osnameservers_other.go
index 3cc9d156f0d6a..17af50dd62d2d 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"
+ "net/netip"
"runtime"
"github.com/gravitational/trace"
@@ -33,6 +34,6 @@ var (
_ = withDNSPort
)
-func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) {
+func platformLoadUpstreamNameservers(ctx context.Context) ([]netip.Addr, error) {
return nil, trace.Wrap(vnetNotImplemented)
}
diff --git a/lib/vnet/dns/osnameservers_darwin.go b/lib/vnet/dns/osnameservers_unix.go
similarity index 71%
rename from lib/vnet/dns/osnameservers_darwin.go
rename to lib/vnet/dns/osnameservers_unix.go
index 8f765ad4a9701..92ddd7316e7d4 100644
--- a/lib/vnet/dns/osnameservers_darwin.go
+++ b/lib/vnet/dns/osnameservers_unix.go
@@ -14,6 +14,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+//go:build linux || darwin
+
package dns
import (
@@ -22,29 +24,40 @@ import (
"log/slog"
"net/netip"
"os"
+ "runtime"
"strings"
"github.com/gravitational/trace"
)
-const (
- confFilePath = "/etc/resolv.conf"
-)
-
// platformLoadUpstreamNameservers reads the OS DNS nameservers found in
// /etc/resolv.conf. The comments in that file make it clear it is not actually
// consulted for DNS hostname resolution, but MacOS seems to keep it up to date
// 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) ([]netip.Addr, error) {
+ // TODO: this is very hacky and just happens to work on the EC2 I've been
+ // testing on, figure out a good way to find upstream nameservers on Linux
+ // or a better way of resolving queries VNet can't handle (names in custom
+ // DNS zones that don't match a teleport app may resolve to some other
+ // company internal app outside of VNet/Teleport).
+ var confFilePath string
+ switch runtime.GOOS {
+ case "darwin":
+ confFilePath = "/etc/resolv.conf"
+ case "linux":
+ confFilePath = "/run/systemd/resolve/resolv.conf"
+ default:
+ return nil, trace.NotImplemented("unsupported os %s", runtime.GOOS)
+ }
f, err := os.Open(confFilePath)
if err != nil {
return nil, trace.Wrap(err, "opening %s", confFilePath)
}
defer f.Close()
- var nameservers []string
+ var nameservers []netip.Addr
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
@@ -63,7 +76,7 @@ func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) {
continue
}
- nameservers = append(nameservers, withDNSPort(ip))
+ nameservers = append(nameservers, ip)
}
slog.DebugContext(ctx, "Loaded host upstream nameservers.", "nameservers", nameservers, "config_file", confFilePath)
diff --git a/lib/vnet/dns/osnameservers_windows.go b/lib/vnet/dns/osnameservers_windows.go
index 2db2063761bd9..605c670ca248c 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) ([]netip.Addr, error) {
interfaces, err := winipcfg.GetIPInterfaceTable(windows.AF_INET)
if err != nil {
return nil, trace.Wrap(err, "looking up local network interfaces")
@@ -38,7 +38,7 @@ func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) {
sort.Slice(interfaces, func(i, j int) bool {
return interfaces[i].Metric < interfaces[j].Metric
})
- var nameservers []string
+ var nameservers []netip.Addr
for _, iface := range interfaces {
ifaceNameservers, err := iface.InterfaceLUID.DNS()
if err != nil {
@@ -48,7 +48,7 @@ func platformLoadUpstreamNameservers(ctx context.Context) ([]string, error) {
if ignoreUpstreamNameserver(ifaceNameserver) {
continue
}
- nameservers = append(nameservers, withDNSPort(ifaceNameserver))
+ nameservers = append(nameservers, 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..234487efb0f29
--- /dev/null
+++ b/lib/vnet/escalate_linux.go
@@ -0,0 +1,32 @@
+package vnet
+
+import (
+ "context"
+ "os"
+ "os/exec"
+
+ "github.com/gravitational/trace"
+)
+
+func execAdminProcess(ctx context.Context, cfg LinuxAdminProcessConfig) error {
+ executableName, err := os.Executable()
+ if err != nil {
+ return trace.Wrap(err, "getting executable path")
+ }
+
+ // TODO: find a proper way to start the service without just running sudo
+ // and hoping there's no password requirement...
+ //
+ // Also need to figure out how we want to set up a service that runs as root
+ // that can be started from Connect, maybe some systemd service but that
+ // doesn't solve how we allow the service to be started.
+ cmd := exec.CommandContext(ctx, "sudo", executableName, "-d",
+ "vnet-service",
+ "--addr", cfg.ClientApplicationServiceAddr,
+ "--cred-path", cfg.ServiceCredentialPath,
+ )
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ log.DebugContext(ctx, "Escalating to root with sudo")
+ return trace.Wrap(cmd.Run(), "escalating to root with sudo")
+}
diff --git a/lib/vnet/network_stack.go b/lib/vnet/network_stack.go
index 25256b4a4eb59..321ee6f38b7d0 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"
@@ -242,7 +243,11 @@ func newNetworkStack(cfg *networkStackConfig) (*networkStack, error) {
if cfg.dnsIPv6 != (tcpip.Address{}) {
upstreamNameserverSource := cfg.upstreamNameserverSource
if upstreamNameserverSource == nil {
- upstreamNameserverSource, err = dns.NewOSUpstreamNameserverSource()
+ localAddr, ok := netip.AddrFromSlice(cfg.dnsIPv6.AsSlice())
+ if !ok {
+ return nil, trace.Errorf("failed to config tcpip.Addr to netip.Addr: %s", cfg.dnsIPv6.String())
+ }
+ upstreamNameserverSource, err = dns.NewOSUpstreamNameserverSource(localAddr)
if err != nil {
return nil, trace.Wrap(err)
}
diff --git a/lib/vnet/osconfig_linux.go b/lib/vnet/osconfig_linux.go
new file mode 100644
index 0000000000000..b13573c958187
--- /dev/null
+++ b/lib/vnet/osconfig_linux.go
@@ -0,0 +1,110 @@
+// 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"
+ "slices"
+
+ "github.com/gravitational/teleport/api/utils"
+ "github.com/gravitational/trace"
+)
+
+type platformOSConfigState struct {
+ configuredIPv6 bool
+ configuredIPv4 bool
+ configuredCidrRanges []string
+ configuredNameserver bool
+ configuredDNSZones []string
+ broughtUpInterface bool
+}
+
+// platformConfigureOS configures the host OS according to cfg.
+func platformConfigureOS(ctx context.Context, cfg *osConfig, state *platformOSConfigState) error {
+ // TODO: we should probably use proper APIs (dbus?) to set up IPs, routes,
+ // DNS etc and add checks that the host is actually using systemd-resolved
+ // before trying to run or supporting other DNS setups.
+ 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 cfg.dnsAddr != "" && !state.configuredNameserver {
+ log.InfoContext(ctx, "Configuring DNS nameserver", "nameserver", cfg.dnsAddr)
+ if err := runCommand(ctx,
+ "resolvectl", "dns", cfg.tunName, cfg.dnsAddr,
+ ); err != nil {
+ return trace.Wrap(err)
+ }
+ state.configuredNameserver = true
+ }
+ if shouldReconfiguredDNSZones(cfg, state) {
+ log.InfoContext(ctx, "Configuring DNS zones", "zones", cfg.dnsZones)
+ domains := make([]string, 0, len(cfg.dnsZones))
+ for _, dnsZone := range cfg.dnsZones {
+ domains = append(domains, "~"+dnsZone)
+ }
+ args := append([]string{"domain", cfg.tunName}, domains...)
+ if err := runCommand(ctx, "resolvectl", args...); err != nil {
+ return trace.Wrap(err)
+ }
+ state.configuredDNSZones = cfg.dnsZones
+ }
+ if 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, "via", cfg.tunIPv4, "dev", cfg.tunName,
+ ); err != nil {
+ return trace.Wrap(err)
+ }
+ state.configuredCidrRanges = append(state.configuredCidrRanges, cidrRange)
+ }
+ }
+ return nil
+}
+
+func shouldReconfiguredDNSZones(cfg *osConfig, state *platformOSConfigState) bool {
+ return !utils.ContainSameUniqueElements(cfg.dnsZones, state.configuredDNSZones)
+}
diff --git a/lib/vnet/osconfig_windows.go b/lib/vnet/osconfig_windows.go
index 0b10495434e1a..fdb2a18ee2166 100644
--- a/lib/vnet/osconfig_windows.go
+++ b/lib/vnet/osconfig_windows.go
@@ -113,7 +113,7 @@ func platformConfigureOS(ctx context.Context, cfg *osConfig, state *platformOSCo
state.configuredV6Address = true
}
- if shouldUpdateDNSConfig(cfg, state) {
+ if shouldReconfiguredDNSZones(cfg, state) {
if err := configureDNS(ctx, cfg.dnsZones, cfg.dnsAddr); err != nil {
return trace.Wrap(err, "configuring DNS")
}
@@ -134,16 +134,6 @@ func addrMaskForCIDR(cidr string) (string, string, error) {
return ipNet.IP.String(), net.IP(ipNet.Mask).String(), nil
}
-func shouldUpdateDNSConfig(cfg *osConfig, state *platformOSConfigState) bool {
- // Always reconfigure if there should be no zones, to make sure we clear
- // any leftover state when starting up.
- if len(cfg.dnsZones) == 0 {
- return true
- }
- // Otherwise, reconfigure if anything has changed.
- return !utils.ContainSameUniqueElements(cfg.dnsZones, state.configuredDNSZones)
-}
-
func configureDNS(ctx context.Context, zones []string, nameserver string) (err error) {
if len(nameserver) == 0 && len(zones) > 0 {
return trace.BadParameter("empty nameserver with non-empty zones")
@@ -222,3 +212,13 @@ func deleteRegistryKey(key string) error {
keyHandle.Close()
return trace.Wrap(deleteErr, "failed to delete DNS registry key %s", key)
}
+
+func shouldReconfiguredDNSZones(cfg *osConfig, state *platformOSConfigState) bool {
+ // Always reconfigure if there should be no zones, to make sure we clear
+ // any leftover state when starting up.
+ if len(cfg.dnsZones) == 0 {
+ return true
+ }
+ // Otherwise, reconfigure if anything has changed.
+ return !utils.ContainSameUniqueElements(cfg.dnsZones, state.configuredDNSZones)
+}
diff --git a/lib/vnet/unsupported_os.go b/lib/vnet/unsupported_os.go
index b21576076ff8a..c431dcd5bf659 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..97aca85e07b08
--- /dev/null
+++ b/lib/vnet/user_process_linux.go
@@ -0,0 +1,105 @@
+// 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"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/grpc"
+ grpccredentials "google.golang.org/grpc/credentials"
+
+ "github.com/gravitational/teleport/api/utils/grpc/interceptors"
+ vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1"
+)
+
+// 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 {
+ ipcCreds, err := newIPCCredentials()
+ if err != nil {
+ return trace.Wrap(err, "creating credentials for IPC")
+ }
+ serverTLSConfig, err := ipcCreds.server.serverTLSConfig()
+ if err != nil {
+ return trace.Wrap(err, "generating gRPC server TLS config")
+ }
+
+ credDir, err := os.MkdirTemp("", "vnet_service_certs")
+ if err != nil {
+ return trace.Wrap(err, "creating temp dir for service certs")
+ }
+ // Write credentials with 0200 so that only root can read them and no user
+ // processes should be able to connect to the service.
+ if err := ipcCreds.client.write(credDir, 0200); err != nil {
+ return trace.Wrap(err, "writing service IPC credentials")
+ }
+
+ listener, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return trace.Wrap(err, "listening on tcp socket")
+ }
+ // grpcServer.Serve takes ownership of (and closes) the listener.
+ grpcServer := grpc.NewServer(
+ grpc.Creds(grpccredentials.NewTLS(serverTLSConfig)),
+ grpc.UnaryInterceptor(interceptors.GRPCServerUnaryErrorInterceptor),
+ grpc.StreamInterceptor(interceptors.GRPCServerStreamErrorInterceptor),
+ )
+ vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService)
+
+ p.processManager.AddCriticalBackgroundTask("admin process", func() error {
+ defer func() {
+ // Delete service credentials after the service terminates.
+ if ipcCreds.client.remove(credDir); err != nil {
+ log.ErrorContext(processCtx, "Failed to remove service credential files", "error", err)
+ }
+ if err := os.RemoveAll(credDir); err != nil {
+ log.ErrorContext(processCtx, "Failed to remove service credential directory", "error", err)
+ }
+ }()
+ return trace.Wrap(execAdminProcess(processCtx, LinuxAdminProcessConfig{
+ ServiceCredentialPath: credDir,
+ ClientApplicationServiceAddr: listener.Addr().String(),
+ }))
+ })
+ p.processManager.AddCriticalBackgroundTask("gRPC service", func() error {
+ log.InfoContext(processCtx, "Starting gRPC service",
+ "addr", listener.Addr().String())
+ 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")
+ }
+}
diff --git a/tool/tsh/common/vnet_linux.go b/tool/tsh/common/vnet_linux.go
new file mode 100644
index 0000000000000..76ae4f3679ffe
--- /dev/null
+++ b/tool/tsh/common/vnet_linux.go
@@ -0,0 +1,64 @@
+// 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"
+
+ vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1"
+ "github.com/gravitational/teleport/lib/vnet"
+)
+
+func newPlatformVnetAdminSetupCommand(app *kingpin.Application) vnetCommandNotSupported {
+ return vnetCommandNotSupported{}
+}
+
+// vnetServiceCommand runs the VNet service.
+type vnetServiceCommand struct {
+ *kingpin.CmdClause
+ cfg vnet.LinuxAdminProcessConfig
+}
+
+func (c *vnetServiceCommand) run(clf *CLIConf) error {
+ return trace.Wrap(vnet.RunLinuxAdminProcess(clf.Context, c.cfg))
+}
+
+func newPlatformVnetServiceCommand(app *kingpin.Application) *vnetServiceCommand {
+ cmd := &vnetServiceCommand{
+ CmdClause: app.Command("vnet-service", "Start the VNet admin subprocess.").Hidden(),
+ }
+ cmd.Flag("addr", "client application service address").Required().StringVar(&cmd.cfg.ClientApplicationServiceAddr)
+ cmd.Flag("cred-path", "path to TLS credentials for connecting to client application").Required().StringVar(&cmd.cfg.ServiceCredentialPath)
+ return cmd
+}
+
+// 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_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/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx
index 4deba14e0d802..bee54b316961e 100644
--- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx
+++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx
@@ -145,7 +145,7 @@ 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';
const [startAttempt, start] = useAsync(