Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/verify-ezvpn-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ on:
ezvpn_ref:
description: "ezvpn git ref (commit SHA / branch / tag) to build against"
required: true
default: "b41e190b130251cd20230d020bc79c6e7285b7a4"
default: "6f7d3475cb4032be31c7ededbb606faba60e3547"
ezvpn_repo:
description: "ezvpn repository (owner/name)"
required: true
Expand Down
73 changes: 73 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# ezvpn-windows — notes for Claude

Native Windows GUI (WinUI 3, .NET) for the `ezvpn` VPN. Sibling of `ezvpn-apple`.
The Rust core + C FFI live in the `../ezvpn` repo (`src/ffi_windows.rs`,
`windows/ezvpn.h`, `build-windows.ps1`, `docs/Windows-App.md`).

## Key facts

- Strict no backward compatibility (0.0.x) or legacy code paths
- The transport is iroh (Rust-only) — never reimplement the protocol in .NET.
The app P/Invokes `ezvpn.dll` (`ezvpn_start` / `ezvpn_status` / `ezvpn_stop`).
- Single elevated process: app manifest requests Administrator; the tunnel runs
in-process (no service, no IPC). `wintun.dll` must be beside `ezvpn.dll`.
- `Ezvpn.Core` is `net8.0` and pure (unit-tested). `Ezvpn.App` is
`net10.0-windows…` WinUI 3, needs a RID to build (defaults to `win-x64`).
- Build the whole solution with `dotnet build ezvpn-windows.slnx` (no `-r` — the
app has a default RID; passing `-r` to a *solution* is rejected by the SDK).
- Native DLLs are runtime-only, so the app compiles/tests without them. They are
copied into output for running/packaging (from `native/`, or from
`..\ezvpn\dist\windows` when `EZVPN_LOCAL_DLL=1`).
- **To check a change on Windows, run `pwsh ci/windows/remote.ps1`.** From the
macOS dev box (where none of this builds) it ships the *working tree* —
uncommitted changes included — to the Hyper-V VM (`windows-ci-build`, shared
with `../wrustic`) and runs build, test, publish and MSI there. `remote.ps1
doctor` reports the VM's toolchain, `remote.ps1 provision` (re)installs it.
See `docs/windows-vm-ci.md`.
- Do **not** reach for `.github/workflows/windows-ci.yml` to find out whether
something works: it needs a commit and a push to trigger, and it is slow even
warm, because a GitHub runner starts with a cold NuGet cache every time. It
runs itself on push/PR — read it after the fact, don't drive it.
`ci\windows\ci.ps1` is the same four steps on the VM, with a warm NuGet cache;
when the two disagree, the workflow is right and `ci.ps1` is stale.

## Conventions

- The `ezvpn_start` config JSON shape is defined in `../ezvpn/windows/ezvpn.h`;
`EzvpnConfig.Build` produces it. Keep them in sync.
- `ClientStatus` mirrors the Rust `ClientStatus` (snake_case) from
`../ezvpn/src/control.rs`.
- The client authenticates with an **ed25519 keypair**, not a pre-shared token:
the config's `auth_key` is the client's `ed25519-sec:…` secret, and its public
half goes on the server's `authorized_keys` file. Keys are generated and parsed
only through the FFI (`ezvpn_generate_client_key` / `ezvpn_client_public_key`,
wrapped by `Core/Interop/AuthKey`) — never reimplement the key format in .NET.
- Like the Apple and Android apps, the app keeps one shared list of **named**
keys (`AuthKeyStore`) that profiles reference by id (`TunnelProfile.AuthKeyId`);
saving a profile copies that key's secret into the profile's own credential,
which is what `ezvpn_start` is handed. So a key deleted from the list doesn't
break profiles already saved with it.
- All secrets live in Windows Credential Manager (`SecretStore`), never in the
profile JSON: `ezvpn-profile-key:<profileId>` (the profile's auth key),
`ezvpn-relay:<profileId>` (the optional relay token) and `ezvpn-key:<keyId>`
(one record per named key — one credential each, because a credential blob caps
out at 2560 bytes).
- Installer uses **WiX v5** (v6/v7 require accepting the paid OSMF EULA). The MSI
is unsigned by design; code signing and MSIX/Store packaging are out of scope.
- Use classic `[DllImport]` (not `[LibraryImport]`) for the `advapi32`
Credential Manager calls — the `CREDENTIAL` struct isn't source-gen
marshallable.
- Icons: `assets\icon.svg` (the shield/keyhole glyph, shared with ezvpn-apple) is
the source of truth. `scripts\render-icons.ps1` renders it to the committed
`src\Ezvpn.App\Assets\ezvpn.ico` (teal, multi-size) plus a gray
`ezvpn-gray.ico` via GDI+ — re-run it (needs Windows PowerShell 5.1) only when
the SVG changes; CI just uses the committed `.ico`s. The teal `ezvpn.ico` is
the `.exe` icon (`<ApplicationIcon>`) and the title-bar icon
(`AppWindow.SetIcon`); the tray shows `ezvpn-gray.ico` until the tunnel is
connected, then swaps to the teal one.
- The system tray (`Services\TrayIcon.cs`) is hand-rolled on `Shell_NotifyIcon`
(WinUI 3 has no tray API): it subclasses the window's WndProc for callbacks and
uses a native `TrackPopupMenuEx` menu. Closing the window hides to the tray;
only the tray's Quit exits the process. `SetConnected(bool)` swaps between the
teal (connected) and gray (not-connected) icons; `MainWindow` drives it from
the active tunnel's connection state.
60 changes: 1 addition & 59 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,59 +1 @@
# ezvpn-windows — notes for Claude

Native Windows GUI (WinUI 3, .NET) for the `ezvpn` VPN. Sibling of `ezvpn-apple`.
The Rust core + C FFI live in the `../ezvpn` repo (`src/ffi_windows.rs`,
`windows/ezvpn.h`, `build-windows.ps1`, `docs/Windows-App.md`).

## Key facts

- The transport is iroh (Rust-only) — never reimplement the protocol in .NET.
The app P/Invokes `ezvpn.dll` (`ezvpn_start` / `ezvpn_status` / `ezvpn_stop`).
- Single elevated process: app manifest requests Administrator; the tunnel runs
in-process (no service, no IPC). `wintun.dll` must be beside `ezvpn.dll`.
- `Ezvpn.Core` is `net8.0` and pure (unit-tested). `Ezvpn.App` is
`net10.0-windows…` WinUI 3, needs a RID to build (defaults to `win-x64`).
- Build the whole solution with `dotnet build ezvpn-windows.slnx` (no `-r` — the
app has a default RID; passing `-r` to a *solution* is rejected by the SDK).
- Native DLLs are runtime-only, so the app compiles/tests without them. They are
copied into output for running/packaging (from `native/`, or from
`..\ezvpn\dist\windows` when `EZVPN_LOCAL_DLL=1`).
- **To check a change on Windows, run `pwsh ci/windows/remote.ps1`.** From the
macOS dev box (where none of this builds) it ships the *working tree* —
uncommitted changes included — to the Hyper-V VM (`windows-ci-build`, shared
with `../wrustic`) and runs build, test, publish and MSI there. `remote.ps1
doctor` reports the VM's toolchain, `remote.ps1 provision` (re)installs it.
See `docs/windows-vm-ci.md`.
- Do **not** reach for `.github/workflows/windows-ci.yml` to find out whether
something works: it needs a commit and a push to trigger, and it is slow even
warm, because a GitHub runner starts with a cold NuGet cache every time. It
runs itself on push/PR — read it after the fact, don't drive it.
`ci\windows\ci.ps1` is the same four steps on the VM, with a warm NuGet cache;
when the two disagree, the workflow is right and `ci.ps1` is stale.

## Conventions

- The `ezvpn_start` config JSON shape is defined in `../ezvpn/windows/ezvpn.h`;
`EzvpnConfig.Build` produces it. Keep them in sync.
- `ClientStatus` mirrors the Rust `ClientStatus` (snake_case) from
`../ezvpn/src/control.rs`.
- Auth tokens live in Windows Credential Manager (`TokenStore`), never in the
profile JSON.
- Installer uses **WiX v5** (v6/v7 require accepting the paid OSMF EULA). The MSI
is unsigned by design; code signing and MSIX/Store packaging are out of scope.
- Use classic `[DllImport]` (not `[LibraryImport]`) for the `advapi32`
Credential Manager calls — the `CREDENTIAL` struct isn't source-gen
marshallable.
- Icons: `assets\icon.svg` (the shield/keyhole glyph, shared with ezvpn-apple) is
the source of truth. `scripts\render-icons.ps1` renders it to the committed
`src\Ezvpn.App\Assets\ezvpn.ico` (teal, multi-size) plus a gray
`ezvpn-gray.ico` via GDI+ — re-run it (needs Windows PowerShell 5.1) only when
the SVG changes; CI just uses the committed `.ico`s. The teal `ezvpn.ico` is
the `.exe` icon (`<ApplicationIcon>`) and the title-bar icon
(`AppWindow.SetIcon`); the tray shows `ezvpn-gray.ico` until the tunnel is
connected, then swaps to the teal one.
- The system tray (`Services\TrayIcon.cs`) is hand-rolled on `Shell_NotifyIcon`
(WinUI 3 has no tray API): it subclasses the window's WndProc for callbacks and
uses a native `TrackPopupMenuEx` menu. Closing the window hides to the tray;
only the tray's Quit exits the process. `SetConnected(bool)` swaps between the
teal (connected) and gray (not-connected) icons; `MainWindow` drives it from
the active tunnel's connection state.
@AGENTS.md
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
monotonically increasing build-timestamp value so MajorUpgrade keeps working
(see installer/Ezvpn.Installer.wixproj and .github/workflows/release.yml). -->
<PropertyGroup>
<EzvpnVersion Condition="'$(EzvpnVersion)' == ''">0.0.36</EzvpnVersion>
<EzvpnVersion Condition="'$(EzvpnVersion)' == ''">0.0.43</EzvpnVersion>
</PropertyGroup>

</Project>
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ See `docs/Windows-App.md` in the `ezvpn` repo for the FFI contract.
| Project | What |
|---|---|
| `src/Ezvpn.Core` | Pure model + config JSON builder + validation + status DTOs + the `ezvpn.dll` P/Invoke wrapper (`EzvpnSession`). No WinUI; unit-tested. |
| `src/Ezvpn.App` | The WinUI 3 app: profile list/detail/edit, connect/disconnect, live status polling. Stores profiles under `%ProgramData%\ezvpn\profiles` and the auth token in Windows Credential Manager. |
| `src/Ezvpn.App` | The WinUI 3 app: profile list/detail/edit, the auth-key manager, connect/disconnect, live status polling. Stores profiles under `%ProgramData%\ezvpn\profiles` and every secret in Windows Credential Manager. |
| `tests/Ezvpn.Core.Tests` | xUnit tests for the pure logic. |
| `installer/` | WiX v5 MSI that bundles the published app + `ezvpn.dll` + `wintun.dll`. |

Expand Down Expand Up @@ -164,8 +164,20 @@ involved.
## Usage

1. Launch ezvpn (accept the UAC prompt).
2. **+** to add a profile: give it a name, the server's iroh node id, the auth
token (required), and optional split-tunnel routes (`10.0.0.0/8`, `fd00::/8`, …).
3. Select it and **Connect**. The status panel shows the assigned IP, gateway,
2. Add an **auth key** (the key button in the left toolbar, or *Manage keys…* in
the profile editor): name it and leave the secret blank to generate a fresh
ed25519 keypair — or paste an `ed25519-sec:…` secret from another device to
reuse that identity. Copy the key's **public** half onto the server's
`authorized_keys` file; the secret never leaves the machine except through the
explicit copy action.
3. **+** to add a profile: give it a name, the server's iroh node id, the auth key
to authenticate with (required), and optional split-tunnel routes
(`10.0.0.0/8`, `fd00::/8`, …).
4. Select it and **Connect**. The status panel shows the assigned IP, gateway,
routes, and the live iroh connection path once connected.
4. **Disconnect** tears down the tunnel and routes.
5. **Disconnect** tears down the tunnel and routes.

Keys are shared across profiles: several profiles can authenticate with the same
device identity. A profile keeps its own copy of the secret it was saved with, so
deleting a key from the list doesn't break profiles already using it — but they
can't be re-saved until a key is picked again.
4 changes: 2 additions & 2 deletions native/native.targets
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@
once an ezvpn release that includes the build-windows-lib asset exists.
scripts\bump-dll.ps1 also mirrors this tag (minus "v") into EzvpnVersion in
Directory.Build.props, which is the version the app UI + MSI report. -->
<EzvpnReleaseTag Condition="'$(EzvpnReleaseTag)' == ''">v0.0.36</EzvpnReleaseTag>
<EzvpnReleaseTag Condition="'$(EzvpnReleaseTag)' == ''">v0.0.43</EzvpnReleaseTag>
<EzvpnDllZipUrl>https://github.com/flexaccessdev/ezvpn/releases/download/$(EzvpnReleaseTag)/ezvpn-windows.dll.zip</EzvpnDllZipUrl>
<EzvpnDllZipSha256>d51ecf7b0df94ad3347121c220c4735caf52fd2b7a0d5b77e7c81436b20c8d78</EzvpnDllZipSha256>
<EzvpnDllZipSha256>792d01e125058aebe0f73b3af54f8e6bab3d29246b51ec1abb40162bb2516f75</EzvpnDllZipSha256>

<!-- Local core build used when EZVPN_LOCAL_DLL=1 (see ..\ezvpn\build-windows.ps1).
In that mode ezvpn.dll is consumed straight from the sibling dist — it is
Expand Down
35 changes: 21 additions & 14 deletions src/Ezvpn.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
<Button x:Name="DeleteButton" Click="DeleteButton_Click" ToolTipService.ToolTip="Delete profile">
<FontIcon Glyph="&#xE74D;" FontFamily="Segoe MDL2 Assets" />
</Button>
<Button x:Name="KeysButton" Click="KeysButton_Click" ToolTipService.ToolTip="Auth keys">
<FontIcon Glyph="&#xE192;" FontFamily="Segoe MDL2 Assets" />
</Button>
</StackPanel>
</Grid>

Expand Down Expand Up @@ -102,31 +105,35 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>

<TextBlock Grid.Row="0" Grid.Column="0" Text="Server node id" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ServerNodeId}" IsTextSelectionEnabled="True" TextWrapping="Wrap" />

<TextBlock Grid.Row="1" Grid.Column="0" Text="Mode" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding Mode}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Auth key" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding AuthKeyName}" TextWrapping="Wrap" />

<TextBlock Grid.Row="2" Grid.Column="0" Text="Mode" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding Mode}" />

<TextBlock Grid.Row="2" Grid.Column="0" Text="Assigned IP" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding AssignedIp}" IsTextSelectionEnabled="True" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Assigned IP" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding AssignedIp}" IsTextSelectionEnabled="True" />

<TextBlock Grid.Row="3" Grid.Column="0" Text="Gateway" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding Gateway}" IsTextSelectionEnabled="True" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Gateway" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding Gateway}" IsTextSelectionEnabled="True" />

<TextBlock Grid.Row="4" Grid.Column="0" Text="Routes" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding RoutesText}" TextWrapping="Wrap" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Routes" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding RoutesText}" TextWrapping="Wrap" />

<TextBlock Grid.Row="5" Grid.Column="0" Text="Connection" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding ConnectionPath}" TextWrapping="Wrap" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="Connection" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="6" Grid.Column="1" Text="{Binding ConnectionPath}" TextWrapping="Wrap" />

<TextBlock Grid.Row="6" Grid.Column="0" Text="Custom relays" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="6" Grid.Column="1" Text="{Binding CustomRelaysText}" TextWrapping="Wrap" IsTextSelectionEnabled="True" />
<TextBlock Grid.Row="7" Grid.Column="0" Text="Custom relays" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="7" Grid.Column="1" Text="{Binding CustomRelaysText}" TextWrapping="Wrap" IsTextSelectionEnabled="True" />

<TextBlock Grid.Row="7" Grid.Column="0" Text="Bypass" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="7" Grid.Column="1" Text="{Binding BypassText}" TextWrapping="Wrap" />
<TextBlock Grid.Row="8" Grid.Column="0" Text="Bypass" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Grid.Row="8" Grid.Column="1" Text="{Binding BypassText}" TextWrapping="Wrap" />
</Grid>
</StackPanel>
</ScrollViewer>
Expand Down
Loading
Loading