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<