# Integrations API

12 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 /cloudflare/ips

Cloudflare IP ranges in use

The list the origin lock allows: its source (built-in copy, Cloudflare API or the published text files), size, age and the state of the 3-hourly refresh. `stale` is set when fetching has failed for more than 24 hours; the last good list stays in use.

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

Response 200: OK (IPListView)
  - `builtinDate` (string, required): The day the built-in list was fetched (YYYY-MM-DD)
  - `changedAt` (string (date-time) | null, required): The last time the content changed; null while the built-in list is in use
  - `failingSince` (string (date-time) | null, required)
  - `fetchedAt` (string (date-time) | null, required): The last successful fetch; null while the built-in list is in use
  - `hash` (string, required)
  - `lastError` (string, required): Why the last fetch failed; empty after a successful one
  - `source` (enum: builtin | api | text, required): Where the list came from: the copy built into the binary, the Cloudflare API or the published text files
  - `stale` (boolean, required): Fetching has failed for more than 24 hours
  - `v4Count` (integer (int64), required)
  - `v6Count` (integer (int64), required)
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## POST /cloudflare/ips/refresh

Refresh Cloudflare's IP ranges now (team admin)

Fetches the list like the periodic job does (API first, text files second), validates it and stores it. A changed list is pushed to the servers with locked routes (a route update, no redeploy). 502 `ip_refresh_failed` when neither source works or the answer fails validation; the stored list stays.

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

Response 200: OK (IPListView)
  - `builtinDate` (string, required): The day the built-in list was fetched (YYYY-MM-DD)
  - `changedAt` (string (date-time) | null, required): The last time the content changed; null while the built-in list is in use
  - `failingSince` (string (date-time) | null, required)
  - `fetchedAt` (string (date-time) | null, required): The last successful fetch; null while the built-in list is in use
  - `hash` (string, required)
  - `lastError` (string, required): Why the last fetch failed; empty after a successful one
  - `source` (enum: builtin | api | text, required): Where the list came from: the copy built into the binary, the Cloudflare API or the published text files
  - `stale` (boolean, required): Fetching has failed for more than 24 hours
  - `v4Count` (integer (int64), required)
  - `v6Count` (integer (int64), required)
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error; 502 Bad Gateway (`ip_refresh_failed`)

## GET /integrations/cloudflare

Get the Cloudflare connection

The API token is never returned.

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

Response 200: OK (CloudflareConnectionView)
  - `accountId` (string, required): Account used for Cloudflare Tunnel calls
  - `accountName` (string, required)
  - `accounts` (AccountRef[] | null, required): Accounts the token's zones belong to
  - `autoManage` (boolean, required): New domains in a connected zone get their DNS records managed automatically
  - `configured` (boolean, required)
  - `defaultProxied` (boolean, required): New managed records start proxied (orange cloud)
  - `status` (enum:  | ok | error, required): error after Cloudflare rejected the token; empty when not configured
  - `statusMessage` (string, required)
  - `tokenExpiresAt` (string (date-time) | null, required)
  - `verifiedAt` (string (date-time) | null, required)
  - `warnings` (string[] | null, required)
  - `zoneCount` (integer (int64), required)
  - `zonesSyncedAt` (string (date-time) | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## PUT /integrations/cloudflare

Connect Cloudflare

Stores a scoped API token (sealed at rest, write-only) after verifying it against Cloudflare and caching its zone list; every domain of the team is matched against the zones. Omit the token to change only the settings. 422 `invalid_token` when Cloudflare rejects the token.

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

Request body (application/json, required): PutInputBody1
  - `accountId` (string): Account used for Cloudflare Tunnel calls; defaults to the account of the token's zones
  - `autoManage` (boolean): New domains in a connected zone get their DNS records managed automatically (default true)
  - `defaultProxied` (boolean): New managed records start proxied (orange cloud; default false)
  - `dns01CredentialId` (string): Use the token of an existing Cloudflare DNS-01 credential instead of typing one
  - `token` (string): Write-only: a Cloudflare API token with Zone → Zone: Read and Zone → DNS: Edit (add Zone → Zone Settings: Read for SSL-mode warnings and Account → Cloudflare Tunnel: Edit for tunnels). Omit to keep the stored token.

Response 200: OK (CloudflareConnectionView)
  - `accountId` (string, required): Account used for Cloudflare Tunnel calls
  - `accountName` (string, required)
  - `accounts` (AccountRef[] | null, required): Accounts the token's zones belong to
  - `autoManage` (boolean, required): New domains in a connected zone get their DNS records managed automatically
  - `configured` (boolean, required)
  - `defaultProxied` (boolean, required): New managed records start proxied (orange cloud)
  - `status` (enum:  | ok | error, required): error after Cloudflare rejected the token; empty when not configured
  - `statusMessage` (string, required)
  - `tokenExpiresAt` (string (date-time) | null, required)
  - `verifiedAt` (string (date-time) | null, required)
  - `warnings` (string[] | null, required)
  - `zoneCount` (integer (int64), required)
  - `zonesSyncedAt` (string (date-time) | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity (`invalid_token`); 500 Internal Server Error; 502 Bad Gateway

## DELETE /integrations/cloudflare

Disconnect Cloudflare

Forgets the token and the zone list. Records NebulaCtrl created stay in Cloudflare (unmanaged) unless `removeRecords=true`.

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

Parameters:
- `removeRecords` (query, boolean): Delete the DNS records NebulaCtrl created from Cloudflare first (the removal is refused when that fails)

Response 204: No Content
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error; 502 Bad Gateway

## POST /integrations/cloudflare/test

Re-verify the token and refresh the zone list

Verifies the stored token, refreshes the cached zones and re-matches every domain of the team.

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

Response 200: OK (CloudflareConnectionView)
  - `accountId` (string, required): Account used for Cloudflare Tunnel calls
  - `accountName` (string, required)
  - `accounts` (AccountRef[] | null, required): Accounts the token's zones belong to
  - `autoManage` (boolean, required): New domains in a connected zone get their DNS records managed automatically
  - `configured` (boolean, required)
  - `defaultProxied` (boolean, required): New managed records start proxied (orange cloud)
  - `status` (enum:  | ok | error, required): error after Cloudflare rejected the token; empty when not configured
  - `statusMessage` (string, required)
  - `tokenExpiresAt` (string (date-time) | null, required)
  - `verifiedAt` (string (date-time) | null, required)
  - `warnings` (string[] | null, required)
  - `zoneCount` (integer (int64), required)
  - `zonesSyncedAt` (string (date-time) | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error; 502 Bad Gateway

## GET /integrations/cloudflare/zones

List the connected zones (zone picker)

The cached zone list; empty without a connection.

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

Response 200: OK (ZonesOutputBody)
  - `items` (Zone[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## GET /integrations/tailscale

Get the Tailscale integration

The OAuth client secret is never returned (`secretSet` only).

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

Response 200: OK (TailscaleIntegration)
  - `allowedTags` (string[] | null, required)
  - `clientId` (string, required)
  - `configured` (boolean, required)
  - `defaultTags` (string[] | null, required)
  - `headscaleCredential` (enum: apikey | preauth, required): apikey: NebulaCtrl mints single-use pre-auth keys and deletes nodes; preauth: one reusable tagged pre-auth key for every sidecar
  - `headscaleUser` (string, required): Headscale user that owns minted pre-auth keys (empty = tags-only keys)
  - `httpsEnabled` (boolean | null, required): Whether the tailnet has HTTPS certificates enabled (known after a successful test)
  - `lastTest` (TailscaleLastTest, required)
  - `loginServer` (string, required): Headscale control URL (headscale provider)
  - `provider` (enum: tailscale_oauth | headscale, required)
  - `secretSet` (boolean, required)
  - `tailnet` (string, required): Tailnet name, or - for the tailnet of the OAuth client
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## PUT /integrations/tailscale

Create or update the Tailscale integration

Stores an OAuth client (scopes `auth_keys` and `devices:core`) or, with `provider: headscale`, a Headscale server (login URL plus an API key, or a reusable tagged pre-auth key). The secret is sealed at rest; omit `clientSecret` to keep the stored one. Nothing contacts the control server until the connection test or first use.

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

Request body (application/json, required): PutInputBody
  - `allowedTags` (string[] | null): Tags NebulaCtrl may use; must include every default tag
  - `clientId` (string): OAuth client id (tailscale_oauth only)
  - `clientSecret` (string): Write-only: the OAuth client secret, the Headscale API key or the Headscale pre-auth key. Omit to keep the stored secret.
  - `defaultTags` (string[] | null): Tags minted keys carry (default tag:nebula-svc)
  - `headscaleCredential` (enum: apikey | preauth): apikey: NebulaCtrl mints single-use pre-auth keys and deletes nodes; preauth: one reusable tagged pre-auth key (headscale only)
  - `headscaleUser` (string): Headscale user that owns minted pre-auth keys; empty creates tags-only keys (headscale only)
  - `loginServer` (string): Headscale control URL (headscale only)
  - `provider` (enum: tailscale_oauth | headscale): tailscale_oauth (default) or headscale
  - `tailnet` (string): Tailnet name; empty or - uses the tailnet of the OAuth client

Response 200: OK (TailscaleIntegration)
  - `allowedTags` (string[] | null, required)
  - `clientId` (string, required)
  - `configured` (boolean, required)
  - `defaultTags` (string[] | null, required)
  - `headscaleCredential` (enum: apikey | preauth, required): apikey: NebulaCtrl mints single-use pre-auth keys and deletes nodes; preauth: one reusable tagged pre-auth key for every sidecar
  - `headscaleUser` (string, required): Headscale user that owns minted pre-auth keys (empty = tags-only keys)
  - `httpsEnabled` (boolean | null, required): Whether the tailnet has HTTPS certificates enabled (known after a successful test)
  - `lastTest` (TailscaleLastTest, required)
  - `loginServer` (string, required): Headscale control URL (headscale provider)
  - `provider` (enum: tailscale_oauth | headscale, required)
  - `secretSet` (boolean, required)
  - `tailnet` (string, required): Tailnet name, or - for the tailnet of the OAuth client
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error

## DELETE /integrations/tailscale

Remove the Tailscale integration

Refused (409 `integration_in_use`) while a server uses a managed tailnet.

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

Response 204: No Content
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`integration_in_use`); 500 Internal Server Error

## GET /integrations/tailscale/devices

List the tailnet's devices (device picker)

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

Response 200: OK (DevicesOutputBody)
  - `items` (TailscaleDevice[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error; 502 Bad Gateway

## POST /integrations/tailscale/test

Test the Tailscale connection

Exchanges a token, lists devices, mints and revokes a 60 second key with the default tags. Leaves no residue.

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

Response 200: OK (TailscaleTestResult)
  - `checks` (TailscaleCheck[] | null, required)
  - `ok` (boolean, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 500 Internal Server Error
