# Mesh API

9 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 /environments/{environmentId}/mesh

Mesh links, grants and routes of an environment

Needs `projects:read`. Links are the tailnet link nodes of the environment (service and replication purpose); grants say which service reaches which provider and why (`reference`, `edge`, `replica`); routes list, per consumer, the providers on other servers with the mesh port the consumer dials.

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

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

Response 200: OK (EnvironmentMeshView)
  - `grants` (EnvGrantView[] | null, required)
  - `links` (EnvLinkView[] | null, required)
  - `routes` (RouteView[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /environments/{environmentId}/mesh/reconcile

Reconcile the mesh links of an environment now

Needs `servers:write`. Mode `add` creates every link, exposure and grant the environment's references and canvas edges need; it adds tailnet devices, so it needs `acceptMeshChanges` (409 `mesh_confirmation_required` states the count; 422 with the blocker code, for example `mesh_server_required`, when the mesh cannot serve a need). Mode `repair` is what runs every 5 minutes: it only removes grants whose source is gone (10 minutes grace; an edge's grant at once) and never adds a device.

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

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

Request body (application/json, required): ReconcileInputBody
  - `acceptMeshChanges` (boolean): Confirm the tailnet devices mode add creates; the 409 mesh_confirmation_required response states how many
  - `mode` (enum: add | repair, required): add creates the links the environment needs (devices need acceptMeshChanges); repair only removes grants without a source

Response 200: OK (ReconcileView)
  - `added` (GrantChange[] | null, required)
  - `devicesAdded` (integer (int64), required)
  - `mode` (enum: add | repair, required)
  - `removed` (GrantChange[] | null, required)
  - `restaged` (string[] | null, required): Consumers whose resolved addresses changed; their staged bar shows it
  - `waiting` (integer (int64), required): Grants kept for their grace period
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`mesh_confirmation_required`); 422 Unprocessable Entity; 500 Internal Server Error

## GET /mesh

Server mesh of the team

The managed tailnet mesh: coordinator health, the ACL mode and state, the server nodes, the link nodes, the access rules NebulaCtrl derives (read-only) and the tagged device count. Servers join the mesh one by one; replicas and cross-server service links use it.

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

Response 200: OK (View)
  - `aclManagedWarning` (string): Shown while managed mode is on for Tailscale
  - `aclMessage` (string, required)
  - `aclMode` (enum: managed | advisory | off, required)
  - `aclState` (enum: unknown | applied | pending | drift | advisory | error, required)
  - `autoJoin` (boolean, required): Online servers with a current agent join the mesh automatically (each adds one device)
  - `coordinator` (CoordinatorView, required)
  - `devices` (DevicesView, required)
  - `enabled` (boolean, required): At least one server joined the mesh (turned on by the first server)
  - `links` (MeshLinkView[] | null, required)
  - `loginServer` (string, required)
  - `managedPushAvailable` (boolean, required): The credential can write the tailnet policy (Tailscale OAuth with the policy_file scope, or a Headscale API key)
  - `meshTag` (string, required): The one tag every mesh node of the team carries; it needs a tagOwners entry on Tailscale
  - `nodes` (NodeView[] | null, required)
  - `paused` (boolean, required): Mesh changes wait (the platform Headscale is being migrated); established connections keep working
  - `pausedReason` (string, required)
  - `provider` (enum: tailscale | headscale | none, required)
  - `rules` (RuleView[] | null, required): The access rules NebulaCtrl generates (read-only)
  - `snippet` (string): The rules to add to the tailnet policy (advisory mode)
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 500 Internal Server Error

## POST /mesh/acl/apply

Apply the access rules now

Regenerates and applies the policy according to the ACL mode (managed writes it, advisory only verifies it). It normally runs by itself a few seconds after every change.

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

Response 200: OK (View)
  - `aclManagedWarning` (string): Shown while managed mode is on for Tailscale
  - `aclMessage` (string, required)
  - `aclMode` (enum: managed | advisory | off, required)
  - `aclState` (enum: unknown | applied | pending | drift | advisory | error, required)
  - `autoJoin` (boolean, required): Online servers with a current agent join the mesh automatically (each adds one device)
  - `coordinator` (CoordinatorView, required)
  - `devices` (DevicesView, required)
  - `enabled` (boolean, required): At least one server joined the mesh (turned on by the first server)
  - `links` (MeshLinkView[] | null, required)
  - `loginServer` (string, required)
  - `managedPushAvailable` (boolean, required): The credential can write the tailnet policy (Tailscale OAuth with the policy_file scope, or a Headscale API key)
  - `meshTag` (string, required): The one tag every mesh node of the team carries; it needs a tagOwners entry on Tailscale
  - `nodes` (NodeView[] | null, required)
  - `paused` (boolean, required): Mesh changes wait (the platform Headscale is being migrated); established connections keep working
  - `pausedReason` (string, required)
  - `provider` (enum: tailscale | headscale | none, required)
  - `rules` (RuleView[] | null, required): The access rules NebulaCtrl generates (read-only)
  - `snippet` (string): The rules to add to the tailnet policy (advisory mode)
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /mesh/acl/check

Compare the generated rules with the stored policy

Read-only drift check: which generated rules are missing from the tailnet policy and which outdated NebulaCtrl rules it still holds. It cannot prove that no broader rule exists; the connectivity test is the ground truth.

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

Response 200: OK (ACLCheck)
  - `message` (string, required)
  - `missing` (Rule[] | null, required): Generated rules absent from the stored policy
  - `readable` (boolean, required)
  - `stale` (Rule[] | null, required): Rules NebulaCtrl owns in the stored policy that are no longer generated
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 500 Internal Server Error

## POST /mesh/acl/preview

Preview the generated access rules

The rules derived from the mesh tables, the policy document (managed Headscale) or the snippet to paste (advisory), and, when the stored policy can be read, what would be added or removed. Nothing is written.

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

Response 200: OK (ACLPreview)
  - `diff` (ACLDiff)
  - `policy` (string, required): What NebulaCtrl owns (managed Headscale: the whole policy) or the snippet to paste (HuJSON with comments)
  - `rules` (RuleView[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 500 Internal Server Error

## POST /mesh/links/{linkId}/reprovision

Re-provision a link node

Re-creates a link node with a fresh single-use key (a new device and tailnet IP; the access rules regenerate).

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.
- `linkId` (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

## PUT /mesh/settings

Change the mesh settings

Auto-join, the mesh tag and the ACL mode. Switching Tailscale to `managed` is an opt-in: it needs the OAuth client's `policy_file` scope, refuses when another team uses the same tailnet (409 `mesh_acl_shared_tailnet`) and answers 422 `mesh_policy_scope_missing` when the scope is missing. The token can then rewrite the whole tailnet policy; NebulaCtrl itself replaces only its own rules and keeps comments. Changing the ACL mode or the tag needs the admin scope.

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

Request body (application/json, required): SettingsInputBody1
  - `aclMode` (enum: managed | advisory | off): managed: NebulaCtrl writes the policy (Headscale with an API key; Tailscale only as an opt-in that needs the OAuth scope policy_file and replaces only NebulaCtrl's own rules). advisory: the rules are shown, never written. off: no rules. Needs the admin scope.
  - `autoJoin` (boolean): Online servers with a current agent join the mesh automatically (each adds one device)
  - `enabled` (boolean): Turning the mesh off is refused (409 mesh_in_use) while servers still have nodes
  - `meshTag` (string): The tag every mesh node carries (default tag:nebula-mesh); it must be in the integration's allowed tags. Needs the admin scope; fixed while nodes exist.

Response 200: OK (View)
  - `aclManagedWarning` (string): Shown while managed mode is on for Tailscale
  - `aclMessage` (string, required)
  - `aclMode` (enum: managed | advisory | off, required)
  - `aclState` (enum: unknown | applied | pending | drift | advisory | error, required)
  - `autoJoin` (boolean, required): Online servers with a current agent join the mesh automatically (each adds one device)
  - `coordinator` (CoordinatorView, required)
  - `devices` (DevicesView, required)
  - `enabled` (boolean, required): At least one server joined the mesh (turned on by the first server)
  - `links` (MeshLinkView[] | null, required)
  - `loginServer` (string, required)
  - `managedPushAvailable` (boolean, required): The credential can write the tailnet policy (Tailscale OAuth with the policy_file scope, or a Headscale API key)
  - `meshTag` (string, required): The one tag every mesh node of the team carries; it needs a tagOwners entry on Tailscale
  - `nodes` (NodeView[] | null, required)
  - `paused` (boolean, required): Mesh changes wait (the platform Headscale is being migrated); established connections keep working
  - `pausedReason` (string, required)
  - `provider` (enum: tailscale | headscale | none, required)
  - `rules` (RuleView[] | null, required): The access rules NebulaCtrl generates (read-only)
  - `snippet` (string): The rules to add to the tailnet policy (advisory mode)
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity (`mesh_policy_scope_missing`); 500 Internal Server Error

## GET /services/{serviceId}/mesh

Mesh exposures, routes and consumers of a service

Needs `projects:read`. `exposures` are the service's ports on its server's mesh node, `routes` the providers on other servers it reaches (a consumer dials `<provider slug>:<mesh port>`), `consumers` the services on other servers that reach it.

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

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

Response 200: OK (ServiceMeshView)
  - `consumers` (ConsumerView[] | null, required): Services on other servers that reach this one
  - `exposures` (ExposureView[] | null, required)
  - `routes` (RouteView[] | null, required): The providers on other servers this service reaches
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error
