---
title: "Connecting servers with the nodeaD plugin — API v1 | Nodea Documentation"
description: "Reference of the \"Connect to the Nodea panel\" button: OAuth flow with PKCE and consent screen, the fleet:enroll scope, the POST /api/v1/fleet/enrollments endpoint, the agent env allow-list and error codes."
url: "https://nodea.io/en/api-docs/fleet-enrollments"
lang: "en"
updated: "2026-10-27"
---

# Connecting servers with the nodeaD plugin — API v1 | Nodea Documentation

> How the nodeaD plugin for DirectAdmin, cPanel and WHM connects an existing server to the panel: OAuth with PKCE and mandatory consent, the one-time fleet:enroll scope, the enrollment endpoint, the agent bootstrap, taking over a working agent with e-mail confirmation, the connection history, moving database passwords and the error codes.

**Connecting servers with the nodeaD plugin** is the "Connect to the Nodea panel" button of the nodeaD plugin for **DirectAdmin**, **cPanel** and **WHM**. The server administrator clicks the button, signs in to the Nodea panel, picks an account and an **existing** server of the panel, and the plugin receives a one-time agent bootstrap and finishes the registration on its own. This document covers the OAuth flow, the POST /api/v1/fleet/enrollments endpoint, the response shape and the error codes. Only the nodeaD plugin uses this path — **there is no MCP counterpart** and no personal-token access.

## Authentication and the fleet:enroll scope

- OAuth client: **"nodeaD server plugin"**, fixed id 019ea33a-c0de-7000-8b1a-000000000002, public client (no secret), **PKCE S256** required, authorization\_code grant only (no refresh token).
- The only allowed scope is fleet:enroll — a request with any other scope (or none) gets 403 before the consent screen. No other client (including clients registered dynamically via DCR) may request fleet:enroll.
- The fleet:enroll scope is **not available to personal access tokens** and does not appear in the API Tokens settings.
- A plugin client token works **only** on POST /api/v1/fleet/enrollments. Every other /api/\* route (including GET /api/v1/me) and the MCP server /mcp (including whoami) answer 403 with the code token\_restricted.
- The access token is **single-use**: after a successful call the panel revokes it (together with any refresh tokens). A retry with the same token gets 401.
- The path works on the application host (https://app.nodea.io, behind Cloudflare) — no mTLS. The plugin uses a fixed panel address with a config override for testing (https://dev-app.nodea.io).

## Step by step

1. The plugin generates a state (at least 43 characters \[A-Za-z0-9\_-\], ≥ 32 random bytes), a code\_verifier and a code\_challenge (S256), and shows the **pairing code** derived from state: XXXX-XXXX = the first 8 characters of SHA-256(state), upper-cased.
2. The plugin opens GET /oauth/authorize in the browser with the OAuth parameters and the server data (table below).
3. The panel shows a **mandatory consent screen** (never skipped): the pairing code, the server data labelled *unverified — reported by the server*, the choice of an account and an **existing** server. A server whose agent has the same machine fingerprint, or that has the same hostname, is marked as a match and listed first — it is only a hint: the panel never selects anything itself, the user always picks the server. When the account has no servers to connect, the screen says so and approval is not possible.
4. After "Authorize" the panel stores the choice under the hash of state (valid for **15 minutes**, bound to the user and the client) and redirects to the relay /oauth/desktop/callback. The plugin fetches the code from GET /api/oauth/desktop/code?state=… (once, 120 s).
5. The plugin exchanges the code at POST /oauth/token (grant\_type=authorization\_code, client\_id, redirect\_uri, code\_verifier, code).
6. The plugin calls POST /api/v1/fleet/enrollments with the token and receives the agent bootstrap.
7. The plugin writes /etc/nodead/ca-bundle.pem, /etc/default/nodead (from the env object) and /etc/nodead/bootstrap.env; the agent then calls the existing POST /api/fleet/enroll (unchanged) within 5 minutes.

### GET /oauth/authorize parameters

ParameterRequiredRule response\_typeyescode client\_idyes019ea33a-c0de-7000-8b1a-000000000002 redirect\_uriyeshttps://app.nodea.io/oauth/desktop/callback (the environment's panel address + /oauth/desktop/callback) scopeyesexactly fleet:enroll stateyes43–512 characters \[A-Za-z0-9\_-\] code\_challenge, code\_challenge\_methodyesPKCE, method S256 nodead\_hostnameyeshostname, up to 253 characters, letters, digits, . and -; stored lower-cased nodead\_ipsyescomma-separated IPv4/IPv6 addresses, 1–16 entries, no duplicates, up to 1024 characters in total nodead\_panelyesdirectadmin | cpanel | whm nodead\_fingerprintyesmachine SHA-256 from nodeaD: 64 characters \[0-9a-f\] nodead\_versionnoup to 64 characters \[0-9A-Za-z.+\_-\]

Invalid server data = a screen with an error message and no way to approve.

## Who can connect a server

- **Account**: an account you own, or an account where you hold the manageproducts permission. Closed accounts are not listed.
- **Existing servers only**: the plugin connects only servers that already exist in the panel (a VM, a dedicated server, an operator host or another existing record). The panel never creates servers — a consent that does not point to an existing server ends with 422 and the code foreign\_servers\_disabled.
- **Existing server**: a server of the selected account that you can manage (a VM of the account or a server owned by the account owner). Proxmox hypervisors are not offered — they have their own agent path.
- A host with a **registered agent in another account** is never taken over — disconnect the agent first ("Disconnect agent" on the server page) in that account. The panel recognises such a host by its machine fingerprint and also by the reported hostname (a full name with a dot) and public IP addresses compared **only with data verified by the panel**: the server address, the VM address, the main address of a dedicated server and the agent inventory sent over mTLS (the hostname is compared only with the hostname from that inventory) — never with the server name typed in the panel (it is not verified) nor with a hostname or addresses reported earlier by the plugin. Only agents that registered (hold a certificate) count. The 409 server\_connected\_elsewhere answer does not reveal which account it is.
- Operator hosts (shared hosting hosts) can be connected to the selected account; they keep the operator-host marker, never change their status after connecting, and customer SSH keys are never sent to them.
- **Server status**: a regular server in the "new" status becomes "connected" on the first contact of the new agent (the first telemetry with an inventory). An operator host never changes its status; servers in other statuses stay unchanged.

## POST /api/v1/fleet/enrollments endpoint

Scope fleet:enroll, feature flag servers, limit **10 requests per minute** per user (on top of the global throttle:api-v1). The token must belong to the nodeaD plugin client. The source address of the connection to this endpoint decides whether database passwords are moved (see "Re-connecting") — the plugin should call the endpoint from the server itself. The same address decides whether replacing a working agent needs the account owner's confirmation (see "Taking over a working agent").

### Request body (JSON)

FieldTypeRule statestringthe same state as in /oauth/authorize (22–512 characters) hostnamestringas nodead\_hostname; must equal the consented one (case-insensitive) ipsstring\[\]1–16 IP addresses, no duplicates machine\_fingerprintstring64 characters \[0-9a-f\]; must equal the consented one panel\_typestringdirectadmin | cpanel | whm; must equal the consented one agent\_versionstringup to 64 characters \[0-9A-Za-z.+\_-\] capabilitiesstring\[\]up to 128 entries, each up to 64 characters \[a-z0-9\_.-\] (may be empty) curl -X POST https://app.nodea.io/api/v1/fleet/enrollments \\ -H "Authorization: Bearer \<token from /oauth/token\>" \\ -H "Content-Type: application/json" \\ -d '{"state":"…","hostname":"da1.example.com","ips":\["203.0.113.10"\], "machine\_fingerprint":"a3f1…","panel\_type":"directadmin", "agent\_version":"0.2.0-nodeaD+1a2b3c4d5e6f","capabilities":\["agent.ping"\]}'

### 201 Created response

{ "data": { "agent\_uuid": "8b1d…", "server\_id": "9c2e…", "wrapping\_token": "s.xxxxx", "expires\_at": "2026-10-26T12:05:00+00:00", "ca\_bundle": "-----BEGIN CERTIFICATE-----\\n…", "control\_plane\_url": "https://fleet.nodea.io:9443", "env": { "CONTROL\_PLANE\_URL": "https://fleet.nodea.io:9443", "REVERB\_WS\_URL": "wss://app.nodea.io:443", "REVERB\_APP\_KEY": "…", "NODEAD\_REVERB\_ORIGIN": "…", "NODEAD\_LOG\_LEVEL": "info" }, "db\_secrets": "transferred" } }

- wrapping\_token — single-use OpenBao token valid for **5 minutes** (expires\_at); goes to BAO\_WRAPPING\_TOKEN in /etc/nodead/bootstrap.env together with AGENT\_UUID, CONTROL\_PLANE\_URL and CA\_BUNDLE\_PATH=/etc/nodead/ca-bundle.pem. The panel never stores or logs it. The response carries Cache-Control: no-store.
- ca\_bundle — the control-plane CA chain of this environment (for /etc/nodead/ca-bundle.pem).
- control\_plane\_url (and CONTROL\_PLANE\_URL in env) — the fleet mTLS address, agreed with nodea-fw: the app.nodea.io panel returns an address with the host fleet.nodea.io, the dev-app.nodea.io panel — with the host dev-app.nodea.io. Always https, any port (today 9443), no path, query or credentials in the address. Any other host for a given panel = the plugin refuses to store the bootstrap. Renaming the fleet (bao.fleet\_control\_plane\_url / FLEET\_CONTROL\_PLANE\_URL) needs a nodeaD release first and an agreement with nodea-fw.
- env — the content of /etc/default/nodead, filtered by the endpoint's allow-list (contract with nodea-fw, runner/env.go); values come from the configuration of the environment you sign in to. Required: Optional (only when the panel generates them; today NODEAD\_REVERB\_ORIGIN and NODEAD\_LOG\_LEVEL): The response **never** contains: CA\_BUNDLE\_PATH, NODEAD\_HOME, NODEAD\_STATE\_DIR, NODEAD\_SECRETS\_DIR, NODEAD\_SELF\_BINARY\_PATH, NODEAD\_TELEMETRY\_DISK\_PATH, NODEAD\_SELF\_UPDATE (for no host on this path), NODEAD\_ZFS\_KMS\_\*, NODEAD\_PVE\_TRYB, NODEAD\_SECRETS\_ROTATION\_ENABLED.
  - CONTROL\_PLANE\_URL — the fleet control-plane mTLS address;
  - REVERB\_WS\_URL — the panel's WebSocket (Reverb) address;
  - REVERB\_APP\_KEY — the Reverb application key.
  - NODEAD\_REVERB\_ORIGIN — the Origin header of the agent's WebSocket connection;
  - NODEAD\_LOG\_LEVEL — log level (info);
  - NODEAD\_TELEMETRY\_INTERVAL, NODEAD\_WAF\_EVENTS\_INTERVAL, NODEAD\_UPDATE\_CHECK\_INTERVAL, NODEAD\_RENEW\_CHECK\_INTERVAL, NODEAD\_RENEW\_THRESHOLD, NODEAD\_ENROLL\_RETRY\_INTERVAL.
- db\_secrets — what happened to the database passwords of the previous agent: none (nothing to move), transferred (moved to the new agent), not\_transferred (the security condition was not met — set the database passwords again), transfer\_failed (copy error — the old passwords stay untouched, set the database passwords again).

### Re-connecting the same host

A connection always lands on the **chosen existing server record** — the panel creates no duplicate and does not change the server address. The previous agent becomes "revoked" and its certificate is revoked. It is detached from the server only after its database passwords were moved (or when it had none) — when the move is refused or fails it stays attached, so deleting the server still purges its passwords and a later legitimate connection can still move them. An agent from an earlier attempt that never registered is reused. Two parallel connections of the same server never leave two active agents: the second one gets 409 enrollment\_in\_progress.

**Database passwords** stored for the previous agent (including an agent disconnected with "Disconnect agent") move to the new agent **only when the new machine is credibly the same machine**:

- the source address of the connection to POST /api/v1/fleet/enrollments is one of the server's main IP addresses in the panel (the server address, the VM address or the main address of a dedicated server) — it cannot be supplied in the request body, so a foreign machine (e.g. after a phished consent) cannot meet it;
- if the previous agent had a stored machine fingerprint, it must equal the reported one.

After a takeover **confirmed by the account owner** (see below) the address condition does not apply — the owner knowingly hands the passwords to the new machine; the fingerprint condition still does.

When the condition holds, the panel copies the passwords, verifies the copy by reading it back and only then deletes the copied entries of the old agent (db\_secrets: transferred). Otherwise it copies and deletes nothing, the old passwords stay untouched, the event goes to the activity log (kv\_not\_transferred with the reason) and the response carries db\_secrets: not\_transferred: set the database passwords again in the panel. To disconnect without re-connecting: "Disconnect agent" on the server page — on an operator host the account owner can do it too; every disconnection is recorded in detail in the activity log.

### Taking over a working agent — e-mail confirmation

When the chosen server has a **registered, non-revoked agent** and the source address of the request is **not** a main IP address of the server in the panel, the panel does not replace the agent right away (protection against a phished consent):

1. The endpoint answers 202 Accepted and **consumes nothing** (neither the choice nor the token): { "message": "…", "code": "takeover\_confirmation\_required", "data": { "takeover\_id": "0b7c…", "expires\_at": "2026-10-26T13:00:00+00:00", "retry\_after": 15 } } The response also carries a Retry-After header equal to data.retry\_after (whole seconds, 5–60).
2. The **account owner** of the server gets an e-mail (in their language): who wants to connect the server, when and from which IP address, the reported hostname and addresses, a phishing warning and the links "Confirm the takeover" and "It was not me — reject". When someone other than the owner connects, they get the same e-mail for information, without the decision links.
3. The link is signed and single-use, valid for **60 minutes** (never longer than the plugin's OAuth token). It opens a page in the panel that requires signing in as the account owner with the right to manage the server; opening the link alone confirms nothing — only the button decides. The same buttons are in the agent section of the server page.
4. The plugin retries **the same** POST (same token and state) every retry\_after seconds: while the takeover waits — 202; after confirmation — the regular 201 (agent replaced, database passwords moved despite the other address if the fingerprint matches); after rejection — 403 takeover\_rejected; after expiry — 410 takeover\_expired. The consent choice lives as long as the pending takeover.
5. A takeover becomes **superseded** (retry → 410 takeover\_expired, start again) when the server is connected another way in the meantime (from the server main IP or by another completed takeover), or when the agent it concerned is no longer the current agent of the server by the time the plugin retries (e.g. it was disconnected or replaced).
6. If an agent registers on the server between the check and the connection itself and the request does not come from the server main IP, the endpoint answers 409 enrollment\_in\_progress without consuming anything — the retry then gets 202 and the owner confirmation is required.
7. At most one takeover per server waits at a time — a request of another consent gets 409 takeover\_pending. Retries fall under the 10 requests per minute limit.

Connecting from the server main IP address (or a server without a registered agent) works as before, without an e-mail.

### Connection history

The agent section of the server page shows the history: plugin connections, takeovers (pending, confirmed, rejected, expired, superseded, done) and disconnections — who, when, from which IP address, the result and whether database passwords were moved. The customer and an administrator see the same list; only the account owner can confirm or reject a pending takeover.

## Error codes

Errors use the {"message": "…", "code": "…"} envelope, validation errors (422) — {"message": "…", "errors": {…}}. 4xx and 503 refusals consume nothing — the token and the choice stay valid until they expire (choice 15 min, token 1 h).

StatuscodeWhen 202takeover\_confirmation\_requiredthe server has a working agent and the request did not come from the server IP address — the account owner got an e-mail; retry the same POST after retry\_after s (fields data.takeover\_id, data.expires\_at, data.retry\_after) 401—no token, token revoked or already used 403—token without the fleet:enroll scope 403invalid\_clientthe token was not issued to the nodeaD plugin client 403server\_forbiddenthe right to the account or server was lost between consent and the call 403takeover\_rejectedthe account owner rejected the agent takeover 404enrollment\_not\_foundno approved consent for this state, user and client 403token\_restricteda plugin client token used on a route other than POST /api/v1/fleet/enrollments (any other /api/\* route, /mcp) 409enrollment\_usedthe consent has already been used 409enrollment\_mismatchhostname, fingerprint or panel differ from the consented ones 409server\_connected\_elsewherethe host has a registered agent in another account (by machine fingerprint, hostname or public IP verified by the panel) 409enrollment\_in\_progressthe same server is being connected by another request, or an agent registered on it in the meantime — retry in a moment 409fingerprint\_conflicta server was chosen while the machine has an active agent on another server of the account 409pve\_host\_unsupportedthe chosen server is a Proxmox hypervisor 409takeover\_pendinganother takeover of this server agent is already waiting for the owner confirmation 410enrollment\_expiredthe consent expired (15 min) 410takeover\_expiredthe takeover was not confirmed within 60 minutes or is superseded (the server agent changed in the meantime) — start the connection again 422foreign\_servers\_disabledthe consent does not point to an existing server — the plugin connects only servers already present in the panel 422—field validation (errors) 429—rate limit 503control\_plane\_unavailablethe agent enrollment service (OpenBao) is temporarily unavailable — retry

## Related

- [Servers — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/servers)
- [Nodea API v1 — introduction: base URL, tokens, scopes, limits | Nodea](https://nodea.io/en/api-docs/overview)
- [SSH Keys — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/ssh-keys)

---

Languages: [pl](https://nodea.pl/pl/dokumentacja-api/fleet-enrollments.md)  
Site index: [llms.txt](https://nodea.io/en/llms.txt)
