A thin layer for linking Windows machines over PowerShell Remoting or SSH. Name a host, run commands on it. Windows only.
Not tied to any specific network. Machines only need stable, mutually reachable addresses — provided by an overlay network (Tailscale, NetBird, ZeroTier), a plain LAN, or anything else. winmesh is about the addresses and WinRM, not how you obtain them.
This is not a new protocol. Under the hood it is stock WinRM and Invoke-Command, or stock ssh — you pick per host with one config field. The value is the opinionated bundle: name-based addressing from a config file, a firewall narrowed to trusted subnets, a local credential store, one-command channel checks, and a set of gotchas you would otherwise learn the hard way. Aimed at a handful of machines, where Ansible is overkill.
- Controller — the machine you run winmesh from (your laptop). It holds the config and the credential store.
- Target — a machine you want to reach. It runs the WinRM listener.
- Config (
config/hosts.psd1) — the list of targets: a short name, an address, a credential id. - Credential store — encrypted per-target credentials, kept locally (never in git). WinRM only.
- Transport —
winrm(default) orssh, set per host. Everything above the transport is identical: same names, same commands, same object results.
You do a one-time setup per machine, then day-to-day you just call Invoke-WinMeshCommand <name> { ... }.
- Windows PowerShell 5.1 or PowerShell 7, on both controller and targets.
- A network that gives the machines stable, mutually reachable addresses (Tailscale / NetBird / ZeroTier / LAN).
- Administrator rights for exactly two steps:
Connect-WinMeshHoston the controller, and the bootstrap script on each target.
git clone https://github.com/AndrewMoryakov/winmesh.git
cd winmesh
Import-Module .\winmesh.psd1A target can only be reached after WinRM is enabled on it — and enabling it needs a local administrator on that machine. There is no way around this remotely: the channel does not exist yet. This is the one manual step.
On the controller, generate the bootstrap script:
New-WinMeshBootstrap -OutFile .\bootstrap.ps1Copy bootstrap.ps1 to the target (RDP, USB stick, or GPO in a domain) and run it there as Administrator. It will:
- enable PowerShell Remoting (
Enable-PSRemoting); - narrow the WinRM firewall rule to your trusted subnets (see Choosing subnets);
- grant a full admin token to local accounts in remote sessions;
- print the machine facts you need for the config (computer name, domain, the exact login string).
Note the printed LoginForCred value — you will use it in Step 3.
If the target is already reachable by WinRM, skip this step.
Copy-Item .\config\hosts.example.psd1 .\config\hosts.psd1
notepad .\config\hosts.psd1Fill in one entry per machine:
Hosts = @{
'workstation-01' = @{
Address = '100.100.10.11' # its overlay/LAN address or DNS name
Credential = 'admin@workstation-01' # any id you like; used in Step 3
}
}Register-WinMeshCredential -Id 'admin@workstation-01'A prompt appears. Enter the login exactly as the bootstrap printed it:
- domain machine →
DOMAIN\user - workgroup / standalone →
COMPUTERNAME\user(e.g.workstation-01\admin)
The password is encrypted with DPAPI — the file can only be read by your account on this controller. Copied elsewhere, it is useless.
Connect-WinMeshHost -Name workstation-01This starts the WinRM service on the controller and adds the target to TrustedHosts (required for IP-based auth). Run once per new target address.
Test-WinMeshHost -Name workstation-01Expect five green checks: port, WS-Management, credentials, command runs, full admin token.
Invoke-WinMeshCommand workstation-01 { hostname; whoami }
Invoke-WinMeshCommand workstation-01 { param($p) Test-Path $p } -ArgumentList 'C:\Windows'
Test-WinMeshFleet # check every host in the config at onceEverything below assumes the host workstation-01 is set up (Steps 1–5).
Run a command and read the result. Invoke-WinMeshCommand returns real objects, not text — pipe them like anything else:
Invoke-WinMeshCommand workstation-01 { Get-Service } |
Where-Object Status -eq 'Running' |
Measure-ObjectPass arguments in. Use param() in the block and -ArgumentList:
Invoke-WinMeshCommand workstation-01 {
param($name, $days)
Get-EventLog -LogName System -After (Get-Date).AddDays(-$days) -EntryType Error |
Where-Object Source -like "*$name*"
} -ArgumentList 'disk', 7Do the same thing on every machine in the fleet. Loop over the config:
$cfg = Get-WinMeshConfig
foreach ($name in $cfg.Hosts.Keys) {
$free = Invoke-WinMeshCommand $name {
(Get-PSDrive C).Free / 1GB
}
"{0,-20} {1,6:N1} GB free on C:" -f $name, $free
}Gate a script on channel health. Test-WinMeshHost -Quiet returns an object with an Ok field and no output — good for automation:
if (-not (Test-WinMeshHost -Name workstation-01 -Quiet).Ok) {
throw 'workstation-01 is unreachable — aborting'
}
# ... proceed knowing the channel worksCheck the whole fleet in a scheduled job:
$down = (Test-WinMeshFleet -Quiet) | Where-Object { -not $_.Ok }
if ($down) {
"$($down.Count) machine(s) down: $($down.Host -join ', ')" | Send-Alert # your notifier
}Copy files to or from a host. File transfer needs a live session; open one with the stored credential:
$cfg = Get-WinMeshConfig
$h = $cfg.Hosts['workstation-01']
$cred = Import-Clixml (Join-Path $cfg.Defaults.CredentialStore "$($h.Credential -replace '[^\w.@-]','_').cred.xml")
$s = New-PSSession -ComputerName $h.Address -Credential $cred
Copy-Item .\report.csv -Destination 'C:\Temp\' -ToSession $s
Copy-Item 'C:\Temp\log.txt' -Destination .\ -FromSession $s
Remove-PSSession $sUse a different config file (e.g. staging vs production):
$env:WINMESH_CONFIG = 'C:\fleets\staging.psd1'
Test-WinMeshFleet
# or per-call:
Invoke-WinMeshCommand nas-lan { hostname } -Config (Get-WinMeshConfig -Path .\lan.psd1)WinRM is the default because it is native and returns objects. SSH is the better choice in two cases: the machines are already joined by an overlay VPN that ships its own SSH server (NetBird does), or WinRM is unavailable to you — blocked by policy, or the port is closed and you cannot open it.
Switching a host is one field. Nothing else in your scripts changes:
Hosts = @{
'workstation-02' = @{
Address = 'workstation-02' # overlay name or IP
Transport = 'ssh'
SshUser = 'Administrator'
}
}Test-WinMeshHost -Name workstation-02
Invoke-WinMeshCommand workstation-02 { Get-Service } | Where-Object Status -eq 'Running'There is no Credential field and no Connect-WinMeshHost step. Over ssh the
client authenticates on its own — a key, an agent, or, on an overlay network, the
peer identity: NetBird's SSH server authenticates the peer, so a machine already
in your mesh needs no key at all. Steps 3 and 4 of the setup simply do not apply.
You still get objects back, not text. The scriptblock and its arguments are
base64-packed into powershell -EncodedCommand, and the result is serialized on
the far side with the same CliXml engine remoting uses, then rehydrated here.
SSH options — per host or in Defaults:
| Key | Default | Meaning |
|---|---|---|
SshUser |
(empty) | remote account; empty means let ssh decide |
SshPort |
22 |
|
SshShell |
powershell |
powershell (5.1, always present) or pwsh |
SshTimeout |
15 |
seconds, becomes ConnectTimeout |
SshOptions |
@() |
extra -o options, e.g. @('StrictHostKeyChecking=accept-new') |
Anything more specific — jump hosts, per-host keys, aliases — belongs in your
~/.ssh/config, which ssh reads as usual. winmesh does not duplicate it.
AllowedSubnets (used by the WinRM bootstrap) applies to WinRM only. If you serve
SSH from Windows OpenSSH rather than the VPN's own server, narrow port 22 yourself:
Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -RemoteAddress '100.64.0.0/10'Repeat Steps 1–5 for each new target. Day-to-day there is nothing to remember beyond the host's short name.
Test-WinMeshFleet # health of the whole fleetThe bootstrap narrows the WinRM port to Defaults.AllowedSubnets from your config. Pick the value that matches how your machines are linked:
| Network | AllowedSubnets |
|---|---|
| Tailscale / NetBird | @('100.64.0.0/10') — the CGNAT range (default) |
| ZeroTier | your network's subnet, e.g. @('10.147.17.0/24') |
| Plain LAN | e.g. @('192.168.1.0/24') |
| Several at once | @('192.168.1.0/24','100.64.0.0/10') |
| Do not narrow (trusted LAN) | @() |
You can also pass it directly, ignoring the config default:
New-WinMeshBootstrap -AllowedSubnets '192.168.1.0/24' -OutFile .\bootstrap-lan.ps1AllowedSubnets is the single place that decides which source networks may reach
a host's WinRM port. It is applied at bootstrap; view or re-apply it later without
remembering firewall commands:
Get-WinMeshFirewallScope -Name workstation-01 # current scope + whether it matches the config
Set-WinMeshFirewallScope -Name workstation-01 -WhatIf # preview
Set-WinMeshFirewallScope -Name workstation-01 # apply the config's AllowedSubnetsSet-WinMeshFirewallScope refuses if narrowing would cut your own live session
(no connected source falls inside the new ranges) — pass -Force only at the
console. A host may override the global default with its own AllowedSubnets
(e.g. a machine reachable through a different overlay):
Hosts = @{
'nas-zt' = @{
Address = '10.147.17.20'
Credential = 'admin@nas-zt'
AllowedSubnets = @('10.147.17.0/24') # this host only; overrides Defaults
}
}Two things must both hold to reach a host: the source must be able to route to
the machine, and its address must fall inside AllowedSubnets.
- You are physically elsewhere (corporate, hotel, home). Nothing to change — Tailscale/NetBird runs over whatever internet you have, so you stay on the overlay and the host is reachable at its overlay address. The usual case.
- Add another overlay (e.g. ZeroTier). Join both machines to it, add its
subnet to
AllowedSubnets(ZeroTier is not in the100.64.0.0/10CGNAT range), runSet-WinMeshFirewallScope, and add the host's new address toTrustedHostson the controller. - A specific trusted LAN, no overlay. Add that subnet — as narrow as possible
(a
/24, or a single/32) — weighing that anything on it can then reach WinRM.
- Prefer overlay membership as the boundary. Reaching a host means being on its overlay, which requires your Tailscale/NetBird auth. The firewall range is a second layer that rejects the physical LAN and public addresses.
- Do not open a whole corporate or public subnet to WinRM. Reach the host over the overlay from that network instead; the overlay tunnels over any internet.
AllowedSubnetsreplaces, never appends. List every network you want at once; an empty list (@()) means "do not narrow" (Any) — only for a trusted LAN.- WinRM 5985 is Negotiate/Kerberos-encrypted, not plaintext, but it is still an admin channel — keep the source range as tight as the setup allows.
- The rule's profile still matters. It applies to Domain+Private; if an overlay adapter is categorized Public the rule will not cover it. Keep overlay adapters Private, or widen the rule's profile once the source range is set.
| Command | What it does | Runs on | Admin |
|---|---|---|---|
Get-WinMeshConfig |
load and validate the config | controller | no |
Register-WinMeshCredential |
save a target's credentials (DPAPI) — winrm only | controller | no |
Connect-WinMeshHost |
set up the client: WinRM + TrustedHosts — winrm only | controller | yes |
Test-WinMeshHost / Test-WinMeshFleet |
check the channel | controller | no |
Invoke-WinMeshCommand |
run a command on a host | controller | no |
New-WinMeshBootstrap |
generate the target-prep script | controller | no |
| (bootstrap on target) | enable WinRM, narrow the port — winrm only | target | yes |
config/hosts.psd1 is a PowerShell data file (.psd1, not YAML — Windows PowerShell 5.1 has no built-in YAML parser, and .psd1 is parsed safely without executing code).
@{
Defaults = @{
Transport = 'winrm' # 'winrm' or 'ssh'
CredentialStore = '~\.winmesh\creds' # where encrypted credentials live
AllowedSubnets = @('100.64.0.0/10') # subnets allowed to reach WinRM
}
Hosts = @{
'workstation-01' = @{
Address = '100.100.10.11'
Credential = 'admin@workstation-01'
Note = 'optional free-text note'
}
'workstation-02' = @{
Address = 'workstation-02' # same fleet, ssh instead of WinRM
Transport = 'ssh'
SshUser = 'Administrator' # no Credential: see "Over SSH instead of WinRM"
}
}
}Point winmesh at a different config with $env:WINMESH_CONFIG or -Config/-Path parameters.
- Does not bypass the first admin step on a target. That is impossible in principle; the module only generates the script.
- Does not move credentials between controllers. A DPAPI file decrypts only where it was created. The store is local by design.
- Does not manage SSH keys or passwords. Over ssh, authentication is whatever your
sshclient already negotiates — a key, an agent, or an overlay network's peer identity. winmesh never prompts, stores, or forwards a secret for the ssh path. - Does not install an SSH server for you. Unlike WinRM there is no bootstrap for it: either the overlay VPN already provides one (NetBird does), or you install Windows OpenSSH Server yourself, once.
Collected from real debugging — each one cost time:
Set-Item WSMan:...blames the remote host when the local WinRM service is actually the problem. If it is stopped, touching theWSMan:drive fails with an error about the target being unreachable.Connect-WinMeshHoststarts the service first.Enable-PSRemotingopens the port withRemoteAddress=Any. Narrowing it to trusted subnets is a mandatory step, built into the bootstrap. Skip it once and the port ends up wider open than you think.- TrustedHosts is required even in a domain when connecting by IP: Kerberos does not apply, so auth falls back to NTLM.
- Scripts are UTF-8 with BOM. Without the BOM, Windows PowerShell 5.1 reads non-ASCII as ANSI and fails to parse the file.
Over SSH specifically:
-EncodedCommandwants base64 of UTF-16LE, not UTF-8. Encode the payload as UTF-8 and PowerShell either reads garbage or refuses to parse. This is why the transport encodes with[Text.Encoding]::Unicode.- You cannot hand-quote a command for the remote shell. Windows OpenSSH runs
cmd.exeby default, but PowerShell ifDefaultShellwas changed — and the two disagree about quoting. Sending one base64 token sidesteps the question entirely, and the same string works under either shell. ConvertTo-CliXmldoes not exist in Windows PowerShell 5.1. Use[System.Management.Automation.PSSerializer]::Serialize()/::Deserialize(), which exist in both 5.1 and 7.- On an overlay network, the server answering port 22 may not be the one you configured. NetBird ships its own SSH server and takes the port on the overlay address, so the Windows OpenSSH service you set up can sit there unused.
Test-WinMeshHostprints the SSH banner for exactly this reason — read it. - A session opened by an overlay's SSH server may not carry a full admin token, even for an Administrator account.
Test-WinMeshHostreports this as a separate check rather than letting it surface later as a confusing access-denied.
On a non-English Windows, or a domain-joined machine whose DC is unreachable:
IsInRole('Administrators')is unreliable on a localized Windows — it may throw, or return a wrong$false. The built-in group can be localized (BUILTIN\Администраторыon a Russian install), and the string overload resolves by name. Measured both ways: on a Russian install where the group was localized, the English literal raisedMethodInvocationException; on another box an unmappable name simply returned$false(5.1 and 7 alike). The localized name, the[WindowsBuiltInRole]::Administratorenum, and the raw SID all returned the right answer. Either way a healthy channel gets reported ascommand runs: False— a throw fails the whole call from inside the remote scriptblock, and a wrong$falseis just wrong. Always use the enum orS-1-5-32-544, never the English name.icaclsandAdd-LocalGroupMemberfail the same way, for a different reason. Passing the name"Administrators"makes Windows resolve it, and on a domain-joined machine that query goes to a domain controller — so with the DC unreachable you get "could not establish trust relationship with the primary domain" while doing something entirely local. Well-known SIDs need no lookup:icacls f /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F", andAdd-LocalGroupMember -Group (Get-LocalGroup -SID S-1-5-32-544).- A domain account can pass publickey auth and still fail to open a session.
The OpenSSH log shows
Accepted publickey for <user>, then the client seesConnection reset by peer— and nothing further is logged, because the failure is past sshd's logging. Key-based logon builds the user's token via S4U, which for a domain account needs a live DC; your own interactive session works only because it runs on cached credentials, which sshd cannot use. The fix is not an sshd setting — use a local account. On a domain-joined machine,Get-LocalGroupMember -SID S-1-5-32-544usually reveals one already. - Pasting a key into a console can split it, and sshd will not tell you. A
long
$key = 'ssh-ed25519 …'line wraps at the console width, the newline lands inside the string, andauthorized_keysreceives two fragments. Malformed lines are skipped silently, so the symptom is a plainPermission deniedwith a correct-looking file. Verify with lengths, not by eye — a valid ed25519 line is a single ~110-character row:Get-Content $f | ForEach-Object { $_.Length }. - Admin keys live somewhere else entirely. For any account in the
administrators group, stock
sshd_configcarriesMatch Group administrators→__PROGRAMDATA__/ssh/administrators_authorized_keys. A key placed inC:\Users\<name>\.ssh\authorized_keysis ignored, and so is that shared file if its ACL is wider than SYSTEM + Administrators. - The rule behind the first two: on a localized Windows, anything that
resolves or formats through the system locale is a portability bug. Two
instances turned up within an hour on the same box —
IsInRole('Administrators')throwing, and a separate CLI whosetoLocaleString()printed1 401whereen-USprints1,401, breaking its own output parser. Both look correct on an English machine and neither is caught by a Linux test run. Pin the locale (toLocaleString('en-US')) and address principals by SID.
- Do not embed non-ASCII literals in a script you transfer. A
.ps1carrying Cyrillic came out mangled afterscp, and PowerShell 5.1 failed to parse it — the BOM gotcha above, arriving through a different door. Derive the localized string at run time instead, which needs no literal at all:([Security.Principal.SecurityIdentifier]'S-1-5-32-544').Translate([Security.Principal.NTAccount]).Value. - Do not hand-quote an ad-hoc command either. The transport avoids this by
base64-packing (see above), but the same trap catches you at the console: a
one-off
powershell -Command "... try { } catch { } ..."oversshlost its braces to two layers of quoting and died withMissingCatchOrFinally. Write a file and runpowershell -File, exactly as the transport effectively does. - A large download initiated on the target may fail where a push from the
controller succeeds.
Invoke-WebRequestaborted withIOExceptionpartway through a 100 MB file, and acurl.exe -C -resume then could not even open the partial file, in a directory proven writable moments earlier.scpof the same bytes from the controller worked first time. Cause not established — security software is the obvious suspect, but it was not confirmed. Worth knowing as a fallback rather than as an explanation.
Windows PowerShell 5.1 or PowerShell 7. A network giving machines stable, mutually reachable addresses — overlay (Tailscale, NetBird, ZeroTier) or plain LAN. Administrator rights only for Connect-WinMeshHost on the controller and the bootstrap on each target — neither applies to ssh hosts. For the ssh transport, an ssh client on the controller (built into Windows 10/11 and Server 2019+) and an SSH server on the target.
Contributions are welcome — see CONTRIBUTING.md for scope, conventions, and how to test. The project stays deliberately small and dependency-free.
MIT — see LICENSE.