A headless CLI for connecting to Netmaker remote-access networks — what Netmaker Desktop does, without a GUI. Runs on Linux and macOS.
Netmaker Desktop (the Remote Access Client) is a graphical app. On a server, a container, or anything over SSH there is nothing to click. nmrac drives the same server API from a shell: SSO login in your browser, pick a network, and it renders a WireGuard config and brings the tunnel up with wg-quick.
$ sudo nmrac connect
Networks (user: you@example.com)
1) [off] office-split-tunnel gw gateway-a
2) [on ] lab-network gw gateway-b 10.20.30.8/32
3) [off] build-network gw gateway-c
Select network [1-3], or q to quit: 1netclient is Netmaker's official headless agent, and if it works for you, use it. But netclient join -s <server> registers the machine as a host, and on a Netmaker Pro server that path is restricted to admin users. A regular user gets:
Server SSO Error
Error reason: only admin users can register using SSO
That check is server-side, in the OAuth register callback — no client version or flag gets around it:
// pro/auth/register_callback.go
if user.PlatformRoleID != schema.AdminRole && user.PlatformRoleID != schema.SuperAdminRole {
response := returnErrTemplate(userClaims.getUserName(), "only admin users can register using SSO", state, reqKeyIf)
w.WriteHeader(http.StatusForbidden)Remote access is a different thing entirely. A remote-access client is an external client of a gateway, not a meshed host, and regular users are expected to have them. nmrac uses those endpoints, so it needs no admin role — the same access your Netmaker Desktop login already gives you.
bash(3.2 is fine, so stock macOSbashworks),curl,jqwireguard-tools—wgandwg-quicknode>= 22, for the browser SSO login — it uses Node's built-inWebSocket, no npm packages. Not needed withnmrac login --paste.- root, for
/etc/wireguardandwg-quick - A Netmaker Pro server with OAuth/SSO configured, and a user attached to at least one remote-access gateway
On Debian/Ubuntu:
sudo apt install curl jq wireguard-toolsOn macOS:
brew install jq wireguard-tools nodeTested against Netmaker server v1.5.1. See Server compatibility.
git clone https://github.com/meta-boy/nmrac
sudo install -m 0755 nmrac/nmrac /usr/local/bin/nmracsudo nmrac server https://netmaker.example.com # once
sudo nmrac login # opens a URL, sign in with your IdP
sudo nmrac list # what you can reach
sudo nmrac connect # pick from a menu
sudo nmrac disconnectlogin prints a URL and waits. Open it, authenticate, and the token comes back over the same socket — nothing to copy or paste.
| Command | What it does |
|---|---|
nmrac server <url> |
Save which Netmaker server to talk to. No argument prints the current one. |
nmrac login [--paste] |
SSO login via browser. --paste falls back to pasting a JWT by hand. |
nmrac logout |
Forget the stored token. Leaves tunnels running. |
nmrac list |
Networks, their gateways, and whether each tunnel is up. |
nmrac connect [--dns|--no-dns] [--id <clientid>] [network] |
Bring a tunnel up. Prompts if no network is given. --id adopts a specific existing client. |
nmrac disconnect [network] |
Take a tunnel down, or all of them, and restore DNS (macOS). |
nmrac status |
WireGuard peer state for active tunnels. |
nmrac fix-dns |
macOS: restore DNS that a tunnel which died uncleanly left behind. connect, disconnect, list and status already do this when run as root. |
nmrac conf <network> |
Print the config the server hands out, without applying it. |
nmrac remove <network> |
Delete this machine's client from a network, server-side. |
connect is idempotent. Each machine gets its own external client on each network, so reconnecting reuses the same client and IP rather than allocating a new one on every call. It will not touch a client belonging to another device.
The first of these that exists on the network wins:
--id <clientid>, orNMRAC_CLIENT_IDfor every network. Use it once to adopt a client made some other way; it is then pinned.- The pinned id:
/etc/nmrac/clientsrecords the client each network last connected with. - The client the server reports for this device. New clients carry a stable device id (
nmrac-<hardware UUID>on macOS,nmrac-<machine-id>on Linux) as bothdevice_idandremote_access_client_id. The gateway listing hands that client back (getUserRemoteAccessGwsV1), and the server refuses a second client with the same device id on a gateway. - Hostname-derived ids from older versions:
<machine>-<network hash>, then<machine>-cli. Each is tried both as-is and without a trailing-2,-3.
If none exists, a new client <machine>-<6 hex of the network name> is created and pinned.
Pinning exists because the hostname is not stable. macOS renames LocalHostName to Name-2 when another device on the LAN claims the same Bonjour name. Older versions derived the id from the hostname alone, so after a rename they no longer found their own client and tried to create another. The server then refused it, seemingly at random.
New ids are one per network, not one per machine. Netmaker requires ext-client ids to be unique across the whole server, not just within a network: isValid in controllers/ext_client.go checks every new id against logic.GetAllExtClients(). So a single id per machine could only ever join one network. If creating a client is still refused, nmrac handles both errors:
duplicate client name: it reuses the client if it is ours on this network, and otherwise picks a fresh name.remote client config already exists on the gateway: it asks the server which client that is and uses it.
| Variable | Default | Purpose |
|---|---|---|
NMRAC_API |
from /etc/nmrac/server |
Server URL; overrides the saved one |
NMRAC_CLIENT_ID |
pinned, else see above | Exact client id to use on every network. Prefer connect --id, which applies to one network |
NMRAC_DEVICE_ID |
nmrac-<hardware UUID / machine-id> |
Device id sent to the server |
NMRAC_STATE_DIR |
/etc/nmrac |
Token, server URL, interface map, pins, DNS snapshots |
NMRAC_WG_DIR |
/etc/wireguard |
Where .conf files are written |
State on disk:
/etc/nmrac/server: server URL (0644)/etc/nmrac/token: your JWT (0600)/etc/nmrac/ifaces: interface-to-network map/etc/nmrac/clients: network-to-client-id pins (0600)/etc/nmrac/dns/nm-*: macOS only, each network service's DNS from before a tunnel came up (0600)/etc/wireguard/nm-*.conf: generated configs (0600)
| Step | Endpoint |
|---|---|
| Login | ws /api/oauth/headless → /api/oauth/register/<state> |
| List networks and gateways | GET /api/users/{user}/remote_access_gw |
| Does this machine have a client? | GET /api/extclients/{net}/{clientid} |
| Create one | POST /api/extclients/{net}/{gwid} |
| Fetch the WireGuard config | GET /api/extclients/{net}/{clientid}/file |
| Remove it | DELETE /api/extclients/{net}/{clientid} |
The config is rendered by the server, the same one the dashboard's View Config gives you. nmrac does not build it, it only strips the DNS = line unless you pass --dns (see below).
Netmaker's OAuth callback dispatches on the length of the state parameter:
// pro/auth/auth.go
node_signin_length = 64
headless_signin_length = 32
switch len(state) {
case node_signin_length: HandleHostSSOCallback(w, r) // admin-gated
case headless_signin_length: HandleHeadlessSSOCallback(w, r) // no admin check
}Both arrive at the same /api/oauth/register/{regKey} URL, but a 64-character state is host registration and admin-only, while a 32-character state is a headless user login — commented in the source as "headless logins such as Netmaker CLI". nmrac opens a websocket to /api/oauth/headless, which mints a 32-character state, and reads the JWT back off that socket.
- Tokens expire. When one does, any command says so; run
nmrac loginagain. Existing tunnels keep working — WireGuard does not care about the API. - No boot persistence. The tunnel does not come back after a reboot.
wg-quick@nm-*.servicewill bring up an existing config, but the token may have expired by then, so a truly durable setup needs a re-login. - DNS is decided per platform. The server always emits a
DNS =line. macOSwg-quickapplies it natively viascutil, so it is kept. Linuxwg-quickshells out toresolvconf, which many minimal images lack, so the line is kept only whenresolvconfis installed and stripped otherwise — the tunnel comes up either way and your existing resolver is left alone. Force it with--dnsor--no-dns. - macOS DNS is restored by nmrac, not just by wg-quick. wg-quick writes the tunnel's resolver onto every network service, including ones that appear later such as another VPN. It keeps the old values only in the memory of the route monitor it backgrounds. If that monitor dies first, nothing puts them back: every service keeps a resolver that only works through the tunnel, and all name lookups fail. The monitor can die through sleep, a reboot with the tunnel up, a killed shell, or
/var/run/wireguardbeing cleared. Soconnectsnapshots every service first.disconnectwaits for wg-quick's own restore, then fixes any service still pointing at a closed tunnel and flushes the DNS cache. It uses the snapshot, or automatic DNS for services the snapshot doesn't name. The same repair runs automatically at the start ofconnect,listandstatuswhenever they run as root, so a tunnel that died in its sleep is cleaned up the next time you usenmracat all.fix-dnsdoes only the repair. Tunnels that are still up are never touched. - Tailscale shares
100.64.0.0/10. Netmaker networks often live in the same CGNAT range Tailscale uses. The tunnel's routes are more specific, so they win, but a Tailscale exit node sends DNS and other traffic its own way.connectwarns when an exit node is active; turn it off while connected. - Interface names. Linux caps them at 15 characters and Netmaker network names are often longer, so configs are named
nm-<first 12 chars>, falling back tonm-<hash>if two networks collide./etc/nmrac/ifaceskeeps the mapping, andlist/statusshow real network names. On macOS the kernel assignsutunNregardless, sostatusshows both (nm-lab-networ -> utun4). - Split vs full tunnel is the gateway's decision, not this tool's.
nmracinstalls exactly theAllowedIPsthe server sends.
| Linux | macOS | |
|---|---|---|
| Interface naming | as configured (nm-*) |
kernel-assigned utunN, mapped via /var/run/wireguard/<name>.name |
| DNS from the gateway | needs resolvconf |
applied natively by wg-quick |
| Machine name | hostname -s |
scutil --get LocalHostName, then ComputerName |
| Address lookup | ip -4 -o addr |
ifconfig |
Interface liveness is read from wg show interfaces, which behaves the same on both.
Netmaker accepts ^[a-zA-Z0-9-]+$, 5 to 32 characters, in a client id (controllers/regex.go:validName) — no dots and no underscores, and it rejects the rest outright rather than cleaning them up. So the machine name is lowercased, every separator is folded to a dash, anything else is dropped, and a long one is truncated to leave room for the network suffix. A macOS hostname like Unknown_aa:bb:cc:dd:ee:ff becomes unknown-aabbccddeeff, which is ugly but stable. Set NMRAC_CLIENT_ID if you want something friendlier.
Developed and tested against Netmaker server v1.5.1 (Pro, self-hosted).
Two API families that look like the right answer are deliberately unused:
/api/v1/rac/*exists on 1.5.1 and is correctly non-admin, but its authorization requires aTARGET_RSRCrequest header. Headers with underscores are dropped by common reverse-proxy defaults (nginx'sunderscores_in_headers off), so behind a proxy every call returns403 target rsrc is missing./api/users/{user}/remote_access_gwis wrapped inContinueIfUserMatchand needs no such header, sonmracuses that instead./api/v1/device/*is the newer desktop-client API, with a proxy-safeX-Host-IDheader and an explicit exemption from those header checks. It does not exist on 1.5.1 (404). On a newer server it would be the better path.
If you run a different version and something breaks, an issue with your server version and the failing response would be useful.
403 target rsrc is missing — see above; it means a request went to /api/v1/rac/*.
no gateway for '<network>' — your user is not attached to a remote-access gateway on that network. An admin attaches users to gateways; nmrac cannot.
--dns needs resolvconf — Linux only: apt install openresolv, or drop the flag and let DNS be skipped.
access denied on an extclient path — on v1.5.1 a client that does not exist answers 403 access denied rather than 404. nmrac treats any non-200 on the existence probe as "not present" and creates the client, so you should not see this; if you do, it is a genuine permission problem on the gateway.
400 client validation: duplicate client name — ext-client ids are unique across the whole server, not per network, so the id is already taken. nmrac reuses the client if it is yours on this network, and otherwise picks a fresh name. The error only reaches you for an explicit --id / NMRAC_CLIENT_ID: choose another name, or nmrac remove the client holding it.
remote client config already exists on the gateway — this device already has a client on that gateway, and nmrac adopts it. If the server will not say which one, run nmrac connect --id <clientid> <network> once. The id is shown on the dashboard. After that it is pinned.
Name lookups fail after disconnecting (macOS) — a tunnel's DNS was left on your network services. Any sudo nmrac command (status is the cheapest) repairs it, as does sudo nmrac fix-dns.
Tunnel up but nothing reachable — check nmrac status for a recent handshake and non-zero transfer. If those look healthy, the crypto path is fine and you are looking at ACLs or filtering. Note that many gateways drop ICMP, so a failed ping proves nothing; try a real port.
MIT — see LICENSE.