Skip to content

Commit 4b90ea5

Browse files
committed
vpn/netstate: clean up PowerShell invocation and diagnose missing WinNAT
On Windows Home the MSFT_NetNat WMI class does not exist, so every *-NetNat cmdlet fails with WBEM_E_INVALID_CLASS (0x80041010) and the UI showed a raw multi-line PowerShell dump with mojibake. Detect the HRESULT (the message text is localized) and return an actionable error instead; document the condition in README's Troubleshooting. Also fix two runPowerShell defects visible in the same flow: the console window of powershell.exe popped up over the GUI (hidden via CREATE_NO_WINDOW), and localized error text was mangled to '?' in logs (fixed by forcing UTF-8 output encoding).
1 parent 9d6437d commit 4b90ea5

3 files changed

Lines changed: 60 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ The privacy exposure — your IP appearing as the source of another device's tra
327327
- **A "what's my IP" site still shows your own IP after enabling.** Check the gateway status (the **VPN Gateway** card, or `awl cli gateway status`): if it's not connected, awl can't reach the exit node, so nothing is being tunnelled.
328328
- **A site works over IPv6 but not through the gateway.** Expected — IPv6 isn't tunnelled (see the note above). Dual-stack hosts fall back to IPv4 automatically; anything IPv6-only won't work while the gateway is on.
329329
- **Turning on *Serve as VPN Gateway* fails on Windows.** Windows effectively allows one NAT instance per host, and it may already be taken by Docker (Windows containers), WSL2 or Internet Connection Sharing — the error message lists the current holders. Free it up, or share this device over SOCKS5 instead: the SOCKS5 exit node doesn't need NAT.
330+
- **Turning on *Serve as VPN Gateway* on Windows fails with "WinNAT is not available" (HRESULT 0x80041010).** awl's exit-node NAT is built on Windows' own WinNAT, and on this installation the `MSFT_NetNat` WMI class doesn't exist. Windows **Home** editions don't ship WinNAT at all — there is no way to enable it there. On Pro/Enterprise/Server it can also be missing when neither Hyper-V nor RAS components are enabled (turning on the Hyper-V feature registers it) or when the WMI repository is corrupted. If you can't get WinNAT on your machine, share this device over SOCKS5 instead — the SOCKS5 exit node doesn't need it.
330331
- **No IPv6 connectivity after awl crashed (Linux).** If awl is killed (not shut down) with the gateway client on, its IPv6 block stays behind. It is removed automatically on the next awl start (and stop).
331332
- **Devices are reachable only via relay from a Windows machine with multiple network interfaces.** awl on Windows pins its peer-to-peer traffic to the interface that holds the default route, so peers reachable only through a secondary network card may fall back to relayed connections.
332333

vpn/netstate/nat_windows.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os/exec"
1313
"slices"
1414
"strings"
15+
"syscall"
1516
"time"
1617

1718
"github.com/tailscale/wf"
@@ -96,6 +97,11 @@ func (m *Manager) setupNAT(awlSubnet, tunIfName string) (*natState, error) {
9697
// NetNat behind (WFP leftovers are impossible — dynamic session). Remove
9798
// it so New-NetNat below gets a clean slate.
9899
cleaned, err := cleanupStaleWinNAT()
100+
if winNATUnavailable(err) {
101+
// Not a cleanup problem: WinNAT does not exist on this host at all,
102+
// and the "pre-clean" framing would only obscure that.
103+
return nil, err
104+
}
99105
if err != nil {
100106
return nil, fmt.Errorf("pre-clean stale WinNAT: %w", err)
101107
}
@@ -417,13 +423,43 @@ const powerShellTimeout = time.Minute
417423
func runPowerShell(command string) ([]byte, error) {
418424
ctx, cancel := context.WithTimeout(context.Background(), powerShellTimeout)
419425
defer cancel()
420-
out, err := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command).CombinedOutput()
426+
// The OutputEncoding prefix forces UTF-8 on the redirected pipes: without
427+
// it PowerShell encodes its (localized) error text in the console
428+
// codepage, which mangles non-ASCII into '?' by the time it reaches logs.
429+
cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
430+
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; "+command)
431+
// awl runs as a GUI process with no console of its own, so a bare
432+
// powershell.exe would get a fresh visible console window.
433+
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
434+
out, err := cmd.CombinedOutput()
421435
if err != nil {
422436
return out, fmt.Errorf("powershell %q: %w (output: %s)", command, err, strings.TrimSpace(string(out)))
423437
}
424438
return out, nil
425439
}
426440

441+
// winNATUnavailable reports whether err is WMI's WBEM_E_INVALID_CLASS from a
442+
// *-NetNat cmdlet: the MSFT_NetNat class does not exist on this installation
443+
// at all. Windows Home editions do not ship WinNAT, and on other editions the
444+
// class can be missing when neither Hyper-V nor RAS components are enabled or
445+
// the WMI repository is corrupted. Matched by HRESULT, not message text — the
446+
// text is localized.
447+
func winNATUnavailable(err error) bool {
448+
return err != nil && strings.Contains(err.Error(), "0x80041010")
449+
}
450+
451+
// winNATUnavailableError replaces the raw multi-line PowerShell dump with an
452+
// actionable message for the winNATUnavailable case. The original error is
453+
// deliberately not wrapped: the HRESULT is the whole story, and this text is
454+
// shown verbatim in the UI.
455+
func winNATUnavailableError() error {
456+
return errors.New("WinNAT is not available on this Windows installation " +
457+
"(WMI class MSFT_NetNat does not exist, HRESULT 0x80041010). " +
458+
"It is missing on Windows Home editions; on other editions it requires Hyper-V or RAS " +
459+
"components. VPN gateway server mode cannot work without it — " +
460+
"see the Troubleshooting section in the README; the SOCKS5 exit node works without WinNAT")
461+
}
462+
427463
// netNATEntry is one WinNAT instance as reported by
428464
// `Get-NetNat | ConvertTo-Json`.
429465
type netNATEntry struct {
@@ -466,6 +502,9 @@ func findNetNAT(entries []netNATEntry, name string) (netNATEntry, bool) {
466502
// listNetNAT returns the current WinNAT instances.
467503
func listNetNAT() ([]netNATEntry, error) {
468504
out, err := runPowerShell("Get-NetNat | Select-Object Name,InternalIPInterfaceAddressPrefix | ConvertTo-Json -Compress")
505+
if winNATUnavailable(err) {
506+
return nil, winNATUnavailableError()
507+
}
469508
if err != nil {
470509
return nil, err
471510
}
@@ -500,6 +539,9 @@ func createWinNAT(awlSubnet string) error {
500539
if err == nil {
501540
return nil
502541
}
542+
if winNATUnavailable(err) {
543+
return winNATUnavailableError()
544+
}
503545

504546
existing := "unavailable"
505547
if entries, listErr := listNetNAT(); listErr == nil {

vpn/netstate/nat_windows_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,22 @@ func TestResyncForwarding(t *testing.T) {
111111
}
112112
}
113113

114+
// winNATUnavailable must recognize WBEM_E_INVALID_CLASS by HRESULT alone:
115+
// the surrounding message is localized (the sample below is from a Russian
116+
// Windows 11 Home), so text matching is not an option.
117+
func TestWinNATUnavailable(t *testing.T) {
118+
realError := errors.New(`powershell "Get-NetNat | Select-Object Name,InternalIPInterfaceAddressPrefix | ConvertTo-Json -Compress": exit status 1 (output: Get-NetNat : Недопустимый класс
119+
At line:1 char:1
120+
+ Get-NetNat | Select-Object Name,InternalIPInterfaceAddressPrefix | Co ...
121+
+ ~~~~~~~~~~
122+
+ CategoryInfo : MetadataError: (MSFT_NetNat:root/StandardCimv2/MSFT_NetNat) [Get-NetNat], CimException
123+
+ FullyQualifiedErrorId : HRESULT 0x80041010,Get-NetNat)`)
124+
require.True(t, winNATUnavailable(realError))
125+
126+
require.False(t, winNATUnavailable(nil))
127+
require.False(t, winNATUnavailable(errors.New(`powershell "New-NetNat": exit status 1 (output: some other failure)`)))
128+
}
129+
114130
// PowerShell's ConvertTo-Json emits three shapes depending on result count:
115131
// nothing at all, a bare object, or an array. parseNetNATJSON must handle all.
116132
func TestParseNetNATJSON(t *testing.T) {

0 commit comments

Comments
 (0)