API

Everything the UI does, it does through this API, so anything on a page can be scripted. Base path /api/v1, JSON in and out, same host as the UI.

This page lists every route the server actually registers. A test (TestAPIDocCoversEveryRoute) walks the live router and fails the build if a route is added without a line here, so the list below is not a sketch of intent — it is what the binary serves. The same router generates docs/openapi.yaml for tooling; the summaries in it are the sentences on this page.

Authentication

Three ways in, checked in this order. Every route past the three public ones refuses a request that carries none of them with 401.

How Send Notes
Session cookie pg_session (set by POST /auth/login) what the UI uses; HttpOnly, SameSite=Lax, 12 h sliding
API token Authorization: Bearer pgt_… or X-API-Token: pgt_… for scripts and CI; may be narrower than its owner, never wider
Trusted proxy X-Forwarded-User and X-Proxy-Secret only when ASM_TRUSTED_PROXY_SECRET is set; the header alone is refused and logged
curl -H "Authorization: Bearer pgt_…" https://host/api/v1/scopes

Roles

Three, ordered; each adds to the one below. The role a route needs is in its table below.

Role Adds
viewer read everything; create and revoke your own API tokens
operator companies, targets, scan profiles, alerts, wordlists, your own VPN configurations — and starting runs
admin accounts, everyone’s tokens, workers and enrollment, deleting a company, deleting anyone’s VPN configuration

A route above your role answers 403 with the role it needs.

Errors

Every error is {"error": "<one sentence, written for a person>"} with the matching status. The sentence says what went wrong and, where there is one, the fix — scanning from local workers needs a VPN so the scan never leaves from this host's own address, and this company has no VPN configuration yet: add one under VPN, or scan from a remote pool.

Status Meaning
400 the request is malformed or missing something
401 not signed in, or the credential is no longer valid
403 signed in, wrong role — or a disabled account
404 no such thing
409 conflicts with state: last administrator, username taken, empty pool
429 sign-in rate limit — 10 tries per 15 minutes, per username and per address

List endpoints return every row up to a fixed cap (hosts: 500; others smaller or unbounded — the inventories this tool holds are thousands, not millions). There is no cursor pagination yet.

Public

Route Purpose
GET /auth/status {setup_required, user?} — whether the install still needs its first administrator, and who you are if signed in
POST /auth/setup create the first administrator — {username, display_name, password}; refuses once any account exists
POST /auth/login {username, password} → sets the session cookie, returns {user}

Signed in (any role)

Route Purpose
POST /auth/logout end this session
GET /auth/me {user, via, must_change_password}
POST /auth/password {current_password, new_password} — signs your other sessions out

Companies and targets

A scope is one company: its own targets, inventory and authorization. Every asset route is under a scope.

Route Role Purpose
GET /scopes viewer all companies; ?mine=true narrows to ones you created
POST /scopes operator {name}
GET /scopes/{scopeID}/footprint viewer what the company owns, as counts: target groups, targets, names, hosts, services, runs, findings, screenshots, schedules, alert channels — and active_runs / live_fleets, which block a delete
GET /scopes/{scopeID}/report viewer ?days=30 (1–365): a printable HTML report of the company: what it exposes now, what changed over the period, findings still present, names and hosts. No scripts and no external requests, so it saves and prints to PDF
GET /scopes/{scopeID}/access viewer {visibility, owner, owner_id, members: [{user_id, username, role, added_by, created_at}], can_manage}
POST /scopes/{scopeID}/members operator {username} — share the company with that account; the owner only (an administrator once the owner’s account is gone). Answers with the access record
DELETE /scopes/{scopeID}/members/{userID} operator stop sharing with that account; the owner, or the member themselves
DELETE /scopes/{scopeID} operator delete the company with everything it owns — a shared company needs the admin role, a private one its owner — — inventory, runs, findings, schedules, alert channels — and remove its runs’ screenshots and raw output from object storage. 409 while a run of it is going; answers {deleted, artifacts_removed, artifacts_failed}
GET /scopes/{scopeID}/summary viewer dashboard counters: domains, ips, services, open_findings
GET /scopes/{scopeID}/target-groups viewer the company’s target groups, each with its entries and authorized (every entry active with a recorded authorization)
POST /scopes/{scopeID}/target-groups operator {name, values, tags, authorize} — one group from a list of domains, IPs and CIDRs; name defaults to the first value; 409 if the name is taken
PATCH /scopes/{scopeID}/target-groups/{groupID} operator {name, values, tags, authorize} — edit the group as one thing: rename, replace its list (entries not in values are removed), apply tags and authorization to every entry
DELETE /scopes/{scopeID}/target-groups/{groupID} operator remove the group and its entries
GET /scopes/{scopeID}/targets viewer ?tag= filters; each entry carries its group_id
POST /scopes/{scopeID}/targets operator {value} or {values:[…]}, kind (domain, cidr, ip, asn — inferred if omitted), tags, mode (passive_only default, active, exclude), authorize: true to record active authorization, group to add them to a named group (created if needed; without it each value is a group of its own)
PATCH /scopes/{scopeID}/targets/{targetID} operator {value, mode, tags, authorize} — edit a target in place: value fixes the host or range itself (kind re-detected; 409 if another target already has it), mode and tags as on add; authorize: true with mode: active records who authorized active scanning and when, false revokes it, and any other mode clears it
DELETE /scopes/{scopeID}/targets/{targetID} operator future runs stop covering it; what earlier runs discovered under it stays in the inventory. 404 if the target is not in this company

Runs

Route Role Purpose
GET /scan-params viewer every tunable a run accepts, with type, range, default and help
GET /scopes/{scopeID}/scan-profiles viewer saved parameter presets
POST /scopes/{scopeID}/scan-profiles operator {name, params, global, default}
GET /scopes/{scopeID}/runs viewer runs with progress counters; each run carries started_by (the account; a scheduled run’s is the schedule’s creator) and started_via (session, token, proxy or schedule)
POST /scopes/{scopeID}/runs operator start a run; the fields are under Starting a run
GET /runs/{runID} viewer {run, progress, fleet?} — fleet is present when the run has its own containers, and carries the reason if it is waiting or failed
GET /runs/{runID}/targets viewer per-target status, counters, skip reasons
GET /runs/{runID}/activity viewer {tasks, stages, workers} — what is running where, right now. Each stage carries found and found_kind (names, addresses, open ports, web endpoints) and, for discovery stages, sources (names per source: subfinder:crtsh, shuffledns, seed); each task carries result with the same counts
GET /runs/{runID}/diff viewer change events this run produced: {kind, asset_kind, asset_id, before, after, created_at}
GET /runs/{runID}/footprint viewer what the run owns, as counts: {tasks, targets, service_observations, screenshots, resolution_records, finding_observations, change_events, has_fleet} — what deleting it removes
GET /runs/{runID}/events viewer server-sent events stream; see the note under Known gaps
POST /runs/{runID}/cancel operator stop: unfinished tasks are cancelled, the run ends as cancelled; works on a paused run too
POST /runs/{runID}/pause operator hold a running run: nothing more is leased, tasks in flight finish, its own fleet stays up; 409 unless running
POST /runs/{runID}/resume operator continue a paused run; 409 unless paused
DELETE /runs/{runID} operator delete a finished run: tasks, targets, observations, fleet record and change events go with it, and its screenshots and raw output are removed from object storage — history that came from the run disappears. 409 while the run is going (stop it first) or its fleet is still being removed; answers {deleted, artifacts_removed, artifacts_failed}
POST /runs/{runID}/rerun operator start a new run with this one’s targets, profile, parameters, wordlists and exit, through the same checks as a fresh start; 201 with the new run

Starting a run:

{
  "profile": "standard",          // passive | standard | deep
  "all": true,                    // or "target_group_ids": ["…"], "targets": ["example.com"], or "tag": "prod"
  "exit": "local",                // required unless profile is passive
  "vpn_config_id": "…",           // local: the tunnel this run leaves through
  "pool_id": "…",                 // remote: the pool of enrolled workers instead
  "worker_count": 0,              // local only: 0 or absent is Auto (sized from the targets, at most 4); 1–8 overrides
  "profile_id": "…",              // a saved preset
  "params": {"httpx_user_agent": "…"},   // ad-hoc overrides
  "wordlist_ids": ["…"]           // explicit lists; empty means the registry defaults
}

Active stages send traffic at the target, so a run with any needs an exit: local — an ephemeral fleet behind a VPN gateway, needs vpn_config_id and a provisioner; or remote — an existing pool with at least one active worker, needs pool_id. There is no “direct from this host”. A passive run needs neither. Refusals are 400 (nothing chosen, bad ids) or 409 (no VPN config in this company, empty pool). See Where scans run from.

Recurring scans

A schedule starts a run on a cadence through the very same code the launch dialog uses, so it is refused for exactly the same reasons — and the refusal is written on the schedule (last_error) where the UI shows it, then retried next cadence. A company whose previous run is still going is skipped, never stacked. next_run_at advances from the planned time, not from when the run actually started, so a slow run does not drift the cadence.

Route Role Purpose
GET /scopes/{scopeID}/schedules viewer [{profile, exit, vpn_config_id, pool_id, worker_count, every_hours, enabled, next_run_at, last_run_id, last_run_at, last_error, profile_id, params, wordlist_ids}]
POST /scopes/{scopeID}/schedules operator {profile, target_group_ids, targets, exit, vpn_config_id \| pool_id, worker_count, every_hours, start_at, profile_id, params, wordlist_ids, enabled} — targets narrows each run to those values, empty is every non-excluded target at the time — every_hours 1…8784 repeats from start_at (default now); 0 runs once at start_at, then disables itself
PATCH /schedules/{scheduleID} operator any of the same fields; disabling stops it without losing it; start_at moves the next run
DELETE /schedules/{scheduleID} operator  
PATCH /scopes/{scopeID} operator {visibility} (shared or private, the owner only) changes who can see it; {name} renames the company (everything it owns stays; audited as scope.rename); {default_exit, default_vpn_config_id, default_pool_id} sets the exit the launch dialog pre-selects. Each part applies only when sent

Runs a schedule starts carry trigger: "scheduled".

Route Role Purpose
GET /scopes/{scopeID}/domains viewer ?q= substring
GET /scopes/{scopeID}/graph viewer {nodes, edges} for the name→address map
GET /scopes/{scopeID}/hosts viewer addresses with ASN, PTR, country, cloud
GET /scopes/{scopeID}/hostrows viewer the Hosts table: one row per name→address pair, ?q= and ?unresolved=true to include names that resolve to nothing. Returns {rows, unresolved_hidden}; each row’s sources says how the name was found (seed, subfinder:<provider>, shuffledns, dns)
GET /hosts/{ipID} viewer everything about one address: {host, names, services, findings}. Each name carries history (one entry per run that resolved it) and also_resolved_to; each service carries history (one entry per run that port-scanned the address), the latest banner/HTTP/TLS, technologies, and cookie names
GET /hosts/{ipID}/services viewer open ports only
GET /services/{serviceID}/screenshot viewer image/png, the most recent capture; ?host= picks one virtual host’s capture, otherwise the address-level one with any host’s as fallback
GET /scopes/{scopeID}/search viewer ?q= in the query language, one company; one row per service per site (virtual host), host says which, ip_id is the host page’s id
GET /scopes/{scopeID}/search/facets viewer ?q= — what the query matched, summarized: {services, sites, products, ports, techs, titles, statuses}, each a list of {value, count} (count of services), top 15
GET /search viewer ?q= across every company; ?scope= narrows
GET /search/facets viewer the same summary across every company; ?scope= narrows

Query language. Terms are field:value, joined with AND / OR; free text searches banners, titles and products. Fields: port, proto, ip, domain, site/host (the virtual host a row is about), country, cloud, asn, product, version, tech, cookie, status, title, cert.expires, new, severity, company/scope. Numeric fields take >= and friends. * is a wildcard anywhere in a value (product:nginx*, title:*login*); field:* means the field has a value (product:* is every port whose product is known); a bare * matches everything, which with the facets is how to read the whole inventory. cookie:webvpn* matches cookie names by prefix — names only, never values.

Findings and alerts

Route Role Purpose
GET /scopes/{scopeID}/findings viewer with per-run history, presence (active/gone), gone_since, evidence (a discovered path’s path, host and status; a nuclei match’s URL), and where it is: ip, ip_id (its host page) and port
PATCH /findings/{findingID} operator {status}: open, acknowledged, resolved, accepted_risk
GET /scopes/{scopeID}/notifications viewer {channels, events} — the second lists the event kinds a channel may subscribe to
POST /scopes/{scopeID}/notifications operator {name, kind: slack\|webhook, url, events, min_severity}; cadence (per_run, the default, or daily) and digest_hour (0–23 UTC) decide how often it hears from us
PATCH /notifications/{channelID} operator {enabled} — {enabled}, {cadence} and {digest_hour}, any of them; answers with the channel
DELETE /notifications/{channelID} operator  
POST /notifications/{channelID}/test operator sends a test digest → {sent, error?}
GET /scopes/{scopeID}/notifications/deliveries viewer every delivery attempt, with status and error

Event kinds: new_finding, finding_gone, finding_returned, new_port, new_subdomain. One digest per completed run per channel.

Wordlists

Route Role Purpose
GET /wordlists viewer ?kind=dns\|dir\|resolvers
POST /wordlists operator multipart: file, name (defaults to the filename), kind (default dns)
PATCH /wordlists/{wordlistID} operator {is_default}
GET /wordlists/{wordlistID}/content viewer the list, up to 4 MB
PUT /wordlists/{wordlistID}/content operator {content} — resolver lists are validated line by line
DELETE /wordlists/{wordlistID} operator built-in lists refuse

Workers and pools

Route Role Purpose
GET /workers viewer every worker: status, egress, load, tools, run_scoped for a run’s own containers
GET /fleets viewer runs’ own fleets — the VPN gateway (tunnel state, exit address, VPN config) and the workers beside it, with the run and company — every fleet up or being built plus those ended in the last day
GET /pools viewer pools a run may choose as its remote exit, with active_workers — remote workers only; the standing local workers run passive stages and are never an exit
PATCH /pools/{poolID} admin {takes_discovery} — whether this pool runs the stages that never touch a target (passive discovery, DNS), moving that work off the control-plane host. A run’s own pool cannot be changed
POST /workers/enrollment-tokens admin {kind: vps, name, pool_id, ttl_mins, max_uses} → {token, install_command, expires_in}. kind: local is refused: the standing worker enrols itself and a run’s fleet is built by the scheduler
POST /workers/{workerID}/{action} admin approve, drain, resume, quarantine
DELETE /workers/{workerID} admin a local worker’s container is removed first

VPN configurations

| Route | Role | Purpose | |—|—|—| A VPN configuration belongs to the account that added it and can be used in any company that account scans; nothing here takes a company id. A run, a schedule or a company default may only name a configuration of the requesting account (403 otherwise), and a schedule stays bound to its creator’s configuration.

Route Role Purpose
GET /vpn-configs viewer your own: name, kind, endpoint host, last egress — never the body

System health

Route Role Purpose
GET /system/health admin {status, components, counts, queue, workers, stranded}: each service (api with its version, database with schema version and round trip, object storage, gateway and scheduler by heartbeat age, provisioner) as ok/degraded/down/off; runs going, live fleets, lease expiries, failed runs and tasks in the last 24 h; the queue by stage with the oldest waiting age; every worker’s heartbeat age; tasks no active worker can lease; and storage: the database split into the record of past runs and the current inventory, each object-storage folder with its bytes and object count, how many runs are kept and the oldest one — re-measured every few minutes, not per request. Nothing deletes a past run, so these only grow
GET /metrics viewer Prometheus text exposition: queue depth and the oldest waiting task per stage, runs by status and what finished in the last 24 h, workers by status, open findings by severity, lease expiries, inventory totals, and each service’s heartbeat age (including every api copy and the last backup set). Install-wide only — nothing is labelled by company, so a scrape cannot name a private one. Cached for 10 s

Private companies

A company is shared — every account on the install sees it — or private: only its owner and the accounts it is shared with. POST /scopes takes "private": true. The rule is one database function, scope_visible, applied to every route that names a company or anything inside one (runs, schedules, alert channels, findings, hosts, services) and to the company list, global search and the fleet list. A company the requester may not see answers 404, as if it did not exist. API tokens and the MCP server act as their account. Roles still apply inside: a viewer a company is shared with can read it, not scan it. A private company whose owner’s account is removed falls to the administrators.

MCP page

Route Role Purpose
GET /mcp/settings viewer what the built-in MCP server (at /mcp on the api’s address) exposes: {path, tools: [{name, description, read_only, destructive}], allow_delete_company}
GET /mcp/skill.zip viewer the skill for AI clients, as a zip that unpacks into a pinkglasses/ skill folder: SKILL.md plus a generated tool reference, the search query language and how scanning works
POST /vpn-configs operator multipart file + name, or JSON {name, config}; kind (wireguard/openvpn) and endpoint are detected from the body; a WireGuard config without a default route is refused; 409 if you already have one by that name
DELETE /vpn-configs/{vpnID} operator your own; an administrator may delete anyone’s

Bodies are sealed with AES-256-GCM at rest and are returned by no endpoint.

Accounts and tokens

Route Role Purpose
GET /users admin  
POST /users admin {username, display_name, password, role}; an empty password makes a proxy-only account
PATCH /users/{userID} admin any of {username, display_name, role, disabled, password}; role and disabled changes end that user’s sessions; the last enabled administrator cannot be demoted or disabled
DELETE /users/{userID} admin not yourself; not the last administrator
GET /tokens viewer yours; an administrator sees everyone’s
POST /tokens viewer {name, role, ttl_days} → {token, secret} — the only time the secret is returned; role may not exceed yours
DELETE /tokens/{tokenID} viewer revoke yours; an administrator, anyone’s

Agent API — what workers speak

Served by the gateway binary (:8090), the one component meant to be reachable from outside. Workers authenticate with the credential minted at enrollment (X-Worker-Id, X-Worker-Credential); results carry a per-task lease token.

Route Purpose
GET /healthz  
GET /install.sh the one-line VPS install script
POST /agent/v1/enroll {token, hostname, name, capabilities, tools, agent_version} → {worker_id, credential}; a local token redeemed from a public address is downgraded to vps and needs approval
GET /agent/v1/connect WebSocket control channel. Server → worker envelopes: job, cancel, heartbeat_ack, rotate_cred. Worker → server: heartbeats {worker_id, running_tasks, stopping, at}. A worker whose row no longer exists is closed with a policy-violation frame and re-enrols
POST /agent/v1/results a batch of observations for one task: {schema, job_id, task_id, lease_token, seq, final, status, observations, errors}. Observations for assets outside the task’s target are refused and the worker quarantined
POST /agent/v1/artifacts/presign a presigned upload URL for a screenshot or raw output
GET /agent/v1/wordlists every ready list — {name, kind, sha256, size_bytes, url} — so a worker fills its cache at start
GET /agent/v1/wordlists/{sha256} the list itself, streamed from object storage by the gateway; workers never reach the store for lists

A job is {job_id, run_id, task_id, lease_token, stage, profile, targets, params, constraints, ingest}; constraints.allow/deny are the CIDRs the worker may and must not touch, enforced on the worker as well as at ingest.

Provisioner API — internal

Served by the provisioner (:8091), the only container holding the Docker socket. Never exposed; every call carries X-Provisioner-Token.

Route Purpose
GET /v1/workers standing worker containers
POST /v1/scale {count}
POST /v1/remove {name}
POST /v1/fleet/create {run_id, workers, enroll_token, vpn_kind?, vpn_config?} → {gateway, workers, egress_ip}; builds the gateway first and starts workers only once it is healthy
POST /v1/fleet/remove {run_id}
GET /v1/fleet/orphans run ids that still have containers, for the sweep

Known gaps

  • GET /runs/{runID}/events streams nothing. The endpoint and the hub exist, and the UI subscribes to it — but nothing in the codebase publishes to the hub, so no event is ever sent. The UI works because it also polls every four seconds; the stream is decoration. Recorded in TODO.
  • The OpenAPI document is paths-and-auth only. docs/openapi.yaml is generated from the router (go run ./tools/openapi > docs/openapi.yaml) and a test fails the build when it is stale; it carries every path, method, role and security scheme, with this page’s sentences as summaries. Request and response bodies are not typed in it — those live in the tables above.
  • No cursor pagination, no export endpoint. Both were in the original sketch and neither exists.