# Servers API

Servers and agents. 37 operations under /api/v1. Errors are RFC 9457 problem+json with a stable `code` field. The complete schemas are in https://nebulactrl.com/api/v1/openapi.json.

## GET /geo/maxmind

Whether the team has a MaxMind GeoLite2 account on file

The licence key is write-only. `serversUsing` counts the team's servers that currently use the MaxMind source.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Response 200: OK (GeoCredentialsStatus)
  - `accountId` (string, required): MaxMind account ID; empty when none is stored
  - `configured` (boolean, required)
  - `serversUsing` (integer (int64), required)
  - `updatedAt` (string (date-time) | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## PUT /geo/maxmind

Store the team's MaxMind account ID and licence key

The key is sealed with the instance secret key. Servers on the MaxMind source receive it with their next reconcile (sent immediately to online agents, never persisted in a command).

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Request body (application/json, required): SetGeoCredentialsInputBody
  - `accountId` (string, required): Numeric MaxMind account ID
  - `licenseKey` (string, required): MaxMind licence key; stored encrypted, never returned

Response 200: OK (GeoCredentialsStatus)
  - `accountId` (string, required): MaxMind account ID; empty when none is stored
  - `configured` (boolean, required)
  - `serversUsing` (integer (int64), required)
  - `updatedAt` (string (date-time) | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error

## DELETE /geo/maxmind

Remove the team's MaxMind account

Refused with 409 `maxmind_in_use` while a server still uses the MaxMind source.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Response 200: OK (GeoCredentialsStatus)
  - `accountId` (string, required): MaxMind account ID; empty when none is stored
  - `configured` (boolean, required)
  - `serversUsing` (integer (int64), required)
  - `updatedAt` (string (date-time) | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 409 Conflict (`maxmind_in_use`); 422 Unprocessable Entity; 500 Internal Server Error

## GET /instance/upgrade

Control-plane version, agent version skew per server and the pre-upgrade backup

The version the control plane runs, whether agents behind it upgrade themselves when they connect, the outcome of the backup the control plane took before its last upgrade, and for every server how its agent's version compares (`behind`, `current`, `ahead`, `unknown`), whether it can be upgraded now (and if not, why) and the state of its latest upgrade.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Response 200: OK (InstanceUpgradeView)
  - `agentAutoUpgrade` (boolean, required): Upgrade an agent that is behind the control plane as soon as it connects. Default on.
  - `behind` (integer (int64), required): Servers whose agent is behind the control plane.
  - `controlPlaneVersion` (string, required)
  - `preUpgradeBackup` (PreUpgradeBackupView)
  - `servers` (ServerUpgradeView[] | null, required)
  - `upgradable` (integer (int64), required): Servers whose agent can be upgraded now.
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 500 Internal Server Error

## PATCH /instance/upgrade

Switch the automatic agent upgrade

Needs the `servers:write` scope and the instance admin (the first account). Audited.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Request body (application/json, required): PatchInputBody
  - `agentAutoUpgrade` (boolean): Upgrade an agent that is behind the control plane when it connects.

Response 200: OK (InstanceUpgradeView)
  - `agentAutoUpgrade` (boolean, required): Upgrade an agent that is behind the control plane as soon as it connects. Default on.
  - `behind` (integer (int64), required): Servers whose agent is behind the control plane.
  - `controlPlaneVersion` (string, required)
  - `preUpgradeBackup` (PreUpgradeBackupView)
  - `servers` (ServerUpgradeView[] | null, required)
  - `upgradable` (integer (int64), required): Servers whose agent can be upgraded now.
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers

List servers

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `cursor` (query, string): Opaque cursor from a previous page's nextCursor
- `limit` (query, integer (int64)): Page size (default 50, max 200)

Response 200: OK (PageServer)
  - `items` (Server[] | null, required)
  - `nextCursor` (string): Present when more items exist
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers/{serverId}

Get a server

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (Server)
  - `acceptWorkloads` (boolean, required): Whether services may be scheduled on this server. Turning it off keeps what already runs here and refuses new placements and moves.
  - `acmeEmail` (string | null, required)
  - `agentVersion` (string | null, required)
  - `baseDomain` (string | null, required)
  - `buildAllowEmulation` (boolean, required): Allow building for another CPU architecture through emulation (slow); needs the agent to report emulation for the platform
  - `buildAllowPreviews` (boolean, required): Allow pull request previews (untrusted code) to build here; off by default
  - `buildEnabled` (boolean, required): The server builds images for other servers and pushes them to a registry (the Builds or Both role). Independent of acceptWorkloads.
  - `buildMaxConcurrent` (integer (int64), required): Builds that run at the same time; more queue on the control plane
  - `composeVersion` (string, required): Docker Compose plugin version the agent reported, empty when it has none
  - `controlPlane` (boolean, required): The host the installer put the control plane on; its proxy serves the dashboard
  - `createdAt` (string (date-time), required)
  - `diskAlert` (boolean, required): True while the root filesystem is at or above the threshold
  - `diskAlertThresholdPercent` (integer (int64), required): Root filesystem usage that raises the disk alert (50-99)
  - `drainMessage` (string, required): Why the last drain failed or was canceled, empty otherwise
  - `drainStartedAt` (string (date-time) | null, required)
  - `drainState` (enum: none | draining | drained | failed, required): Evacuation state; draining and drained servers accept no new services
  - `facts` (Facts, required)
  - `geoEnabled` (boolean, required): Count where requests come from (country, and city at the city level) from the client IP; IPs are never stored
  - `geoLevel` (enum: country | city, required): country needs a ~8 MB database on the server, city ~130 MB
  - `geoRefreshDays` (integer (int64), required): Check for a newer database every N days (1-90)
  - `geoSource` (enum: dbip | maxmind, required): dbip = DB-IP Lite (CC BY 4.0, no key); maxmind = MaxMind GeoLite2 with the team's account
  - `id` (string, required)
  - `lastSeenAt` (string (date-time) | null, required)
  - `latest` (Latest, required)
  - `metricsRetentionDays` (integer (int64), required): How long resource metric history is kept (1-90)
  - `name` (string, required)
  - `patches` (ServerPatchSummary, required)
  - `protocolVersion` (integer (int32) | null, required)
  - `proxy` (Proxy, required)
  - `publicIp` (string | null, required)
  - `serviceCount` (integer (int64), required)
  - `status` (enum: pending | online | offline | revoked, required)
  - `trafficAnalyticsEnabled` (boolean, required): Aggregate Traefik access logs into traffic analytics
  - `trafficDailyRetentionDays` (integer (int64), required): Daily traffic history (7-365 days)
  - `trafficHourlyRetentionDays` (integer (int64), required): Hourly traffic history (2-90 days)
  - `tunnel` (ServerTunnelSummary, required)
  - `unrestricted` (boolean, required): An instance admin made this server unrestricted: stacks and services on it may use privileged mode, host networking, devices, GPUs, a runtime, the host PID namespace, unconfined seccomp or AppArmor, NET_ADMIN, SYS_ADMIN, the Docker socket and host bind mounts
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PATCH /servers/{serverId}

Update a server's name, host settings, retention or alert settings

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Request body (application/json, required): UpdateServerInputBody
  - `acceptWorkloads` (boolean): false stops new services being scheduled on this server (what already runs here keeps running); default true
  - `acmeEmail` (string): Empty string clears
  - `baseDomain` (string): Empty string clears
  - `buildAllowEmulation` (boolean): Allow cross-architecture builds through emulation (default off)
  - `buildAllowPreviews` (boolean): Let pull request previews build on this server (default off: previews build on their own server)
  - `buildEnabled` (boolean): Make the server a build server: it builds git sources for other servers and pushes the images to a registry. Combine with acceptWorkloads: Deployments (true/false), Builds (false/true) or Both (true/true).
  - `buildMaxConcurrent` (integer (int64)): Parallel builds on this server (default 2); more queue on the control plane
  - `diskAlertThresholdPercent` (integer (int64)): Disk usage percent that raises the disk alert (default 80)
  - `geoEnabled` (boolean): Count where requests come from (default on); needs traffic analytics
  - `geoLevel` (enum: country | city): country (default, ~8 MB database) or city (~130 MB)
  - `geoRefreshDays` (integer (int64)): Check for a newer database every N days (default 7)
  - `geoSource` (enum: dbip | maxmind): dbip = DB-IP Lite (default, no key); maxmind = MaxMind GeoLite2 (needs the team's account, see PUT /geo/maxmind)
  - `metricsRetentionDays` (integer (int64)): Days of resource metric history (default 7)
  - `name` (string)
  - `publicIp` (string): Empty string clears
  - `trafficAnalyticsEnabled` (boolean): Turns Traefik access-log analytics on or off (default on)
  - `trafficDailyRetentionDays` (integer (int64)): Days of daily traffic history (default 90)
  - `trafficHourlyRetentionDays` (integer (int64)): Days of hourly traffic history (default 7)

Response 200: OK (Server)
  - `acceptWorkloads` (boolean, required): Whether services may be scheduled on this server. Turning it off keeps what already runs here and refuses new placements and moves.
  - `acmeEmail` (string | null, required)
  - `agentVersion` (string | null, required)
  - `baseDomain` (string | null, required)
  - `buildAllowEmulation` (boolean, required): Allow building for another CPU architecture through emulation (slow); needs the agent to report emulation for the platform
  - `buildAllowPreviews` (boolean, required): Allow pull request previews (untrusted code) to build here; off by default
  - `buildEnabled` (boolean, required): The server builds images for other servers and pushes them to a registry (the Builds or Both role). Independent of acceptWorkloads.
  - `buildMaxConcurrent` (integer (int64), required): Builds that run at the same time; more queue on the control plane
  - `composeVersion` (string, required): Docker Compose plugin version the agent reported, empty when it has none
  - `controlPlane` (boolean, required): The host the installer put the control plane on; its proxy serves the dashboard
  - `createdAt` (string (date-time), required)
  - `diskAlert` (boolean, required): True while the root filesystem is at or above the threshold
  - `diskAlertThresholdPercent` (integer (int64), required): Root filesystem usage that raises the disk alert (50-99)
  - `drainMessage` (string, required): Why the last drain failed or was canceled, empty otherwise
  - `drainStartedAt` (string (date-time) | null, required)
  - `drainState` (enum: none | draining | drained | failed, required): Evacuation state; draining and drained servers accept no new services
  - `facts` (Facts, required)
  - `geoEnabled` (boolean, required): Count where requests come from (country, and city at the city level) from the client IP; IPs are never stored
  - `geoLevel` (enum: country | city, required): country needs a ~8 MB database on the server, city ~130 MB
  - `geoRefreshDays` (integer (int64), required): Check for a newer database every N days (1-90)
  - `geoSource` (enum: dbip | maxmind, required): dbip = DB-IP Lite (CC BY 4.0, no key); maxmind = MaxMind GeoLite2 with the team's account
  - `id` (string, required)
  - `lastSeenAt` (string (date-time) | null, required)
  - `latest` (Latest, required)
  - `metricsRetentionDays` (integer (int64), required): How long resource metric history is kept (1-90)
  - `name` (string, required)
  - `patches` (ServerPatchSummary, required)
  - `protocolVersion` (integer (int32) | null, required)
  - `proxy` (Proxy, required)
  - `publicIp` (string | null, required)
  - `serviceCount` (integer (int64), required)
  - `status` (enum: pending | online | offline | revoked, required)
  - `trafficAnalyticsEnabled` (boolean, required): Aggregate Traefik access logs into traffic analytics
  - `trafficDailyRetentionDays` (integer (int64), required): Daily traffic history (7-365 days)
  - `trafficHourlyRetentionDays` (integer (int64), required): Hourly traffic history (2-90 days)
  - `tunnel` (ServerTunnelSummary, required)
  - `unrestricted` (boolean, required): An instance admin made this server unrestricted: stacks and services on it may use privileged mode, host networking, devices, GPUs, a runtime, the host PID namespace, unconfined seccomp or AppArmor, NET_ADMIN, SYS_ADMIN, the Docker socket and host bind mounts
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/agent-upgrade

Upgrade one server's agent to the control plane's version

The agent downloads the binary from this control plane, checks its SHA-256 against the one in the command, replaces itself, restarts when its running work is done and rolls back if the new binary does not start. Needs the Owner or Admin role; audited. 409 `server_offline`, `agent_current`, `agent_newer` or `upgrade_in_progress`; 422 `agent_predates_upgrade` (re-run the agent installer once) or `no_agent_binary`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Request body (application/json, required): ServerInputBody
  - `force` (boolean): Install a version this agent rolled back before.

Response 202: Accepted (ServerUpgradeView)
  - `agentVersion` (string)
  - `canSelfUpgrade` (boolean, required)
  - `finishedAt` (string (date-time))
  - `message` (string)
  - `name` (string, required)
  - `reason` (string): Why the agent cannot be upgraded now.
  - `requestedAt` (string (date-time))
  - `serverId` (string, required)
  - `skew` (enum: current | behind | ahead | unknown, required): The agent against the control plane: behind (older), ahead (newer), current, or unknown when a version is not a release version.
  - `state` (enum: queued | restarting | succeeded | failed)
  - `status` (string, required): pending, online, offline or revoked.
  - `targetVersion` (string)
  - `trigger` (enum: auto | manual)
  - `upgradable` (boolean, required): The agent can be upgraded now.
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`server_offline`); 422 Unprocessable Entity (`agent_predates_upgrade`); 500 Internal Server Error

## GET /servers/{serverId}/cleanup

Docker cleanup settings and history of a server

Whether the scheduled cleanup is on, when it runs next and the most recent runs with what each prune step removed. A cleanup removes dangling images, unused images older than `unusedImageMinAgeHours` (default 72), build cache older than `buildCacheMinAgeDays` (default 7) and unused NebulaCtrl networks, and optionally stopped containers NebulaCtrl does not manage. It never removes the current or a rollback-eligible release's image, a pinned infrastructure image, or any volume.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (CleanupView)
  - `agentSupported` (boolean, required): The server's agent can run a cleanup
  - `buildCacheMinAgeDays` (integer (int64), required): Build cache older than this many days is pruned (default 7)
  - `cron` (string, required): The schedule as a 5-field cron expression, evaluated in UTC (default 0 4 * * *)
  - `diskAlert` (boolean, required): The disk alert is active now
  - `diskAlertThresholdPercent` (integer (int64), required): The server's disk alert threshold, which starts the emergency cleanup when diskTriggerPercent is null
  - `diskTriggerPercent` (integer (int64) | null, required): Disk usage (50-99) that starts an emergency cleanup; null follows the server's disk alert threshold
  - `enabled` (boolean, required): Scheduled and emergency cleanups run (default true)
  - `nextRunAt` (string (date-time) | null, required): The next scheduled cleanup; null when cleanup is off
  - `removeStoppedUnmanaged` (boolean, required): Also remove stopped containers NebulaCtrl does not manage (default false)
  - `running` (boolean, required): A cleanup is waiting for the agent or running
  - `runs` (CleanupRun[], required): The most recent runs, newest first
  - `schedule` (string, required): The schedule in words
  - `unusedImageMinAgeHours` (integer (int64), required): Unused images older than this are removed unless a release or the infrastructure needs them (default 72)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /servers/{serverId}/cleanup

Change the Docker cleanup settings of a server

Needs the `servers:write` scope. `cron` sets the schedule (UTC), `unusedImageMinAgeHours` and `buildCacheMinAgeDays` the age limits and `diskTriggerPercent` the disk usage that starts an emergency run (omitted or 0: the server's disk alert threshold); omitted or 0 keeps each default. Turning the schedule off also turns off the emergency run; "Run cleanup now" still works. 422 `validation_failed` for a bad cron expression (or one that fires more often than every 15 minutes) or an out-of-range value.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Request body (application/json, required): UpdateCleanupInputBody
  - `buildCacheMinAgeDays` (integer (int64)): Prune build cache older than this many days (1-365). 0 or omitted keeps the default, 7.
  - `cron` (string): The schedule: a 5-field cron expression (or @daily, @weekly, ...) evaluated in UTC. Runs must be at least 15 minutes apart. Empty keeps the default, 0 4 * * *.
  - `diskTriggerPercent` (integer (int64)): Start an emergency cleanup when disk usage reaches this percent (50-99). 0 or omitted follows the server's disk alert threshold.
  - `enabled` (boolean, required): Run the scheduled (daily, 04:00 UTC) and emergency cleanups
  - `removeStoppedUnmanaged` (boolean, required): Also remove stopped containers that NebulaCtrl does not manage. Their volumes are kept.
  - `unusedImageMinAgeHours` (integer (int64)): Remove unused images older than this many hours (1-8760). 0 or omitted keeps the default, 72.

Response 200: OK (CleanupView)
  - `agentSupported` (boolean, required): The server's agent can run a cleanup
  - `buildCacheMinAgeDays` (integer (int64), required): Build cache older than this many days is pruned (default 7)
  - `cron` (string, required): The schedule as a 5-field cron expression, evaluated in UTC (default 0 4 * * *)
  - `diskAlert` (boolean, required): The disk alert is active now
  - `diskAlertThresholdPercent` (integer (int64), required): The server's disk alert threshold, which starts the emergency cleanup when diskTriggerPercent is null
  - `diskTriggerPercent` (integer (int64) | null, required): Disk usage (50-99) that starts an emergency cleanup; null follows the server's disk alert threshold
  - `enabled` (boolean, required): Scheduled and emergency cleanups run (default true)
  - `nextRunAt` (string (date-time) | null, required): The next scheduled cleanup; null when cleanup is off
  - `removeStoppedUnmanaged` (boolean, required): Also remove stopped containers NebulaCtrl does not manage (default false)
  - `running` (boolean, required): A cleanup is waiting for the agent or running
  - `runs` (CleanupRun[], required): The most recent runs, newest first
  - `schedule` (string, required): The schedule in words
  - `unusedImageMinAgeHours` (integer (int64), required): Unused images older than this are removed unless a release or the infrastructure needs them (default 72)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity (`validation_failed`); 500 Internal Server Error

## POST /servers/{serverId}/cleanup/runs

Run the Docker cleanup of a server now

Queues the same cleanup the schedule runs. The result (reclaimed bytes, items per step) appears in the server's cleanup history. 409 `server_offline` when the server is offline, 409 `cleanup_running` while one is in flight.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Response 202: Accepted (CleanupRun)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `finishedAt` (string (date-time) | null, required)
  - `id` (string, required)
  - `items` (integer (int64), required): Things removed: containers, images, networks or volumes
  - `mode` (enum: standard | volumes, required): standard is the scheduled cleanup; volumes the explicit removal of unused volumes
  - `reclaimedBytes` (integer (int64), required)
  - `status` (enum: pending | succeeded | failed, required)
  - `steps` (CleanupStep[], required): What each prune step did; empty until the agent reports
  - `trigger` (enum: scheduled | emergency | manual, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`server_offline`, `cleanup_running`); 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/cleanup/volumes

Remove unused Docker volumes of a server

Removes volumes that no container (running or stopped) mounts and that NebulaCtrl does not manage: anonymous volumes and volumes made outside NebulaCtrl. Volumes of NebulaCtrl services (`nc-vol-*`, or labeled as managed) are never removed. This deletes data and cannot be undone: `confirm` must be the server's name. The schedule never does this.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Request body (application/json, required): PruneVolumesInputBody
  - `confirm` (string, required): The server's name, typed to confirm

Response 202: Accepted (CleanupRun)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `finishedAt` (string (date-time) | null, required)
  - `id` (string, required)
  - `items` (integer (int64), required): Things removed: containers, images, networks or volumes
  - `mode` (enum: standard | volumes, required): standard is the scheduled cleanup; volumes the explicit removal of unused volumes
  - `reclaimedBytes` (integer (int64), required)
  - `status` (enum: pending | succeeded | failed, required)
  - `steps` (CleanupStep[], required): What each prune step did; empty until the agent reports
  - `trigger` (enum: scheduled | emergency | manual, required)
Errors: 400 Bad Request; 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers/{serverId}/hardening

Host hardening of a server: the Lynis index, the safe fixes and what is left

The Security tab: Lynis's hardening index from the latest audit, every curated safe fix with its state on the host (applied, already compliant, pending, unavailable), the findings that remain with an explanation and the commands to deal with them, and the history of runs. NebulaCtrl never offers a fix for SSH, the firewall or accounts.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (HardeningView)
  - `agentSupported` (boolean, required): The server's agent can audit the host
  - `auditedAt` (string (date-time) | null, required)
  - `firstIndex` (integer (int64) | null, required): The score of the first audit
  - `fixes` (HardeningFix[], required)
  - `hardeningIndex` (integer (int64) | null, required): Lynis's 0 to 100 score from the latest audit; null before the first audit
  - `lynisInstalled` (boolean, required)
  - `lynisVersion` (string)
  - `mode` (enum: host | helper | ): How the agent reaches the host: directly, or through a privileged helper container
  - `nextAuditAt` (string (date-time) | null, required): The next weekly audit; null when the weekly audit is off
  - `pendingSafe` (integer (int64), required): Default fixes that would change something now
  - `rebootRequired` (boolean, required): An applied fix takes effect after the next reboot
  - `remaining` (HardeningRemaining[], required)
  - `running` (boolean, required): An audit or fix run waits for the agent or is running
  - `runs` (HardeningRun[], required): The most recent runs, newest first
  - `suggestions` (integer (int64), required)
  - `testsDone` (integer (int64), required)
  - `warnings` (integer (int64), required)
  - `weekly` (boolean, required): A weekly audit runs (default true)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /servers/{serverId}/hardening

Switch the weekly Lynis audit of a server

Needs the `servers:write` scope and the Owner or Admin role. Audited.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Request body (application/json, required): SettingsInputBody
  - `weekly` (boolean, required): Audit the host every week (default true)

Response 200: OK (HardeningView)
  - `agentSupported` (boolean, required): The server's agent can audit the host
  - `auditedAt` (string (date-time) | null, required)
  - `firstIndex` (integer (int64) | null, required): The score of the first audit
  - `fixes` (HardeningFix[], required)
  - `hardeningIndex` (integer (int64) | null, required): Lynis's 0 to 100 score from the latest audit; null before the first audit
  - `lynisInstalled` (boolean, required)
  - `lynisVersion` (string)
  - `mode` (enum: host | helper | ): How the agent reaches the host: directly, or through a privileged helper container
  - `nextAuditAt` (string (date-time) | null, required): The next weekly audit; null when the weekly audit is off
  - `pendingSafe` (integer (int64), required): Default fixes that would change something now
  - `rebootRequired` (boolean, required): An applied fix takes effect after the next reboot
  - `remaining` (HardeningRemaining[], required)
  - `running` (boolean, required): An audit or fix run waits for the agent or is running
  - `runs` (HardeningRun[], required): The most recent runs, newest first
  - `suggestions` (integer (int64), required)
  - `testsDone` (integer (int64), required)
  - `warnings` (integer (int64), required)
  - `weekly` (boolean, required): A weekly audit runs (default true)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/hardening/apply

Apply safe hardening fixes

Applies the selected fixes (the default set when none is selected), checks each one and undoes it by itself if it did not take effect, then audits again so the new index is known. Each fix is reversible with the rollback endpoint. Never changes SSH, the firewall or accounts. Needs the Owner or Admin role; audited.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Request body (application/json, required): FixesInputBody
  - `fixIds` (string[] | null): The fixes to apply or undo. Apply with none selects the default set (the ones marked default in the fix list); rollback needs at least one.

Response 202: Accepted (HardeningRun)
  - `action` (enum: audit | apply | rollback, required)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `finishedAt` (string (date-time) | null, required)
  - `fixIds` (string[], required)
  - `hardeningIndex` (integer (int64) | null, required): The hardening index the run measured; null when it did not audit
  - `id` (string, required)
  - `indexBefore` (integer (int64) | null, required): The hardening index before the run; null when there was no audit yet
  - `lynisVersion` (string)
  - `message` (string)
  - `outcomes` (HardeningFixOutcome[], required): What an apply or rollback did, per fix
  - `status` (enum: pending | succeeded | failed | skipped, required): skipped: Lynis is not installed on the server
  - `trigger` (enum: scheduled | manual, required)
Errors: 400 Bad Request; 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/hardening/audit

Audit the host with Lynis now

Queues a Lynis audit on the server. It only reads the host. With `installLynis`, the agent first installs Lynis (from CISOfy's repository, else the distribution's). Needs the Owner or Admin role. 409 `server_offline` when the server is offline, 409 `hardening_running` while a run is in flight.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Request body (application/json, required): AuditInputBody
  - `installLynis` (boolean): Install Lynis from the distribution's or CISOfy's package repository when the server does not have it

Response 202: Accepted (HardeningRun)
  - `action` (enum: audit | apply | rollback, required)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `finishedAt` (string (date-time) | null, required)
  - `fixIds` (string[], required)
  - `hardeningIndex` (integer (int64) | null, required): The hardening index the run measured; null when it did not audit
  - `id` (string, required)
  - `indexBefore` (integer (int64) | null, required): The hardening index before the run; null when there was no audit yet
  - `lynisVersion` (string)
  - `message` (string)
  - `outcomes` (HardeningFixOutcome[], required): What an apply or rollback did, per fix
  - `status` (enum: pending | succeeded | failed | skipped, required): skipped: Lynis is not installed on the server
  - `trigger` (enum: scheduled | manual, required)
Errors: 400 Bad Request; 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`server_offline`, `hardening_running`); 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/hardening/rollback

Undo safe hardening fixes

Restores what NebulaCtrl changed for the selected fixes: the previous sysctl values, file modes, login.defs lines and banners. A fix NebulaCtrl did not apply is left alone. Needs the Owner or Admin role; audited.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Request body (application/json, required): FixesInputBody
  - `fixIds` (string[] | null): The fixes to apply or undo. Apply with none selects the default set (the ones marked default in the fix list); rollback needs at least one.

Response 202: Accepted (HardeningRun)
  - `action` (enum: audit | apply | rollback, required)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `finishedAt` (string (date-time) | null, required)
  - `fixIds` (string[], required)
  - `hardeningIndex` (integer (int64) | null, required): The hardening index the run measured; null when it did not audit
  - `id` (string, required)
  - `indexBefore` (integer (int64) | null, required): The hardening index before the run; null when there was no audit yet
  - `lynisVersion` (string)
  - `message` (string)
  - `outcomes` (HardeningFixOutcome[], required): What an apply or rollback did, per fix
  - `status` (enum: pending | succeeded | failed | skipped, required): skipped: Lynis is not installed on the server
  - `trigger` (enum: scheduled | manual, required)
Errors: 400 Bad Request; 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers/{serverId}/mesh

Mesh node of a server

Whether the server joined the mesh, its node (state, tailnet addresses, peers, exposed services) and the link nodes that run on it.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (ServerMeshView)
  - `agentCapable` (boolean, required)
  - `enabled` (boolean, required)
  - `links` (MeshLinkView[] | null, required): Link nodes that run on this server
  - `node` (NodeView, required)
  - `paused` (boolean, required)
  - `tunDevice` (boolean, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /servers/{serverId}/mesh

Turn the mesh on or off for a server

Enabling adds one tagged device to the team's tailnet (it may be billed by Tailscale) and needs `acceptMeshChanges`; with `?dryRun=true` it returns the plan instead (devices added, whether the host has /dev/net/tun, warnings and blockers). Disabling is refused (409 `mesh_server_in_use`) while replicas or cross-server links use the server's node. The node key is minted when the command is sent and never stored.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)
- `dryRun` (query, boolean): Return the plan (devices added, blockers, warnings) without changing anything

Request body (application/json, required): SetServerMeshInputBody
  - `acceptMeshChanges` (boolean): Confirms that enabling adds tailnet devices (tagged devices may be billed by Tailscale)
  - `enabled` (boolean, required)

Response 202: Accepted (ServerMeshResult)
  - `applied` (boolean, required)
  - `enabled` (boolean, required)
  - `plan` (ServerMeshPlan, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/mesh/check

Test the mesh connectivity of a server

The agent checks whether the server's node reaches the other servers' nodes (connected, refused, timeout, no_path). This is the ground truth for advisory ACL modes.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Request body (application/json, required): CheckServerInputBody
  - `peerServerId` (string): Check only this peer (default: every other server with a mesh node)

Response 200: OK (CheckServerOutputBody)
  - `results` (ServerCheckResult[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/mesh/reprovision

Re-provision the mesh node of a server

Deletes the node's device from the tailnet and re-creates it with a fresh single-use key and a fresh state volume. The node gets a new tailnet IP and the access rules regenerate. Use it when the node needs attention.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Response 202: Accepted (AcceptedOutputBody)
  - `accepted` (boolean, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers/{serverId}/patches

Pending OS updates and reboot-required of a server

The Updates panel: how many packages are waiting (and how many are security updates), whether the host asks for a reboot, the pending packages with a warning where an upgrade is risky, the production services that run on the server, and the recent runs. The agent checks every server about every 6 hours; `known` is false until the first check completes.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (PatchView)
  - `agentSupported` (boolean, required): The agent advertised patching support; false means update the agent first
  - `busy` (enum: check | apply | reboot | , required): The run in flight, if any
  - `checkedAt` (string (date-time) | null, required)
  - `controlPlane` (boolean, required): This server runs the control plane: upgrades and reboots take the dashboard down
  - `known` (boolean, required): A check has completed at least once
  - `manager` (enum: apt | dnf | , required): Empty until the first check
  - `message` (string): A caution from the last check
  - `online` (boolean, required)
  - `packages` (PatchPackage[], required): Pending upgrades, security first, at most 200
  - `productionServices` (ProductionService[], required)
  - `rebootPackages` (string[], required): Packages that asked for the reboot, when the host says
  - `rebootRequired` (boolean, required)
  - `runs` (PatchRun[], required): The most recent runs, newest first, without logs
  - `security` (integer (int64), required): How many of the updates are security updates
  - `stale` (boolean, required): The last check is older than 12 hours
  - `supported` (boolean, required): The host has a supported package manager (apt or dnf)
  - `updates` (integer (int64), required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/patches/apply

Upgrade the server's packages

Runs `apt-get upgrade` or `dnf upgrade` (security updates only with `securityOnly`) on the host as root, keeping the configuration files you changed, then checks again. The upgrade runs detached on the host, so it finishes even when Docker restarts. Never reboots. Instance admin only; `confirm` must be the server's name; a server that runs production services or the control plane also needs `acknowledgeProduction` (422 `production_ack_required`), and a token that asks for production approvals gets 409 `approval_required`. Needs a completed check first (409 `no_check_yet`). Audited.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Request body (application/json, required): ApplyInputBody
  - `acknowledgeProduction` (boolean): Required when production services or the control plane run on the server
  - `confirm` (string, required): The server's name, typed by the person
  - `securityOnly` (boolean): Upgrade only the packages with a security update

Response 202: Accepted (PatchRun)
  - `action` (enum: check | apply | reboot, required)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `exitCode` (integer (int64) | null, required): The package manager's exit status (upgrades only)
  - `finishedAt` (string (date-time) | null, required)
  - `id` (string, required)
  - `log` (string): The tail of what the package manager printed (upgrades only; only in the single-run read)
  - `message` (string)
  - `rebootingSince` (string (date-time) | null, required)
  - `requestedBy` (string): The person who started the run; absent for automatic checks
  - `securityOnly` (boolean, required)
  - `status` (enum: pending | rebooting | succeeded | failed, required): rebooting: the host accepted the reboot and NebulaCtrl waits for the agent to reconnect on a new boot
  - `trigger` (enum: scheduled | manual, required)
  - `updatesAfter` (integer (int64) | null, required)
  - `updatesBefore` (integer (int64) | null, required)
Errors: 400 Bad Request; 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`approval_required`, `no_check_yet`); 422 Unprocessable Entity (`production_ack_required`); 500 Internal Server Error

## POST /servers/{serverId}/patches/check

Check the server for updates now

Refreshes the package lists on the host and records the pending updates and reboot-required. Changes no package. Needs the Owner or Admin role. 409 `server_offline` when the server is offline, 409 `patching_running` while a run is in flight.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Response 202: Accepted (PatchRun)
  - `action` (enum: check | apply | reboot, required)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `exitCode` (integer (int64) | null, required): The package manager's exit status (upgrades only)
  - `finishedAt` (string (date-time) | null, required)
  - `id` (string, required)
  - `log` (string): The tail of what the package manager printed (upgrades only; only in the single-run read)
  - `message` (string)
  - `rebootingSince` (string (date-time) | null, required)
  - `requestedBy` (string): The person who started the run; absent for automatic checks
  - `securityOnly` (boolean, required)
  - `status` (enum: pending | rebooting | succeeded | failed, required): rebooting: the host accepted the reboot and NebulaCtrl waits for the agent to reconnect on a new boot
  - `trigger` (enum: scheduled | manual, required)
  - `updatesAfter` (integer (int64) | null, required)
  - `updatesBefore` (integer (int64) | null, required)
Errors: 400 Bad Request; 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`server_offline`, `patching_running`); 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers/{serverId}/patches/plan

What an upgrade of the server would do

The plan shown before the confirmation: the packages that would be upgraded, the risky ones among them and why, whether a reboot will probably be needed, whether Docker restarts, the production services on the server and what the apply request must carry (the server's name, and `acknowledgeProduction` when `requiresAcknowledgement`). Changes nothing.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)
- `securityOnly` (query, boolean): Plan to upgrade only the packages with a security update

Response 200: OK (PatchPlan)
  - `checkedAt` (string (date-time) | null, required)
  - `confirm` (string, required): What the person types to confirm: the server's name
  - `controlPlane` (boolean, required)
  - `manager` (enum: apt | dnf | , required)
  - `notReadyReason` (string)
  - `packages` (PatchPackage[], required)
  - `productionServices` (ProductionService[], required)
  - `ready` (boolean, required): The plan can be applied now
  - `rebootLikely` (boolean, required): The upgrade probably leaves the server wanting a reboot
  - `requiresAcknowledgement` (boolean, required)
  - `restartsDocker` (boolean, required): Docker is among the packages: its daemon restarts and containers may stop for a moment
  - `risks` (PatchRisk[], required)
  - `security` (integer (int64), required)
  - `securityOnly` (boolean, required)
  - `server` (string, required)
  - `servicesOnServer` (integer (int64), required)
  - `stale` (boolean, required)
  - `updates` (integer (int64), required): Packages that would be upgraded
  - `warnings` (string[], required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/patches/reboot

Reboot the server

Schedules a reboot of the host a few seconds ahead. Every service on the server goes down until it boots and Docker starts them again. The run stays `rebooting` until the agent reconnects on a new boot (it fails after 20 minutes). Instance admin only; `confirm` must be the server's name; a server that runs production services or the control plane also needs `acknowledgeProduction`. Audited.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Request body (application/json, required): RebootInputBody
  - `acknowledgeProduction` (boolean): Required when production services or the control plane run on the server
  - `confirm` (string, required): The server's name, typed by the person

Response 202: Accepted (PatchRun)
  - `action` (enum: check | apply | reboot, required)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `exitCode` (integer (int64) | null, required): The package manager's exit status (upgrades only)
  - `finishedAt` (string (date-time) | null, required)
  - `id` (string, required)
  - `log` (string): The tail of what the package manager printed (upgrades only; only in the single-run read)
  - `message` (string)
  - `rebootingSince` (string (date-time) | null, required)
  - `requestedBy` (string): The person who started the run; absent for automatic checks
  - `securityOnly` (boolean, required)
  - `status` (enum: pending | rebooting | succeeded | failed, required): rebooting: the host accepted the reboot and NebulaCtrl waits for the agent to reconnect on a new boot
  - `trigger` (enum: scheduled | manual, required)
  - `updatesAfter` (integer (int64) | null, required)
  - `updatesBefore` (integer (int64) | null, required)
Errors: 400 Bad Request; 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers/{serverId}/patches/runs/{runId}

One check, upgrade or reboot of a server, with the upgrade's log

The log is the tail of what the package manager printed. A run in `rebooting` waits for the agent to reconnect on a new boot.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)
- `runId` (path, string, required)

Response 200: OK (PatchRun)
  - `action` (enum: check | apply | reboot, required)
  - `createdAt` (string (date-time), required)
  - `error` (string)
  - `exitCode` (integer (int64) | null, required): The package manager's exit status (upgrades only)
  - `finishedAt` (string (date-time) | null, required)
  - `id` (string, required)
  - `log` (string): The tail of what the package manager printed (upgrades only; only in the single-run read)
  - `message` (string)
  - `rebootingSince` (string (date-time) | null, required)
  - `requestedBy` (string): The person who started the run; absent for automatic checks
  - `securityOnly` (boolean, required)
  - `status` (enum: pending | rebooting | succeeded | failed, required): rebooting: the host accepted the reboot and NebulaCtrl waits for the agent to reconnect on a new boot
  - `trigger` (enum: scheduled | manual, required)
  - `updatesAfter` (integer (int64) | null, required)
  - `updatesBefore` (integer (int64) | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/revoke

Revoke a server's agent

Revokes the agent identity and pending enrollment tokens, cancels commands that were never delivered (their deployments fail with "Server revoked. No changes were made.") and disconnects the agent. Containers already running on the host are left untouched.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (Server)
  - `acceptWorkloads` (boolean, required): Whether services may be scheduled on this server. Turning it off keeps what already runs here and refuses new placements and moves.
  - `acmeEmail` (string | null, required)
  - `agentVersion` (string | null, required)
  - `baseDomain` (string | null, required)
  - `buildAllowEmulation` (boolean, required): Allow building for another CPU architecture through emulation (slow); needs the agent to report emulation for the platform
  - `buildAllowPreviews` (boolean, required): Allow pull request previews (untrusted code) to build here; off by default
  - `buildEnabled` (boolean, required): The server builds images for other servers and pushes them to a registry (the Builds or Both role). Independent of acceptWorkloads.
  - `buildMaxConcurrent` (integer (int64), required): Builds that run at the same time; more queue on the control plane
  - `composeVersion` (string, required): Docker Compose plugin version the agent reported, empty when it has none
  - `controlPlane` (boolean, required): The host the installer put the control plane on; its proxy serves the dashboard
  - `createdAt` (string (date-time), required)
  - `diskAlert` (boolean, required): True while the root filesystem is at or above the threshold
  - `diskAlertThresholdPercent` (integer (int64), required): Root filesystem usage that raises the disk alert (50-99)
  - `drainMessage` (string, required): Why the last drain failed or was canceled, empty otherwise
  - `drainStartedAt` (string (date-time) | null, required)
  - `drainState` (enum: none | draining | drained | failed, required): Evacuation state; draining and drained servers accept no new services
  - `facts` (Facts, required)
  - `geoEnabled` (boolean, required): Count where requests come from (country, and city at the city level) from the client IP; IPs are never stored
  - `geoLevel` (enum: country | city, required): country needs a ~8 MB database on the server, city ~130 MB
  - `geoRefreshDays` (integer (int64), required): Check for a newer database every N days (1-90)
  - `geoSource` (enum: dbip | maxmind, required): dbip = DB-IP Lite (CC BY 4.0, no key); maxmind = MaxMind GeoLite2 with the team's account
  - `id` (string, required)
  - `lastSeenAt` (string (date-time) | null, required)
  - `latest` (Latest, required)
  - `metricsRetentionDays` (integer (int64), required): How long resource metric history is kept (1-90)
  - `name` (string, required)
  - `patches` (ServerPatchSummary, required)
  - `protocolVersion` (integer (int32) | null, required)
  - `proxy` (Proxy, required)
  - `publicIp` (string | null, required)
  - `serviceCount` (integer (int64), required)
  - `status` (enum: pending | online | offline | revoked, required)
  - `trafficAnalyticsEnabled` (boolean, required): Aggregate Traefik access logs into traffic analytics
  - `trafficDailyRetentionDays` (integer (int64), required): Daily traffic history (7-365 days)
  - `trafficHourlyRetentionDays` (integer (int64), required): Hourly traffic history (2-90 days)
  - `tunnel` (ServerTunnelSummary, required)
  - `unrestricted` (boolean, required): An instance admin made this server unrestricted: stacks and services on it may use privileged mode, host networking, devices, GPUs, a runtime, the host PID namespace, unconfined seccomp or AppArmor, NET_ADMIN, SYS_ADMIN, the Docker socket and host bind mounts
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## GET /servers/{serverId}/tunnel

Cloudflare Tunnel state of a server

Whether the server has a Cloudflare Tunnel, the connector's observed state and the ingress sync. A server without a tunnel answers `state: off` and says whether one can be enabled.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (TunnelView)
  - `accountId` (string)
  - `accountReady` (boolean, required): The connection has an account ID (needed for tunnels)
  - `active` (boolean, required)
  - `agentSupported` (boolean, required): The server's agent can run the cloudflared connector
  - `cloudflareConnected` (boolean, required): The team has a Cloudflare connection
  - `cname` (string): The DNS target of the tunnel: <tunnelId>.cfargotunnel.com
  - `connections` (integer (int64), required): Ready connections to Cloudflare's edge
  - `enabled` (boolean, required): The server has a tunnel (or is removing one)
  - `image` (string)
  - `ingressError` (string)
  - `ingressHosts` (integer (int64), required)
  - `ingressSyncedAt` (string (date-time) | null, required)
  - `message` (string, required): Why the connector needs attention, or the last teardown problem
  - `observedAt` (string (date-time) | null, required)
  - `state` (enum: off | provisioning | online | offline | needs_attention | removing, required): Observed connector state; off when the server has no tunnel
  - `tunnelId` (string)
  - `tunnelName` (string)
  - `upstream` (string, required): Where the tunnel forwards every request
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/tunnel

Enable a Cloudflare Tunnel on a server

Creates a remotely-managed tunnel with the team's Cloudflare connection, pushes its ingress rules (every hostname of the server's services to the proxy, then a 404 catch-all) and tells the agent to run the cloudflared connector. The connector token is fetched when the command is sent and is never stored. Once the connector connects, the server's domains in connected zones point at the tunnel and ports 80 and 443 may stay closed. Needs the `servers:write` and `proxy:write` scopes. The Cloudflare token needs the permission Account, Cloudflare Tunnel, Edit (422 `cloudflare_tunnel_forbidden`).

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Response 202: Accepted (TunnelView)
  - `accountId` (string)
  - `accountReady` (boolean, required): The connection has an account ID (needed for tunnels)
  - `active` (boolean, required)
  - `agentSupported` (boolean, required): The server's agent can run the cloudflared connector
  - `cloudflareConnected` (boolean, required): The team has a Cloudflare connection
  - `cname` (string): The DNS target of the tunnel: <tunnelId>.cfargotunnel.com
  - `connections` (integer (int64), required): Ready connections to Cloudflare's edge
  - `enabled` (boolean, required): The server has a tunnel (or is removing one)
  - `image` (string)
  - `ingressError` (string)
  - `ingressHosts` (integer (int64), required)
  - `ingressSyncedAt` (string (date-time) | null, required)
  - `message` (string, required): Why the connector needs attention, or the last teardown problem
  - `observedAt` (string (date-time) | null, required)
  - `state` (enum: off | provisioning | online | offline | needs_attention | removing, required): Observed connector state; off when the server has no tunnel
  - `tunnelId` (string)
  - `tunnelName` (string)
  - `upstream` (string, required): Where the tunnel forwards every request
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity (`cloudflare_tunnel_forbidden`); 500 Internal Server Error; 502 Bad Gateway

## DELETE /servers/{serverId}/tunnel

Disable the Cloudflare Tunnel of a server

Points the server's domains back at its public IP, removes the connector and deletes the tunnel at Cloudflare shortly afterwards. Disabling a tunnel that is already being removed retries the delete. Ports 80 and 443 must be reachable again before the DNS change propagates.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)
- `force` (query, boolean): Drop the tunnel record without asking Cloudflare (it may linger in the Cloudflare dashboard). Use it when the Cloudflare connection is gone or Cloudflare keeps refusing the delete.

Response 202: Accepted (TunnelView)
  - `accountId` (string)
  - `accountReady` (boolean, required): The connection has an account ID (needed for tunnels)
  - `active` (boolean, required)
  - `agentSupported` (boolean, required): The server's agent can run the cloudflared connector
  - `cloudflareConnected` (boolean, required): The team has a Cloudflare connection
  - `cname` (string): The DNS target of the tunnel: <tunnelId>.cfargotunnel.com
  - `connections` (integer (int64), required): Ready connections to Cloudflare's edge
  - `enabled` (boolean, required): The server has a tunnel (or is removing one)
  - `image` (string)
  - `ingressError` (string)
  - `ingressHosts` (integer (int64), required)
  - `ingressSyncedAt` (string (date-time) | null, required)
  - `message` (string, required): Why the connector needs attention, or the last teardown problem
  - `observedAt` (string (date-time) | null, required)
  - `state` (enum: off | provisioning | online | offline | needs_attention | removing, required): Observed connector state; off when the server has no tunnel
  - `tunnelId` (string)
  - `tunnelName` (string)
  - `upstream` (string, required): Where the tunnel forwards every request
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/{serverId}/tunnel/sync

Re-sync the Cloudflare Tunnel of a server

Re-pushes the ingress rules and re-sends the connector command (creating the container again when it is gone). Use it when the connector needs attention.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serverId` (path, string, required)

Response 202: Accepted (TunnelView)
  - `accountId` (string)
  - `accountReady` (boolean, required): The connection has an account ID (needed for tunnels)
  - `active` (boolean, required)
  - `agentSupported` (boolean, required): The server's agent can run the cloudflared connector
  - `cloudflareConnected` (boolean, required): The team has a Cloudflare connection
  - `cname` (string): The DNS target of the tunnel: <tunnelId>.cfargotunnel.com
  - `connections` (integer (int64), required): Ready connections to Cloudflare's edge
  - `enabled` (boolean, required): The server has a tunnel (or is removing one)
  - `image` (string)
  - `ingressError` (string)
  - `ingressHosts` (integer (int64), required)
  - `ingressSyncedAt` (string (date-time) | null, required)
  - `message` (string, required): Why the connector needs attention, or the last teardown problem
  - `observedAt` (string (date-time) | null, required)
  - `state` (enum: off | provisioning | online | offline | needs_attention | removing, required): Observed connector state; off when the server has no tunnel
  - `tunnelId` (string)
  - `tunnelName` (string)
  - `upstream` (string, required): Where the tunnel forwards every request
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error; 502 Bad Gateway

## GET /servers/{serverId}/unrestricted

The Unrestricted switch and Compose availability of a server

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Response 200: OK (UnrestrictedSettings)
  - `available` (boolean, required): The agent can run Compose stacks (Docker Compose plugin 2.24 or newer)
  - `composeVersion` (string, required): Docker Compose plugin version the agent reported, empty when it has none
  - `dockerServices` (integer (int64), required): Live services placed on this server that use Docker options which need an unrestricted server
  - `stacks` (integer (int64), required): Live Compose stacks placed on this server
  - `unrestricted` (boolean, required): An instance admin allowed this server to run stacks and services with privileged mode, host networking, devices, GPUs, a runtime, the host PID namespace, unconfined seccomp or AppArmor, NET_ADMIN, SYS_ADMIN, the Docker socket and host bind mounts
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /servers/{serverId}/unrestricted

Make a server unrestricted, or restricted again

By default Compose stacks and services run under safe allow-lists: no privileged mode, host network, host PID namespace, devices, GPUs, custom runtime, unconfined seccomp or AppArmor, NET_ADMIN or SYS_ADMIN, Docker socket or host bind mounts. An unrestricted server lifts them for everything on it, so anyone who can deploy there can take over the server. Instance admin only; enabling needs `confirm` to equal the server's name. Audited as `server.unrestricted.enable` or `.disable`. Disabling does not touch running containers: the next deploy, rollback or reconcile of a stack or service that needs the lifted rules fails until it is edited or moved.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serverId` (path, string, required)

Request body (application/json, required): SetUnrestrictedInputBody
  - `confirm` (string): The server's name; required when enabling
  - `unrestricted` (boolean, required)

Response 200: OK (UnrestrictedSettings)
  - `available` (boolean, required): The agent can run Compose stacks (Docker Compose plugin 2.24 or newer)
  - `composeVersion` (string, required): Docker Compose plugin version the agent reported, empty when it has none
  - `dockerServices` (integer (int64), required): Live services placed on this server that use Docker options which need an unrestricted server
  - `stacks` (integer (int64), required): Live Compose stacks placed on this server
  - `unrestricted` (boolean, required): An instance admin allowed this server to run stacks and services with privileged mode, host networking, devices, GPUs, a runtime, the host PID namespace, unconfined seccomp or AppArmor, NET_ADMIN, SYS_ADMIN, the Docker socket and host bind mounts
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/agent-upgrades

Upgrade agents one server at a time (staged)

Upgrades the given servers (every server whose agent can be upgraded now when none are given) in name order. Each upgrade must be confirmed by the agent reconnecting with the new version before the next one starts; the first failure stops the rollout and leaves the remaining servers untouched. Needs the Owner or Admin role; audited. 409 `nothing_to_upgrade` when no agent qualifies.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.

Request body (application/json, required): FleetInputBody
  - `force` (boolean): Install a version an agent rolled back before.
  - `serverIds` (string[] | null): The servers to upgrade. Empty: every server whose agent can be upgraded now.

Response 202: Accepted (RolloutView)
  - `serverIds` (string[] | null, required): The servers in upgrade order. They are upgraded one at a time; the rollout stops at the first failure.
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`nothing_to_upgrade`); 422 Unprocessable Entity; 500 Internal Server Error

## POST /servers/enrollment-tokens

Create a pending server and a one-time enrollment token

The token is valid for one hour and can enroll exactly one agent. It is returned only in this response (and in an idempotent replay of it).

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.

Request body (application/json, required): CreateTokenInputBody1
  - `acmeEmail` (string)
  - `baseDomain` (string)
  - `name` (string, required)
  - `publicIp` (string)

Response 201: Created (EnrollmentToken)
  - `expiresAt` (string (date-time), required)
  - `installCommand` (string, required)
  - `server` (Server, required)
  - `token` (string, required): Shown once; only its hash is stored
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error
