Skip to content

Latest commit

 

History

History
640 lines (505 loc) · 20.5 KB

server-api.md

File metadata and controls

640 lines (505 loc) · 20.5 KB

SubMan Server API

SubMan Server API is an owner-operated REST API for trusted backend scripts. The first version is designed for automation such as sing-box-vps installing or updating nodes in the SubMan workspace.

The API runs inside the existing SvelteKit app on Cloudflare Workers. It writes to the same Workspace Gist used by the browser UI.

The machine-readable source for schemas, operation IDs, parameters, response headers, and examples is openapi.yaml.

Supported Surface And Compatibility

The supported public API consists of GET /api/health and the node endpoints documented below. The current unversioned paths are the first compatibility generation: compatible changes should remain additive. A future breaking contract must use a new versioned path or an explicitly negotiated version.

Subscriptions, aggregate rules, publication, output files, and client exports do not yet have public REST endpoints. External callers must not PATCH the Gist or call /api/workspaces/:workspaceId/mutations. That route is an internal browser transport with a different credential and mutation envelope and has no public compatibility guarantee.

The staged design for expanding this surface is documented in roadmap.md. It does not change the current supported contract.

Base URL

Use the origin where SubMan is deployed:

https://subman.example.com

Examples below use https://subman.example.com. Replace it with your deployed SubMan domain.

Authentication

All endpoints except GET /api/health require:

Authorization: Bearer <SUBMAN_API_TOKEN>

SUBMAN_API_TOKEN is your own SubMan API token. It is separate from the GitHub token, but it is one shared full-access bearer: there are no scopes, per-client identities, per-client revocation, or built-in caller rate limits. Give it only to trusted backend scripts over TLS, keep it out of URLs and logs, and rotate it periodically. CORS is not an authentication boundary.

Server Configuration

Before using the API in Cloudflare Workers, configure two secrets:

bun wrangler secret put GITHUB_TOKEN
bun wrangler secret put SUBMAN_API_TOKEN

GITHUB_TOKEN must have GitHub gist permission. The Worker uses it to find or create the Workspace Gist and passes it separately to the Workspace coordinator for each request. It is never included in a mutation payload.

SUBMAN_API_TOKEN is an arbitrary long secret value that callers must send in the Authorization header.

Then deploy:

bun run build
bun run deploy

Workspace Behavior

The API uses the same workspace identity as the UI:

  • Gist description: SubMan-Data
  • Workspace file: subman.json

If the Workspace Gist does not exist, the API creates only a bootstrap marker. Each write builds a revisioned node mutation and submits it to the WorkspaceCoordinator Durable Object for that Gist. The coordinator reads the latest document, applies one mutation, and commits subman.json in a verified Gist PATCH. Browser and Server API writes therefore share the same ordering, idempotency, tombstone, and conflict rules.

Public Node API writes are synchronous. A write 2xx response means the coordinator committed and read-back verified the remote Workspace. Browser completion states such as local-durable-queued, peer-owned, and retry-scheduled do not apply to public API callers.

Reads return the observed remote snapshot; they do not commit or publish anything. Workspace discovery is shared with the UI, but the API does not use the browser's saved binding. It searches the Gists accessible to the Worker's GITHUB_TOKEN. With no matching Workspace, even a node GET can create a private bootstrap Gist; V1 and bootstrap reads report revision 0. Multiple valid candidates or invalid matching candidates require operator selection/repair and currently return the sanitized 502 gist_read_failed; the API has no Workspace selector parameter. Check workspace.gistId against the intended Gist before writing. A revision ETag is scoped to that Workspace and does not identify the Gist by itself.

Updating a node does not regenerate existing aggregate or sing-box output files. Review and publish affected outputs separately in the browser.

The first write to a V1 Workspace preserves the exact original bytes as subman.v1.backup.json before committing V2. See docs/workspace-v2-operations.md for migration and rollback procedures.

Common Headers

For JSON requests:

Authorization: Bearer <SUBMAN_API_TOKEN>
Content-Type: application/json
If-Match: "subman-revision-12"

If-Match is optional on writes. Use the strong ETag from a previous successful node response to prevent a stale read-modify-write. If-Match: * means the caller does not require a specific revision. A stale or weak validator returns 412 precondition_failed and the current revision.

Every successful node response includes:

ETag: "subman-revision-13"
X-SubMan-Revision: 13
Cache-Control: no-store

Data Types

Node

{
  "id": "string",
  "name": "vps-1 vless",
  "type": "vless",
  "raw": "vless://...",
  "tags": [
    {
      "id": "sing-box-vps",
      "label": "sing-box-vps"
    }
  ],
  "enabled": true,
  "updatedAt": "2026-05-06T00:00:00.000Z",
  "source": "single"
}

Allowed type values:

  • vless
  • vmess
  • trojan
  • ss
  • ssr
  • hysteria2
  • tuic
  • anytls
  • other

Allowed source values:

  • single
  • subscription

Node Create Body

POST /api/nodes and PUT /api/nodes/by-key/:externalKey accept:

{
  "name": "vps-1 vless",
  "type": "vless",
  "raw": "vless://...",
  "enabled": true,
  "tags": ["sing-box-vps", "auto"],
  "source": "single"
}

Rules:

  • name is required.
  • If another node already has the same name, the API saves the node with a timestamp suffix such as HK 2026-05-26 06:32.
  • type is required and must be one of the allowed proxy types.
  • raw is required.
  • If another node already has the same trimmed raw URI, the API rejects the write with 409 duplicate_node_raw.
  • enabled defaults to true.
  • source defaults to single.
  • tags defaults to an empty array.
  • tags may be strings or { "id": "...", "label": "..." } objects.
  • Strings are trimmed and tags are deduplicated by their normalized label. Unknown body fields are ignored. Send the documented types: create/upsert currently defaults non-boolean enabled, non-array tags, and missing, empty, or non-string source; PATCH validates supplied optional fields.

Node Patch Body

PATCH /api/nodes/:id accepts any subset of the node create body:

{
  "name": "vps-1 renamed",
  "enabled": false
}

Missing fields keep their existing values.

Supplying tags replaces the entire tag list, including an existing external-key marker. To update tags on a machine-managed node while preserving its key, use by-key PUT with the full desired node payload. Do not copy the returned external: marker into request tags; caller-supplied markers are rejected.

Patch requests use the same duplicate handling as create requests: duplicate names are made unique, and duplicate raw URIs on another node are rejected.

Response Envelope

List responses:

{
  "data": [],
  "workspace": {
    "gistId": "gist-id",
    "file": "subman.json",
    "revision": 12
  }
}

Single-node write responses:

{
  "data": {
    "id": "node-id",
    "name": "vps-1 vless",
    "type": "vless",
    "raw": "vless://...",
    "tags": [],
    "enabled": true,
    "updatedAt": "2026-05-06T00:00:00.000Z",
    "source": "single"
  },
  "workspace": {
    "gistId": "gist-id",
    "file": "subman.json",
    "revision": 13
  }
}

Error responses:

{
  "error": {
    "code": "unauthorized",
    "message": "Unauthorized",
    "disposition": "auth-required"
  }
}

Errors always use a stable code and disposition. A GitHub failure may add a bounded gateway object containing only operation, status, category, request ID, retry-after, and rate-limit reset. Raw GitHub response bodies, credentials, exception messages, stacks, and Workspace documents are never returned. A conflict may add only { "workspace": { "revision": 13 } }. Branch on code and disposition, not the human-readable message.

When safe GitHub metadata includes retryAfter, the response also carries the standard Retry-After header. The gateway object is optional: discovery/read failures that occur before coordinator submission may return a sanitized 502 without upstream metadata.

Request And Domain Limits

JSON writes require application/json or a +json media type. The Worker counts streamed bytes rather than trusting only Content-Length; the request limit is 9 MiB. Invalid media type, malformed JSON, and oversize bodies return stable 415, 400, and 413 errors respectively.

Created or edited values use UTF-8/count limits: node raw 16 KiB, subscription URL 8 KiB, names 256 bytes, labels 128 bytes, external keys 256 bytes, 64 tags per entity, 5,000 entities per collection, rename maps of at most 1,000 entries and 64 KiB, output content 1 MiB, and canonical Workspace documents 8 MiB. Unchanged oversized legacy fields remain readable and may be reduced; unrelated changes do not reject them.

For by-key writes the generated external: prefix takes 9 bytes of the 128-byte label limit. New or changed keys therefore fit 119 UTF-8 bytes, and caller tags must leave one of the 64 slots for that marker (63 distinct labels). The mutation protocol also has a 256-byte external-key limit; it does not bypass the resulting tag limit. Domain limits return 400 invalid_mutation, not HTTP 413. Growing an already oversized Workspace returns 500 workspace_size_limit with operator-repair. Limits on writes do not constrain readable legacy responses to the same sizes.

Endpoints

Health Check

GET /api/health

Authentication: not required.

Returns whether the required server-side secrets are configured. Secret values are never returned.

HTTP status is always 200, including ok: false. ok: true does not validate GitHub permissions, Workspace discovery, or the coordinator binding.

Example:

curl -sS "https://subman.example.com/api/health"

Response:

{
  "ok": true,
  "config": {
    "githubToken": true,
    "submanApiToken": true
  }
}

List Nodes

GET /api/nodes

Example:

curl -sS "https://subman.example.com/api/nodes" \
  -H "Authorization: Bearer $SUBMAN_API_TOKEN"

Response:

{
  "data": [
    {
      "id": "node-id",
      "name": "vps-1 vless",
      "type": "vless",
      "raw": "vless://...",
      "tags": [],
      "enabled": true,
      "updatedAt": "2026-05-06T00:00:00.000Z",
      "source": "single"
    }
  ],
  "workspace": {
    "gistId": "gist-id",
    "file": "subman.json",
    "revision": 12
  }
}

Create Node

POST /api/nodes

Creates a new node every time unless the submitted raw URI already exists. If the submitted name already exists, the saved name receives a timestamp suffix. For installer scripts, prefer PUT /api/nodes/by-key/:externalKey to avoid creating separate records for the same machine-managed node.

Example:

curl --fail-with-body -sS -X POST "https://subman.example.com/api/nodes" \
  -H "Authorization: Bearer $SUBMAN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"vps-1 vless","type":"vless","raw":"vless://...","enabled":true,"tags":["manual"]}'

Success status: 201 Created.

Get Node

GET /api/nodes/:id

Example:

curl -sS "https://subman.example.com/api/nodes/node-id" \
  -H "Authorization: Bearer $SUBMAN_API_TOKEN"

Update Node

PATCH /api/nodes/:id

Example:

curl --fail-with-body -sS -X PATCH "https://subman.example.com/api/nodes/node-id" \
  -H "Authorization: Bearer $SUBMAN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "subman-revision-12"' \
  -d '{"enabled":false,"name":"vps-1 disabled"}'

Delete Node

DELETE /api/nodes/:id

Deletes the node and removes its id from aggregate rule nodeIds.

Example:

curl --fail-with-body -sS -X DELETE "https://subman.example.com/api/nodes/node-id" \
  -H "Authorization: Bearer $SUBMAN_API_TOKEN"

Response:

{
  "data": {
    "deleted": true
  },
  "workspace": {
    "gistId": "gist-id",
    "file": "subman.json",
    "revision": 13
  }
}

Upsert Node By External Key

PUT /api/nodes/by-key/:externalKey

This is the recommended endpoint for automation scripts. One stable externalKey addresses one node, so repeated calls update that resource instead of creating duplicates. This is resource-identity idempotency, not request replay idempotency: each successful update may change updatedAt and advance the Workspace revision. The API does not support Idempotency-Key.

The PUT payload replaces all writable fields; omitted enabled, source, and tags take their create defaults rather than keeping the existing values. The key marker is added by the coordinator. Omitting other ordinary tags removes them. Deleting the node removes its key marker; a later by-key PUT can create a new node ID, while the original deleted ID remains tombstoned.

It still follows normal node validation: duplicate names are made unique, and a raw URI that already belongs to a different node is rejected with 409 duplicate_node_raw.

The API stores the external key as a hidden-style tag label:

external:<externalKey>

Choose a stable externalKey, for example:

  • vps-1-vless
  • hostname-vless-reality
  • server-id-protocol-port

URL-encode the key exactly once as a path segment. The framework decodes it once; literal % and strings such as %2F remain part of the key. Matching is case-sensitive after trimming. Keep a new or changed key within 119 UTF-8 bytes. The external: tag label namespace is reserved for this identity mapping; do not use it for unrelated caller tags.

Example:

curl --fail-with-body -sS -X PUT "https://subman.example.com/api/nodes/by-key/vps-1-vless" \
  -H "Authorization: Bearer $SUBMAN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"vps-1 vless","type":"vless","raw":"vless://...","enabled":true,"tags":["sing-box-vps"]}'

Shell Integration Example

#!/usr/bin/env bash
set -euo pipefail

SUBMAN_BASE_URL="https://subman.example.com"
SUBMAN_API_TOKEN="${SUBMAN_API_TOKEN:?SUBMAN_API_TOKEN is required}"

NODE_HOSTNAME="$(hostname)"
NODE_KEY="${NODE_HOSTNAME}-vless-reality"
NODE_NAME="${NODE_HOSTNAME} vless reality"
NODE_RAW="vless://..."

PAYLOAD="$(jq -cn \
  --arg name "${NODE_NAME}" \
  --arg raw "${NODE_RAW}" \
  '{name: $name, type: "vless", raw: $raw, enabled: true, tags: ["sing-box-vps", "auto"]}')"

curl --fail-with-body -sS -X PUT "${SUBMAN_BASE_URL}/api/nodes/by-key/${NODE_KEY}" \
  -H "Authorization: Bearer ${SUBMAN_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data-binary "${PAYLOAD}"

Use a URL encoder when NODE_KEY is not already limited to unreserved URL characters. A JSON serializer such as jq is required when values are dynamic; do not build JSON by string interpolation.

TypeScript Integration Example

type SubManResult = {
  error?: { code: string; disposition: string };
};

const response = await fetch(
  `${submanOrigin}/api/nodes/by-key/${encodeURIComponent(externalKey)}`,
  {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${submanApiToken}`,
      "Content-Type": "application/json",
      ...(previousEtag ? { "If-Match": previousEtag } : {}),
    },
    body: JSON.stringify(node),
  },
);
const result = (await response.json()) as SubManResult;
if (!response.ok) {
  if (!result.error) throw new Error(`Unexpected HTTP ${response.status}`);
  throw new Error(`${result.error.code}: ${result.error.disposition}`);
}
const committedEtag = response.headers.get("etag");

Production integrations should map stable codes/dispositions to typed outcomes instead of throwing a generic error as this compact example does.

Retry Matrix

Method Known non-commit failure Unknown outcome or lost response
GET Retry retryable-upstream with bounded backoff. Retry safely.
PUT .../by-key Retry only confirmed pre-write retryable failures; honor Retry-After. GET the list and locate the exact external:<key> tag first. Blind replay can advance revision again.
PATCH Re-read on state conflict; retry only after reapplying intent. GET first and compare the node plus revision.
POST Correct validation/domain errors before a new request. Do not blindly retry; a node may already exist.
DELETE Resolve tombstone/not-found responses as current state. GET first; repeated delete is not request-idempotent.

The API does not accept Idempotency-Key. If-Match protects optimistic concurrency but does not make an unknown-outcome request safe to replay.

A write error, including a GitHub timeout or 5xx after PATCH, may occur after GitHub accepted the change. retryable-upstream describes failure handling, not proof that no write occurred. For uncertain writes, re-read the node(s) and revision before deciding to resubmit; verification/index failures need operator review. Preserve safe retry timing while checking. There is no GET-by-key route.

Status Codes

Status Code Meaning
200 - Read, update, delete, or upsert succeeded.
201 - Node created by POST /api/nodes.
400 bad_request Invalid JSON body or unsupported field value.
400 invalid_json The request body is missing, malformed, or invalid UTF-8 JSON.
400 invalid_mutation A node mutation or resulting edited value violates a schema/domain limit.
400 invalid_workspace_document / invalid_bootstrap_marker Stored Workspace data requires operator repair.
401 unauthorized Missing or invalid SUBMAN_API_TOKEN.
401 / 403 gist_read_failed / gist_write_failed GitHub authentication or permission failed; reconnect or repair the Worker secret.
404 not_found Requested node id does not exist.
404 entity_not_found A write targeted a node that does not exist.
404 workspace_not_found The configured Workspace Gist no longer exists.
412 precondition_failed If-Match does not match the current Workspace revision.
409 duplicate_node_raw Submitted raw URI already belongs to another node.
409 revision_conflict Another mutation committed after this request loaded the Workspace; retry from the latest revision.
409 entity_deleted A stale write attempted to restore a tombstoned entity.
409 migration_backup_conflict The existing V1 backup does not match the current V1 config.
422 unsupported_schema The Workspace uses a newer unsupported schema.
413 payload_too_large The streamed JSON body exceeds 9 MiB.
415 unsupported_media_type The request is not JSON.
429 gist_read_failed / gist_write_failed GitHub rate limited the operation; honor safe retry metadata.
500 server_error A required Worker secret or coordinator binding is missing, or an unexpected failure occurred.
500 workspace_size_limit / commit_index_failed / invalid_journal_record Workspace size or coordinator state requires operator repair; a commit may already exist.
502 gist_read_failed / gist_write_failed / write_verification_failed Discovery, network, or upstream verification failed; a write may already have committed.
504 gist_read_failed / gist_write_failed A bounded GitHub request timed out.

Operational Notes

  • Keep GITHUB_TOKEN only in Cloudflare Secrets.
  • Give backend scripts only SUBMAN_API_TOKEN.
  • Rotate SUBMAN_API_TOKEN periodically and immediately if a script host is compromised.
  • The Durable Object serializes concurrent mutations and rejects stale revisions, but GitHub Gist remains a low-frequency storage backend.
  • Use bounded backoff and safe Retry-After/rate-limit metadata. A revision_conflict or precondition_failed is state-conflict: reload current state and deliberately reapply the intent.
  • Before retrying a write, establish whether it committed; an upstream error disposition alone is not non-commit evidence.
  • GET is safe to retry. After an unknown write outcome, re-read before acting: PUT .../by-key can advance revision again, PATCH can reapply a change, POST can create another node, and repeated DELETE can return a tombstone or not-found conflict.
  • CORS is intentionally not opened for arbitrary browser origins and must not be treated as access control.