claimward-vpn-client
The shared Go client library for the Claimward VPN: sign-in, the enrollment
wire protocol, the userspace WireGuard tunnel, and the live route-push client.
This module ships no binary. It is imported by:
claimward-vpn-server — for the shared wire types (pkg/protocol) and gRPC route stubs (pkg/routespb);
- the platform apps —
claimward-vpn-app-osx (cmd/claimward-app + the privileged cmd/claimward-helper), and the -linux / -windows apps — which own the actual runnable binaries.
Looking for something to run? Build one of the app repos above; this repo is the
library they share.
Packages
| Package |
Purpose |
pkg/protocol |
Wire contract (/enroll, /heartbeat, /deregister, /tenants) — the single source of truth shared with the server |
pkg/auth |
Interactive sign-in behind a pluggable Provider: GitHub device-authorization flow (default), OIDC Authorization Code + PKCE, or go-authn device flow, whose provider also registers the device's WireGuard key (KeyRegistrar). Returns the bearer Token sent to the server |
pkg/oidc |
The OIDC Authorization Code + PKCE flow (issuer discovery, loopback redirect capture) used by the oidc auth provider |
pkg/browser |
Opens a URL in the user's default browser using absolute opener paths, so it works from GUI apps launched by LaunchServices |
pkg/client |
High-level client: Enroll/Heartbeat/Deregister/Tenants against the server, plus TunnelConfig to turn an EnrollResponse into a wgtun.Config |
pkg/wgkey |
WireGuard key generation / parsing |
pkg/wgtun |
Userspace WireGuard tunnel via wireguard-go (+ interface, route and DNS setup: darwin and Linux with their tools, Windows on a Wintun adapter through winipcfg); needs elevated privileges |
pkg/routeclient |
Watches the server's RouteService (gRPC) and reports live route updates without re-enrolling |
pkg/routespb |
Generated gRPC/protobuf stubs for the RouteService (shared with the server) |
pkg/appcore |
The app's business logic, shared by the macOS, Linux and Windows apps: sign-in, the tenant chosen for the session, connect/disconnect through the helper, status for the UI |
pkg/helper |
The privileged helper (root / SYSTEM), shared by the three apps: enrolls, owns the tunnel, keeps the server's lease, watches route pushes. Each app's helper binary is a main that loads its configuration and listens |
pkg/hproto, pkg/helperclient |
The helper's socket protocol (connect, tenants, renew, down, status) and the app's client for it |
pkg/tokenstore |
0600 on-disk session store (auth tokens, device key, chosen tenant), used by pkg/appcore in all three apps |
Architecture (end to end)
app --auth Provider--> IdP (GitHub device flow / OIDC PKCE / go-authn device flow → bearer token)
app --connect (bearer, device key, tenant) over the helper socket--> helper (root / SYSTEM)
helper --POST /enroll (Bearer token, wg pubkey, tenant)--> server (only a server named in helper.json)
server --wgctrl--> wg0 kernel iface (adds peer, allocates IP)
helper <--assigned IP, server pubkey, endpoint, the tenant's routes + DNS-- server
helper --wireguard-go--> utunN / utun / Wintun "Claimward" (tunnel up)
helper --gRPC RouteService.Watch (TLS)--> server (optional: live route updates)
Library usage
package main
import (
"context"
"fmt"
"log"
"github.com/claimward/claimward-vpn-client/pkg/auth"
"github.com/claimward/claimward-vpn-client/pkg/client"
"github.com/claimward/claimward-vpn-client/pkg/protocol"
"github.com/claimward/claimward-vpn-client/pkg/routeclient"
"github.com/claimward/claimward-vpn-client/pkg/wgkey"
)
func main() {
ctx := context.Background()
// 1. Interactive sign-in. Default provider is GitHub (device flow); set
// Provider "oidc" with OIDCIssuer/OIDCClientID for an OIDC issuer.
provider, err := auth.New(auth.Config{
Provider: "github",
GitHubClientID: "Iv1.0123456789abcdef",
})
if err != nil {
log.Fatal(err)
}
tok, err := provider.Login(ctx, func(p auth.DevicePrompt) {
fmt.Printf("visit %s and enter code %s\n", p.VerificationURI, p.UserCode)
})
if err != nil {
log.Fatal(err)
}
// 2. Generate a WireGuard keypair and enroll with the server.
keys, err := wgkey.Generate()
if err != nil {
log.Fatal(err)
}
// With go-authn, the PUBLIC key is registered at the provider first, and
// the token for the server is the one RegisterKey returns. Keep its
// Refresh: the provider rotates refresh tokens.
if reg, ok := provider.(auth.KeyRegistrar); ok {
if tok, err = reg.RegisterKey(ctx, tok, keys.Public.String(), "laptop"); err != nil {
log.Fatal(err)
}
}
c := client.New("https://vpn.example.com")
resp, err := c.Enroll(ctx, tok.Value, keys.Public,
protocol.DeviceInfo{Name: "laptop", OS: "darwin", Platform: "cli"}, "")
if err != nil {
log.Fatal(err)
}
// 3. Turn the enrollment response into a tunnel config. Bring it up with
// pkg/wgtun (wgtun.Up), which needs elevated privileges.
tun, err := client.TunnelConfig(resp, keys.Private)
if err != nil {
log.Fatal(err)
}
fmt.Println("tunnel address:", tun.Address)
// 4. Optionally watch the server for live route changes over gRPC.
if resp.GRPCEndpoint != "" {
go routeclient.Watch(ctx, resp.GRPCEndpoint, tok.Value, keys.Public.String(),
func(u routeclient.Update) {
fmt.Println("routes updated:", u.AllowedIPs)
})
}
}
With go-authn
Provider: "go-authn", with OIDCIssuer and OIDCClientID, signs in at a
go-authn provider: an OpenID Connect
provider in front of a SAML federation such as RENATER or eduGAIN, which also
keeps whose each WireGuard key is. claimward-vpn-server then enrolls a key
only if its owner registered it there (AUTH_PROVIDER=go-authn on the
server). The flow is:
Login: the device flow, scope openid wireguard. The token opens the
provider's key registry and nothing else; a go-authn provider addresses
such a token to itself alone;
RegisterKey (the KeyRegistrar interface):
- registers the device's public key (
POST /wireguard/key), renewing it
when it is already the person's;
- then refreshes asking for
openid alone (RFC 6749 §6), which gives the
token for claimward-vpn-server;
- when the sign-in token has lapsed, it first refreshes with no scope, which
asks for everything granted;
- it returns the rotated refresh token, which replaces the old one, since
the old one is spent;
Enroll with that token.
The private key never leaves the device. The provider's client needs
wireguard_keys = true and a refresh_lifetime.
The privileged helper
The helper runs as root (or SYSTEM). Any process that can reach its socket can
make it act, so what it does is bounded by its own configuration, never by
the request:
- It enrolls only with a server its configuration names. A helper that
took the server from the request would let any local process point it at a
server of its own, answering with routes for
0.0.0.0/0: every packet of
the machine, sent where that process chose.
- It takes no tunnel configuration from a request. The tunnel is what that
server answered. The earlier
up and update-routes actions, which took
one, are gone.
- Its socket is
0660, owned by root and one group (admin on macOS,
claimward on Linux), in a directory only root can write. The earlier
helper's socket was 0666.
- Its configuration must be root's, and writable by root alone, or the
helper does not start.
On Windows the same rules are ACLs, which the helper sets and reads back
itself rather than trusting an installer to have done it:
- the socket's directory,
C:\ProgramData\Claimward, gets a protected
DACL (nothing inherited from ProgramData, which lets every user create files
there): SYSTEM and Administrators in full control, the socket's group allowed
to list and traverse it and nothing more. It is created already carrying
that DACL, and a directory that is a junction or a link is refused;
- the socket gets its own DACL: SYSTEM and Administrators, and the group
allowed to connect (read/write);
- the group is the local group
Claimward Users (the app's installer
creates it and adds the person). If it does not exist the socket is opened
to INTERACTIVE — everybody logged on at the machine, console or Remote
Desktop — and the helper logs that it did;
helper.json must be owned by SYSTEM or Administrators, and writable by
nobody else: the helper reads the file's owner and DACL and refuses any
allow entry that grants write, append, delete, WRITE_DAC, WRITE_OWNER
or generic write/all to another SID, and a null DACL.
The rules themselves (pkg/helper/acl.go) are pure functions, tested on every
platform; the Windows CI job applies them to real files and reads them back.
{
"servers": ["https://vpn.example.org"],
"group": "claimward"
}
A route watch (pkg/routeclient) carries the bearer token, so it runs over
TLS except to a loopback address. Against a server whose gRPC is not TLS,
the handshake fails before the token is sent, and the tunnel stays up
without live route updates.
The lease
The server removes a peer whose lease ends (LEASE_TTL, 24 h by default).
While the tunnel is up the helper renews it (POST /api/v1/heartbeat) at half
of what the lease has left, between 30 s and 10 minutes (the longest a device
stays cut off after the server restarts), and acts on the answer:
| The server answers |
The helper |
| a renewed lease |
renews again at half of it |
404 not_enrolled: it forgot the device (restarted, or the lease ran out) |
enrolls again, with the same key and tenant, and brings the tunnel up from that answer; if that fails too, takes the tunnel down |
403 (not_a_member, key_not_registered): access withdrawn |
takes the tunnel down |
401, a 5xx, nothing |
retries within a minute, sooner than the lease ends |
It renews with the last bearer it was given. That is enough for a GitHub
token, but not for a go-authn access token, which expires in minutes and
which only the app can refresh. So while it runs, pkg/appcore brings the
helper a fresh bearer (the renew action) at 40% of what the lease has left,
before the helper's own renewal is due. An app restarted under a running
tunnel starts doing so at its first Status. renew names no server, key or
tenant: it renews the enrollment the helper made itself.
Disconnecting (down) deregisters the peer, giving its address back,
rather than leave it held until the lease ends. A helper that is stopping
does not, so one restarted by its service manager finds the lease still
there. The helper's status reports the lease (lease_expires_at) and why it
last failed to keep the tunnel (last_error).
Tenants
A person may belong to several tenants and chooses one per session.
appcore.Core:
Tenants asks the server, through the helper, which tenants the person
may join;
SetTenant records the choice in the session; a new sign-in forgets it;
Connect enrolls into that tenant. A person in several who has not
chosen gets ErrTenantRequired, with the tenants in Status().
App configuration
pkg/appcore reads config.json under os.UserConfigDir()/Claimward/
(~/Library/Application Support on macOS, ~/.config on Linux, %AppData%
on Windows), then these environment overrides:
| Key |
Environment |
Notes |
server_url |
CLAIMWARD_SERVER |
required; must also be listed in the helper's servers |
provider |
CLAIMWARD_AUTH_PROVIDER |
github (default), oidc or go-authn |
github_client_id |
CLAIMWARD_GITHUB_CLIENT_ID |
required for github |
oidc_issuer |
CLAIMWARD_OIDC_ISSUER |
required for oidc and go-authn |
oidc_client_id |
CLAIMWARD_OIDC_CLIENT_ID |
required for oidc and go-authn |
socket_path |
CLAIMWARD_HELPER_SOCKET |
default /var/run/claimward-helper.sock, C:\ProgramData\Claimward\helper.sock on Windows |
The helper's own helper.json takes servers (required), group (default
admin on macOS, claimward on Linux, Claimward Users on Windows) and
socket.
Notes
- The session store (
pkg/tokenstore) is a 0600 JSON file under the user's
config dir (os.UserConfigDir()), on every platform.
- Creating the tun device and changing routes (
pkg/wgtun) require elevated
privileges; each app delegates this to its privileged helper (pkg/helper).
pkg/wgtun implements macOS, Linux and Windows. On Windows the device is a
Wintun adapter named Claimward: wintun.dll (https://www.wintun.net,
signed by WireGuard LLC) must sit beside the executable that calls
wgtun.Up, which the Windows app's packaging takes care of. Address,
routes, DNS and MTU are set through winipcfg (the IP Helper API, pure Go,
CGO_ENABLED=0), as WireGuard for Windows does.
- DNS push from the server is applied on Windows; on macOS and Linux it is
carried in the protocol but not yet applied by
wgtun — TODO.
License
BSD 3-Clause — see LICENSE.