diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index 7b41d7d278005..c3b17dd6f9a53 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -258,6 +258,65 @@ func (s *Service) GetServiceInfo(ctx context.Context, _ *api.GetServiceInfoReque }, nil } +// RunDiagnostics runs a set of heuristics to determine if VNet actually works +// on the device. It requires VNet to be started. +func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsRequest) (*api.RunDiagnosticsResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.status != statusRunning { + return nil, trace.CompareFailed("VNet is not running") + } + + if s.networkStackInfo.InterfaceName == "" { + return nil, trace.BadParameter("no interface name, this is a bug") + } + + if s.networkStackInfo.Ipv6Prefix == "" { + return nil, trace.BadParameter("no IPv6 prefix, this is a bug") + } + + nsa := &diagv1.NetworkStackAttempt{} + if ns, err := s.getNetworkStack(ctx); err != nil { + nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_ERROR + nsa.Error = err.Error() + } else { + nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_OK + nsa.NetworkStack = ns + } + + diagChecks, err := s.platformDiagChecks(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ + Clock: s.cfg.Clock, + NetworkStackAttempt: nsa, + DiagChecks: diagChecks, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return &api.RunDiagnosticsResponse{ + Report: report, + }, nil +} + +func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, error) { + unifiedClusterConfig, err := s.vnetProcess.GetUnifiedClusterConfigProvider().GetUnifiedClusterConfig(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + return &diagv1.NetworkStack{ + InterfaceName: s.networkStackInfo.InterfaceName, + Ipv6Prefix: s.networkStackInfo.Ipv6Prefix, + Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, + DnsZones: unifiedClusterConfig.AllDNSZones(), + }, nil +} + func (s *Service) stopLocked() error { if s.status == statusClosed { return trace.CompareFailed("VNet service has been closed") diff --git a/lib/teleterm/vnet/service_darwin.go b/lib/teleterm/vnet/service_darwin.go index 362246bf292c9..221350086ac5d 100644 --- a/lib/teleterm/vnet/service_darwin.go +++ b/lib/teleterm/vnet/service_darwin.go @@ -21,38 +21,10 @@ import ( "github.com/gravitational/trace" - api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1" - diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" "github.com/gravitational/teleport/lib/vnet/diag" ) -// RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that -// is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. -func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsRequest) (*api.RunDiagnosticsResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.status != statusRunning { - return nil, trace.CompareFailed("VNet is not running") - } - - if s.networkStackInfo.InterfaceName == "" { - return nil, trace.BadParameter("no interface name, this is a bug") - } - - if s.networkStackInfo.Ipv6Prefix == "" { - return nil, trace.BadParameter("no IPv6 prefix, this is a bug") - } - - nsa := &diagv1.NetworkStackAttempt{} - if ns, err := s.getNetworkStack(ctx); err != nil { - nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_ERROR - nsa.Error = err.Error() - } else { - nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_OK - nsa.NetworkStack = ns - } - +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { routeConflictDiag, err := diag.NewRouteConflictDiag(&diag.RouteConflictConfig{ VnetIfaceName: s.networkStackInfo.InterfaceName, Routing: &diag.DarwinRouting{}, @@ -69,32 +41,8 @@ func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsReq return nil, trace.Wrap(err) } - report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ - Clock: s.cfg.Clock, - NetworkStackAttempt: nsa, - DiagChecks: []diag.DiagCheck{ - routeConflictDiag, - sshDiag, - }, - }) - if err != nil { - return nil, trace.Wrap(err) - } - - return &api.RunDiagnosticsResponse{ - Report: report, - }, nil -} - -func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, error) { - unifiedClusterConfig, err := s.vnetProcess.GetUnifiedClusterConfigProvider().GetUnifiedClusterConfig(ctx) - if err != nil { - return nil, trace.Wrap(err) - } - return &diagv1.NetworkStack{ - InterfaceName: s.networkStackInfo.InterfaceName, - Ipv6Prefix: s.networkStackInfo.Ipv6Prefix, - Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, - DnsZones: unifiedClusterConfig.AllDNSZones(), + return []diag.DiagCheck{ + routeConflictDiag, + sshDiag, }, nil } diff --git a/lib/teleterm/vnet/service_other.go b/lib/teleterm/vnet/service_other.go new file mode 100644 index 0000000000000..e710809f91ffd --- /dev/null +++ b/lib/teleterm/vnet/service_other.go @@ -0,0 +1,29 @@ +// 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 && !windows + +package vnet + +import ( + "context" + + "github.com/gravitational/teleport/lib/vnet/diag" +) + +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { + return nil, nil +} diff --git a/lib/teleterm/vnet/service_windows.go b/lib/teleterm/vnet/service_windows.go new file mode 100644 index 0000000000000..d41fe2ee00f7d --- /dev/null +++ b/lib/teleterm/vnet/service_windows.go @@ -0,0 +1,48 @@ +// 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 vnet + +import ( + "context" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/vnet/diag" +) + +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { + routeConflictDiag, err := diag.NewRouteConflictDiag(&diag.RouteConflictConfig{ + VnetIfaceName: s.networkStackInfo.InterfaceName, + Routing: &diag.WindowsRouting{}, + Interfaces: &diag.NetInterfaces{}, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ + ProfilePath: s.cfg.profilePath, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return []diag.DiagCheck{ + routeConflictDiag, + sshDiag, + }, nil +} diff --git a/lib/vnet/diag/routeconflict_other.go b/lib/vnet/diag/routeconflict_other.go index ce0acdaca1553..5ca8360dabd60 100644 --- a/lib/vnet/diag/routeconflict_other.go +++ b/lib/vnet/diag/routeconflict_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows // Teleport // Copyright (C) 2025 Gravitational, Inc. diff --git a/lib/vnet/diag/routeconflict_windows.go b/lib/vnet/diag/routeconflict_windows.go new file mode 100644 index 0000000000000..9dc89fc2d775d --- /dev/null +++ b/lib/vnet/diag/routeconflict_windows.go @@ -0,0 +1,79 @@ +// 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 diag + +import ( + "context" + "net/netip" + "os/exec" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows" + "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" +) + +var ipv4Broadcast = netip.AddrFrom4([4]byte{255, 255, 255, 255}) + +// WindowsRouting provides Windows-specific [Routing] implementation used by [RouteConflictDiag]. +type WindowsRouting struct{} + +// GetRouteDestinations gets routes from the OS and then extracts the only +// information needed from them: the route destination and the index of the +// network interface. It operates solely on IPv4 routes. +func (wr *WindowsRouting) GetRouteDestinations() ([]RouteDest, error) { + rows, err := winipcfg.GetIPForwardTable2(windows.AF_INET) + if err != nil { + return nil, trace.Wrap(err) + } + rds := make([]RouteDest, 0, len(rows)) + for _, row := range rows { + prefix := row.DestinationPrefix.Prefix() + addr := prefix.Addr() + if addr.IsLinkLocalMulticast() || addr == ipv4Broadcast { + // All interfaces seem to get a link local multicast and broadcast + // route assigned which would always appear as a conflict, so skip + // them. + continue + } + if prefix.IsSingleIP() { + rds = append(rds, &RouteDestIP{ + Addr: addr, + ifaceIndex: int(row.InterfaceIndex), + }) + } else { + rds = append(rds, &RouteDestPrefix{ + Prefix: prefix, + ifaceIndex: int(row.InterfaceIndex), + }) + } + } + return rds, nil +} + +func (n *NetInterfaces) interfaceApp(ctx context.Context, ifaceName string) (string, error) { + // Interfaces usually have descriptive names on Windows (the TUN interfaces + // used by VNet and Tailscale do, at least). + return ifaceName, nil +} + +func (c *RouteConflictDiag) commands(ctx context.Context) []*exec.Cmd { + return []*exec.Cmd{ + exec.CommandContext(ctx, "netstat.exe", "-rn"), + exec.CommandContext(ctx, "ipconfig.exe", "/all"), + exec.CommandContext(ctx, "netsh.exe", "namespace", "show", "effectivepolicy"), + } +} diff --git a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx index 9ef08eceffc83..ca303cd2942ca 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx @@ -114,7 +114,6 @@ const VnetConnectionItemBase = forwardRef< diagnosticsAttempt, getDisabledDiagnosticsReason, showDiagWarningIndicator, - isDiagSupported, } = useVnetContext(); const { close: closeConnectionsPanel } = useConnectionsContext(); const rootClusterUri = useStoreSelector( @@ -259,18 +258,16 @@ const VnetConnectionItemBase = forwardRef< )} - {isDiagSupported && ( - { - e.stopPropagation(); - props.runDiagnosticsFromVnetPanel(); - }} - > - - - )} + { + 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 8ec5a33d8f246..290698fa115c7 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx @@ -46,7 +46,6 @@ type StoryProps = { | 'error' | 'processing' | 'processing-with-previous-results'; - vnetDiag: boolean; runDiagnostics: 'success' | 'error' | 'processing'; diagReport: 'ok' | 'issues-found' | 'failed-checks'; isWorkspacePresent: boolean; @@ -60,7 +59,6 @@ const defaultArgs: StoryProps = { clusters: ['teleport.example.com'], sshConfigured: false, fetchStatus: 'success', - vnetDiag: true, runDiagnostics: 'success', diagReport: 'ok', isWorkspacePresent: true, @@ -117,9 +115,7 @@ const meta: Meta = { export default meta; function VnetSliderStep(props: StoryProps) { - const appContext = new MockAppContext({ - platform: props.vnetDiag ? 'darwin' : 'win32', - }); + const appContext = new MockAppContext(); 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 1f20aa48be18b..3779cf77be607 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -54,10 +54,6 @@ 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; @@ -149,7 +145,6 @@ export const VnetContextProvider: FC< [mainProcessClient] ); const isSupported = platform === 'darwin' || platform === 'win32'; - const isDiagSupported = platform === 'darwin'; const [startAttempt, start] = useAsync( useCallback(async () => { @@ -429,10 +424,6 @@ export const VnetContextProvider: FC< useEffect( function periodicallyRunDiagnostics() { - if (!isDiagSupported) { - return; - } - if (status.value !== 'running') { return; } @@ -454,7 +445,6 @@ export const VnetContextProvider: FC< }; }, [ - isDiagSupported, diagnosticsIntervalMs, runDiagnosticsAndShowNotification, status.value, @@ -466,7 +456,6 @@ export const VnetContextProvider: FC<