diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml new file mode 100644 index 0000000..4ddefa4 --- /dev/null +++ b/.github/workflows/contract.yml @@ -0,0 +1,27 @@ +name: Contract + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Enable Yarn Classic + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + - run: yarn install --frozen-lockfile + - run: yarn check:contract + - run: yarn exec hexo clean && yarn exec hexo generate diff --git a/_config.cackle.yml b/_config.cackle.yml index 4efe1a6..3afe3dc 100644 --- a/_config.cackle.yml +++ b/_config.cackle.yml @@ -1,28 +1,46 @@ -# dae API - Cackle Theme Configuration - # 侧边栏导航 menu: - title: Overview file: index + - title: Discovery + file: docs/discovery - title: Version file: docs/version + - title: Capabilities + file: docs/capabilities - title: API Configuration file: docs/api-config - - title: Runtime Status + - title: Errors + file: docs/errors + - title: Operations + file: docs/operations + - title: Runtime file: docs/runtime-status - - title: Node Latency + - title: Runtime Memory + file: docs/runtime-memory + - title: Datapath + file: docs/datapath + - title: Nodes file: docs/node-latency - - title: Check Nodes + - title: Probes file: docs/check-nodes - title: Groups file: docs/groups - title: Connections file: docs/connections + - title: Recorded Flows + file: docs/flows + - title: Routing Simulation + file: docs/routing-trace + - title: Events + file: docs/events + - title: honk Implementation Evidence + file: docs/honk-mapping - title: DNS Query file: docs/dns-query - title: DNS Cache file: docs/dns-cache - - title: Configuration + - title: Configuration (Deferred) file: docs/configuration - title: Reload file: docs/reload @@ -49,7 +67,7 @@ language_negotiation: # 品牌标识 brand: logo: i-menu-book - text: dae API + text: dae/honk API favicon: /images/favicon.svg # 首页内容 @@ -58,23 +76,23 @@ home: - hero - features - versions - hero_badge: dae API - hero_title: dae API Documentation - hero_subtitle: REST API for dae transparent proxy + hero_badge: dae/honk API + hero_title: dae/honk Native API + hero_subtitle: Per-flow decisions, runtime evidence, and capability-driven control hero_cta: Get Started versions_title: Versions versions_subtitle: Documentation versions features: - icon: i-history - title: Runtime Statistics - desc: Monitor real-time runtime statistics + title: Native API + desc: Draft resources, operations, errors, and compatibility rules - icon: i-light-mode - title: Node Latency - desc: Check latency of each node + title: Datapath Semantics + desc: Keep direct, proxy, DNS, and visibility semantics explicit - icon: i-search title: DNS Cache - desc: Query and manage DNS cache + desc: Inspect, flush, and delete cache entries when the engine exposes it # 底部信息 footer: - text: dae © 2026 · Built with Hexo + text: dae/honk © 2026 · Built with Hexo diff --git a/_config.yml b/_config.yml index 192b61d..2ff24f4 100644 --- a/_config.yml +++ b/_config.yml @@ -33,6 +33,7 @@ category_dir: categories code_dir: downloads/code i18n_dir: :lang skip_render: + - openapi.yaml # Writing new_post_name: :title.md # File name of new posts diff --git a/api/common.yaml b/api/common.yaml new file mode 100644 index 0000000..5ced810 --- /dev/null +++ b/api/common.yaml @@ -0,0 +1,375 @@ +securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + description: | + Bearer authentication is mandatory on every request except CORS preflights + whenever a deployment secret is set. + The anonymous security alternative is permitted only on an explicitly + secretless loopback listener, never as a fallback for invalid credentials. + Native permissions are expressed by x-permission; see API Configuration. +headers: + NoStore: + description: Native responses are not cacheable. + required: true + schema: + type: string + const: no-store + NoSniff: + description: Prevent MIME-type sniffing. + required: true + schema: + type: string + const: nosniff + ETag: + description: Quoted group configuration revision. + required: true + schema: + type: string + minLength: 1 +parameters: + Detail: + name: detail + in: query + schema: + type: string + enum: [ summary, full ] + default: summary + example: full + Limit1000: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + example: 100 + Cursor: + name: cursor + in: query + description: | + Opaque cursor bound to the resource, running adapter instance, filters, + and retained snapshot. Restart, changed filters, or snapshot expiry or + eviction invalidates it. GET /flows returns 410 snapshot_expired; + GET /nodes and GET /dns/cache return 400 invalid_request for a cursor + that is unknown or no longer valid. Discard it and restart the page walk + without a cursor; never silently continue against a new snapshot. + schema: + type: string + minLength: 1 + GroupId: + name: groupId + in: path + required: true + schema: + type: string + minLength: 1 + example: group-proxy + FlowId: + name: flow_id + in: path + required: true + schema: + type: string + minLength: 1 + example: flow-23 + DnsEntryId: + name: entry_id + in: path + required: true + schema: + type: string + minLength: 1 + example: dns-entry-01HZX4K8W5 + OperationId: + name: id + in: path + required: true + schema: + type: string + minLength: 1 + example: op-01HZX4K8W7 + IfMatch: + name: If-Match + in: header + required: true + schema: + type: string + minLength: 1 + example: '"17"' + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + schema: + type: string + minLength: 1 + LastEventId: + name: Last-Event-ID + in: header + required: false + schema: + type: string + minLength: 1 + example: instance-7:123 +responses: + OperationAccepted: + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/OperationAccepted + BadRequest: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Malformed parameters or request shape + Unauthorized: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Credentials are missing or invalid + Forbidden: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Authenticated caller lacks the required permission + NotFound: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Resource/action unavailable, absent, expired, or concealed + Conflict: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: State, idempotency, cursor, or coherent-snapshot conflict + Gone: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Flow or paginated snapshot retention expired + PreconditionFailed: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: If-Match does not equal the current configuration revision or on-disk source content hash + TooLarge: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Request size or advertised fan-out limit exceeded + UnsupportedMediaType: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Unsupported request Content-Type + Unprocessable: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Unsupported field, value, or transition, or error diagnostics from configuration replacement validation + PreconditionRequired: + $ref: ./openapi.yaml#/components/responses/ErrorResponseCommon + description: Required If-Match is absent + RateLimited: + description: Advertised request or operation rate exceeded + headers: + Retry-After: + description: Retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + Unavailable: + description: Bounded queue or required runtime component unavailable + headers: + Retry-After: + description: Retry delay in seconds when retryable. + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + ErrorResponseCommon: + description: Native API error + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse +schemas: + EmptyObject: + type: object + additionalProperties: false + maxProperties: 0 + SafeUInt: + type: integer + minimum: 0 + maximum: 9007199254740991 + NullableSafeUInt: + type: [ integer, "null" ] + minimum: 0 + maximum: 9007199254740991 + UInt64: + type: string + maxLength: 20 + not: + pattern: "[^0-9]" + pattern: ^(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)$ + description: Canonical unsigned 64-bit decimal string, 0 through 18446744073709551615. Never a JSON number. + NullableUInt64: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/UInt64 + Timestamp: + type: string + format: date-time + NullableTimestamp: + type: [ string, "null" ] + format: date-time + ErrorCode: + type: string + description: >- + Closed catalogue of HTTP error codes (docs/errors.md keeps the prose). Adding a + code is a contract change; adapters never invent codes. Errors embedded in + resources (operation.error, datapath.errors, lifecycle.last_error) carry an + adapter-defined code and stay plain SafeError. + enum: + - invalid_request + - authentication_required + - permission_denied + - resource_not_found + - capability_not_supported + - state_conflict + - idempotency_conflict + - event_cursor_expired + - snapshot_unavailable + - snapshot_expired + - flow_expired + - stale_revision + - request_too_large + - unsupported_media_type + - unsupported_value + - precondition_required + - rate_limited + - temporarily_unavailable + SafeError: + type: object + required: [ code, message ] + properties: + code: + type: string + minLength: 1 + message: + type: string + minLength: 1 + details: + type: [ object, "null" ] + description: Safe structured error; never raw engine output. + ApiError: + allOf: + - $ref: ./openapi.yaml#/components/schemas/SafeError + - type: object + properties: + code: + $ref: ./openapi.yaml#/components/schemas/ErrorCode + description: HTTP error body; the code comes from ErrorCode. + ErrorResponse: + type: object + required: [ error, request_id ] + properties: + error: + $ref: ./openapi.yaml#/components/schemas/ApiError + request_id: + type: [ string, "null" ] + ConfigDiagnostic: + type: object + required: [ level, source_id, line, column, span, code, message ] + description: | + Safe diagnostic for an accepted or candidate source, never raw parser output. + Coordinates refer to the original source before redaction. Use null for + unknown locations; do not fabricate positions from setting names. + properties: + level: + type: string + enum: [ error, warning, info ] + source_id: + type: string + minLength: 1 + description: Source ID in the effective snapshot, validation request, or source set being validated for replacement. + line: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + minimum: 1 + description: One-based line, or null if unknown. + column: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + minimum: 1 + description: One-based UTF-8 byte column, or null if unknown; not a character or UTF-16 offset. + span: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/ConfigDiagnosticSpan + code: + type: string + minLength: 1 + description: Adapter-defined diagnostic code, independent of the HTTP ErrorCode catalogue. + message: + type: string + minLength: 1 + description: Safe operator-facing description; never source excerpts, credentials, private paths, or raw engine errors. + ConfigDiagnosticSpan: + type: object + required: [ start_line, start_column, end_line, end_column ] + description: | + One-based lines and UTF-8 byte columns; start inclusive, end exclusive. + The end must not precede the start. A zero-width span is permitted. + When line and column are known, they equal the span start. + properties: + start_line: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + start_column: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + end_line: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + end_column: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + Visibility: + type: string + enum: [ full, partial, none ] + ObservedBy: + type: string + enum: [ userspace, ebpf, mixed ] + Transport: + type: string + enum: [ tcp, udp ] + IpVersion: + type: string + enum: [ ipv4, ipv6 ] + IpAddress: + oneOf: + - type: string + format: ipv4 + - type: string + format: ipv6 diff --git a/api/config.yaml b/api/config.yaml new file mode 100644 index 0000000..7fbe69c --- /dev/null +++ b/api/config.yaml @@ -0,0 +1,916 @@ +paths: + /api/v1/config: + get: + operationId: getConfig + summary: Read the effective configuration + description: | + Requires resources.config.available. Returns one coherent snapshot of the + accepted sources and retained diagnostics for generation_id and revision, + not a fresh read of files that may have changed since loading. The source + set is complete and bounded by resources.config.max_sources; never truncate it. + Omit source content unless resources.config.content is true. Apply visibility + filters to paths, content and diagnostics; observe never grants raw secrets. + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + "200": + description: Effective configuration snapshot + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/EffectiveConfig + examples: + redacted: + summary: Content withheld; the SHA-256 value is an illustrative placeholder. + value: + generation_id: generation-42 + revision: "17" + sources: + - id: source-main + path: + kind: main + content_sha256: "0000000000000000000000000000000000000000000000000000000000000000" + bytes: 128 + writable: true + loaded_at: 2026-08-15T09:30:00Z + line_count: 8 + diagnostics: + - level: warning + source_id: source-main + line: null + column: null + span: null + code: deprecated_setting + message: A deprecated setting was accepted. + secrets_redacted: true + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + editable: + summary: Unredacted dae text when resources.config.content is true. + value: + generation_id: generation-42 + revision: "17" + sources: + - id: source-main + path: config.dae + kind: main + content_sha256: d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1 + bytes: 31 + writable: true + loaded_at: 2026-08-15T09:30:00Z + content: "routing {\n fallback: direct\n}\n" + line_count: 3 + diagnostics: [] + secrets_redacted: false + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + description: Credentials are missing or invalid + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + authentication_required: + value: + error: { code: authentication_required, message: Credentials are required. } + request_id: request-config-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "403": + description: Authenticated caller lacks observe permission + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + permission_denied: + value: + error: { code: permission_denied, message: Observe permission is required. } + request_id: request-config-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "404": + description: Configuration readback is unavailable + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + capability_not_supported: + value: + error: { code: capability_not_supported, message: Configuration readback is unavailable. } + request_id: request-config-3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "429": + description: Advertised request rate exceeded + headers: + Retry-After: + description: Retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + rate_limited: + value: + error: { code: rate_limited, message: Request rate exceeded. } + request_id: request-config-4 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 + /api/v1/config/sources/{source_id}: + parameters: + - name: source_id + in: path + required: true + description: Opaque ID of an accepted source, never a path supplied by the caller. + schema: + type: string + minLength: 1 + example: source-main + get: + operationId: getConfigSource + summary: Read one accepted configuration source + description: | + Requires resources.config.available. Returns the same ConfigSource as + GET /config, not a fresh read of disk. Include optional content only when + resources.config.content is true; apply the same path and secret redaction. + Unknown source IDs return 404 resource_not_found. Redacted text must never + be saved as a replacement; compare its UTF-8 SHA-256 with content_sha256 + before using returned content as an editing representation. + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + "200": + description: Accepted source; content remains subject to visibility policy + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ConfigSource + examples: + editable: + summary: Unredacted dae text when resources.config.content is true. + value: + id: source-main + path: config.dae + kind: main + content_sha256: d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1 + bytes: 31 + writable: true + loaded_at: 2026-08-15T09:30:00Z + content: "routing {\n fallback: direct\n}\n" + line_count: 3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + redacted: + value: + id: source-main + path: + kind: main + content_sha256: "0000000000000000000000000000000000000000000000000000000000000000" + bytes: 128 + writable: true + loaded_at: 2026-08-15T09:30:00Z + line_count: 8 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + description: Unknown source ID or unavailable configuration readback + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + resource_not_found: + value: + error: { code: resource_not_found, message: Configuration source not found. } + request_id: request-source-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + capability_not_supported: + value: + error: { code: capability_not_supported, message: Configuration readback is unavailable. } + request_id: request-source-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + put: + operationId: replaceConfigSource + summary: Replace one configuration source and reload + description: | + Requires control, resources.config.available, resources.config.writable, + and writable: true on the accepted source. A disabled write switch or a + read-only source returns 403 permission_denied. Generated and subscription + sources are never writable. Unknown IDs return 404 resource_not_found. + The body replaces the complete source with UTF-8 dae text; no partial + patches, caller-supplied file paths, or multi-source writes are accepted. + Enforce resources.config.max_bytes on replacement UTF-8 bytes and the + shared limits.max_json_body_bytes independently; excess returns 413. + Require If-Match before validating or writing: missing returns 428 + precondition_required; a hash different from the current on-disk bytes + returns 412 stale_revision, even if it matches the accepted snapshot. + Validate the resulting source set with the same full-mode checks as + POST /config/validate, substituting the replacement for this source. + Resolve dependencies only from submitted text, authorized local files, + and cached data; never use the network or refresh caches during validation. + Missing or inaccessible dependencies produce error diagnostics. + If any diagnostic has level error, return 422 unsupported_value with + error.details.diagnostics using ConfigDiagnostic; never write any file + or start a reload. Warnings and info alone do not prevent a write. + Otherwise atomically replace the file using a temporary file in the same + directory and rename, preserving its mode. Serialize the hash check, + validation, and replacement against concurrent API writes; recheck the + on-disk hash before replacement and return 412 if it changed. + After the write, start a reload operation and return 202 OperationAccepted + with kind reload, Location, and Retry-After. This is not reload completion. + A successful reload publishes generation.changed when events are available; + GET /config then shows the new accepted content_sha256. If reload fails, + the previous generation remains active; the file write is not rolled back. + Idempotency-Key follows the operation retention rules: scope it to caller, + method, and path in this instance. A retained same-body replay returns the + original operation without another write or hash check; a different body + returns 409 idempotency_conflict. + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - name: If-Match + in: header + required: true + description: | + One strong entity tag containing the source's content_sha256 from + GET /config, enclosed in double quotes. Compare the digest with the + current on-disk content, not the snapshot revision. Wildcards, weak + tags, and tag lists are not accepted. + schema: + type: string + pattern: '^"[0-9a-f]{64}"$' + example: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + - $ref: ./openapi.yaml#/components/parameters/IdempotencyKey + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [ content ] + properties: + content: + type: string + description: Complete UTF-8 dae source text; empty text is validated, not rejected as malformed. + examples: + replacement: + value: + content: "routing {\n fallback: block\n}\n" + x-headers: + Content-Type: application/json + If-Match: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + invalid: + value: + content: "routing {\n" + x-headers: + Content-Type: application/json + If-Match: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + responses: + "202": + description: Source written atomically and reload accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationAccepted + - type: object + properties: + kind: + const: reload + examples: + queued: + value: + operation_id: op-config-01 + kind: reload + status: queued + href: /api/v1/operations/op-config-01 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-config-01 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + description: Control permission is absent, editing is disabled, or the source is read-only + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + permission_denied: + value: + error: { code: permission_denied, message: Configuration source is not writable. } + request_id: request-write-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "404": + $ref: ./config.yaml#/paths/~1api~1v1~1config~1sources~1{source_id}/get/responses/404 + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "412": + description: If-Match does not match the current on-disk content hash; nothing is written + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + stale_revision: + value: + error: { code: stale_revision, message: Source changed on disk; reconcile before retrying. } + request_id: request-write-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "413": + $ref: ./openapi.yaml#/components/responses/TooLarge + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "422": + description: Full validation found error diagnostics; no file is written and no reload starts + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + allOf: + - $ref: ./openapi.yaml#/components/schemas/ErrorResponse + - type: object + properties: + error: + type: object + required: [ details ] + properties: + code: + const: unsupported_value + details: + type: object + required: [ diagnostics ] + properties: + diagnostics: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/ConfigDiagnostic + contains: + type: object + required: [ level ] + properties: + level: + const: error + examples: + invalid: + value: + error: + code: unsupported_value + message: Configuration validation failed. + details: + diagnostics: + - level: error + source_id: source-main + line: 1 + column: 9 + span: { start_line: 1, start_column: 9, end_line: 1, end_column: 10 } + code: unclosed_block + message: Block is not closed. + request_id: request-write-3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "428": + description: If-Match is required; nothing is written + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + precondition_required: + value: + error: { code: precondition_required, message: If-Match is required. } + request_id: request-write-4 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + /api/v1/config/validate: + post: + operationId: validateConfig + summary: Validate candidate configuration without applying it + description: | + Requires resources.config_validate.available and control because the body + may contain secrets. Syntax mode parses only submitted text. Full mode also + checks semantics and resolves includes/subscriptions from submitted sources + or adapter-authorized local files and cached data, never from the network. + A missing or inaccessible dependency produces an error diagnostic, not a + successful partial validation. Neither mode writes files, refreshes caches, + applies configuration, publishes a generation, or starts an operation. + Return 200 for completed validation, including invalid candidates. Malformed + JSON/request shape or duplicate effective source IDs returns 400; an + unadvertised mode returns 422 unsupported_value. Enforce max_bytes over the + sum of UTF-8 source bytes and max_sources over the source count, including + locally resolved dependencies in full mode; exceeding either returns 413. + The shared max_json_body_bytes limit applies independently to the HTTP body. + Never echo candidate text, secrets, or private paths in diagnostics or errors. + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ConfigValidationRequest + examples: + syntax_error: + value: + sources: + - id: candidate-main + content: "global {\n" + mode: syntax + full: + value: + sources: + - content: "routing {\n fallback: direct\n}\n" + mode: full + responses: + "200": + description: Completed dry-run validation; valid is not an apply guarantee + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ConfigValidationResult + examples: + invalid: + value: + valid: false + diagnostics: + - level: error + source_id: candidate-main + line: 1 + column: 8 + span: { start_line: 1, start_column: 8, end_line: 1, end_column: 9 } + code: unclosed_block + message: Block is not closed. + generation_id: generation-42 + validated_at: 2026-08-15T10:00:00Z + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + valid: + value: + valid: true + diagnostics: [] + generation_id: generation-42 + validated_at: 2026-08-15T10:00:00Z + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + description: Malformed JSON, invalid request shape, or duplicate effective source IDs + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + invalid_request: + value: + error: { code: invalid_request, message: Source IDs must be unique. } + request_id: request-validate-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + description: Credentials are missing or invalid + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + authentication_required: + value: + error: { code: authentication_required, message: Credentials are required. } + request_id: request-validate-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "403": + description: Authenticated caller lacks control permission + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + permission_denied: + value: + error: { code: permission_denied, message: Control permission is required. } + request_id: request-validate-3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "404": + description: Configuration validation is unavailable + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + capability_not_supported: + value: + error: { code: capability_not_supported, message: Configuration validation is unavailable. } + request_id: request-validate-4 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "413": + description: Source bytes, source count, or JSON body exceeds an advertised limit + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + too_many_bytes: + value: + error: { code: request_too_large, message: Source bytes exceed the advertised max_bytes. } + request_id: request-validate-5 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + too_many_sources: + value: + error: { code: request_too_large, message: Source count exceeds the advertised max_sources. } + request_id: request-validate-6 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "415": + description: Unsupported request Content-Type + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + unsupported_media_type: + value: + error: { code: unsupported_media_type, message: Content-Type must be application/json. } + request_id: request-validate-7 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "422": + description: Requested mode is not advertised by resources.config_validate.modes + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + unsupported_value: + value: + error: { code: unsupported_value, message: Requested validation mode is unsupported. } + request_id: request-validate-8 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "429": + description: Advertised request rate exceeded + headers: + Retry-After: + description: Retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + rate_limited: + value: + error: { code: rate_limited, message: Request rate exceeded. } + request_id: request-validate-9 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 +schemas: + EffectiveConfig: + type: object + required: [ generation_id, revision, sources, diagnostics, secrets_redacted ] + properties: + generation_id: + type: string + minLength: 1 + description: Running generation whose accepted sources and diagnostics are returned. + revision: + type: string + minLength: 1 + description: Opaque configuration revision used by Runtime.generation.config_revision and GroupSummary.config_revision; preserve without numeric parsing. + sources: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/ConfigSource + diagnostics: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/ConfigDiagnostic + secrets_redacted: + type: boolean + description: True when content is withheld or paths, text, or diagnostic messages are redacted under visibility policy. + ConfigSource: + type: object + required: [ id, path, kind, content_sha256, bytes, writable, loaded_at, line_count ] + properties: + id: + type: string + minLength: 1 + description: Unique opaque source ID within this configuration snapshot; never a credential-bearing path or URL. + path: + type: string + minLength: 1 + description: Display path only, replaced with when hidden by visibility policy; not a file-access capability. + kind: + type: string + enum: [ main, include, subscription, generated ] + content_sha256: + type: string + pattern: ^[0-9a-f]{64}$ + minLength: 64 + maxLength: 64 + description: Lowercase SHA-256 of the accepted source bytes before redaction; not necessarily the digest of displayed content. + bytes: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + description: Accepted source size in bytes before redaction. + writable: + type: boolean + description: | + True only when server-wide editing is enabled and this source permits + replacement by a control caller. False for engine-written includes, + generated sources, and subscriptions; observe alone never grants writes. + loaded_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + description: Time these source bytes were accepted, not the current file modification time. + content: + type: string + description: Optional dae text, only when resources.config.content is true; still subject to secret redaction. Use for editing only if its UTF-8 SHA-256 matches content_sha256. + line_count: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + description: Lines in the accepted source before redaction; empty text has zero lines, and a final newline does not add an empty line. + if: + properties: + kind: + enum: [ subscription, generated ] + required: [ kind ] + then: + properties: + writable: + const: false + ConfigValidationMode: + type: string + enum: [ syntax, full ] + ConfigValidationSource: + type: object + additionalProperties: false + required: [ content ] + properties: + id: + type: string + minLength: 1 + description: Request-local diagnostic ID. If omitted, use source-N where N is the one-based array index; all effective IDs must be unique. Never put secrets in IDs. + path: + type: string + minLength: 1 + description: Optional engine-native source name and include-resolution base within the adapter's authorized local roots; never grants arbitrary file access. + content: + type: string + description: Candidate engine-native source text; empty text is a candidate, not a malformed request. + ConfigValidationRequest: + type: object + additionalProperties: false + required: [ sources, mode ] + properties: + sources: + type: array + minItems: 1 + description: Ordered candidate sources; the first is the main source. Supplied content takes precedence over local files at the same resolved path. + items: + $ref: ./openapi.yaml#/components/schemas/ConfigValidationSource + mode: + $ref: ./openapi.yaml#/components/schemas/ConfigValidationMode + ConfigValidationResult: + type: object + required: [ valid, diagnostics, generation_id, validated_at ] + properties: + valid: + type: boolean + description: True exactly when validation completed without error diagnostics; warnings and info do not invalidate the candidate. No promise that a later apply will succeed. + diagnostics: + type: array + description: Source IDs identify submitted sources. Attribute a dependency failure to the referring submitted source and its include/subscription location, not an undisclosed local path. + items: + $ref: ./openapi.yaml#/components/schemas/ConfigDiagnostic + generation_id: + type: string + minLength: 1 + description: Running generation captured when validation starts, for context only; not a new candidate generation or an apply precondition. + validated_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + description: Time validation completed. + if: + properties: + valid: + const: true + then: + properties: + diagnostics: + not: + contains: + properties: + level: + const: error + required: [ level ] + else: + properties: + diagnostics: + contains: + properties: + level: + const: error + required: [ level ] diff --git a/api/discovery.yaml b/api/discovery.yaml new file mode 100644 index 0000000..bdc03b0 --- /dev/null +++ b/api/discovery.yaml @@ -0,0 +1,677 @@ +paths: + /api: + get: + operationId: getDiscovery + summary: Discover the native API + security: [] + responses: + "200": + description: Native API discovery + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Discovery + examples: + draft: + value: + name: dae/honk-native + status: draft + api_major: 1 + base_path: /api/v1 + links: + version: /api/v1/version + capabilities: /api/v1/capabilities + config: /api/v1/config + config_validate: /api/v1/config/validate + runtime: /api/v1/runtime + runtime_outbounds: /api/v1/runtime/outbounds + traffic_history: /api/v1/runtime/traffic/history + operations: /api/v1/operations/{id} + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + /api/v1/version: + get: + operationId: getVersion + summary: Read native and engine version identity + security: [] + responses: + "200": + description: Version identity + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Version + examples: + build: + value: + api: + name: dae/honk-native + major: 1 + status: draft + engine: + name: honk + version: 0.0.1-alpha + build: + revision: abc1234 + target: x86_64-unknown-linux-gnu + built_at: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + /api/v1/capabilities: + get: + operationId: getCapabilities + summary: Negotiate resources, visibility, and limits + security: [] + responses: + "200": + description: Adapter capabilities + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Capabilities + examples: + available: + value: + observed_at: 2026-08-15T10:00:00Z + profiles: [ base ] + limits: + max_request_target_bytes: 4096 + max_header_bytes: 16384 + max_json_body_bytes: 65536 + resources: + config: + available: true + content: false + writable: true + max_bytes: 65536 + max_sources: 32 + config_validate: + available: true + modes: [ syntax, full ] + max_bytes: 65536 + max_sources: 32 + runtime: + available: true + runtime_memory: + available: true + metrics: [ process.rss_bytes, cgroup.current_bytes, cgroup.limit_bytes, cgroup.events.high, cgroup.events.oom, cgroup.events.oom_kill ] + runtime_outbounds: + available: true + traffic_history: + available: true + max_window_seconds: 3600 + max_points: 360 + datapath: + available: true + kinds: [ ebpf ] + details: [ attachments, maps ] + nodes: + available: true + groups: + available: true + config_patch: true + selection: true + max_patch_operations: 32 + probes: + available: true + targets: [ node, group ] + kinds: [ tcp_connect, http, dns ] + purposes: [ data, dns ] + transports: [ tcp, udp ] + ip_versions: [ ipv4, ipv6 ] + limits: + max_members_per_job: 128 + max_results_per_job: 512 + max_active_jobs: 4 + max_queued_jobs: 16 + max_concurrent_per_target: 2 + job_timeout_ms: 30000 + per_principal_requests_per_minute: 60 + global_requests_per_minute: 240 + connections: + available: true + flows: + available: true + recording: on + scopes: [ userspace_tcp, userspace_udp ] + max_flows: 10000 + max_steps_per_flow: 256 + retention_seconds: 300 + snapshot_ttl_seconds: 30 + max_page_size: 1000 + routing_trace: + available: true + resolve_modes: [ none ] + max_addresses: 16 + max_rule_steps: 4096 + timeout_ms: 5000 + per_principal_requests_per_minute: 30 + global_requests_per_minute: 120 + events: + available: true + kinds: [ stream.ready, runtime.updated, flow.updated, flow.gap, operation.updated, generation.changed ] + retention_seconds: 60 + max_buffered_events: 4096 + max_clients: 16 + heartbeat_seconds: 15 + dns_query: + available: true + record_types: [ A, AAAA, HTTPS ] + limits: + max_types_per_request: 8 + query_timeout_ms: 5000 + max_response_bytes: 65536 + per_principal_requests_per_minute: 120 + global_requests_per_minute: 480 + dns_cache: + available: true + read: true + delete_entry: true + delete_name: true + flush: true + entry_kinds: [ positive, negative ] + operations: + available: true + retention_seconds: 300 + reload: + available: true + suspend: + available: false + resume: + available: false + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff +schemas: + Discovery: + type: object + required: [ name, status, api_major, base_path, links ] + properties: + name: + type: string + const: dae/honk-native + status: + type: string + const: draft + api_major: + type: integer + const: 1 + base_path: + type: string + const: /api/v1 + links: + type: object + required: [ version, capabilities, config, config_validate, runtime, runtime_outbounds, traffic_history, operations ] + properties: + version: + type: string + const: /api/v1/version + capabilities: + type: string + const: /api/v1/capabilities + config: + type: string + const: /api/v1/config + config_validate: + type: string + const: /api/v1/config/validate + runtime: + type: string + const: /api/v1/runtime + runtime_outbounds: + type: string + const: /api/v1/runtime/outbounds + traffic_history: + type: string + const: /api/v1/runtime/traffic/history + operations: + type: string + const: /api/v1/operations/{id} + Version: + type: object + required: [ api, engine ] + properties: + api: + type: object + required: [ name, major, status ] + properties: + name: + type: string + const: dae/honk-native + major: + type: integer + const: 1 + status: + type: string + const: draft + engine: + type: object + required: [ name, version ] + properties: + name: + type: string + minLength: 1 + version: + type: string + minLength: 1 + build: + oneOf: + - type: "null" + - type: object + required: [ revision, target, built_at ] + properties: + revision: + type: [ string, "null" ] + target: + type: [ string, "null" ] + built_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + AvailableResource: + type: object + required: [ available ] + properties: + available: + type: boolean + Capabilities: + type: object + required: [ observed_at, profiles, limits, resources ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + profiles: + type: array + uniqueItems: true + items: + type: string + enum: [ base, full_transparency ] + limits: + type: object + required: [ max_request_target_bytes, max_header_bytes, max_json_body_bytes ] + properties: + max_request_target_bytes: + type: integer + minimum: 1 + max_header_bytes: + type: integer + minimum: 1 + max_json_body_bytes: + type: integer + minimum: 1 + resources: + type: object + required: [ config, config_validate, runtime, runtime_memory, runtime_outbounds, traffic_history, datapath, nodes, groups, probes, connections, flows, routing_trace, events, dns_query, dns_cache, operations, reload, suspend, resume ] + properties: + config: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ content, writable, max_bytes, max_sources ] + properties: + available: + type: boolean + content: + type: boolean + default: false + description: Visibility flag permitting optional source text; not permission to disclose secrets. False by default. + writable: + type: boolean + description: | + Server-wide switch for replacing accepted sources under control; + individual sources may still be read-only. True requires full + validation and reload operations, including resources.reload.available + and resources.operations.available. Independent of content visibility + and the optional dry-run endpoint. + max_bytes: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + description: Maximum UTF-8 bytes in replacement content; the shared JSON body ceiling applies independently. + max_sources: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + description: Maximum complete effective source set the adapter can expose; never silently truncate it. + config_validate: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ modes, max_bytes, max_sources ] + properties: + available: + type: boolean + modes: + type: array + minItems: 1 + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/ConfigValidationMode + max_bytes: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + description: Maximum total UTF-8 source bytes, including local dependencies in full mode; the shared JSON body ceiling also applies. + max_sources: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + description: Maximum sources per validation, including local dependencies in full mode. + runtime: + $ref: ./openapi.yaml#/components/schemas/AvailableResource + runtime_memory: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ metrics ] + properties: + available: + type: boolean + metrics: + type: array + uniqueItems: true + items: + type: string + enum: [ process.rss_bytes, cgroup.current_bytes, cgroup.limit_bytes, cgroup.events.high, cgroup.events.oom, cgroup.events.oom_kill, kernel.ebpf_bytes ] + runtime_outbounds: + $ref: ./openapi.yaml#/components/schemas/AvailableResource + traffic_history: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ max_window_seconds, max_points ] + properties: + available: + type: boolean + max_window_seconds: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + description: Maximum look-back window in seconds; not a guarantee against bounded-ring eviction. + max_points: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + description: Maximum returned samples per history request. + datapath: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ kinds, details ] + properties: + available: + type: boolean + kinds: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/DatapathKind + details: + type: array + uniqueItems: true + items: + type: string + enum: [ attachments, maps ] + nodes: + $ref: ./openapi.yaml#/components/schemas/AvailableResource + groups: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ config_patch, selection, max_patch_operations ] + properties: + available: + type: boolean + config_patch: + type: boolean + selection: + type: boolean + max_patch_operations: + type: integer + minimum: 1 + probes: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ targets, kinds, purposes, transports, ip_versions, limits ] + properties: + available: + type: boolean + targets: + type: array + uniqueItems: true + items: + type: string + enum: [ node, group ] + kinds: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/ProbeKind + purposes: + type: array + uniqueItems: true + items: + type: string + enum: [ data, dns ] + transports: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/Transport + ip_versions: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/IpVersion + limits: + $ref: ./openapi.yaml#/components/schemas/ProbeLimits + connections: + $ref: ./openapi.yaml#/components/schemas/AvailableResource + flows: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ recording, scopes, max_flows, max_steps_per_flow, retention_seconds, snapshot_ttl_seconds, max_page_size ] + properties: + available: + type: boolean + recording: + type: string + enum: [ off, on, sampled ] + scopes: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/FlowScope + max_flows: + type: integer + minimum: 1 + max_steps_per_flow: + type: integer + minimum: 1 + retention_seconds: + type: integer + minimum: 0 + snapshot_ttl_seconds: + type: integer + minimum: 1 + max_page_size: + type: integer + minimum: 1 + maximum: 1000 + routing_trace: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ resolve_modes, max_addresses, max_rule_steps, timeout_ms, per_principal_requests_per_minute, global_requests_per_minute ] + properties: + available: + type: boolean + resolve_modes: + type: array + uniqueItems: true + items: + type: string + enum: [ none, live ] + max_addresses: + type: integer + minimum: 1 + max_rule_steps: + type: integer + minimum: 1 + timeout_ms: + type: integer + minimum: 1 + per_principal_requests_per_minute: + type: integer + minimum: 1 + global_requests_per_minute: + type: integer + minimum: 1 + events: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ kinds, retention_seconds, max_buffered_events, max_clients, heartbeat_seconds ] + properties: + available: + type: boolean + kinds: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/EventKind + retention_seconds: + type: integer + minimum: 0 + max_buffered_events: + type: integer + minimum: 1 + max_clients: + type: integer + minimum: 1 + heartbeat_seconds: + type: integer + minimum: 1 + maximum: 15 + dns_query: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ record_types, limits ] + properties: + available: + type: boolean + record_types: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + limits: + $ref: ./openapi.yaml#/components/schemas/DnsQueryLimits + dns_cache: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ read, delete_entry, delete_name, flush, entry_kinds ] + properties: + available: + type: boolean + read: + type: boolean + delete_entry: + type: boolean + delete_name: + type: boolean + flush: + type: boolean + entry_kinds: + type: array + uniqueItems: true + items: + type: string + enum: [ positive, negative ] + operations: + type: object + required: [ available ] + if: + properties: + available: + const: true + then: + required: [ retention_seconds ] + properties: + available: + type: boolean + retention_seconds: + type: integer + minimum: 0 + reload: + $ref: ./openapi.yaml#/components/schemas/AvailableResource + suspend: + $ref: ./openapi.yaml#/components/schemas/AvailableResource + resume: + $ref: ./openapi.yaml#/components/schemas/AvailableResource diff --git a/api/dns.yaml b/api/dns.yaml new file mode 100644 index 0000000..1512ae7 --- /dev/null +++ b/api/dns.yaml @@ -0,0 +1,522 @@ +paths: + /api/v1/dns/query: + get: + operationId: queryDns + summary: Execute a routed diagnostic DNS query + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - name: domain + in: query + required: true + description: DNS name within the 255-wire-octet and 63-octet-label limits. + schema: + type: string + minLength: 1 + example: example.com + - name: type + in: query + description: Unique record types; repeated query parameter. + style: form + explode: true + schema: + type: array + minItems: 1 + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + default: [ A ] + example: [ A, AAAA ] + - name: upstream + in: query + schema: + type: string + minLength: 1 + - name: cache_mode + in: query + schema: + type: string + enum: [ normal, bypass ] + default: normal + - $ref: ./openapi.yaml#/components/parameters/Detail + responses: + "200": + description: Per-record-type DNS results, including DNS failures + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/DnsQueryResponse + examples: + dual_stack: + value: + domain: example.com + cache_mode: normal + query_time: 2026-08-14T00:00:00Z + results: + - type: A + cached: false + cache_entry_id: dns-entry-01HZX4K8W5 + upstream: alidns + route: + source: dns.routing + rule: domain(example.com) + status: NOERROR + elapsed_ms: 12 + question: + name: example.com. + type: A + answers: + - name: example.com. + type: A + class: IN + ttl: 600 + data: 93.184.216.34 + - type: AAAA + cached: false + cache_entry_id: null + upstream: alidns + route: + source: dns.routing + rule: domain(example.com) + status: NODATA + elapsed_ms: 11 + question: + name: example.com. + type: AAAA + answers: [] + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "413": + $ref: ./openapi.yaml#/components/responses/TooLarge + "422": + $ref: ./openapi.yaml#/components/responses/Unprocessable + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + /api/v1/dns/cache: + get: + operationId: listDnsCache + summary: Read a paginated DNS cache snapshot + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: name + in: query + schema: + type: string + minLength: 1 + - name: domain + in: query + schema: + type: string + minLength: 1 + - name: type + in: query + style: form + explode: true + schema: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + - name: include_expired + in: query + schema: + type: boolean + default: false + - $ref: ./openapi.yaml#/components/parameters/Limit1000 + - $ref: ./openapi.yaml#/components/parameters/Cursor + - $ref: ./openapi.yaml#/components/parameters/Detail + responses: + "200": + description: DNS cache page + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/DnsCacheList + examples: + entries: + value: + observed_at: 2026-08-15T12:00:00Z + coverage: + positive: true + negative: true + persistent: false + entries: + - entry_id: dns-entry-01HZX4K8W5 + domain: example.com. + type: A + class: IN + status: NOERROR + answers: + - name: example.com. + type: A + class: IN + data: 93.184.216.34 + ttl: 3600 + expires_at: 2026-08-15T13:00:00Z + stale_until: null + - entry_id: dns-entry-01HZX4K8W6 + domain: missing.example.com. + type: A + class: IN + status: NXDOMAIN + answers: [] + expires_at: 2026-08-15T12:05:00Z + stale_until: 2026-08-15T12:06:00Z + total: 1024 + next_cursor: eyJvZmZzZXQiOjEwMH0 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + delete: + operationId: deleteDnsCacheByName + summary: Delete cache entries for one exact name + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - name: name + in: query + required: true + description: Exact canonicalizable DNS name; required to prevent an accidental flush. + schema: + type: string + minLength: 1 + example: example.com. + - name: type + in: query + style: form + explode: true + schema: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + example: [ A, AAAA ] + responses: + "200": + description: Idempotent matching deletion result + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/DeleteMatchingCount + examples: + deleted: + value: + matched: 2 + deleted: 2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + /api/v1/dns/cache/{entry_id}: + delete: + operationId: deleteDnsCacheEntry + summary: Delete one opaque DNS cache entry + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/DnsEntryId + responses: + "200": + description: Idempotent deletion result + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/DeleteCount + examples: + deleted: + value: + deleted: 1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + /api/v1/dns/cache/flush: + post: + operationId: flushDnsCache + summary: Flush the complete runtime DNS cache + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: false + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/EmptyObject + responses: + "200": + description: Cache invalidation barrier result + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/DeleteMatchingCount + examples: + flushed: + value: + matched: 1024 + deleted: 1024 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable +schemas: + DnsQueryLimits: + type: object + required: [ max_types_per_request, query_timeout_ms, max_response_bytes, per_principal_requests_per_minute, global_requests_per_minute ] + properties: + max_types_per_request: + type: integer + minimum: 1 + query_timeout_ms: + type: integer + minimum: 1 + max_response_bytes: + type: integer + minimum: 1 + per_principal_requests_per_minute: + type: integer + minimum: 1 + global_requests_per_minute: + type: integer + minimum: 1 + DnsRecordType: + type: string + pattern: ^(?:[A-Z][A-Z0-9-]*|TYPE[0-9]{1,5}|[0-9]{1,5})$ + DnsAnswer: + type: object + required: [ name, type, class, ttl, data ] + properties: + name: + type: string + minLength: 1 + type: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + class: + type: string + minLength: 1 + ttl: + type: integer + minimum: 0 + maximum: 4294967295 + data: + type: string + DnsQuestion: + type: object + required: [ name, type ] + properties: + name: + type: string + minLength: 1 + type: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + DnsRoute: + type: object + required: [ source, rule ] + properties: + source: + type: string + enum: [ forced, dns.routing, default ] + rule: + type: [ string, "null" ] + DnsQueryResult: + type: object + required: [ type, cached, cache_entry_id, upstream, route, status, elapsed_ms, question ] + properties: + type: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + cached: + type: boolean + cache_entry_id: + type: [ string, "null" ] + upstream: + type: [ string, "null" ] + route: + $ref: ./openapi.yaml#/components/schemas/DnsRoute + status: + type: string + minLength: 1 + elapsed_ms: + type: integer + minimum: 0 + maximum: 9007199254740991 + question: + $ref: ./openapi.yaml#/components/schemas/DnsQuestion + answers: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/DnsAnswer + DnsQueryResponse: + type: object + required: [ domain, cache_mode, query_time, results ] + properties: + domain: + type: string + minLength: 1 + cache_mode: + type: string + enum: [ normal, bypass ] + query_time: + $ref: ./openapi.yaml#/components/schemas/Timestamp + results: + type: array + minItems: 1 + items: + $ref: ./openapi.yaml#/components/schemas/DnsQueryResult + DnsCacheCoverage: + type: object + required: [ positive, negative, persistent ] + properties: + positive: + type: boolean + negative: + type: boolean + persistent: + type: boolean + DnsCacheEntry: + type: object + required: [ entry_id, domain, type, class, status, expires_at, stale_until ] + properties: + entry_id: + type: string + minLength: 1 + domain: + type: string + minLength: 2 + pattern: \.$ + type: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + class: + type: string + minLength: 1 + status: + type: string + minLength: 1 + answers: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/DnsAnswer + expires_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + stale_until: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + DnsCacheList: + type: object + required: [ observed_at, coverage, entries, total, next_cursor ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + coverage: + $ref: ./openapi.yaml#/components/schemas/DnsCacheCoverage + entries: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/DnsCacheEntry + total: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + next_cursor: + type: [ string, "null" ] + DeleteCount: + type: object + required: [ deleted ] + properties: + deleted: + type: integer + minimum: 0 + DeleteMatchingCount: + type: object + required: [ matched, deleted ] + properties: + matched: + type: integer + minimum: 0 + deleted: + type: integer + minimum: 0 diff --git a/api/events.yaml b/api/events.yaml new file mode 100644 index 0000000..8e77316 --- /dev/null +++ b/api/events.yaml @@ -0,0 +1,191 @@ +paths: + /api/v1/events: + get: + operationId: streamEvents + summary: Follow bounded resumable invalidation events + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: kinds + in: query + description: Comma-separated advertised event kinds. + schema: + type: string + minLength: 1 + example: flow.updated,flow.gap + - name: flow_id + in: query + schema: + type: string + minLength: 1 + - $ref: ./openapi.yaml#/components/parameters/LastEventId + responses: + "200": + description: Server-sent events; validate data using the schema selected by the event name. + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + text/event-stream: + schema: + type: string + x-event-data-schemas: + stream.ready: "#/components/schemas/StreamReadyEvent" + runtime.updated: "#/components/schemas/RuntimeUpdatedEvent" + flow.updated: "#/components/schemas/FlowUpdatedEvent" + flow.gap: "#/components/schemas/FlowGapEvent" + operation.updated: "#/components/schemas/OperationUpdatedEvent" + generation.changed: "#/components/schemas/GenerationChangedEvent" + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + description: >- + Last-Event-ID cannot be replayed (event_cursor_expired); sent before any 200 + stream opens. Drop the cursor, reconnect without it and resnapshot. + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + event_cursor_expired: + value: + error: + code: event_cursor_expired + message: Event cursor is older than the retained window; reconnect without it. + details: null + request_id: request-01HZX4K8W6 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable +schemas: + EventKind: + type: string + enum: [ stream.ready, runtime.updated, flow.updated, flow.gap, operation.updated, generation.changed ] + StreamReadyEvent: + type: object + required: [ instance_id, observed_at ] + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + not: + anyOf: + - required: [ href ] + - required: [ resource_id ] + - required: [ revision ] + - required: [ status ] + - required: [ reason ] + - required: [ previous_generation_id ] + - required: [ generation_id ] + RuntimeUpdatedEvent: + type: object + required: [ instance_id, observed_at, href ] + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + href: + const: /api/v1/runtime + FlowUpdatedEvent: + type: object + required: [ instance_id, observed_at, resource_id, revision, href ] + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + resource_id: + type: string + minLength: 1 + revision: + type: integer + minimum: 1 + maximum: 9007199254740991 + href: + type: string + pattern: ^/api/v1/flows/[^/]+$ + FlowGapEvent: + type: object + required: [ instance_id, observed_at, resource_id, reason, dropped_records ] + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + resource_id: + type: [ string, "null" ] + reason: + type: string + enum: [ buffer_overflow, sampled, evicted, recording_changed ] + dropped_records: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + OperationUpdatedEvent: + type: object + required: [ instance_id, observed_at, resource_id, status, href ] + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + resource_id: + type: string + minLength: 1 + status: + type: string + enum: [ queued, running, succeeded, failed ] + href: + type: string + pattern: ^/api/v1/operations/[^/]+$ + GenerationChangedEvent: + type: object + required: [ instance_id, observed_at, previous_generation_id, generation_id ] + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + previous_generation_id: + type: string + minLength: 1 + generation_id: + type: string + minLength: 1 +examples: + FlowUpdated: + summary: Flow revision notification + x-event: flow.updated + x-event-id: instance-7:124 + value: + instance_id: instance-7 + observed_at: 2026-09-14T10:00:00Z + resource_id: flow-23 + revision: 8 + href: /api/v1/flows/flow-23 diff --git a/api/flow-steps.yaml b/api/flow-steps.yaml new file mode 100644 index 0000000..b1b2772 --- /dev/null +++ b/api/flow-steps.yaml @@ -0,0 +1,563 @@ +schemas: + TrafficRoutingInput: + type: object + required: [ network, src_ip, src_port, dst_ip, dst_port, domain, pname, src_mac, dscp, mark, ingress, domain_rule_ids ] + properties: + network: + $ref: ./openapi.yaml#/components/schemas/Transport + src_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + src_port: + type: [ integer, "null" ] + minimum: 0 + maximum: 65535 + dst_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + dst_port: + type: [ integer, "null" ] + minimum: 0 + maximum: 65535 + domain: + type: [ string, "null" ] + pname: + type: [ string, "null" ] + src_mac: + type: [ string, "null" ] + dscp: + type: [ integer, "null" ] + minimum: 0 + maximum: 63 + mark: + type: [ integer, "null" ] + minimum: 0 + maximum: 4294967295 + ingress: + type: [ string, "null" ] + enum: [ lan, wan, null ] + domain_rule_ids: + oneOf: + - type: "null" + - type: array + items: + type: string + minLength: 1 + uniqueItems: true + DnsRequestRoutingInput: + type: object + required: [ name, qtype, source_ip, original_dst ] + properties: + name: + type: string + minLength: 1 + qtype: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + source_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + original_dst: + type: [ string, "null" ] + DnsResponseRoutingInput: + type: object + required: [ name, qtype, answer_ips, from_upstream ] + properties: + name: + type: string + minLength: 1 + qtype: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + answer_ips: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/IpAddress + from_upstream: + type: string + minLength: 1 + InputStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: input + data: + $ref: ./openapi.yaml#/components/schemas/InputStepData + InputStepData: + type: object + required: [ values, source ] + properties: + values: + allOf: + - $ref: ./openapi.yaml#/components/schemas/FlowInput + - type: object + required: [ pname ] + properties: + pname: + type: [ string, "null" ] + source: + type: string + enum: [ kernel, socket, sniffer, dns_mapping ] + RouteStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: route + data: + $ref: ./openapi.yaml#/components/schemas/RouteStepData + RouteStepData: + type: object + required: [ evaluation_id, chain, plane, rule_id, rules, outbound, must, mark, input, dns_action ] + properties: + evaluation_id: + type: string + minLength: 1 + chain: + type: string + enum: [ traffic, dns_request, dns_response, dns_upstream ] + plane: + type: string + enum: [ kernel, userspace ] + rule_id: + type: [ string, "null" ] + rules: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/RuleEvaluation + outbound: + type: [ string, "null" ] + description: Traffic outbound, or DNS upstream tag for upstream/requery; null for other DNS actions. + must: + type: [ boolean, "null" ] + mark: + type: [ integer, "null" ] + minimum: 0 + maximum: 4294967295 + input: + type: [ object, "null" ] + additionalProperties: true + description: Immutable inputs consumed by this evaluation, typed by chain below; null means missing capture. + dns_action: + type: [ string, "null" ] + enum: [ upstream, asis, accept, reject, requery, null ] + allOf: + - oneOf: + - properties: + chain: + enum: [ traffic, dns_upstream ] + input: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/TrafficRoutingInput + dns_action: + type: "null" + - properties: + chain: + const: dns_request + input: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/DnsRequestRoutingInput + dns_action: + enum: [ upstream, asis, reject, null ] + must: + type: "null" + mark: + type: "null" + - properties: + chain: + const: dns_response + input: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/DnsResponseRoutingInput + dns_action: + enum: [ accept, reject, requery, null ] + must: + type: "null" + mark: + type: "null" + - if: + properties: + dns_action: + enum: [ asis, accept, reject ] + then: + properties: + outbound: + type: "null" + DatapathStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: datapath + data: + $ref: ./openapi.yaml#/components/schemas/DatapathStepData + DatapathStepData: + type: object + required: [ plane, action, reason, error ] + properties: + plane: + type: string + enum: [ kernel, userspace ] + action: + type: string + enum: [ pass, redirect, hold, arm_direct, activate_direct, activate_proxy, drop ] + reason: + type: string + minLength: 1 + error: + type: [ string, "null" ] + DialModeStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: dial_mode + data: + $ref: ./openapi.yaml#/components/schemas/DialModeStepData + DialModeStepData: + type: object + required: [ configured, effective_target, domain, domain_source, verification, reason ] + properties: + configured: + type: string + minLength: 1 + effective_target: + type: string + enum: [ ip, domain, none, unknown ] + domain: + type: [ string, "null" ] + domain_source: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/DomainSource + verification: + type: string + enum: [ matched, other_family_trusted, failed, not_required, unavailable ] + reason: + type: string + minLength: 1 + DnsStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: dns + data: + $ref: ./openapi.yaml#/components/schemas/DnsStepData + DnsStepData: + type: object + required: [ lookup_id, parent_lookup_id, attempt_id, purpose, name, qtype, source, upstream_transport, carrier_transport, cache, cache_entry_id, upstream, route_evaluation_ids, status, addresses, selected_ip, error ] + properties: + lookup_id: + type: string + minLength: 1 + parent_lookup_id: + type: [ string, "null" ] + attempt_id: + type: [ string, "null" ] + purpose: + type: string + enum: [ domain_verification, dial_target, proxy_server, intercepted_query, family_preference, refresh ] + name: + type: string + minLength: 1 + qtype: + $ref: ./openapi.yaml#/components/schemas/DnsRecordType + source: + type: string + enum: [ hosts, cache, upstream, coalesced, unknown ] + upstream_transport: + type: [ string, "null" ] + enum: [ udp, tcp, dot, doh, doq, doh3, null ] + carrier_transport: + type: [ string, "null" ] + enum: [ tcp, udp, null ] + cache: + type: string + enum: [ hit, miss, stale, bypass, unknown ] + cache_entry_id: + type: [ string, "null" ] + upstream: + type: [ string, "null" ] + route_evaluation_ids: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + status: + type: string + minLength: 1 + addresses: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/IpAddress + selected_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + error: + type: [ string, "null" ] + RerouteStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: reroute + data: + $ref: ./openapi.yaml#/components/schemas/RerouteStepData + RerouteStepData: + type: object + required: [ performed, reason, from_evaluation_id, to_evaluation_id ] + properties: + performed: + type: [ boolean, "null" ] + reason: + type: string + minLength: 1 + from_evaluation_id: + type: [ string, "null" ] + to_evaluation_id: + type: [ string, "null" ] + OutboundStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: outbound + data: + $ref: ./openapi.yaml#/components/schemas/OutboundStepData + OutboundStepData: + type: object + required: [ attempt_id, parent_attempt_id, kind, evaluation_id, routing_source, routed_outbound, effective_outbound, mode_override, selection_path, leaf_node_id, leaf_node_name, target, target_kind, dial_ip, server_addr, resolution_location, status, error ] + properties: + attempt_id: + type: string + minLength: 1 + parent_attempt_id: + type: [ string, "null" ] + kind: + type: string + enum: [ leaf, transport ] + evaluation_id: + type: [ string, "null" ] + minLength: 1 + routing_source: + type: string + enum: [ evaluation, forced, builtin, unknown ] + routed_outbound: + type: [ string, "null" ] + effective_outbound: + type: [ string, "null" ] + mode_override: + type: string + enum: [ none, direct, global, unknown ] + selection_path: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/SelectionPathItem + leaf_node_id: + type: [ string, "null" ] + leaf_node_name: + type: [ string, "null" ] + description: Sanitized leaf name captured at decision time, or null when unavailable; leaf_node_id remains authoritative. + target: + type: [ string, "null" ] + target_kind: + type: string + enum: [ ip, domain, none, unknown ] + dial_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + server_addr: + type: [ string, "null" ] + resolution_location: + type: string + enum: [ original_ip, local_dns, outbound_remote, not_applicable, unknown ] + status: + type: string + enum: [ started, succeeded, failed, cancelled ] + error: + type: [ string, "null" ] + if: + properties: + routing_source: + const: evaluation + then: + properties: + evaluation_id: + type: string + else: + properties: + evaluation_id: + type: "null" + SelectionPathItem: + type: object + required: [ group_id, member_id, member_name, policy, reason, selection ] + properties: + group_id: + type: string + minLength: 1 + member_id: + type: [ string, "null" ] + minLength: 1 + member_name: + type: [ string, "null" ] + description: Sanitized member name captured at decision time, or null when unavailable; member_id remains authoritative. + policy: + type: string + minLength: 1 + reason: + type: string + minLength: 1 + selection: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/SelectionDecision + SelectionDecision: + type: object + required: [ previous_member_id, metric, tolerance_ms, candidates ] + properties: + previous_member_id: + type: [ string, "null" ] + metric: + type: [ string, "null" ] + tolerance_ms: + type: [ number, "null" ] + candidates: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/SelectionCandidate + SelectionCandidate: + type: object + required: [ member_id, member_name, leaf_node_id, leaf_node_name, eligible, sorting_latency_ms, score, selected, reason ] + properties: + member_id: + type: string + minLength: 1 + member_name: + type: [ string, "null" ] + description: Sanitized member name captured at decision time, or null when unavailable; member_id remains authoritative. + leaf_node_id: + type: [ string, "null" ] + leaf_node_name: + type: [ string, "null" ] + description: Sanitized leaf name captured at decision time, or null when unavailable; leaf_node_id remains authoritative. + eligible: + type: [ boolean, "null" ] + sorting_latency_ms: + type: [ number, "null" ] + score: + type: [ number, "null" ] + selected: + type: boolean + reason: + type: string + minLength: 1 + ConnectionStep: + allOf: + - $ref: ./openapi.yaml#/components/schemas/StepCommon + - type: object + required: [ stage, data ] + properties: + stage: + const: connection + data: + $ref: ./openapi.yaml#/components/schemas/ConnectionStepData + ConnectionStepData: + type: object + required: [ state, reason, milestone, attempt_id, reply_received, error ] + properties: + state: + $ref: ./openapi.yaml#/components/schemas/ConnectionState + reason: + type: string + minLength: 1 + milestone: + type: string + enum: [ transport_ready, target_request_sent, target_confirmed, first_reply, terminal, unknown ] + attempt_id: + type: [ string, "null" ] + reply_received: + type: [ boolean, "null" ] + error: + type: [ string, "null" ] + RuleResult: + type: string + enum: [ matched, not_matched, skipped, indeterminate ] + RuleCondition: + type: object + required: [ id, expression, result, missing_inputs ] + properties: + id: + type: string + minLength: 1 + expression: + type: [ string, "null" ] + result: + $ref: ./openapi.yaml#/components/schemas/RuleResult + missing_inputs: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + RuleEvaluation: + type: object + required: [ rule_id, expression, result, missing_inputs, conditions ] + properties: + rule_id: + type: string + minLength: 1 + expression: + type: [ string, "null" ] + result: + $ref: ./openapi.yaml#/components/schemas/RuleResult + missing_inputs: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + conditions: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/RuleCondition + StepCommon: + type: object + required: [ seq, observed_at, elapsed_us, generation_id, evidence ] + properties: + seq: + type: integer + minimum: 1 + maximum: 9007199254740991 + observed_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + elapsed_us: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + generation_id: + type: [ string, "null" ] + evidence: + type: string + enum: [ observed, reconstructed ] diff --git a/api/flows.yaml b/api/flows.yaml new file mode 100644 index 0000000..38d4212 --- /dev/null +++ b/api/flows.yaml @@ -0,0 +1,949 @@ +paths: + /api/v1/connections: + get: + operationId: listConnections + summary: Read a scope-labelled live connection snapshot + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: type + in: query + schema: + type: string + enum: [ tcp, udp, all ] + default: all + - name: src + in: query + description: Exact source IP literal, without a port; applied with type before limit. Totals and truncated describe only matching visible entries. + schema: + $ref: ./openapi.yaml#/components/schemas/IpAddress + example: 192.168.1.100 + - $ref: ./openapi.yaml#/components/parameters/Limit1000 + - $ref: ./openapi.yaml#/components/parameters/Detail + responses: + "200": + description: Connection snapshot + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ConnectionList + examples: + visible: + value: + observed_at: 2026-08-15T10:00:00Z + instance_id: instance-7 + visibility: partial + truncated: false + tcp: + - id: tcp-01HZX4K8W5 + flow_id: flow-23 + pname: curl + state: active + src: 192.168.1.100:12345 + dst: 1.2.3.4:443 + domain: example.com + outbound: proxy + chain: [ group-proxy, node-hk-01 ] + chain_source: evaluation + rule_id: null + rule_expression: null + rule_source: unknown + ingress: wan + domain_source: tls_sni + started_at: 2026-08-15T09:59:50Z + observed_by: userspace + upload_bytes: "20480" + download_bytes: "1048576" + upload_bytes_per_second: "4096" + download_bytes_per_second: "32768" + udp: + - id: udp-01HZX4K8W6 + flow_id: null + pname: null + state: unknown + src: 192.168.1.100:5353 + dst: 8.8.8.8:53 + domain: null + outbound: direct + chain: [] + chain_source: unknown + rule_id: null + rule_expression: null + rule_source: unknown + ingress: null + domain_source: null + started_at: 2026-08-15T09:59:55Z + observed_by: ebpf + upload_bytes: null + download_bytes: null + upload_bytes_per_second: null + download_bytes_per_second: null + total_tcp: 1 + total_udp: 1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + /api/v1/flows: + get: + operationId: listFlows + summary: List active and retained terminal flow decisions + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: network + in: query + schema: + type: string + enum: [ tcp, udp, all ] + default: all + - name: state + in: query + schema: + oneOf: + - $ref: ./openapi.yaml#/components/schemas/ConnectionState + - const: all + default: all + - name: connection_id + in: query + description: Exact opaque connection ID within the current adapter instance; never inferred from a tuple. Matches active and retained terminal flows. + schema: + type: string + minLength: 1 + example: tcp-01HZX4K8W5 + - $ref: ./openapi.yaml#/components/parameters/Limit1000 + - $ref: ./openapi.yaml#/components/parameters/Cursor + - $ref: ./openapi.yaml#/components/parameters/Detail + responses: + "200": + description: Point-in-time flow page + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/FlowList + examples: + visible: + value: + instance_id: instance-7 + observed_at: 2026-09-14T10:00:00Z + coverage: + userspace_tcp: full + userspace_udp: partial + kernel_direct: none + kernel_block: none + dns_intercept: partial + kernel_bypass: none + dropped_records: "3" + flows: + - id: flow-23 + instance_id: instance-7 + revision: 8 + network: tcp + state: active + pname: curl + connection_id: tcp-01HZX4K8W5 + outbound: proxy + chain: [ group-proxy, node-hk-01 ] + chain_source: evaluation + rule_id: null + rule_expression: null + rule_source: unknown + ingress: wan + domain_source: tls_sni + observed_by: userspace + started_at: 2026-08-15T09:59:50Z + ended_at: null + trace_status: partial + next_cursor: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "410": + $ref: ./openapi.yaml#/components/responses/Gone + "503": + description: Bounded recorder memory cannot admit a snapshot + headers: + Retry-After: + description: Retry delay in seconds when retryable. + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + snapshot_full: + value: + error: + code: temporarily_unavailable + message: Flow snapshot capacity is full. + request_id: request-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 + /api/v1/flows/{flow_id}: + get: + operationId: getFlow + summary: Read one recorded causal flow trace + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/FlowId + responses: + "200": + description: Recorded flow detail + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/FlowDetail + examples: + partial_handoff: + value: + id: flow-23 + instance_id: instance-7 + revision: 8 + network: tcp + state: active + pname: curl + connection_id: tcp-01HZX4K8W5 + outbound: proxy + chain: [ group-proxy, node-hk-01 ] + chain_source: evaluation + rule_id: null + rule_expression: null + rule_source: unknown + ingress: wan + domain_source: tls_sni + observed_by: userspace + started_at: 2026-08-15T09:59:50Z + ended_at: null + trace_status: partial + input: + src: 192.168.1.100:12345 + dst: 1.2.3.4:443 + domain: example.com + domain_source: tls_sni + pid: null + process_path: null + src_mac: null + ingress: wan + domain_rule_ids: null + dscp: 0 + mark: 0 + trace: + status: partial + missing: [ not_instrumented ] + steps: + - seq: 1 + stage: route + observed_at: 2026-08-15T09:59:50Z + elapsed_us: 0 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-1 + chain: traffic + plane: kernel + input: null + dns_action: null + rule_id: null + rules: [] + outbound: proxy + must: false + mark: 0 + - seq: 2 + stage: dial_mode + observed_at: 2026-08-15T09:59:50Z + elapsed_us: 100 + generation_id: generation-42 + evidence: observed + data: + configured: domain+ + effective_target: domain + domain: example.com + domain_source: tls_sni + verification: not_required + reason: preserve_initial_route + - seq: 3 + stage: reroute + observed_at: 2026-08-15T09:59:50Z + elapsed_us: 120 + generation_id: generation-42 + evidence: observed + data: + performed: false + reason: dial_mode_preserves_route + from_evaluation_id: eval-1 + to_evaluation_id: null + - seq: 4 + stage: outbound + observed_at: 2026-08-15T09:59:50Z + elapsed_us: 150 + generation_id: generation-42 + evidence: observed + data: + attempt_id: attempt-1 + parent_attempt_id: null + kind: leaf + evaluation_id: eval-1 + routing_source: evaluation + routed_outbound: proxy + effective_outbound: proxy + mode_override: none + selection_path: + - group_id: group-proxy + member_id: node-hk-01 + member_name: hk-01 + policy: selector + reason: manual_selection + selection: null + leaf_node_id: node-hk-01 + leaf_node_name: hk-01 + target: example.com:443 + target_kind: domain + dial_ip: null + server_addr: 203.0.113.7:443 + resolution_location: outbound_remote + status: succeeded + error: null + - seq: 5 + stage: connection + observed_at: 2026-08-15T09:59:50Z + elapsed_us: 20000 + generation_id: generation-42 + evidence: observed + data: + state: active + reason: transport_ready + milestone: transport_ready + attempt_id: attempt-1 + reply_received: null + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + interleaved_dns: + summary: Evaluation-owned DNS inputs and explicit non-adjacent references + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + value: + id: flow-24 + instance_id: instance-7 + revision: 8 + network: tcp + state: active + pname: curl + connection_id: tcp-24 + outbound: proxy + chain: [ node-hk-01 ] + chain_source: evaluation + rule_id: rule-traffic + rule_expression: null + rule_source: kernel + ingress: wan + domain_source: explicit + observed_by: userspace + started_at: 2026-09-14T09:59:59Z + ended_at: null + trace_status: complete + input: + src: 192.0.2.10:53000 + dst: 198.51.100.20:443 + domain: example.com + domain_source: explicit + pid: null + process_path: null + src_mac: null + ingress: wan + domain_rule_ids: null + dscp: 0 + mark: 0 + trace: + status: complete + missing: [] + steps: + - seq: 1 + stage: route + observed_at: 2026-09-14T10:00:00Z + elapsed_us: 10 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-traffic + chain: traffic + plane: kernel + rule_id: rule-traffic + rules: + - rule_id: rule-traffic + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: proxy + must: false + mark: 0 + input: + network: tcp + src_ip: 192.0.2.10 + src_port: 53000 + dst_ip: 198.51.100.20 + dst_port: 443 + domain: example.com + pname: curl + src_mac: null + dscp: 0 + mark: 0 + ingress: wan + domain_rule_ids: null + dns_action: null + - seq: 2 + stage: route + observed_at: 2026-09-14T10:00:00Z + elapsed_us: 20 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-dns-request + chain: dns_request + plane: userspace + rule_id: rule-dns_request + rules: + - rule_id: rule-dns_request + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: dns-primary + must: null + mark: null + input: + name: example.com + qtype: A + source_ip: 192.0.2.10 + original_dst: null + dns_action: upstream + - seq: 3 + stage: route + observed_at: 2026-09-14T10:00:00Z + elapsed_us: 30 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-dns-upstream + chain: dns_upstream + plane: userspace + rule_id: rule-dns_upstream + rules: + - rule_id: rule-dns_upstream + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: proxy + must: null + mark: null + input: + network: udp + src_ip: 0.0.0.0 + src_port: 0 + dst_ip: 203.0.113.53 + dst_port: 53 + domain: resolver.example + pname: null + src_mac: null + dscp: null + mark: null + ingress: null + domain_rule_ids: null + dns_action: null + - seq: 4 + stage: outbound + observed_at: 2026-09-14T10:00:00Z + elapsed_us: 40 + generation_id: generation-42 + evidence: observed + data: + attempt_id: attempt-dns + parent_attempt_id: null + kind: leaf + evaluation_id: eval-dns-upstream + routing_source: evaluation + routed_outbound: proxy + effective_outbound: proxy + mode_override: none + selection_path: [] + leaf_node_id: node-hk-01 + leaf_node_name: hk-01 + target: 203.0.113.53:53 + target_kind: ip + dial_ip: 203.0.113.53 + server_addr: 203.0.113.7:443 + resolution_location: original_ip + status: succeeded + error: null + - seq: 5 + stage: dns + observed_at: 2026-09-14T10:00:00Z + elapsed_us: 50 + generation_id: generation-42 + evidence: observed + data: + lookup_id: lookup-1 + parent_lookup_id: null + attempt_id: attempt-dns + purpose: domain_verification + name: example.com + qtype: A + source: upstream + upstream_transport: udp + carrier_transport: tcp + cache: miss + cache_entry_id: null + upstream: dns-primary + route_evaluation_ids: [ eval-dns-request, eval-dns-upstream, eval-dns-response ] + status: NOERROR + addresses: [ 198.51.100.20 ] + selected_ip: 198.51.100.20 + error: null + - seq: 6 + stage: route + observed_at: 2026-09-14T10:00:00Z + elapsed_us: 60 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-dns-response + chain: dns_response + plane: userspace + rule_id: rule-dns_response + rules: + - rule_id: rule-dns_response + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: null + must: null + mark: null + input: + name: example.com + qtype: A + answer_ips: [ 198.51.100.20 ] + from_upstream: dns-primary + dns_action: accept + - seq: 7 + stage: outbound + observed_at: 2026-09-14T10:00:00Z + elapsed_us: 70 + generation_id: generation-42 + evidence: observed + data: + attempt_id: attempt-app + parent_attempt_id: null + kind: leaf + evaluation_id: eval-traffic + routing_source: evaluation + routed_outbound: proxy + effective_outbound: proxy + mode_override: none + selection_path: [] + leaf_node_id: node-hk-01 + leaf_node_name: hk-01 + target: example.com:443 + target_kind: domain + dial_ip: null + server_addr: 203.0.113.7:443 + resolution_location: outbound_remote + status: succeeded + error: null + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "410": + $ref: ./openapi.yaml#/components/responses/Gone +schemas: + ConnectionState: + type: string + enum: [ observed, routing, dialing, active, closed, blocked, failed, unknown ] + Connection: + type: object + required: [ id, flow_id, pname, state, outbound, chain, chain_source, rule_id, rule_expression, rule_source, ingress, domain_source, started_at, observed_by, upload_bytes, download_bytes, upload_bytes_per_second, download_bytes_per_second ] + properties: + id: + type: string + minLength: 1 + flow_id: + type: [ string, "null" ] + pname: + type: [ string, "null" ] + state: + $ref: ./openapi.yaml#/components/schemas/ConnectionState + src: + type: string + minLength: 1 + dst: + type: string + minLength: 1 + domain: + type: [ string, "null" ] + outbound: + type: [ string, "null" ] + chain: + type: array + items: + type: string + description: Application outbound selection_path group IDs followed by the leaf node ID, in order; empty for direct/block or an unknown path. + chain_source: + type: string + enum: [ evaluation, reconstructed, unknown ] + description: Selection captured at evaluation, reconstructed from retained evidence, or unavailable; never a current group snapshot. + rule_id: + type: [ string, "null" ] + description: Generation-scoped traffic rule ID, or null when unavailable. + rule_expression: + type: [ string, "null" ] + description: Sanitized display expression for that rule, or null when unavailable. + rule_source: + type: string + enum: [ kernel, recomputed, unknown ] + description: Deciding kernel rule, recomputed userspace evidence, or unavailable provenance; see honk-mapping's matched_rule row. + ingress: + type: [ string, "null" ] + enum: [ lan, wan, null ] + domain_source: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/DomainSource + started_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + observed_by: + $ref: ./openapi.yaml#/components/schemas/ObservedBy + upload_bytes: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + download_bytes: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + upload_bytes_per_second: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + download_bytes_per_second: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + ConnectionList: + type: object + required: [ observed_at, instance_id, visibility, truncated, tcp, udp, total_tcp, total_udp ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + instance_id: + type: string + minLength: 1 + visibility: + $ref: ./openapi.yaml#/components/schemas/Visibility + truncated: + type: boolean + description: Whether limit omitted visible entries matching type and src. + tcp: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/Connection + udp: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/Connection + total_tcp: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + description: Visible live TCP entries matching type and src before limit; zero when type excludes TCP. + total_udp: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + description: Visible live UDP entries matching type and src before limit; zero when type excludes UDP. + FlowScope: + type: string + enum: [ userspace_tcp, userspace_udp, kernel_direct, kernel_block, dns_intercept, kernel_bypass ] + TraceStatus: + type: string + enum: [ complete, partial, disabled ] + FlowInput: + type: object + required: [ src, dst, domain, domain_source, pid, process_path, src_mac, ingress, domain_rule_ids, dscp, mark ] + properties: + src: + type: [ string, "null" ] + dst: + type: [ string, "null" ] + domain: + type: [ string, "null" ] + domain_source: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/DomainSource + pid: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + process_path: + type: [ string, "null" ] + src_mac: + type: [ string, "null" ] + ingress: + type: [ string, "null" ] + enum: [ lan, wan, null ] + domain_rule_ids: + oneOf: + - type: "null" + - type: array + uniqueItems: true + items: + type: string + minLength: 1 + dscp: + type: [ integer, "null" ] + minimum: 0 + maximum: 63 + mark: + type: [ integer, "null" ] + minimum: 0 + maximum: 4294967295 + DomainSource: + type: string + enum: [ tls_sni, http_host, quic_sni, dns_mapping, explicit, unknown ] + FlowSummary: + type: object + required: [ id, instance_id, revision, network, state, pname, connection_id, outbound, chain, chain_source, rule_id, rule_expression, rule_source, ingress, domain_source, observed_by, started_at, ended_at, trace_status ] + properties: + id: + type: string + minLength: 1 + instance_id: + type: string + minLength: 1 + revision: + type: integer + minimum: 1 + maximum: 9007199254740991 + network: + $ref: ./openapi.yaml#/components/schemas/Transport + state: + $ref: ./openapi.yaml#/components/schemas/ConnectionState + pname: + type: [ string, "null" ] + connection_id: + type: [ string, "null" ] + outbound: + type: [ string, "null" ] + chain: + type: array + items: + type: string + description: Application outbound selection_path group IDs followed by the leaf node ID, in order; empty for direct/block or an unknown path. + chain_source: + type: string + enum: [ evaluation, reconstructed, unknown ] + description: Selection captured at evaluation, reconstructed from retained evidence, or unavailable; never a current group snapshot. + rule_id: + type: [ string, "null" ] + description: Generation-scoped traffic rule ID, or null when unavailable. + rule_expression: + type: [ string, "null" ] + description: Sanitized display expression for that rule, or null when unavailable. + rule_source: + type: string + enum: [ kernel, recomputed, unknown ] + description: Deciding kernel rule, recomputed userspace evidence, or unavailable provenance; see honk-mapping's matched_rule row. + ingress: + type: [ string, "null" ] + enum: [ lan, wan, null ] + domain_source: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/DomainSource + observed_by: + $ref: ./openapi.yaml#/components/schemas/ObservedBy + started_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + ended_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + trace_status: + $ref: ./openapi.yaml#/components/schemas/TraceStatus + input: + $ref: ./openapi.yaml#/components/schemas/FlowInput + FlowCoverage: + type: object + required: [ userspace_tcp, userspace_udp, kernel_direct, kernel_block, dns_intercept, kernel_bypass ] + properties: + userspace_tcp: + $ref: ./openapi.yaml#/components/schemas/Visibility + userspace_udp: + $ref: ./openapi.yaml#/components/schemas/Visibility + kernel_direct: + $ref: ./openapi.yaml#/components/schemas/Visibility + kernel_block: + $ref: ./openapi.yaml#/components/schemas/Visibility + dns_intercept: + $ref: ./openapi.yaml#/components/schemas/Visibility + kernel_bypass: + $ref: ./openapi.yaml#/components/schemas/Visibility + FlowList: + type: object + required: [ instance_id, observed_at, coverage, dropped_records, flows, next_cursor ] + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + coverage: + $ref: ./openapi.yaml#/components/schemas/FlowCoverage + dropped_records: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + flows: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/FlowSummary + next_cursor: + type: [ string, "null" ] + FlowDetail: + allOf: + - $ref: ./openapi.yaml#/components/schemas/FlowSummary + - type: object + required: [ input, trace ] + properties: + input: + $ref: ./openapi.yaml#/components/schemas/FlowInput + trace: + $ref: ./openapi.yaml#/components/schemas/FlowTrace + not: + required: [ mode ] + - oneOf: + - properties: + trace_status: + const: complete + trace: + properties: + status: + const: complete + - properties: + trace_status: + const: partial + trace: + properties: + status: + const: partial + - properties: + trace_status: + const: disabled + trace: + properties: + status: + const: disabled + description: Recorded evidence identified by id and instance_id; never a simulation. + FlowTrace: + type: object + required: [ status, missing, steps ] + properties: + status: + $ref: ./openapi.yaml#/components/schemas/TraceStatus + missing: + type: array + uniqueItems: true + items: + type: string + enum: [ not_instrumented, started_late, buffer_overflow, sampled, redacted, evicted ] + steps: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/FlowStep + if: + properties: + status: + const: complete + then: + properties: + missing: + maxItems: 0 + steps: + items: + properties: + evidence: + const: observed + if: + properties: + stage: + const: route + then: + properties: + data: + properties: + input: + type: object + else: + properties: + missing: + minItems: 1 + FlowStep: + oneOf: + - $ref: ./openapi.yaml#/components/schemas/InputStep + - $ref: ./openapi.yaml#/components/schemas/RouteStep + - $ref: ./openapi.yaml#/components/schemas/DatapathStep + - $ref: ./openapi.yaml#/components/schemas/DialModeStep + - $ref: ./openapi.yaml#/components/schemas/DnsStep + - $ref: ./openapi.yaml#/components/schemas/RerouteStep + - $ref: ./openapi.yaml#/components/schemas/OutboundStep + - $ref: ./openapi.yaml#/components/schemas/ConnectionStep + discriminator: + propertyName: stage + mapping: + input: ./openapi.yaml#/components/schemas/InputStep + route: ./openapi.yaml#/components/schemas/RouteStep + datapath: ./openapi.yaml#/components/schemas/DatapathStep + dial_mode: ./openapi.yaml#/components/schemas/DialModeStep + dns: ./openapi.yaml#/components/schemas/DnsStep + reroute: ./openapi.yaml#/components/schemas/RerouteStep + outbound: ./openapi.yaml#/components/schemas/OutboundStep + connection: ./openapi.yaml#/components/schemas/ConnectionStep diff --git a/api/nodes-groups.yaml b/api/nodes-groups.yaml new file mode 100644 index 0000000..cd717aa --- /dev/null +++ b/api/nodes-groups.yaml @@ -0,0 +1,845 @@ +paths: + /api/v1/nodes: + get: + operationId: listNodes + summary: List nodes and latest typed health samples + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: group_id + in: query + schema: + type: string + minLength: 1 + example: group-proxy + - $ref: ./openapi.yaml#/components/parameters/Limit1000 + - $ref: ./openapi.yaml#/components/parameters/Cursor + responses: + "200": + description: Node page + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/NodeList + examples: + nodes: + value: + observed_at: 2026-08-15T10:00:00Z + nodes: + - id: node-hk-01 + name: hk-01 + protocol: vless + subscription_tag: provider-a + group_ids: [ group-proxy ] + health: + - transport: tcp + purpose: shared + ip_version: ipv4 + warmth: cold + measurement: http_round_trip + sample_source: probe + state: healthy + latency_ms: 45 + moving_avg_ms: 47.5 + avg10_ms: null + observed_at: 2026-08-15T10:00:00Z + error: null + next_cursor: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + /api/v1/groups: + get: + operationId: listGroups + summary: List group summaries + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + "200": + description: Group summaries + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/GroupList + examples: + groups: + value: + - id: group-proxy + name: proxy + config_revision: "17" + policy: + kind: urltest + native: min_moving_avg + member_count: 2 + selection: + tcp_member_id: node-hk-01 + udp_member_id: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + /api/v1/groups/{groupId}: + parameters: + - $ref: ./openapi.yaml#/components/parameters/GroupId + get: + operationId: getGroup + summary: Read a complete group resource + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + "200": + description: Current group + headers: + ETag: + $ref: ./openapi.yaml#/components/headers/ETag + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Group + examples: + current: + value: + id: group-proxy + name: proxy + config_revision: "17" + policy: + kind: urltest + native: min_moving_avg + members: + - id: node-hk-01 + name: hk-01 + kind: node + - id: group-jp + name: jp + kind: group + config: + default_member_id: null + final_outbound: direct + check_url: null + check_interval: 30 + tolerance: 50 + idle_timeout: null + interrupt_connections: false + runtime: + selection: + tcp: + member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + source: health + udp: null + health: + - member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + transport: tcp + purpose: shared + ip_version: ipv4 + warmth: unknown + measurement: http_round_trip + sample_source: probe + state: healthy + latency_ms: 45 + moving_avg_ms: 47.5 + avg10_ms: null + sorting_latency_ms: 72.5 + ranking: + metric: moving_avg_ms + recovery_penalty_ms: 10 + group_offset_ms: 15 + score: null + reason: minimum_with_hysteresis + observed_at: 2026-08-15T10:00:00Z + error: null + capabilities: + can_select: false + supports_nested_groups: true + mutable_config: [ policy, default_member_id, final_outbound, check_url, check_interval, tolerance, idle_timeout, interrupt_connections ] + probe_transports: [ tcp, udp ] + x-headers: + Content-Type: application/json + ETag: '"17"' + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + patch: + operationId: patchGroup + summary: Patch mutable group configuration + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/IfMatch + - $ref: ./openapi.yaml#/components/parameters/IdempotencyKey + requestBody: + required: true + content: + application/json-patch+json: + schema: + $ref: ./openapi.yaml#/components/schemas/JsonPatch + examples: + tolerance: + value: + - op: replace + path: /config/tolerance + value: 100 + - op: replace + path: /config/interrupt_connections + value: true + responses: + "200": + description: Updated group + headers: + ETag: + $ref: ./openapi.yaml#/components/headers/ETag + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Group + "202": + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/OperationAccepted + examples: + queued: + value: + operation_id: op-01HZX4K8WA + kind: group_update + status: queued + href: /api/v1/operations/op-01HZX4K8WA + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8WA + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "412": + description: If-Match does not equal the current configuration revision + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + stale_revision: + value: + error: + code: stale_revision + message: The resource changed; fetch it again before retrying. + details: + field: null + request_id: request-01HZX4K8W5 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "413": + $ref: ./openapi.yaml#/components/responses/TooLarge + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "422": + $ref: ./openapi.yaml#/components/responses/Unprocessable + "428": + $ref: ./openapi.yaml#/components/responses/PreconditionRequired + /api/v1/groups/{groupId}/selection: + parameters: + - $ref: ./openapi.yaml#/components/parameters/GroupId + put: + operationId: selectGroupMember + summary: Replace a supported runtime group selection + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/GroupSelectionRequest + examples: + tcp_udp: + value: + member_id: node-hk-01 + network: both + responses: + "200": + description: Applied runtime selection + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/GroupSelectionResult + examples: + selected: + value: + group_id: group-proxy + member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + network: both + source: runtime + selection_revision: "8" + connections_interrupted: false + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "422": + $ref: ./openapi.yaml#/components/responses/Unprocessable +schemas: + HealthObservation: + type: object + required: [ transport, purpose, ip_version, warmth, measurement, sample_source, state, latency_ms, moving_avg_ms, avg10_ms, observed_at, error ] + properties: + transport: + $ref: ./openapi.yaml#/components/schemas/Transport + purpose: + type: string + enum: [ data, dns, shared ] + ip_version: + $ref: ./openapi.yaml#/components/schemas/IpVersion + warmth: + type: string + enum: [ cold, warm, mixed, unknown ] + measurement: + type: string + enum: [ tcp_connect, http_headers, http_round_trip, dns_round_trip, quic_handshake, mixed, unknown ] + sample_source: + type: string + enum: [ probe, traffic, restored, derived, mixed, unknown ] + state: + type: string + enum: [ healthy, unavailable, unknown ] + latency_ms: + type: [ number, "null" ] + minimum: 0 + moving_avg_ms: + type: [ number, "null" ] + minimum: 0 + avg10_ms: + type: [ number, "null" ] + minimum: 0 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + error: + type: [ string, "null" ] + allOf: + - if: + properties: + state: + enum: [ unavailable, unknown ] + then: + properties: + latency_ms: + type: "null" + Node: + type: object + required: [ id, name, protocol, subscription_tag, group_ids, health ] + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + protocol: + type: [ string, "null" ] + subscription_tag: + type: [ string, "null" ] + group_ids: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + health: + type: array + description: Latest observations, unique by (transport, purpose, measurement, ip_version, warmth) within this node. + items: + $ref: ./openapi.yaml#/components/schemas/HealthObservation + NodeList: + type: object + required: [ observed_at, nodes, next_cursor ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + nodes: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/Node + next_cursor: + type: [ string, "null" ] + GroupPolicy: + type: object + required: [ kind, native ] + properties: + kind: + type: string + enum: [ selector, urltest, loadbalance, fallback, random, score ] + native: + type: string + minLength: 1 + GroupPolicyRequest: + type: object + additionalProperties: false + required: [ kind, native ] + properties: + kind: + type: string + enum: [ selector, urltest, loadbalance, fallback, random, score ] + native: + type: string + minLength: 1 + GroupMember: + type: object + required: [ id, name, kind ] + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + kind: + type: string + enum: [ node, group ] + GroupConfig: + type: object + required: [ default_member_id, final_outbound, check_url, check_interval, tolerance, idle_timeout, interrupt_connections ] + properties: + default_member_id: + type: [ string, "null" ] + final_outbound: + type: [ string, "null" ] + check_url: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/SafeHttpUrl + check_interval: + type: [ integer, "null" ] + minimum: 1 + tolerance: + type: [ number, "null" ] + minimum: 0 + idle_timeout: + type: [ integer, "null" ] + minimum: 0 + interrupt_connections: + type: boolean + SafeHttpUrl: + type: string + format: uri + pattern: ^https?://(?![^/?#]*@) + description: Absolute HTTP(S) URL without userinfo; server also enforces administrator SSRF policy. + GroupSelection: + type: object + required: [ member_id, source ] + properties: + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: [ string, "null" ] + source: + type: string + minLength: 1 + GroupRanking: + type: object + required: [ metric, recovery_penalty_ms, group_offset_ms, score, reason ] + properties: + metric: + type: [ string, "null" ] + recovery_penalty_ms: + type: [ number, "null" ] + group_offset_ms: + type: [ number, "null" ] + score: + type: [ number, "null" ] + reason: + type: [ string, "null" ] + GroupHealthObservation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/HealthObservation + - type: object + required: [ member_id, sorting_latency_ms, ranking ] + properties: + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: [ string, "null" ] + sorting_latency_ms: + type: [ number, "null" ] + ranking: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/GroupRanking + GroupRuntime: + type: object + required: [ selection, health ] + properties: + selection: + type: object + required: [ tcp, udp ] + properties: + tcp: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/GroupSelection + udp: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/GroupSelection + health: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/GroupHealthObservation + GroupCapabilities: + type: object + required: [ can_select, supports_nested_groups, mutable_config, probe_transports ] + properties: + can_select: + type: boolean + supports_nested_groups: + type: boolean + mutable_config: + type: array + uniqueItems: true + items: + type: string + enum: [ policy, default_member_id, final_outbound, check_url, check_interval, tolerance, idle_timeout, interrupt_connections ] + probe_transports: + type: array + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/Transport + Group: + type: object + required: [ id, name, config_revision, policy, members, config, runtime, capabilities ] + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + config_revision: + type: string + minLength: 1 + policy: + $ref: ./openapi.yaml#/components/schemas/GroupPolicy + members: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/GroupMember + config: + $ref: ./openapi.yaml#/components/schemas/GroupConfig + runtime: + $ref: ./openapi.yaml#/components/schemas/GroupRuntime + capabilities: + $ref: ./openapi.yaml#/components/schemas/GroupCapabilities + GroupSummary: + type: object + required: [ id, name, config_revision, policy, member_count, selection ] + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + config_revision: + type: string + minLength: 1 + description: Same opaque configuration revision as Group.config_revision; preserve without numeric parsing. + policy: + $ref: ./openapi.yaml#/components/schemas/GroupPolicy + member_count: + type: integer + minimum: 0 + selection: + type: object + required: [ tcp_member_id, udp_member_id ] + properties: + tcp_member_id: + type: [ string, "null" ] + udp_member_id: + type: [ string, "null" ] + GroupList: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/GroupSummary + JsonPatch: + type: array + minItems: 1 + items: + oneOf: + - $ref: ./openapi.yaml#/components/schemas/PolicyPatch + - $ref: ./openapi.yaml#/components/schemas/MemberIdPatch + - $ref: ./openapi.yaml#/components/schemas/OutboundPatch + - $ref: ./openapi.yaml#/components/schemas/CheckUrlPatch + - $ref: ./openapi.yaml#/components/schemas/PositiveIntegerPatch + - $ref: ./openapi.yaml#/components/schemas/TolerancePatch + - $ref: ./openapi.yaml#/components/schemas/IdleTimeoutPatch + - $ref: ./openapi.yaml#/components/schemas/InterruptPatch + - $ref: ./openapi.yaml#/components/schemas/RemovePatch + - $ref: ./openapi.yaml#/components/schemas/CopyMovePatch + description: Bounded by resources.groups.max_patch_operations. + PolicyPatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + const: /policy + value: + $ref: "./openapi.yaml#/components/schemas/GroupPolicyRequest" + MemberIdPatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + const: /config/default_member_id + value: + type: [ string, "null" ] + OutboundPatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + const: /config/final_outbound + value: + type: [ string, "null" ] + CheckUrlPatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + const: /config/check_url + value: + oneOf: + - type: "null" + - $ref: "./openapi.yaml#/components/schemas/SafeHttpUrl" + PositiveIntegerPatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + enum: [ /config/check_interval ] + value: + type: [ integer, "null" ] + minimum: 1 + TolerancePatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + const: /config/tolerance + value: + type: [ number, "null" ] + minimum: 0 + IdleTimeoutPatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + const: /config/idle_timeout + value: + type: [ integer, "null" ] + minimum: 0 + InterruptPatch: + type: object + additionalProperties: false + required: [ op, path, value ] + properties: + op: + type: string + enum: [ add, replace, test ] + path: + type: string + const: /config/interrupt_connections + value: + type: boolean + MutableGroupPath: + type: string + enum: [ /policy, /config/default_member_id, /config/final_outbound, /config/check_url, /config/check_interval, /config/tolerance, /config/idle_timeout, /config/interrupt_connections ] + RemovePatch: + type: object + additionalProperties: false + required: [ op, path ] + properties: + op: + const: remove + path: + $ref: ./openapi.yaml#/components/schemas/MutableGroupPath + CopyMovePatch: + type: object + additionalProperties: false + required: [ op, path, from ] + properties: + op: + enum: [ copy, move ] + path: + $ref: ./openapi.yaml#/components/schemas/MutableGroupPath + from: + $ref: ./openapi.yaml#/components/schemas/MutableGroupPath + GroupSelectionRequest: + type: object + additionalProperties: false + required: [ member_id, network ] + properties: + member_id: + type: string + minLength: 1 + network: + type: string + enum: [ tcp, udp, both ] + GroupSelectionResult: + type: object + required: [ group_id, member_id, network, source, selection_revision, connections_interrupted ] + properties: + group_id: + type: string + minLength: 1 + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: [ string, "null" ] + network: + type: string + enum: [ tcp, udp, both ] + source: + type: string + const: runtime + selection_revision: + type: string + minLength: 1 + connections_interrupted: + type: boolean diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..a697915 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,469 @@ +openapi: 3.1.0 +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +info: + title: dae/honk Native API + version: 0.1.0-draft + description: | + Normative wire contract for the native control-plane API. Unknown response + extension fields are permitted. Request objects reject unknown fields. + Published as a generated bundle at source/openapi.yaml; edit the api/ sources, + not the published file. +servers: + - url: http://localhost:9527 +security: [] +paths: + /api: + $ref: ./discovery.yaml#/paths/~1api + /api/v1/version: + $ref: ./discovery.yaml#/paths/~1api~1v1~1version + /api/v1/capabilities: + $ref: ./discovery.yaml#/paths/~1api~1v1~1capabilities + /api/v1/config: + $ref: ./config.yaml#/paths/~1api~1v1~1config + /api/v1/config/validate: + $ref: ./config.yaml#/paths/~1api~1v1~1config~1validate + /api/v1/config/sources/{source_id}: + $ref: ./config.yaml#/paths/~1api~1v1~1config~1sources~1{source_id} + /api/v1/runtime: + $ref: ./runtime.yaml#/paths/~1api~1v1~1runtime + /api/v1/runtime/memory: + $ref: ./runtime.yaml#/paths/~1api~1v1~1runtime~1memory + /api/v1/runtime/outbounds: + $ref: ./runtime.yaml#/paths/~1api~1v1~1runtime~1outbounds + /api/v1/runtime/traffic/history: + $ref: ./runtime.yaml#/paths/~1api~1v1~1runtime~1traffic~1history + /api/v1/datapath: + $ref: ./runtime.yaml#/paths/~1api~1v1~1datapath + /api/v1/nodes: + $ref: ./nodes-groups.yaml#/paths/~1api~1v1~1nodes + /api/v1/groups: + $ref: ./nodes-groups.yaml#/paths/~1api~1v1~1groups + /api/v1/groups/{groupId}: + $ref: ./nodes-groups.yaml#/paths/~1api~1v1~1groups~1{groupId} + /api/v1/groups/{groupId}/selection: + $ref: ./nodes-groups.yaml#/paths/~1api~1v1~1groups~1{groupId}~1selection + /api/v1/probes: + $ref: ./operations-probes.yaml#/paths/~1api~1v1~1probes + /api/v1/connections: + $ref: ./flows.yaml#/paths/~1api~1v1~1connections + /api/v1/flows: + $ref: ./flows.yaml#/paths/~1api~1v1~1flows + /api/v1/flows/{flow_id}: + $ref: ./flows.yaml#/paths/~1api~1v1~1flows~1{flow_id} + /api/v1/routing/trace: + $ref: ./routing.yaml#/paths/~1api~1v1~1routing~1trace + /api/v1/events: + $ref: ./events.yaml#/paths/~1api~1v1~1events + /api/v1/dns/query: + $ref: ./dns.yaml#/paths/~1api~1v1~1dns~1query + /api/v1/dns/cache: + $ref: ./dns.yaml#/paths/~1api~1v1~1dns~1cache + /api/v1/dns/cache/{entry_id}: + $ref: ./dns.yaml#/paths/~1api~1v1~1dns~1cache~1{entry_id} + /api/v1/dns/cache/flush: + $ref: ./dns.yaml#/paths/~1api~1v1~1dns~1cache~1flush + /api/v1/operations/reload: + $ref: ./operations-probes.yaml#/paths/~1api~1v1~1operations~1reload + /api/v1/operations/suspend: + $ref: ./operations-probes.yaml#/paths/~1api~1v1~1operations~1suspend + /api/v1/operations/resume: + $ref: ./operations-probes.yaml#/paths/~1api~1v1~1operations~1resume + /api/v1/operations/{id}: + $ref: ./operations-probes.yaml#/paths/~1api~1v1~1operations~1{id} +components: + securitySchemes: + bearerAuth: + $ref: ./common.yaml#/securitySchemes/bearerAuth + headers: + NoStore: + $ref: ./common.yaml#/headers/NoStore + NoSniff: + $ref: ./common.yaml#/headers/NoSniff + ETag: + $ref: ./common.yaml#/headers/ETag + parameters: + Detail: + $ref: ./common.yaml#/parameters/Detail + Limit1000: + $ref: ./common.yaml#/parameters/Limit1000 + Cursor: + $ref: ./common.yaml#/parameters/Cursor + GroupId: + $ref: ./common.yaml#/parameters/GroupId + FlowId: + $ref: ./common.yaml#/parameters/FlowId + DnsEntryId: + $ref: ./common.yaml#/parameters/DnsEntryId + OperationId: + $ref: ./common.yaml#/parameters/OperationId + IfMatch: + $ref: ./common.yaml#/parameters/IfMatch + IdempotencyKey: + $ref: ./common.yaml#/parameters/IdempotencyKey + LastEventId: + $ref: ./common.yaml#/parameters/LastEventId + responses: + OperationAccepted: + $ref: ./common.yaml#/responses/OperationAccepted + BadRequest: + $ref: ./common.yaml#/responses/BadRequest + Unauthorized: + $ref: ./common.yaml#/responses/Unauthorized + Forbidden: + $ref: ./common.yaml#/responses/Forbidden + NotFound: + $ref: ./common.yaml#/responses/NotFound + Conflict: + $ref: ./common.yaml#/responses/Conflict + Gone: + $ref: ./common.yaml#/responses/Gone + PreconditionFailed: + $ref: ./common.yaml#/responses/PreconditionFailed + TooLarge: + $ref: ./common.yaml#/responses/TooLarge + UnsupportedMediaType: + $ref: ./common.yaml#/responses/UnsupportedMediaType + Unprocessable: + $ref: ./common.yaml#/responses/Unprocessable + PreconditionRequired: + $ref: ./common.yaml#/responses/PreconditionRequired + RateLimited: + $ref: ./common.yaml#/responses/RateLimited + Unavailable: + $ref: ./common.yaml#/responses/Unavailable + ErrorResponseCommon: + $ref: ./common.yaml#/responses/ErrorResponseCommon + schemas: + EmptyObject: + $ref: ./common.yaml#/schemas/EmptyObject + SafeUInt: + $ref: ./common.yaml#/schemas/SafeUInt + NullableSafeUInt: + $ref: ./common.yaml#/schemas/NullableSafeUInt + UInt64: + $ref: ./common.yaml#/schemas/UInt64 + NullableUInt64: + $ref: ./common.yaml#/schemas/NullableUInt64 + Timestamp: + $ref: ./common.yaml#/schemas/Timestamp + NullableTimestamp: + $ref: ./common.yaml#/schemas/NullableTimestamp + ErrorCode: + $ref: ./common.yaml#/schemas/ErrorCode + SafeError: + $ref: ./common.yaml#/schemas/SafeError + ApiError: + $ref: ./common.yaml#/schemas/ApiError + ErrorResponse: + $ref: ./common.yaml#/schemas/ErrorResponse + Discovery: + $ref: ./discovery.yaml#/schemas/Discovery + Version: + $ref: ./discovery.yaml#/schemas/Version + AvailableResource: + $ref: ./discovery.yaml#/schemas/AvailableResource + Capabilities: + $ref: ./discovery.yaml#/schemas/Capabilities + EffectiveConfig: + $ref: ./config.yaml#/schemas/EffectiveConfig + ConfigSource: + $ref: ./config.yaml#/schemas/ConfigSource + ConfigDiagnostic: + $ref: ./common.yaml#/schemas/ConfigDiagnostic + ConfigDiagnosticSpan: + $ref: ./common.yaml#/schemas/ConfigDiagnosticSpan + ConfigValidationMode: + $ref: ./config.yaml#/schemas/ConfigValidationMode + ConfigValidationSource: + $ref: ./config.yaml#/schemas/ConfigValidationSource + ConfigValidationRequest: + $ref: ./config.yaml#/schemas/ConfigValidationRequest + ConfigValidationResult: + $ref: ./config.yaml#/schemas/ConfigValidationResult + ProbeLimits: + $ref: ./operations-probes.yaml#/schemas/ProbeLimits + DnsQueryLimits: + $ref: ./dns.yaml#/schemas/DnsQueryLimits + Runtime: + $ref: ./runtime.yaml#/schemas/Runtime + LastReload: + $ref: ./runtime.yaml#/schemas/LastReload + TrafficSummary: + $ref: ./runtime.yaml#/schemas/TrafficSummary + RuntimeMemory: + $ref: ./runtime.yaml#/schemas/RuntimeMemory + RuntimeOutbounds: + $ref: ./runtime.yaml#/schemas/RuntimeOutbounds + OutboundCounters: + $ref: ./runtime.yaml#/schemas/OutboundCounters + TrafficHistory: + $ref: ./runtime.yaml#/schemas/TrafficHistory + TrafficHistorySample: + $ref: ./runtime.yaml#/schemas/TrafficHistorySample + DatapathKind: + $ref: ./runtime.yaml#/schemas/DatapathKind + DatapathState: + $ref: ./runtime.yaml#/schemas/DatapathState + Visibility: + $ref: ./common.yaml#/schemas/Visibility + ObservedBy: + $ref: ./common.yaml#/schemas/ObservedBy + EbpfRoutingSummary: + $ref: ./runtime.yaml#/schemas/EbpfRoutingSummary + EbpfSummary: + $ref: ./runtime.yaml#/schemas/EbpfSummary + DatapathSummary: + $ref: ./runtime.yaml#/schemas/DatapathSummary + Datapath: + $ref: ./runtime.yaml#/schemas/Datapath + EbpfDetail: + $ref: ./runtime.yaml#/schemas/EbpfDetail + EbpfAttachment: + $ref: ./runtime.yaml#/schemas/EbpfAttachment + EbpfMaps: + $ref: ./runtime.yaml#/schemas/EbpfMaps + MapOccupancy: + $ref: ./runtime.yaml#/schemas/MapOccupancy + Transport: + $ref: ./common.yaml#/schemas/Transport + IpVersion: + $ref: ./common.yaml#/schemas/IpVersion + HealthObservation: + $ref: ./nodes-groups.yaml#/schemas/HealthObservation + Node: + $ref: ./nodes-groups.yaml#/schemas/Node + NodeList: + $ref: ./nodes-groups.yaml#/schemas/NodeList + GroupPolicy: + $ref: ./nodes-groups.yaml#/schemas/GroupPolicy + GroupPolicyRequest: + $ref: ./nodes-groups.yaml#/schemas/GroupPolicyRequest + GroupMember: + $ref: ./nodes-groups.yaml#/schemas/GroupMember + GroupConfig: + $ref: ./nodes-groups.yaml#/schemas/GroupConfig + SafeHttpUrl: + $ref: ./nodes-groups.yaml#/schemas/SafeHttpUrl + GroupSelection: + $ref: ./nodes-groups.yaml#/schemas/GroupSelection + GroupRanking: + $ref: ./nodes-groups.yaml#/schemas/GroupRanking + GroupHealthObservation: + $ref: ./nodes-groups.yaml#/schemas/GroupHealthObservation + GroupRuntime: + $ref: ./nodes-groups.yaml#/schemas/GroupRuntime + GroupCapabilities: + $ref: ./nodes-groups.yaml#/schemas/GroupCapabilities + Group: + $ref: ./nodes-groups.yaml#/schemas/Group + GroupSummary: + $ref: ./nodes-groups.yaml#/schemas/GroupSummary + GroupList: + $ref: ./nodes-groups.yaml#/schemas/GroupList + JsonPatch: + $ref: ./nodes-groups.yaml#/schemas/JsonPatch + PolicyPatch: + $ref: ./nodes-groups.yaml#/schemas/PolicyPatch + MemberIdPatch: + $ref: ./nodes-groups.yaml#/schemas/MemberIdPatch + OutboundPatch: + $ref: ./nodes-groups.yaml#/schemas/OutboundPatch + CheckUrlPatch: + $ref: ./nodes-groups.yaml#/schemas/CheckUrlPatch + PositiveIntegerPatch: + $ref: ./nodes-groups.yaml#/schemas/PositiveIntegerPatch + TolerancePatch: + $ref: ./nodes-groups.yaml#/schemas/TolerancePatch + IdleTimeoutPatch: + $ref: ./nodes-groups.yaml#/schemas/IdleTimeoutPatch + InterruptPatch: + $ref: ./nodes-groups.yaml#/schemas/InterruptPatch + MutableGroupPath: + $ref: ./nodes-groups.yaml#/schemas/MutableGroupPath + RemovePatch: + $ref: ./nodes-groups.yaml#/schemas/RemovePatch + CopyMovePatch: + $ref: ./nodes-groups.yaml#/schemas/CopyMovePatch + GroupSelectionRequest: + $ref: ./nodes-groups.yaml#/schemas/GroupSelectionRequest + GroupSelectionResult: + $ref: ./nodes-groups.yaml#/schemas/GroupSelectionResult + ProbeKind: + $ref: ./operations-probes.yaml#/schemas/ProbeKind + ProbeTarget: + $ref: ./operations-probes.yaml#/schemas/ProbeTarget + NodeProbeTarget: + $ref: ./operations-probes.yaml#/schemas/NodeProbeTarget + GroupProbeTarget: + $ref: ./operations-probes.yaml#/schemas/GroupProbeTarget + ProbeTargetResponse: + $ref: ./operations-probes.yaml#/schemas/ProbeTargetResponse + ProbeMembers: + $ref: ./operations-probes.yaml#/schemas/ProbeMembers + ProbeRequest: + $ref: ./operations-probes.yaml#/schemas/ProbeRequest + ProbeResultItem: + $ref: ./operations-probes.yaml#/schemas/ProbeResultItem + ProbeResult: + $ref: ./operations-probes.yaml#/schemas/ProbeResult + TransportBooleanMap: + $ref: ./operations-probes.yaml#/schemas/TransportBooleanMap + TransportSelectionMap: + $ref: ./operations-probes.yaml#/schemas/TransportSelectionMap + ConnectionState: + $ref: ./flows.yaml#/schemas/ConnectionState + Connection: + $ref: ./flows.yaml#/schemas/Connection + ConnectionList: + $ref: ./flows.yaml#/schemas/ConnectionList + FlowScope: + $ref: ./flows.yaml#/schemas/FlowScope + TraceStatus: + $ref: ./flows.yaml#/schemas/TraceStatus + FlowInput: + $ref: ./flows.yaml#/schemas/FlowInput + TrafficRoutingInput: + $ref: ./flow-steps.yaml#/schemas/TrafficRoutingInput + DnsRequestRoutingInput: + $ref: ./flow-steps.yaml#/schemas/DnsRequestRoutingInput + DnsResponseRoutingInput: + $ref: ./flow-steps.yaml#/schemas/DnsResponseRoutingInput + DomainSource: + $ref: ./flows.yaml#/schemas/DomainSource + FlowSummary: + $ref: ./flows.yaml#/schemas/FlowSummary + FlowCoverage: + $ref: ./flows.yaml#/schemas/FlowCoverage + FlowList: + $ref: ./flows.yaml#/schemas/FlowList + FlowDetail: + $ref: ./flows.yaml#/schemas/FlowDetail + FlowTrace: + $ref: ./flows.yaml#/schemas/FlowTrace + FlowStep: + $ref: ./flows.yaml#/schemas/FlowStep + InputStep: + $ref: ./flow-steps.yaml#/schemas/InputStep + InputStepData: + $ref: ./flow-steps.yaml#/schemas/InputStepData + RouteStep: + $ref: ./flow-steps.yaml#/schemas/RouteStep + RouteStepData: + $ref: ./flow-steps.yaml#/schemas/RouteStepData + DatapathStep: + $ref: ./flow-steps.yaml#/schemas/DatapathStep + DatapathStepData: + $ref: ./flow-steps.yaml#/schemas/DatapathStepData + DialModeStep: + $ref: ./flow-steps.yaml#/schemas/DialModeStep + DialModeStepData: + $ref: ./flow-steps.yaml#/schemas/DialModeStepData + DnsStep: + $ref: ./flow-steps.yaml#/schemas/DnsStep + DnsStepData: + $ref: ./flow-steps.yaml#/schemas/DnsStepData + RerouteStep: + $ref: ./flow-steps.yaml#/schemas/RerouteStep + RerouteStepData: + $ref: ./flow-steps.yaml#/schemas/RerouteStepData + OutboundStep: + $ref: ./flow-steps.yaml#/schemas/OutboundStep + OutboundStepData: + $ref: ./flow-steps.yaml#/schemas/OutboundStepData + SelectionPathItem: + $ref: ./flow-steps.yaml#/schemas/SelectionPathItem + SelectionDecision: + $ref: ./flow-steps.yaml#/schemas/SelectionDecision + SelectionCandidate: + $ref: ./flow-steps.yaml#/schemas/SelectionCandidate + ConnectionStep: + $ref: ./flow-steps.yaml#/schemas/ConnectionStep + ConnectionStepData: + $ref: ./flow-steps.yaml#/schemas/ConnectionStepData + RuleResult: + $ref: ./flow-steps.yaml#/schemas/RuleResult + RuleCondition: + $ref: ./flow-steps.yaml#/schemas/RuleCondition + RuleEvaluation: + $ref: ./flow-steps.yaml#/schemas/RuleEvaluation + IpAddress: + $ref: ./common.yaml#/schemas/IpAddress + RoutingTraceInput: + $ref: ./routing.yaml#/schemas/RoutingTraceInput + RoutingTraceRequest: + $ref: ./routing.yaml#/schemas/RoutingTraceRequest + RoutingEvaluation: + $ref: ./routing.yaml#/schemas/RoutingEvaluation + SimulationDnsData: + $ref: ./routing.yaml#/schemas/SimulationDnsData + RoutingTraceResponse: + $ref: ./routing.yaml#/schemas/RoutingTraceResponse + DnsRecordType: + $ref: ./dns.yaml#/schemas/DnsRecordType + DnsAnswer: + $ref: ./dns.yaml#/schemas/DnsAnswer + DnsQuestion: + $ref: ./dns.yaml#/schemas/DnsQuestion + DnsRoute: + $ref: ./dns.yaml#/schemas/DnsRoute + DnsQueryResult: + $ref: ./dns.yaml#/schemas/DnsQueryResult + DnsQueryResponse: + $ref: ./dns.yaml#/schemas/DnsQueryResponse + DnsCacheCoverage: + $ref: ./dns.yaml#/schemas/DnsCacheCoverage + DnsCacheEntry: + $ref: ./dns.yaml#/schemas/DnsCacheEntry + DnsCacheList: + $ref: ./dns.yaml#/schemas/DnsCacheList + DeleteCount: + $ref: ./dns.yaml#/schemas/DeleteCount + DeleteMatchingCount: + $ref: ./dns.yaml#/schemas/DeleteMatchingCount + OperationKind: + $ref: ./operations-probes.yaml#/schemas/OperationKind + OperationAccepted: + $ref: ./operations-probes.yaml#/schemas/OperationAccepted + Operation: + $ref: ./operations-probes.yaml#/schemas/Operation + QueuedOperation: + $ref: ./operations-probes.yaml#/schemas/QueuedOperation + RunningOperation: + $ref: ./operations-probes.yaml#/schemas/RunningOperation + FailedOperation: + $ref: ./operations-probes.yaml#/schemas/FailedOperation + ProbeSucceededOperation: + $ref: ./operations-probes.yaml#/schemas/ProbeSucceededOperation + ReloadSucceededOperation: + $ref: ./operations-probes.yaml#/schemas/ReloadSucceededOperation + ReloadResult: + $ref: ./operations-probes.yaml#/schemas/ReloadResult + SuspendSucceededOperation: + $ref: ./operations-probes.yaml#/schemas/SuspendSucceededOperation + ResumeSucceededOperation: + $ref: ./operations-probes.yaml#/schemas/ResumeSucceededOperation + GroupUpdateSucceededOperation: + $ref: ./operations-probes.yaml#/schemas/GroupUpdateSucceededOperation + GroupUpdateResult: + $ref: ./operations-probes.yaml#/schemas/GroupUpdateResult + EventKind: + $ref: ./events.yaml#/schemas/EventKind + StreamReadyEvent: + $ref: ./events.yaml#/schemas/StreamReadyEvent + RuntimeUpdatedEvent: + $ref: ./events.yaml#/schemas/RuntimeUpdatedEvent + FlowUpdatedEvent: + $ref: ./events.yaml#/schemas/FlowUpdatedEvent + FlowGapEvent: + $ref: ./events.yaml#/schemas/FlowGapEvent + OperationUpdatedEvent: + $ref: ./events.yaml#/schemas/OperationUpdatedEvent + GenerationChangedEvent: + $ref: ./events.yaml#/schemas/GenerationChangedEvent + StepCommon: + $ref: ./flow-steps.yaml#/schemas/StepCommon + OperationCommon: + $ref: ./operations-probes.yaml#/schemas/OperationCommon + examples: + FlowUpdated: + $ref: ./events.yaml#/examples/FlowUpdated diff --git a/api/operations-probes.yaml b/api/operations-probes.yaml new file mode 100644 index 0000000..3dae0f7 --- /dev/null +++ b/api/operations-probes.yaml @@ -0,0 +1,853 @@ +paths: + /api/v1/probes: + post: + operationId: createProbe + summary: Start a bounded typed probe operation + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/IdempotencyKey + requestBody: + required: true + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ProbeRequest + examples: + dns_udp: + value: + target: + type: group + group_id: group-proxy + kind: dns + purpose: dns + transport: [ udp ] + ip_version: ipv4 + members: [ node-hk-01, group-jp ] + warmth: cold + responses: + "202": + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/OperationAccepted + examples: + queued: + value: + operation_id: op-01HZX4K8W9 + kind: probe + status: queued + href: /api/v1/operations/op-01HZX4K8W9 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8W9 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "413": + $ref: ./openapi.yaml#/components/responses/TooLarge + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "422": + $ref: ./openapi.yaml#/components/responses/Unprocessable + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + description: Probe queue is full + headers: + Retry-After: + description: Positive retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + queue_full: + value: + error: + code: temporarily_unavailable + message: Probe queue is full. + request_id: request-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 + /api/v1/operations/reload: + post: + operationId: startReload + summary: Start an asynchronous reload + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/IdempotencyKey + requestBody: + required: false + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/EmptyObject + examples: + empty: + value: {} + responses: + "202": + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/OperationAccepted + examples: + queued: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: queued + href: /api/v1/operations/op-01HZX4K8W7 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8W7 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + /api/v1/operations/suspend: + post: + operationId: startSuspend + summary: Start an asynchronous suspend transition + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/IdempotencyKey + requestBody: + required: false + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/EmptyObject + examples: + empty: + value: {} + responses: + "202": + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/OperationAccepted + examples: + queued: + value: + operation_id: op-01HZX4K8W8 + kind: suspend + status: queued + href: /api/v1/operations/op-01HZX4K8W8 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8W8 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + /api/v1/operations/resume: + post: + operationId: startResume + summary: Start an asynchronous resume transition + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/IdempotencyKey + requestBody: + required: false + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/EmptyObject + examples: + empty: + value: {} + responses: + "202": + $ref: ./openapi.yaml#/components/responses/OperationAccepted + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable + /api/v1/operations/{id}: + get: + operationId: getOperation + summary: Read an asynchronous operation + x-permission: observe-owner-or-control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/OperationId + responses: + "200": + description: Current or terminal operation state + headers: + Retry-After: + description: Positive polling floor in seconds; present while nonterminal. + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Operation + examples: + probe_complete: + value: + operation_id: op-01HZX4K8W9 + kind: probe + status: succeeded + created_at: 2026-08-15T09:59:59Z + started_at: 2026-08-15T10:00:00Z + finished_at: 2026-08-15T10:00:01Z + result: + target: + type: group + group_id: group-proxy + selection_changed: + tcp: false + udp: false + selection_before: + tcp: node-hk-01 + udp: null + selection_after: + tcp: node-hk-01 + udp: null + results: + - member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + kind: dns + purpose: dns + transport: udp + ip_version: ipv4 + warmth: cold + state: healthy + latency_ms: 45 + health_updated: true + error: null + observed_at: 2026-08-15T10:00:00Z + - member_id: group-jp + resolved_leaf_node_id: node-jp-01 + kind: dns + purpose: dns + transport: udp + ip_version: ipv4 + warmth: cold + state: unavailable + latency_ms: null + health_updated: true + error: udp_probe_timeout + observed_at: 2026-08-15T10:00:00Z + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + reload_running: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: running + created_at: 2026-08-15T09:29:59Z + started_at: 2026-08-15T09:30:00Z + finished_at: null + result: null + error: null + x-headers: + Content-Type: application/json + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + reload_complete: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: succeeded + created_at: 2026-08-15T09:29:59Z + started_at: 2026-08-15T09:30:00Z + finished_at: 2026-08-15T09:30:01Z + result: + active_generation_id: generation-42 + datapath_generation_id: generation-42 + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + reload_complete_reload: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: succeeded + created_at: 2026-08-15T09:29:59Z + started_at: 2026-08-15T09:30:00Z + finished_at: 2026-08-15T09:30:00Z + result: + active_generation_id: generation-42 + datapath_generation_id: generation-42 + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + suspend_complete: + value: + operation_id: op-01HZX4K8W8 + kind: suspend + status: succeeded + created_at: 2026-08-15T10:00:59Z + started_at: 2026-08-15T10:01:00Z + finished_at: 2026-08-15T10:01:00Z + result: + runtime_state: suspended + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited +schemas: + ProbeLimits: + type: object + required: [ max_members_per_job, max_results_per_job, max_active_jobs, max_queued_jobs, max_concurrent_per_target, job_timeout_ms, per_principal_requests_per_minute, global_requests_per_minute ] + properties: + max_members_per_job: + type: integer + minimum: 1 + max_results_per_job: + type: integer + minimum: 1 + max_active_jobs: + type: integer + minimum: 1 + max_queued_jobs: + type: integer + minimum: 0 + max_concurrent_per_target: + type: integer + minimum: 1 + job_timeout_ms: + type: integer + minimum: 1 + per_principal_requests_per_minute: + type: integer + minimum: 1 + global_requests_per_minute: + type: integer + minimum: 1 + ProbeKind: + type: string + enum: [ tcp_connect, http, dns ] + ProbeTarget: + unevaluatedProperties: false + oneOf: + - $ref: ./openapi.yaml#/components/schemas/NodeProbeTarget + - $ref: ./openapi.yaml#/components/schemas/GroupProbeTarget + NodeProbeTarget: + type: object + required: [ type, node_id ] + properties: + type: + type: string + const: node + node_id: + type: string + minLength: 1 + GroupProbeTarget: + type: object + required: [ type, group_id ] + properties: + type: + type: string + const: group + group_id: + type: string + minLength: 1 + ProbeTargetResponse: + oneOf: + - $ref: ./openapi.yaml#/components/schemas/NodeProbeTarget + - $ref: ./openapi.yaml#/components/schemas/GroupProbeTarget + ProbeMembers: + oneOf: + - type: string + enum: [ direct, leaves ] + - type: array + minItems: 1 + uniqueItems: true + items: + type: string + minLength: 1 + ProbeRequest: + type: object + additionalProperties: false + required: [ target, kind, purpose, transport, ip_version, warmth ] + properties: + target: + $ref: ./openapi.yaml#/components/schemas/ProbeTarget + kind: + $ref: ./openapi.yaml#/components/schemas/ProbeKind + purpose: + type: string + enum: [ data, dns ] + transport: + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + items: + $ref: ./openapi.yaml#/components/schemas/Transport + ip_version: + type: string + enum: [ ipv4, ipv6, any ] + members: + $ref: ./openapi.yaml#/components/schemas/ProbeMembers + default: direct + warmth: + type: string + enum: [ cold, warm ] + allOf: + - if: + properties: + target: + properties: + type: + const: node + required: [ type ] + then: + not: + required: [ members ] + - oneOf: + - properties: + kind: + const: tcp_connect + purpose: + const: data + transport: + type: array + minItems: 1 + maxItems: 1 + items: + const: tcp + - properties: + kind: + const: http + purpose: + const: data + transport: + type: array + minItems: 1 + maxItems: 1 + items: + const: tcp + - properties: + kind: + const: dns + purpose: + const: dns + ProbeResultItem: + type: object + required: [ member_id, resolved_leaf_node_id, kind, purpose, transport, ip_version, warmth, state, latency_ms, health_updated, error, observed_at ] + properties: + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: [ string, "null" ] + kind: + $ref: ./openapi.yaml#/components/schemas/ProbeKind + purpose: + type: string + enum: [ data, dns ] + transport: + $ref: ./openapi.yaml#/components/schemas/Transport + ip_version: + $ref: ./openapi.yaml#/components/schemas/IpVersion + warmth: + type: string + enum: [ cold, warm, unknown ] + state: + type: string + enum: [ healthy, unavailable, unknown ] + latency_ms: + type: [ number, "null" ] + minimum: 0 + health_updated: + type: boolean + error: + type: [ string, "null" ] + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + allOf: + - if: + properties: + state: + enum: [ unavailable, unknown ] + then: + properties: + latency_ms: + type: "null" + ProbeResult: + type: object + required: [ target, selection_changed, selection_before, selection_after, results ] + properties: + target: + $ref: ./openapi.yaml#/components/schemas/ProbeTargetResponse + selection_changed: + $ref: ./openapi.yaml#/components/schemas/TransportBooleanMap + selection_before: + $ref: ./openapi.yaml#/components/schemas/TransportSelectionMap + selection_after: + $ref: ./openapi.yaml#/components/schemas/TransportSelectionMap + results: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/ProbeResultItem + TransportBooleanMap: + type: object + required: [ tcp, udp ] + properties: + tcp: + type: boolean + udp: + type: boolean + TransportSelectionMap: + type: object + required: [ tcp, udp ] + properties: + tcp: + type: [ string, "null" ] + udp: + type: [ string, "null" ] + OperationKind: + type: string + enum: [ probe, reload, suspend, resume, group_update ] + OperationAccepted: + type: object + required: [ operation_id, kind, status, href ] + properties: + operation_id: + type: string + minLength: 1 + kind: + $ref: ./openapi.yaml#/components/schemas/OperationKind + status: + type: string + const: queued + href: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Operation: + oneOf: + - $ref: ./openapi.yaml#/components/schemas/QueuedOperation + - $ref: ./openapi.yaml#/components/schemas/RunningOperation + - $ref: ./openapi.yaml#/components/schemas/FailedOperation + - $ref: ./openapi.yaml#/components/schemas/ProbeSucceededOperation + - $ref: ./openapi.yaml#/components/schemas/ReloadSucceededOperation + - $ref: ./openapi.yaml#/components/schemas/SuspendSucceededOperation + - $ref: ./openapi.yaml#/components/schemas/ResumeSucceededOperation + - $ref: ./openapi.yaml#/components/schemas/GroupUpdateSucceededOperation + QueuedOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + status: + const: queued + started_at: + type: "null" + finished_at: + type: "null" + result: + type: "null" + error: + type: "null" + RunningOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + status: + const: running + finished_at: + type: "null" + result: + type: "null" + error: + type: "null" + FailedOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + status: + const: failed + finished_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + result: + type: "null" + error: + $ref: ./openapi.yaml#/components/schemas/SafeError + ProbeSucceededOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + kind: + const: probe + status: + const: succeeded + finished_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + result: + $ref: ./openapi.yaml#/components/schemas/ProbeResult + error: + type: "null" + ReloadSucceededOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + kind: + const: reload + status: + const: succeeded + finished_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + result: + $ref: ./openapi.yaml#/components/schemas/ReloadResult + error: + type: "null" + ReloadResult: + type: object + required: [ active_generation_id, datapath_generation_id ] + properties: + active_generation_id: + type: [ string, "null" ] + datapath_generation_id: + type: [ string, "null" ] + SuspendSucceededOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + kind: + const: suspend + status: + const: succeeded + finished_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + result: + type: object + required: [ runtime_state ] + properties: + runtime_state: + type: [ string, "null" ] + enum: [ suspended, null ] + error: + type: "null" + ResumeSucceededOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + kind: + const: resume + status: + const: succeeded + finished_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + result: + type: object + required: [ runtime_state ] + properties: + runtime_state: + type: [ string, "null" ] + enum: [ running, null ] + error: + type: "null" + GroupUpdateSucceededOperation: + allOf: + - $ref: ./openapi.yaml#/components/schemas/OperationCommon + - type: object + properties: + kind: + const: group_update + status: + const: succeeded + finished_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + result: + $ref: ./openapi.yaml#/components/schemas/GroupUpdateResult + error: + type: "null" + GroupUpdateResult: + type: object + required: [ group_id, config_revision ] + properties: + group_id: + type: string + minLength: 1 + config_revision: + type: string + minLength: 1 + OperationCommon: + type: object + required: [ operation_id, kind, status, created_at, started_at, finished_at, result, error ] + properties: + operation_id: + type: string + minLength: 1 + kind: + $ref: ./openapi.yaml#/components/schemas/OperationKind + status: + type: string + enum: [ queued, running, succeeded, failed ] + created_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + started_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + finished_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + result: + type: [ object, "null" ] + additionalProperties: true + error: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/SafeError diff --git a/api/routing.yaml b/api/routing.yaml new file mode 100644 index 0000000..56d559f --- /dev/null +++ b/api/routing.yaml @@ -0,0 +1,244 @@ +paths: + /api/v1/routing/trace: + post: + operationId: traceRouting + summary: Simulate bounded routing evaluation + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/RoutingTraceRequest + examples: + hypothetical: + value: + input: + network: tcp + domain: example.com + dst_ip: 198.51.100.20 + dst_port: 443 + src_ip: 192.0.2.10 + src_port: 53000 + pname: null + dscp: 0 + mark: 0 + resolve: none + responses: + "200": + description: Hypothetical routing evaluation; never a recorded flow + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/RoutingTraceResponse + examples: + indeterminate: + value: + mode: simulation + instance_id: instance-7 + generation_id: generation-42 + observed_at: 2026-09-14T10:00:00Z + evaluations: + - dst_ip: 198.51.100.20 + decision: indeterminate + outbound: null + missing_inputs: [ pname ] + rules: + - rule_id: traffic:0 + expression: pname(curl) -> direct + result: indeterminate + missing_inputs: [ pname ] + conditions: + - id: traffic:0/pname + expression: pname(curl) + result: indeterminate + missing_inputs: [ pname ] + - rule_id: traffic:1 + expression: dip(198.51.100.0/24) -> proxy + result: matched + missing_inputs: [] + conditions: + - id: traffic:1/dip + expression: dip(198.51.100.0/24) + result: matched + missing_inputs: [] + - rule_id: traffic:2 + expression: "fallback: block" + result: skipped + missing_inputs: [] + conditions: [] + dns: [] + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + $ref: ./openapi.yaml#/components/responses/BadRequest + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "409": + $ref: ./openapi.yaml#/components/responses/Conflict + "413": + $ref: ./openapi.yaml#/components/responses/TooLarge + "415": + $ref: ./openapi.yaml#/components/responses/UnsupportedMediaType + "422": + $ref: ./openapi.yaml#/components/responses/Unprocessable + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + "503": + $ref: ./openapi.yaml#/components/responses/Unavailable +schemas: + RoutingTraceInput: + type: object + additionalProperties: false + required: [ network, dst_port ] + properties: + network: + $ref: ./openapi.yaml#/components/schemas/Transport + domain: + type: [ string, "null" ] + minLength: 1 + dst_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + dst_port: + type: integer + minimum: 1 + maximum: 65535 + src_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + src_port: + type: [ integer, "null" ] + minimum: 1 + maximum: 65535 + pname: + type: [ string, "null" ] + dscp: + type: [ integer, "null" ] + minimum: 0 + maximum: 63 + mark: + type: [ integer, "null" ] + minimum: 0 + maximum: 4294967295 + anyOf: + - required: [ domain ] + properties: + domain: + type: string + minLength: 1 + - required: [ dst_ip ] + properties: + dst_ip: + $ref: ./openapi.yaml#/components/schemas/IpAddress + RoutingTraceRequest: + type: object + additionalProperties: false + required: [ input ] + properties: + input: + $ref: ./openapi.yaml#/components/schemas/RoutingTraceInput + resolve: + type: string + enum: [ none, live ] + default: none + allOf: + - if: + required: [ resolve ] + properties: + resolve: + const: live + then: + properties: + input: + allOf: + - required: [ domain ] + properties: + domain: + type: string + minLength: 1 + - properties: + dst_ip: + type: "null" + RoutingEvaluation: + type: object + required: [ dst_ip, decision, outbound, missing_inputs, rules ] + properties: + dst_ip: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/IpAddress + decision: + type: string + enum: [ determinate, indeterminate ] + outbound: + type: [ string, "null" ] + missing_inputs: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + rules: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/RuleEvaluation + SimulationDnsData: + allOf: + - $ref: ./openapi.yaml#/components/schemas/DnsStepData + - type: object + properties: + lookup_id: + type: string + minLength: 1 + description: Simulation-local lookup ID; never a recorded-flow identity. + purpose: + const: dial_target + attempt_id: + type: "null" + description: Simulations never create outbound attempts. + RoutingTraceResponse: + type: object + required: [ mode, instance_id, generation_id, observed_at, evaluations, dns ] + properties: + mode: + type: string + const: simulation + instance_id: + type: string + minLength: 1 + generation_id: + type: string + minLength: 1 + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + evaluations: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/RoutingEvaluation + dns: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/SimulationDnsData + not: + anyOf: + - required: [ id ] + - required: [ flow_id ] + - required: [ connection_id ] + description: Hypothetical output. It never identifies or claims history for a live flow. diff --git a/api/runtime.yaml b/api/runtime.yaml new file mode 100644 index 0000000..db97e26 --- /dev/null +++ b/api/runtime.yaml @@ -0,0 +1,739 @@ +paths: + /api/v1/runtime: + get: + operationId: getRuntime + summary: Read coherent runtime summary + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/Detail + responses: + "200": + description: Runtime snapshot + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Runtime + examples: + snapshot: + value: + observed_at: 2026-08-15T10:00:00Z + instance_id: instance-7 + lifecycle: + state: running + started_at: 2026-08-15T08:00:00Z + uptime_seconds: "7200" + generation: + active_id: generation-42 + config_revision: "17" + state: active + activated_at: 2026-08-15T09:30:00Z + datapath: + kind: ebpf + state: active + visibility: partial + ebpf: + backend: real + programs: loaded + hooks: attached + routing: + state: published + generation_id: generation-42 + health: healthy + last_error: null + checked_at: 2026-08-15T10:00:00Z + traffic: + scope: visible + observed_by: mixed + counter_since: 2026-08-15T08:00:00Z + sampled_at: 2026-08-15T10:00:00Z + connections: + tcp: 42 + udp: 128 + total: 170 + bytes: + upload: "123456789" + download: "987654321" + rates: + window_seconds: 1 + upload_bytes_per_second: "4096" + download_bytes_per_second: "32768" + process: + pid: 1234 + cpu_percent: null + last_reload: + operation_id: op-01HZX4K8W7 + status: succeeded + finished_at: 2026-08-15T09:30:00Z + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + /api/v1/runtime/memory: + get: + operationId: getRuntimeMemory + summary: Read lightweight process, cgroup, and eBPF memory + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + "200": + description: Memory snapshot + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/RuntimeMemory + examples: + snapshot: + value: + observed_at: 2026-08-15T10:00:00Z + process: + rss_bytes: "67108864" + cgroup: + scope: service + current_bytes: "83886080" + limit_bytes: "536870912" + events: + high: "0" + oom: "0" + oom_kill: "0" + kernel: + ebpf_bytes: null + sampled_at: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + /api/v1/runtime/outbounds: + get: + operationId: getRuntimeOutbounds + summary: Read per-outbound cumulative counters + description: | + Mirrors honk's Clash-surface /stats counters for visible traffic, not + a sum of live connections. All rows share counter_since; restart or + counter reset starts a new interval. Requires resources.runtime_outbounds.available. + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + "200": + description: Outbound counter snapshot + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/RuntimeOutbounds + examples: + snapshot: + value: + observed_at: 2026-08-15T10:00:00Z + counter_since: 2026-08-15T08:00:00Z + outbounds: + - name: direct + kind: builtin + active_connections: 12 + total_connections: "240" + upload_bytes: "123456" + download_bytes: "654321" + errors: "0" + - name: proxy + kind: group + active_connections: 158 + total_connections: "1200" + upload_bytes: "123333333" + download_bytes: "987000000" + errors: "3" + - name: block + kind: builtin + active_connections: 0 + total_connections: "0" + upload_bytes: "0" + download_bytes: "0" + errors: "0" + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + /api/v1/runtime/traffic/history: + get: + operationId: getTrafficHistory + summary: Read bounded traffic history + description: | + Reads a bounded in-memory ring of visible traffic samples without + starting sampling on GET. This is the only traffic history the native + API serves; SSE does not replay it. Requires resources.traffic_history.available. + A query above either advertised limit returns 400 invalid_request, + never a silently clamped window or point limit. + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: window_seconds + in: query + description: Look-back window ending at observed_at; defaults to resources.traffic_history.max_window_seconds. + schema: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + example: 60 + - name: max_points + in: query + description: Maximum returned samples; defaults to resources.traffic_history.max_points. + schema: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + example: 3 + responses: + "200": + description: Retained traffic samples, oldest first + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/TrafficHistory + examples: + recent: + value: + observed_at: 2026-08-15T10:00:00Z + window_seconds: 60 + sampled_every_seconds: 20 + samples: + - sampled_at: 2026-08-15T09:59:20Z + upload_bytes_per_second: "2048" + download_bytes_per_second: "16384" + connections: 162 + - sampled_at: 2026-08-15T09:59:40Z + upload_bytes_per_second: null + download_bytes_per_second: null + connections: null + - sampled_at: 2026-08-15T10:00:00Z + upload_bytes_per_second: "4096" + download_bytes_per_second: "32768" + connections: 170 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "400": + description: Invalid query or window_seconds/max_points above the advertised limit + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/ErrorResponse + examples: + window_too_large: + value: + error: + code: invalid_request + message: window_seconds exceeds the advertised limit. + request_id: request-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + too_many_points: + value: + error: + code: invalid_request + message: max_points exceeds the advertised limit. + request_id: request-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound + "429": + $ref: ./openapi.yaml#/components/responses/RateLimited + /api/v1/datapath: + get: + operationId: getDatapath + summary: Read detailed datapath state + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - $ref: ./openapi.yaml#/components/parameters/Detail + responses: + "200": + description: Datapath snapshot + headers: + Cache-Control: + $ref: ./openapi.yaml#/components/headers/NoStore + X-Content-Type-Options: + $ref: ./openapi.yaml#/components/headers/NoSniff + content: + application/json: + schema: + $ref: ./openapi.yaml#/components/schemas/Datapath + examples: + active: + value: + observed_at: 2026-08-15T10:00:00Z + kind: ebpf + state: active + visibility: partial + ebpf: + backend: real + programs: loaded + hooks: attached + routing: + state: published + generation_id: generation-42 + epoch: "3" + attachments: + - name: wan_ingress + interface: eth0 + direction: ingress + state: attached + maps: + state: ready + conn_state: + occupancy: 1200 + capacity: 524288 + occupancy_known: true + health: healthy + last_error: null + checked_at: 2026-08-15T10:00:00Z + errors: [] + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + "401": + $ref: ./openapi.yaml#/components/responses/Unauthorized + "403": + $ref: ./openapi.yaml#/components/responses/Forbidden + "404": + $ref: ./openapi.yaml#/components/responses/NotFound +schemas: + Runtime: + type: object + required: [ observed_at, instance_id, lifecycle, generation, datapath, traffic, process, last_reload ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + instance_id: + type: string + minLength: 1 + lifecycle: + type: object + required: [ state, started_at, uptime_seconds ] + properties: + state: + type: string + enum: [ starting, running, reloading, suspended, draining, degraded, failed ] + started_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + uptime_seconds: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + generation: + type: object + required: [ active_id, config_revision, state, activated_at ] + properties: + active_id: + type: string + minLength: 1 + config_revision: + type: [ string, "null" ] + state: + type: string + enum: [ active, reloading ] + activated_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + datapath: + $ref: ./openapi.yaml#/components/schemas/DatapathSummary + traffic: + $ref: ./openapi.yaml#/components/schemas/TrafficSummary + process: + type: object + required: [ cpu_percent ] + properties: + pid: + type: [ integer, "null" ] + minimum: 0 + maximum: 4294967295 + cpu_percent: + type: [ number, "null" ] + minimum: 0 + last_reload: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/LastReload + LastReload: + type: object + required: [ operation_id, status, finished_at, error ] + properties: + operation_id: + type: string + minLength: 1 + status: + type: string + enum: [ succeeded, failed ] + finished_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + error: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/SafeError + TrafficSummary: + type: object + required: [ scope, observed_by, counter_since, sampled_at, connections, bytes, rates ] + properties: + scope: + type: string + const: visible + observed_by: + $ref: ./openapi.yaml#/components/schemas/ObservedBy + counter_since: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + sampled_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + description: Traffic sample timestamp, or null when unavailable. Never substitute the HTTP snapshot timestamp. + connections: + type: object + required: [ tcp, udp, total ] + properties: + tcp: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + udp: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + total: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + bytes: + type: object + required: [ upload, download ] + properties: + upload: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + download: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + rates: + oneOf: + - type: "null" + - type: object + required: [ window_seconds, upload_bytes_per_second, download_bytes_per_second ] + properties: + window_seconds: + type: number + exclusiveMinimum: 0 + description: Duration of the rate interval ending at sampled_at, in seconds. + upload_bytes_per_second: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + download_bytes_per_second: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + RuntimeOutbounds: + type: object + required: [ observed_at, counter_since, outbounds ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + counter_since: + $ref: ./openapi.yaml#/components/schemas/Timestamp + description: Shared cumulative counter reset boundary; changes on restart or reset. Never compute deltas across this boundary. + outbounds: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/OutboundCounters + OutboundCounters: + type: object + required: [ name, kind, active_connections, total_connections, upload_bytes, download_bytes, errors ] + properties: + name: + type: string + minLength: 1 + description: Engine-visible outbound name, retained with its counters across reloads. + kind: + type: string + enum: [ group, node, builtin ] + description: Configured group, leaf node, or engine builtin such as direct/block. + active_connections: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + description: Currently active connections attributed to this outbound. + total_connections: + $ref: ./openapi.yaml#/components/schemas/UInt64 + description: Cumulative connections attributed to this outbound since counter_since. + upload_bytes: + $ref: ./openapi.yaml#/components/schemas/UInt64 + download_bytes: + $ref: ./openapi.yaml#/components/schemas/UInt64 + errors: + $ref: ./openapi.yaml#/components/schemas/UInt64 + description: Cumulative outbound failures since counter_since; policy blocks are not errors. + TrafficHistory: + type: object + required: [ observed_at, window_seconds, sampled_every_seconds, samples ] + description: | + Samples lie in (observed_at - window_seconds, observed_at], oldest first. + Retention is bounded by age and capacity and is cleared on process restart. + Return fewer points for short retention, or an empty array before sampling. + If needed, select every Nth stored sample backwards from the newest to + fit max_points; preserve original timestamps and rates, without interpolation. + Choose the smallest positive N that fits the point limit. A cumulative + counter reset makes the spanning rate sample null, never a spike. + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + window_seconds: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + minimum: 1 + description: Requested look-back window, not the age of the oldest retained sample. + sampled_every_seconds: + type: number + exclusiveMinimum: 0 + description: Nominal interval between returned samples after thinning, or the recorder interval for an empty result; not the rate averaging interval. + samples: + type: array + description: At most max_points retained samples; missed intervals remain gaps, never fabricated zero traffic. + items: + $ref: ./openapi.yaml#/components/schemas/TrafficHistorySample + TrafficHistorySample: + type: object + required: [ sampled_at, upload_bytes_per_second, download_bytes_per_second, connections ] + properties: + sampled_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + description: Original sample timestamp, never the HTTP snapshot time. + upload_bytes_per_second: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + download_bytes_per_second: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + connections: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + description: Visible active TCP and UDP connections at sampled_at, or null when unavailable. + RuntimeMemory: + type: object + required: [ observed_at, process, cgroup, kernel ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + process: + oneOf: + - type: "null" + - type: object + properties: + rss_bytes: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + cgroup: + oneOf: + - type: "null" + - type: object + required: [ scope ] + properties: + scope: + type: string + enum: [ service, shared, unknown ] + current_bytes: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + limit_bytes: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + events: + oneOf: + - type: "null" + - type: object + properties: + high: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + oom: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + oom_kill: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + kernel: + oneOf: + - type: "null" + - type: object + required: [ sampled_at ] + properties: + ebpf_bytes: + $ref: ./openapi.yaml#/components/schemas/NullableUInt64 + sampled_at: + $ref: ./openapi.yaml#/components/schemas/NullableTimestamp + DatapathKind: + type: string + enum: [ ebpf, userspace, mock, unknown ] + DatapathState: + type: string + enum: [ active, degraded, detached, failed, disabled, unknown ] + EbpfRoutingSummary: + type: object + required: [ state, generation_id ] + properties: + state: + type: string + enum: [ published, not_published, error, unknown ] + generation_id: + type: [ string, "null" ] + epoch: + type: [ string, "null" ] + EbpfSummary: + type: object + required: [ backend, programs, hooks, routing, health, last_error, checked_at ] + properties: + backend: + type: string + enum: [ real, mock, unknown ] + programs: + type: string + enum: [ loaded, not_loaded, error, unknown ] + hooks: + type: string + enum: [ attached, partially_attached, detached, unknown ] + routing: + $ref: ./openapi.yaml#/components/schemas/EbpfRoutingSummary + health: + type: string + enum: [ healthy, degraded, failed, unknown ] + last_error: + type: [ string, "null" ] + checked_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + DatapathSummary: + type: object + required: [ kind, state, visibility, ebpf ] + properties: + kind: + $ref: ./openapi.yaml#/components/schemas/DatapathKind + state: + $ref: ./openapi.yaml#/components/schemas/DatapathState + visibility: + $ref: ./openapi.yaml#/components/schemas/Visibility + ebpf: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/EbpfSummary + Datapath: + type: object + required: [ observed_at, kind, state, visibility, ebpf, errors ] + properties: + observed_at: + $ref: ./openapi.yaml#/components/schemas/Timestamp + kind: + $ref: ./openapi.yaml#/components/schemas/DatapathKind + state: + $ref: ./openapi.yaml#/components/schemas/DatapathState + visibility: + $ref: ./openapi.yaml#/components/schemas/Visibility + ebpf: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/EbpfDetail + errors: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/SafeError + EbpfDetail: + allOf: + - $ref: ./openapi.yaml#/components/schemas/EbpfSummary + - type: object + required: [] + properties: + attachments: + type: array + items: + $ref: ./openapi.yaml#/components/schemas/EbpfAttachment + maps: + $ref: ./openapi.yaml#/components/schemas/EbpfMaps + EbpfAttachment: + type: object + required: [ name, interface, direction, state ] + properties: + name: + type: string + minLength: 1 + interface: + type: string + minLength: 1 + direction: + type: string + enum: [ ingress, egress ] + state: + type: string + enum: [ attached, detached, error, unknown ] + EbpfMaps: + type: object + required: [ state, conn_state ] + properties: + state: + type: string + enum: [ ready, partial, error, unknown ] + conn_state: + oneOf: + - type: "null" + - $ref: ./openapi.yaml#/components/schemas/MapOccupancy + MapOccupancy: + type: object + required: [ occupancy, capacity, occupancy_known ] + properties: + occupancy: + $ref: ./openapi.yaml#/components/schemas/NullableSafeUInt + capacity: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + occupancy_known: + type: boolean + if: + properties: + occupancy_known: + const: true + then: + properties: + occupancy: + $ref: ./openapi.yaml#/components/schemas/SafeUInt + else: + properties: + occupancy: + type: "null" diff --git a/package.json b/package.json index 5d56270..c490a74 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,13 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "hexo generate", + "build": "npm run prepare:contract && hexo clean && hexo generate", "clean": "hexo clean", "deploy": "hexo deploy", - "server": "hexo server" + "server": "npm run prepare:contract && hexo clean && hexo server", + "bundle": "redocly bundle api/openapi.yaml --output source/openapi.yaml --component-renaming-conflicts-severity=error", + "prepare:contract": "npm run bundle && redocly lint source/openapi.yaml --config redocly.yaml && node tools/check-contract.mjs", + "check:contract": "npm run prepare:contract && node --experimental-eventsource --test tools/check-contract.test.mjs" }, "hexo": { "version": "8.1.2" @@ -22,5 +25,11 @@ "hexo-renderer-stylus": "^3.0.1", "hexo-server": "^3.0.0", "hexo-theme-landscape": "^1.0.0" + }, + "devDependencies": { + "@redocly/cli": "2.53.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "yaml": "^2.8.1" } } \ No newline at end of file diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000..ee402d7 --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,9 @@ +extends: + - spec +rules: + no-invalid-media-type-examples: + severity: error + allowAdditionalProperties: true + no-invalid-parameter-examples: + severity: error + allowAdditionalProperties: true diff --git a/scripts/api-examples.js b/scripts/api-examples.js new file mode 100644 index 0000000..5f5d075 --- /dev/null +++ b/scripts/api-examples.js @@ -0,0 +1,67 @@ +'use strict'; + +const { readFile } = require('node:fs/promises'); +const path = require('node:path'); +const { parse } = require('yaml'); +const { checkContract } = require(path.join(hexo.base_dir, 'tools/check-contract.mjs')); +const { renderExample } = require(path.join(hexo.base_dir, 'tools/contract.mjs')); + +let prepared; +function contract() { + prepared ??= readFile(path.join(hexo.source_dir, 'openapi.yaml'), 'utf8').then((source) => { + const result = checkContract(parse(source)); + if (result.errors.length) throw new Error(result.errors.join('\n')); + return { spec: result.context.spec, examples: result.examples, renderExample }; + }); + return prepared; +} + +async function render(key, format) { + const { examples, renderExample } = await contract(); + const example = examples.get(key); + if (!example) throw new Error(`Unknown canonical API example: ${key}`); + const code = renderExample(example, format); + const language = example.kind === 'event' ? 'text' : format === 'http' ? 'http' : 'json'; + if (hexo.extend.highlight.query(hexo.config.syntax_highlighter)) { + return hexo.extend.highlight.exec(hexo.config.syntax_highlighter, { + context: hexo, + args: [code, { lang: language, lines_length: code.split('\n').length }], + }); + } + return hexo.render.renderSync({ + text: `\`\`\`${language}\n${code}${code.endsWith('\n') ? '' : '\n'}\`\`\`\n`, + engine: 'markdown', + }); +} + +hexo.extend.tag.register('api_example', async (args) => { + const [operation, target, name, format = 'json'] = args; + if (args.length < 3 || args.length > 4 || !['json', 'http'].includes(format)) { + throw new Error('api_example expects operation, request/status, example name, and optional http'); + } + return render(`${operation}:${target}:${name}`, format); +}, { async: true }); + +hexo.extend.tag.register('api_request', async (args) => { + if (args.length < 1 || args.length > 2) throw new Error('api_request expects operation and optional body example name'); + return render(`${args[0]}:request${args[1] ? `:${args[1]}` : ''}`, 'http'); +}, { async: true }); + +hexo.extend.tag.register('api_event', async (args) => { + if (args.length !== 1) throw new Error('api_event expects a named component example'); + return render(`event:${args[0]}`, 'sse'); +}, { async: true }); + +hexo.extend.tag.register('api_endpoints', async (args) => { + if (args.length) throw new Error('api_endpoints takes no arguments'); + const { spec } = await contract(); + const rows = ['| Method | Path | Description |', '|---|---|---|']; + for (const [route, item] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (!operation.operationId) continue; + const summary = String(operation.summary || '').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' '); + rows.push(`| ${method.toUpperCase()} | \`${route}\` | ${summary} |`); + } + } + return hexo.render.renderSync({ text: rows.join('\n'), engine: 'markdown' }); +}, { async: true }); diff --git a/scripts/check-links.js b/scripts/check-links.js new file mode 100644 index 0000000..0affe8e --- /dev/null +++ b/scripts/check-links.js @@ -0,0 +1,17 @@ +'use strict'; + +hexo.extend.filter.register('after_generate', function () { + const routes = new Set(this.route.list()); + const pages = this.locals.get('pages'); + const root = this.config.root || '/'; + for (const page of pages.toArray()) { + const markdown = String(page._content || ''); + for (const [, href] of markdown.matchAll(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*)?\)/g)) { + if (/^(?:[a-z][a-z\d+.-]*:|#|\/\/)/i.test(href)) continue; + const url = new URL(href, `https://docs.invalid${root}${page.path}`); + if (!url.pathname.endsWith('.html')) continue; + const target = decodeURIComponent(url.pathname).slice(root.length); + if (!routes.has(target)) throw new Error(`${page.source}: unresolved documentation link ${href}`); + } + } +}); diff --git a/source/openapi.yaml b/source/openapi.yaml new file mode 100644 index 0000000..68a92c8 --- /dev/null +++ b/source/openapi.yaml @@ -0,0 +1,8428 @@ +openapi: 3.1.0 +info: + title: dae/honk Native API + version: 0.1.0-draft + description: | + Normative wire contract for the native control-plane API. Unknown response + extension fields are permitted. Request objects reject unknown fields. + Published as a generated bundle at source/openapi.yaml; edit the api/ sources, + not the published file. +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +servers: + - url: http://localhost:9527 +security: [] +paths: + /api: + get: + operationId: getDiscovery + summary: Discover the native API + security: [] + responses: + '200': + description: Native API discovery + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Discovery' + examples: + draft: + value: + name: dae/honk-native + status: draft + api_major: 1 + base_path: /api/v1 + links: + version: /api/v1/version + capabilities: /api/v1/capabilities + config: /api/v1/config + config_validate: /api/v1/config/validate + runtime: /api/v1/runtime + runtime_outbounds: /api/v1/runtime/outbounds + traffic_history: /api/v1/runtime/traffic/history + operations: /api/v1/operations/{id} + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + /api/v1/version: + get: + operationId: getVersion + summary: Read native and engine version identity + security: [] + responses: + '200': + description: Version identity + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + examples: + build: + value: + api: + name: dae/honk-native + major: 1 + status: draft + engine: + name: honk + version: 0.0.1-alpha + build: + revision: abc1234 + target: x86_64-unknown-linux-gnu + built_at: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + /api/v1/capabilities: + get: + operationId: getCapabilities + summary: Negotiate resources, visibility, and limits + security: [] + responses: + '200': + description: Adapter capabilities + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Capabilities' + examples: + available: + value: + observed_at: '2026-08-15T10:00:00Z' + profiles: + - base + limits: + max_request_target_bytes: 4096 + max_header_bytes: 16384 + max_json_body_bytes: 65536 + resources: + config: + available: true + content: false + writable: true + max_bytes: 65536 + max_sources: 32 + config_validate: + available: true + modes: + - syntax + - full + max_bytes: 65536 + max_sources: 32 + runtime: + available: true + runtime_memory: + available: true + metrics: + - process.rss_bytes + - cgroup.current_bytes + - cgroup.limit_bytes + - cgroup.events.high + - cgroup.events.oom + - cgroup.events.oom_kill + runtime_outbounds: + available: true + traffic_history: + available: true + max_window_seconds: 3600 + max_points: 360 + datapath: + available: true + kinds: + - ebpf + details: + - attachments + - maps + nodes: + available: true + groups: + available: true + config_patch: true + selection: true + max_patch_operations: 32 + probes: + available: true + targets: + - node + - group + kinds: + - tcp_connect + - http + - dns + purposes: + - data + - dns + transports: + - tcp + - udp + ip_versions: + - ipv4 + - ipv6 + limits: + max_members_per_job: 128 + max_results_per_job: 512 + max_active_jobs: 4 + max_queued_jobs: 16 + max_concurrent_per_target: 2 + job_timeout_ms: 30000 + per_principal_requests_per_minute: 60 + global_requests_per_minute: 240 + connections: + available: true + flows: + available: true + recording: 'on' + scopes: + - userspace_tcp + - userspace_udp + max_flows: 10000 + max_steps_per_flow: 256 + retention_seconds: 300 + snapshot_ttl_seconds: 30 + max_page_size: 1000 + routing_trace: + available: true + resolve_modes: + - none + max_addresses: 16 + max_rule_steps: 4096 + timeout_ms: 5000 + per_principal_requests_per_minute: 30 + global_requests_per_minute: 120 + events: + available: true + kinds: + - stream.ready + - runtime.updated + - flow.updated + - flow.gap + - operation.updated + - generation.changed + retention_seconds: 60 + max_buffered_events: 4096 + max_clients: 16 + heartbeat_seconds: 15 + dns_query: + available: true + record_types: + - A + - AAAA + - HTTPS + limits: + max_types_per_request: 8 + query_timeout_ms: 5000 + max_response_bytes: 65536 + per_principal_requests_per_minute: 120 + global_requests_per_minute: 480 + dns_cache: + available: true + read: true + delete_entry: true + delete_name: true + flush: true + entry_kinds: + - positive + - negative + operations: + available: true + retention_seconds: 300 + reload: + available: true + suspend: + available: false + resume: + available: false + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + /api/v1/config: + get: + operationId: getConfig + summary: Read the effective configuration + description: | + Requires resources.config.available. Returns one coherent snapshot of the + accepted sources and retained diagnostics for generation_id and revision, + not a fresh read of files that may have changed since loading. The source + set is complete and bounded by resources.config.max_sources; never truncate it. + Omit source content unless resources.config.content is true. Apply visibility + filters to paths, content and diagnostics; observe never grants raw secrets. + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + '200': + description: Effective configuration snapshot + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/EffectiveConfig' + examples: + redacted: + summary: Content withheld; the SHA-256 value is an illustrative placeholder. + value: + generation_id: generation-42 + revision: '17' + sources: + - id: source-main + path: + kind: main + content_sha256: '0000000000000000000000000000000000000000000000000000000000000000' + bytes: 128 + writable: true + loaded_at: '2026-08-15T09:30:00Z' + line_count: 8 + diagnostics: + - level: warning + source_id: source-main + line: null + column: null + span: null + code: deprecated_setting + message: A deprecated setting was accepted. + secrets_redacted: true + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + editable: + summary: Unredacted dae text when resources.config.content is true. + value: + generation_id: generation-42 + revision: '17' + sources: + - id: source-main + path: config.dae + kind: main + content_sha256: d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1 + bytes: 31 + writable: true + loaded_at: '2026-08-15T09:30:00Z' + content: | + routing { + fallback: direct + } + line_count: 3 + diagnostics: [] + secrets_redacted: false + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + description: Credentials are missing or invalid + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + authentication_required: + value: + error: + code: authentication_required + message: Credentials are required. + request_id: request-config-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '403': + description: Authenticated caller lacks observe permission + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + permission_denied: + value: + error: + code: permission_denied + message: Observe permission is required. + request_id: request-config-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '404': + description: Configuration readback is unavailable + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + capability_not_supported: + value: + error: + code: capability_not_supported + message: Configuration readback is unavailable. + request_id: request-config-3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '429': + description: Advertised request rate exceeded + headers: + Retry-After: + description: Retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + rate_limited: + value: + error: + code: rate_limited + message: Request rate exceeded. + request_id: request-config-4 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 + /api/v1/config/validate: + post: + operationId: validateConfig + summary: Validate candidate configuration without applying it + description: | + Requires resources.config_validate.available and control because the body + may contain secrets. Syntax mode parses only submitted text. Full mode also + checks semantics and resolves includes/subscriptions from submitted sources + or adapter-authorized local files and cached data, never from the network. + A missing or inaccessible dependency produces an error diagnostic, not a + successful partial validation. Neither mode writes files, refreshes caches, + applies configuration, publishes a generation, or starts an operation. + Return 200 for completed validation, including invalid candidates. Malformed + JSON/request shape or duplicate effective source IDs returns 400; an + unadvertised mode returns 422 unsupported_value. Enforce max_bytes over the + sum of UTF-8 source bytes and max_sources over the source count, including + locally resolved dependencies in full mode; exceeding either returns 413. + The shared max_json_body_bytes limit applies independently to the HTTP body. + Never echo candidate text, secrets, or private paths in diagnostics or errors. + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfigValidationRequest' + examples: + syntax_error: + value: + sources: + - id: candidate-main + content: | + global { + mode: syntax + full: + value: + sources: + - content: | + routing { + fallback: direct + } + mode: full + responses: + '200': + description: Completed dry-run validation; valid is not an apply guarantee + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ConfigValidationResult' + examples: + invalid: + value: + valid: false + diagnostics: + - level: error + source_id: candidate-main + line: 1 + column: 8 + span: + start_line: 1 + start_column: 8 + end_line: 1 + end_column: 9 + code: unclosed_block + message: Block is not closed. + generation_id: generation-42 + validated_at: '2026-08-15T10:00:00Z' + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + valid: + value: + valid: true + diagnostics: [] + generation_id: generation-42 + validated_at: '2026-08-15T10:00:00Z' + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + description: Malformed JSON, invalid request shape, or duplicate effective source IDs + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalid_request: + value: + error: + code: invalid_request + message: Source IDs must be unique. + request_id: request-validate-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + description: Credentials are missing or invalid + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + authentication_required: + value: + error: + code: authentication_required + message: Credentials are required. + request_id: request-validate-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '403': + description: Authenticated caller lacks control permission + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + permission_denied: + value: + error: + code: permission_denied + message: Control permission is required. + request_id: request-validate-3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '404': + description: Configuration validation is unavailable + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + capability_not_supported: + value: + error: + code: capability_not_supported + message: Configuration validation is unavailable. + request_id: request-validate-4 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '413': + description: Source bytes, source count, or JSON body exceeds an advertised limit + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + too_many_bytes: + value: + error: + code: request_too_large + message: Source bytes exceed the advertised max_bytes. + request_id: request-validate-5 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + too_many_sources: + value: + error: + code: request_too_large + message: Source count exceeds the advertised max_sources. + request_id: request-validate-6 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '415': + description: Unsupported request Content-Type + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unsupported_media_type: + value: + error: + code: unsupported_media_type + message: Content-Type must be application/json. + request_id: request-validate-7 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '422': + description: Requested mode is not advertised by resources.config_validate.modes + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unsupported_value: + value: + error: + code: unsupported_value + message: Requested validation mode is unsupported. + request_id: request-validate-8 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '429': + description: Advertised request rate exceeded + headers: + Retry-After: + description: Retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + rate_limited: + value: + error: + code: rate_limited + message: Request rate exceeded. + request_id: request-validate-9 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 + /api/v1/config/sources/{source_id}: + parameters: + - name: source_id + in: path + required: true + description: Opaque ID of an accepted source, never a path supplied by the caller. + schema: + type: string + minLength: 1 + example: source-main + get: + operationId: getConfigSource + summary: Read one accepted configuration source + description: | + Requires resources.config.available. Returns the same ConfigSource as + GET /config, not a fresh read of disk. Include optional content only when + resources.config.content is true; apply the same path and secret redaction. + Unknown source IDs return 404 resource_not_found. Redacted text must never + be saved as a replacement; compare its UTF-8 SHA-256 with content_sha256 + before using returned content as an editing representation. + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + '200': + description: Accepted source; content remains subject to visibility policy + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ConfigSource' + examples: + editable: + summary: Unredacted dae text when resources.config.content is true. + value: + id: source-main + path: config.dae + kind: main + content_sha256: d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1 + bytes: 31 + writable: true + loaded_at: '2026-08-15T09:30:00Z' + content: | + routing { + fallback: direct + } + line_count: 3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + redacted: + value: + id: source-main + path: + kind: main + content_sha256: '0000000000000000000000000000000000000000000000000000000000000000' + bytes: 128 + writable: true + loaded_at: '2026-08-15T09:30:00Z' + line_count: 8 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown source ID or unavailable configuration readback + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + resource_not_found: + value: + error: + code: resource_not_found + message: Configuration source not found. + request_id: request-source-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + capability_not_supported: + value: + error: + code: capability_not_supported + message: Configuration readback is unavailable. + request_id: request-source-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '429': + $ref: '#/components/responses/RateLimited' + put: + operationId: replaceConfigSource + summary: Replace one configuration source and reload + description: | + Requires control, resources.config.available, resources.config.writable, + and writable: true on the accepted source. A disabled write switch or a + read-only source returns 403 permission_denied. Generated and subscription + sources are never writable. Unknown IDs return 404 resource_not_found. + The body replaces the complete source with UTF-8 dae text; no partial + patches, caller-supplied file paths, or multi-source writes are accepted. + Enforce resources.config.max_bytes on replacement UTF-8 bytes and the + shared limits.max_json_body_bytes independently; excess returns 413. + Require If-Match before validating or writing: missing returns 428 + precondition_required; a hash different from the current on-disk bytes + returns 412 stale_revision, even if it matches the accepted snapshot. + Validate the resulting source set with the same full-mode checks as + POST /config/validate, substituting the replacement for this source. + Resolve dependencies only from submitted text, authorized local files, + and cached data; never use the network or refresh caches during validation. + Missing or inaccessible dependencies produce error diagnostics. + If any diagnostic has level error, return 422 unsupported_value with + error.details.diagnostics using ConfigDiagnostic; never write any file + or start a reload. Warnings and info alone do not prevent a write. + Otherwise atomically replace the file using a temporary file in the same + directory and rename, preserving its mode. Serialize the hash check, + validation, and replacement against concurrent API writes; recheck the + on-disk hash before replacement and return 412 if it changed. + After the write, start a reload operation and return 202 OperationAccepted + with kind reload, Location, and Retry-After. This is not reload completion. + A successful reload publishes generation.changed when events are available; + GET /config then shows the new accepted content_sha256. If reload fails, + the previous generation remains active; the file write is not rolled back. + Idempotency-Key follows the operation retention rules: scope it to caller, + method, and path in this instance. A retained same-body replay returns the + original operation without another write or hash check; a different body + returns 409 idempotency_conflict. + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - name: If-Match + in: header + required: true + description: | + One strong entity tag containing the source's content_sha256 from + GET /config, enclosed in double quotes. Compare the digest with the + current on-disk content, not the snapshot revision. Wildcards, weak + tags, and tag lists are not accepted. + schema: + type: string + pattern: ^"[0-9a-f]{64}"$ + example: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: + - content + properties: + content: + type: string + description: Complete UTF-8 dae source text; empty text is validated, not rejected as malformed. + examples: + replacement: + value: + content: | + routing { + fallback: block + } + x-headers: + Content-Type: application/json + If-Match: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + invalid: + value: + content: | + routing { + x-headers: + Content-Type: application/json + If-Match: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + responses: + '202': + description: Source written atomically and reload accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/OperationAccepted' + - type: object + properties: + kind: + const: reload + examples: + queued: + value: + operation_id: op-config-01 + kind: reload + status: queued + href: /api/v1/operations/op-config-01 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-config-01 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Control permission is absent, editing is disabled, or the source is read-only + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + permission_denied: + value: + error: + code: permission_denied + message: Configuration source is not writable. + request_id: request-write-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '404': + $ref: '#/components/responses/404' + '409': + $ref: '#/components/responses/Conflict' + '412': + description: If-Match does not match the current on-disk content hash; nothing is written + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + stale_revision: + value: + error: + code: stale_revision + message: Source changed on disk; reconcile before retrying. + request_id: request-write-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '413': + $ref: '#/components/responses/TooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '422': + description: Full validation found error diagnostics; no file is written and no reload starts + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ErrorResponse' + - type: object + properties: + error: + type: object + required: + - details + properties: + code: + const: unsupported_value + details: + type: object + required: + - diagnostics + properties: + diagnostics: + type: array + items: + $ref: '#/components/schemas/ConfigDiagnostic' + contains: + type: object + required: + - level + properties: + level: + const: error + examples: + invalid: + value: + error: + code: unsupported_value + message: Configuration validation failed. + details: + diagnostics: + - level: error + source_id: source-main + line: 1 + column: 9 + span: + start_line: 1 + start_column: 9 + end_line: 1 + end_column: 10 + code: unclosed_block + message: Block is not closed. + request_id: request-write-3 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '428': + description: If-Match is required; nothing is written + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + precondition_required: + value: + error: + code: precondition_required + message: If-Match is required. + request_id: request-write-4 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/runtime: + get: + operationId: getRuntime + summary: Read coherent runtime summary + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/Detail' + responses: + '200': + description: Runtime snapshot + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Runtime' + examples: + snapshot: + value: + observed_at: '2026-08-15T10:00:00Z' + instance_id: instance-7 + lifecycle: + state: running + started_at: '2026-08-15T08:00:00Z' + uptime_seconds: '7200' + generation: + active_id: generation-42 + config_revision: '17' + state: active + activated_at: '2026-08-15T09:30:00Z' + datapath: + kind: ebpf + state: active + visibility: partial + ebpf: + backend: real + programs: loaded + hooks: attached + routing: + state: published + generation_id: generation-42 + health: healthy + last_error: null + checked_at: '2026-08-15T10:00:00Z' + traffic: + scope: visible + observed_by: mixed + counter_since: '2026-08-15T08:00:00Z' + sampled_at: '2026-08-15T10:00:00Z' + connections: + tcp: 42 + udp: 128 + total: 170 + bytes: + upload: '123456789' + download: '987654321' + rates: + window_seconds: 1 + upload_bytes_per_second: '4096' + download_bytes_per_second: '32768' + process: + pid: 1234 + cpu_percent: null + last_reload: + operation_id: op-01HZX4K8W7 + status: succeeded + finished_at: '2026-08-15T09:30:00Z' + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + /api/v1/runtime/memory: + get: + operationId: getRuntimeMemory + summary: Read lightweight process, cgroup, and eBPF memory + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + '200': + description: Memory snapshot + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/RuntimeMemory' + examples: + snapshot: + value: + observed_at: '2026-08-15T10:00:00Z' + process: + rss_bytes: '67108864' + cgroup: + scope: service + current_bytes: '83886080' + limit_bytes: '536870912' + events: + high: '0' + oom: '0' + oom_kill: '0' + kernel: + ebpf_bytes: null + sampled_at: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + /api/v1/runtime/outbounds: + get: + operationId: getRuntimeOutbounds + summary: Read per-outbound cumulative counters + description: | + Mirrors honk's Clash-surface /stats counters for visible traffic, not + a sum of live connections. All rows share counter_since; restart or + counter reset starts a new interval. Requires resources.runtime_outbounds.available. + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + '200': + description: Outbound counter snapshot + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/RuntimeOutbounds' + examples: + snapshot: + value: + observed_at: '2026-08-15T10:00:00Z' + counter_since: '2026-08-15T08:00:00Z' + outbounds: + - name: direct + kind: builtin + active_connections: 12 + total_connections: '240' + upload_bytes: '123456' + download_bytes: '654321' + errors: '0' + - name: proxy + kind: group + active_connections: 158 + total_connections: '1200' + upload_bytes: '123333333' + download_bytes: '987000000' + errors: '3' + - name: block + kind: builtin + active_connections: 0 + total_connections: '0' + upload_bytes: '0' + download_bytes: '0' + errors: '0' + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + /api/v1/runtime/traffic/history: + get: + operationId: getTrafficHistory + summary: Read bounded traffic history + description: | + Reads a bounded in-memory ring of visible traffic samples without + starting sampling on GET. This is the only traffic history the native + API serves; SSE does not replay it. Requires resources.traffic_history.available. + A query above either advertised limit returns 400 invalid_request, + never a silently clamped window or point limit. + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: window_seconds + in: query + description: Look-back window ending at observed_at; defaults to resources.traffic_history.max_window_seconds. + schema: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + example: 60 + - name: max_points + in: query + description: Maximum returned samples; defaults to resources.traffic_history.max_points. + schema: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + example: 3 + responses: + '200': + description: Retained traffic samples, oldest first + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/TrafficHistory' + examples: + recent: + value: + observed_at: '2026-08-15T10:00:00Z' + window_seconds: 60 + sampled_every_seconds: 20 + samples: + - sampled_at: '2026-08-15T09:59:20Z' + upload_bytes_per_second: '2048' + download_bytes_per_second: '16384' + connections: 162 + - sampled_at: '2026-08-15T09:59:40Z' + upload_bytes_per_second: null + download_bytes_per_second: null + connections: null + - sampled_at: '2026-08-15T10:00:00Z' + upload_bytes_per_second: '4096' + download_bytes_per_second: '32768' + connections: 170 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + description: Invalid query or window_seconds/max_points above the advertised limit + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + window_too_large: + value: + error: + code: invalid_request + message: window_seconds exceeds the advertised limit. + request_id: request-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + too_many_points: + value: + error: + code: invalid_request + message: max_points exceeds the advertised limit. + request_id: request-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + /api/v1/datapath: + get: + operationId: getDatapath + summary: Read detailed datapath state + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/Detail' + responses: + '200': + description: Datapath snapshot + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Datapath' + examples: + active: + value: + observed_at: '2026-08-15T10:00:00Z' + kind: ebpf + state: active + visibility: partial + ebpf: + backend: real + programs: loaded + hooks: attached + routing: + state: published + generation_id: generation-42 + epoch: '3' + attachments: + - name: wan_ingress + interface: eth0 + direction: ingress + state: attached + maps: + state: ready + conn_state: + occupancy: 1200 + capacity: 524288 + occupancy_known: true + health: healthy + last_error: null + checked_at: '2026-08-15T10:00:00Z' + errors: [] + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /api/v1/nodes: + get: + operationId: listNodes + summary: List nodes and latest typed health samples + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: group_id + in: query + schema: + type: string + minLength: 1 + example: group-proxy + - $ref: '#/components/parameters/Limit1000' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: Node page + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/NodeList' + examples: + nodes: + value: + observed_at: '2026-08-15T10:00:00Z' + nodes: + - id: node-hk-01 + name: hk-01 + protocol: vless + subscription_tag: provider-a + group_ids: + - group-proxy + health: + - transport: tcp + purpose: shared + ip_version: ipv4 + warmth: cold + measurement: http_round_trip + sample_source: probe + state: healthy + latency_ms: 45 + moving_avg_ms: 47.5 + avg10_ms: null + observed_at: '2026-08-15T10:00:00Z' + error: null + next_cursor: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /api/v1/groups: + get: + operationId: listGroups + summary: List group summaries + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + '200': + description: Group summaries + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/GroupList' + examples: + groups: + value: + - id: group-proxy + name: proxy + config_revision: '17' + policy: + kind: urltest + native: min_moving_avg + member_count: 2 + selection: + tcp_member_id: node-hk-01 + udp_member_id: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /api/v1/groups/{groupId}: + parameters: + - $ref: '#/components/parameters/GroupId' + get: + operationId: getGroup + summary: Read a complete group resource + x-permission: observe + security: + - bearerAuth: [] + - {} + responses: + '200': + description: Current group + headers: + ETag: + $ref: '#/components/headers/ETag' + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + examples: + current: + value: + id: group-proxy + name: proxy + config_revision: '17' + policy: + kind: urltest + native: min_moving_avg + members: + - id: node-hk-01 + name: hk-01 + kind: node + - id: group-jp + name: jp + kind: group + config: + default_member_id: null + final_outbound: direct + check_url: null + check_interval: 30 + tolerance: 50 + idle_timeout: null + interrupt_connections: false + runtime: + selection: + tcp: + member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + source: health + udp: null + health: + - member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + transport: tcp + purpose: shared + ip_version: ipv4 + warmth: unknown + measurement: http_round_trip + sample_source: probe + state: healthy + latency_ms: 45 + moving_avg_ms: 47.5 + avg10_ms: null + sorting_latency_ms: 72.5 + ranking: + metric: moving_avg_ms + recovery_penalty_ms: 10 + group_offset_ms: 15 + score: null + reason: minimum_with_hysteresis + observed_at: '2026-08-15T10:00:00Z' + error: null + capabilities: + can_select: false + supports_nested_groups: true + mutable_config: + - policy + - default_member_id + - final_outbound + - check_url + - check_interval + - tolerance + - idle_timeout + - interrupt_connections + probe_transports: + - tcp + - udp + x-headers: + Content-Type: application/json + ETag: '"17"' + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + operationId: patchGroup + summary: Patch mutable group configuration + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json-patch+json: + schema: + $ref: '#/components/schemas/JsonPatch' + examples: + tolerance: + value: + - op: replace + path: /config/tolerance + value: 100 + - op: replace + path: /config/interrupt_connections + value: true + responses: + '200': + description: Updated group + headers: + ETag: + $ref: '#/components/headers/ETag' + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '202': + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/OperationAccepted' + examples: + queued: + value: + operation_id: op-01HZX4K8WA + kind: group_update + status: queued + href: /api/v1/operations/op-01HZX4K8WA + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8WA + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '412': + description: If-Match does not equal the current configuration revision + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + stale_revision: + value: + error: + code: stale_revision + message: The resource changed; fetch it again before retrying. + details: + field: null + request_id: request-01HZX4K8W5 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '413': + $ref: '#/components/responses/TooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '422': + $ref: '#/components/responses/Unprocessable' + '428': + $ref: '#/components/responses/PreconditionRequired' + /api/v1/groups/{groupId}/selection: + parameters: + - $ref: '#/components/parameters/GroupId' + put: + operationId: selectGroupMember + summary: Replace a supported runtime group selection + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GroupSelectionRequest' + examples: + tcp_udp: + value: + member_id: node-hk-01 + network: both + responses: + '200': + description: Applied runtime selection + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/GroupSelectionResult' + examples: + selected: + value: + group_id: group-proxy + member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + network: both + source: runtime + selection_revision: '8' + connections_interrupted: false + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '422': + $ref: '#/components/responses/Unprocessable' + /api/v1/probes: + post: + operationId: createProbe + summary: Start a bounded typed probe operation + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProbeRequest' + examples: + dns_udp: + value: + target: + type: group + group_id: group-proxy + kind: dns + purpose: dns + transport: + - udp + ip_version: ipv4 + members: + - node-hk-01 + - group-jp + warmth: cold + responses: + '202': + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/OperationAccepted' + examples: + queued: + value: + operation_id: op-01HZX4K8W9 + kind: probe + status: queued + href: /api/v1/operations/op-01HZX4K8W9 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8W9 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '413': + $ref: '#/components/responses/TooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/RateLimited' + '503': + description: Probe queue is full + headers: + Retry-After: + description: Positive retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + queue_full: + value: + error: + code: temporarily_unavailable + message: Probe queue is full. + request_id: request-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 + /api/v1/connections: + get: + operationId: listConnections + summary: Read a scope-labelled live connection snapshot + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: type + in: query + schema: + type: string + enum: + - tcp + - udp + - all + default: all + - name: src + in: query + description: Exact source IP literal, without a port; applied with type before limit. Totals and truncated describe only matching visible entries. + schema: + $ref: '#/components/schemas/IpAddress' + example: 192.168.1.100 + - $ref: '#/components/parameters/Limit1000' + - $ref: '#/components/parameters/Detail' + responses: + '200': + description: Connection snapshot + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectionList' + examples: + visible: + value: + observed_at: '2026-08-15T10:00:00Z' + instance_id: instance-7 + visibility: partial + truncated: false + tcp: + - id: tcp-01HZX4K8W5 + flow_id: flow-23 + pname: curl + state: active + src: 192.168.1.100:12345 + dst: 1.2.3.4:443 + domain: example.com + outbound: proxy + chain: + - group-proxy + - node-hk-01 + chain_source: evaluation + rule_id: null + rule_expression: null + rule_source: unknown + ingress: wan + domain_source: tls_sni + started_at: '2026-08-15T09:59:50Z' + observed_by: userspace + upload_bytes: '20480' + download_bytes: '1048576' + upload_bytes_per_second: '4096' + download_bytes_per_second: '32768' + udp: + - id: udp-01HZX4K8W6 + flow_id: null + pname: null + state: unknown + src: 192.168.1.100:5353 + dst: 8.8.8.8:53 + domain: null + outbound: direct + chain: [] + chain_source: unknown + rule_id: null + rule_expression: null + rule_source: unknown + ingress: null + domain_source: null + started_at: '2026-08-15T09:59:55Z' + observed_by: ebpf + upload_bytes: null + download_bytes: null + upload_bytes_per_second: null + download_bytes_per_second: null + total_tcp: 1 + total_udp: 1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /api/v1/flows: + get: + operationId: listFlows + summary: List active and retained terminal flow decisions + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: network + in: query + schema: + type: string + enum: + - tcp + - udp + - all + default: all + - name: state + in: query + schema: + oneOf: + - $ref: '#/components/schemas/ConnectionState' + - const: all + default: all + - name: connection_id + in: query + description: Exact opaque connection ID within the current adapter instance; never inferred from a tuple. Matches active and retained terminal flows. + schema: + type: string + minLength: 1 + example: tcp-01HZX4K8W5 + - $ref: '#/components/parameters/Limit1000' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Detail' + responses: + '200': + description: Point-in-time flow page + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/FlowList' + examples: + visible: + value: + instance_id: instance-7 + observed_at: '2026-09-14T10:00:00Z' + coverage: + userspace_tcp: full + userspace_udp: partial + kernel_direct: none + kernel_block: none + dns_intercept: partial + kernel_bypass: none + dropped_records: '3' + flows: + - id: flow-23 + instance_id: instance-7 + revision: 8 + network: tcp + state: active + pname: curl + connection_id: tcp-01HZX4K8W5 + outbound: proxy + chain: + - group-proxy + - node-hk-01 + chain_source: evaluation + rule_id: null + rule_expression: null + rule_source: unknown + ingress: wan + domain_source: tls_sni + observed_by: userspace + started_at: '2026-08-15T09:59:50Z' + ended_at: null + trace_status: partial + next_cursor: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + '503': + description: Bounded recorder memory cannot admit a snapshot + headers: + Retry-After: + description: Retry delay in seconds when retryable. + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + snapshot_full: + value: + error: + code: temporarily_unavailable + message: Flow snapshot capacity is full. + request_id: request-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + Retry-After: 1 + /api/v1/flows/{flow_id}: + get: + operationId: getFlow + summary: Read one recorded causal flow trace + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/FlowId' + responses: + '200': + description: Recorded flow detail + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/FlowDetail' + examples: + partial_handoff: + value: + id: flow-23 + instance_id: instance-7 + revision: 8 + network: tcp + state: active + pname: curl + connection_id: tcp-01HZX4K8W5 + outbound: proxy + chain: + - group-proxy + - node-hk-01 + chain_source: evaluation + rule_id: null + rule_expression: null + rule_source: unknown + ingress: wan + domain_source: tls_sni + observed_by: userspace + started_at: '2026-08-15T09:59:50Z' + ended_at: null + trace_status: partial + input: + src: 192.168.1.100:12345 + dst: 1.2.3.4:443 + domain: example.com + domain_source: tls_sni + pid: null + process_path: null + src_mac: null + ingress: wan + domain_rule_ids: null + dscp: 0 + mark: 0 + trace: + status: partial + missing: + - not_instrumented + steps: + - seq: 1 + stage: route + observed_at: '2026-08-15T09:59:50Z' + elapsed_us: 0 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-1 + chain: traffic + plane: kernel + input: null + dns_action: null + rule_id: null + rules: [] + outbound: proxy + must: false + mark: 0 + - seq: 2 + stage: dial_mode + observed_at: '2026-08-15T09:59:50Z' + elapsed_us: 100 + generation_id: generation-42 + evidence: observed + data: + configured: domain+ + effective_target: domain + domain: example.com + domain_source: tls_sni + verification: not_required + reason: preserve_initial_route + - seq: 3 + stage: reroute + observed_at: '2026-08-15T09:59:50Z' + elapsed_us: 120 + generation_id: generation-42 + evidence: observed + data: + performed: false + reason: dial_mode_preserves_route + from_evaluation_id: eval-1 + to_evaluation_id: null + - seq: 4 + stage: outbound + observed_at: '2026-08-15T09:59:50Z' + elapsed_us: 150 + generation_id: generation-42 + evidence: observed + data: + attempt_id: attempt-1 + parent_attempt_id: null + kind: leaf + evaluation_id: eval-1 + routing_source: evaluation + routed_outbound: proxy + effective_outbound: proxy + mode_override: none + selection_path: + - group_id: group-proxy + member_id: node-hk-01 + member_name: hk-01 + policy: selector + reason: manual_selection + selection: null + leaf_node_id: node-hk-01 + leaf_node_name: hk-01 + target: example.com:443 + target_kind: domain + dial_ip: null + server_addr: 203.0.113.7:443 + resolution_location: outbound_remote + status: succeeded + error: null + - seq: 5 + stage: connection + observed_at: '2026-08-15T09:59:50Z' + elapsed_us: 20000 + generation_id: generation-42 + evidence: observed + data: + state: active + reason: transport_ready + milestone: transport_ready + attempt_id: attempt-1 + reply_received: null + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + interleaved_dns: + summary: Evaluation-owned DNS inputs and explicit non-adjacent references + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + value: + id: flow-24 + instance_id: instance-7 + revision: 8 + network: tcp + state: active + pname: curl + connection_id: tcp-24 + outbound: proxy + chain: + - node-hk-01 + chain_source: evaluation + rule_id: rule-traffic + rule_expression: null + rule_source: kernel + ingress: wan + domain_source: explicit + observed_by: userspace + started_at: '2026-09-14T09:59:59Z' + ended_at: null + trace_status: complete + input: + src: 192.0.2.10:53000 + dst: 198.51.100.20:443 + domain: example.com + domain_source: explicit + pid: null + process_path: null + src_mac: null + ingress: wan + domain_rule_ids: null + dscp: 0 + mark: 0 + trace: + status: complete + missing: [] + steps: + - seq: 1 + stage: route + observed_at: '2026-09-14T10:00:00Z' + elapsed_us: 10 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-traffic + chain: traffic + plane: kernel + rule_id: rule-traffic + rules: + - rule_id: rule-traffic + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: proxy + must: false + mark: 0 + input: + network: tcp + src_ip: 192.0.2.10 + src_port: 53000 + dst_ip: 198.51.100.20 + dst_port: 443 + domain: example.com + pname: curl + src_mac: null + dscp: 0 + mark: 0 + ingress: wan + domain_rule_ids: null + dns_action: null + - seq: 2 + stage: route + observed_at: '2026-09-14T10:00:00Z' + elapsed_us: 20 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-dns-request + chain: dns_request + plane: userspace + rule_id: rule-dns_request + rules: + - rule_id: rule-dns_request + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: dns-primary + must: null + mark: null + input: + name: example.com + qtype: A + source_ip: 192.0.2.10 + original_dst: null + dns_action: upstream + - seq: 3 + stage: route + observed_at: '2026-09-14T10:00:00Z' + elapsed_us: 30 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-dns-upstream + chain: dns_upstream + plane: userspace + rule_id: rule-dns_upstream + rules: + - rule_id: rule-dns_upstream + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: proxy + must: null + mark: null + input: + network: udp + src_ip: 0.0.0.0 + src_port: 0 + dst_ip: 203.0.113.53 + dst_port: 53 + domain: resolver.example + pname: null + src_mac: null + dscp: null + mark: null + ingress: null + domain_rule_ids: null + dns_action: null + - seq: 4 + stage: outbound + observed_at: '2026-09-14T10:00:00Z' + elapsed_us: 40 + generation_id: generation-42 + evidence: observed + data: + attempt_id: attempt-dns + parent_attempt_id: null + kind: leaf + evaluation_id: eval-dns-upstream + routing_source: evaluation + routed_outbound: proxy + effective_outbound: proxy + mode_override: none + selection_path: [] + leaf_node_id: node-hk-01 + leaf_node_name: hk-01 + target: 203.0.113.53:53 + target_kind: ip + dial_ip: 203.0.113.53 + server_addr: 203.0.113.7:443 + resolution_location: original_ip + status: succeeded + error: null + - seq: 5 + stage: dns + observed_at: '2026-09-14T10:00:00Z' + elapsed_us: 50 + generation_id: generation-42 + evidence: observed + data: + lookup_id: lookup-1 + parent_lookup_id: null + attempt_id: attempt-dns + purpose: domain_verification + name: example.com + qtype: A + source: upstream + upstream_transport: udp + carrier_transport: tcp + cache: miss + cache_entry_id: null + upstream: dns-primary + route_evaluation_ids: + - eval-dns-request + - eval-dns-upstream + - eval-dns-response + status: NOERROR + addresses: + - 198.51.100.20 + selected_ip: 198.51.100.20 + error: null + - seq: 6 + stage: route + observed_at: '2026-09-14T10:00:00Z' + elapsed_us: 60 + generation_id: generation-42 + evidence: observed + data: + evaluation_id: eval-dns-response + chain: dns_response + plane: userspace + rule_id: rule-dns_response + rules: + - rule_id: rule-dns_response + expression: null + result: matched + missing_inputs: [] + conditions: [] + outbound: null + must: null + mark: null + input: + name: example.com + qtype: A + answer_ips: + - 198.51.100.20 + from_upstream: dns-primary + dns_action: accept + - seq: 7 + stage: outbound + observed_at: '2026-09-14T10:00:00Z' + elapsed_us: 70 + generation_id: generation-42 + evidence: observed + data: + attempt_id: attempt-app + parent_attempt_id: null + kind: leaf + evaluation_id: eval-traffic + routing_source: evaluation + routed_outbound: proxy + effective_outbound: proxy + mode_override: none + selection_path: [] + leaf_node_id: node-hk-01 + leaf_node_name: hk-01 + target: example.com:443 + target_kind: domain + dial_ip: null + server_addr: 203.0.113.7:443 + resolution_location: outbound_remote + status: succeeded + error: null + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + /api/v1/routing/trace: + post: + operationId: traceRouting + summary: Simulate bounded routing evaluation + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingTraceRequest' + examples: + hypothetical: + value: + input: + network: tcp + domain: example.com + dst_ip: 198.51.100.20 + dst_port: 443 + src_ip: 192.0.2.10 + src_port: 53000 + pname: null + dscp: 0 + mark: 0 + resolve: none + responses: + '200': + description: Hypothetical routing evaluation; never a recorded flow + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingTraceResponse' + examples: + indeterminate: + value: + mode: simulation + instance_id: instance-7 + generation_id: generation-42 + observed_at: '2026-09-14T10:00:00Z' + evaluations: + - dst_ip: 198.51.100.20 + decision: indeterminate + outbound: null + missing_inputs: + - pname + rules: + - rule_id: traffic:0 + expression: pname(curl) -> direct + result: indeterminate + missing_inputs: + - pname + conditions: + - id: traffic:0/pname + expression: pname(curl) + result: indeterminate + missing_inputs: + - pname + - rule_id: traffic:1 + expression: dip(198.51.100.0/24) -> proxy + result: matched + missing_inputs: [] + conditions: + - id: traffic:1/dip + expression: dip(198.51.100.0/24) + result: matched + missing_inputs: [] + - rule_id: traffic:2 + expression: 'fallback: block' + result: skipped + missing_inputs: [] + conditions: [] + dns: [] + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '413': + $ref: '#/components/responses/TooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/events: + get: + operationId: streamEvents + summary: Follow bounded resumable invalidation events + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: kinds + in: query + description: Comma-separated advertised event kinds. + schema: + type: string + minLength: 1 + example: flow.updated,flow.gap + - name: flow_id + in: query + schema: + type: string + minLength: 1 + - $ref: '#/components/parameters/LastEventId' + responses: + '200': + description: Server-sent events; validate data using the schema selected by the event name. + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + text/event-stream: + schema: + type: string + x-event-data-schemas: + stream.ready: '#/components/schemas/StreamReadyEvent' + runtime.updated: '#/components/schemas/RuntimeUpdatedEvent' + flow.updated: '#/components/schemas/FlowUpdatedEvent' + flow.gap: '#/components/schemas/FlowGapEvent' + operation.updated: '#/components/schemas/OperationUpdatedEvent' + generation.changed: '#/components/schemas/GenerationChangedEvent' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Last-Event-ID cannot be replayed (event_cursor_expired); sent before any 200 stream opens. Drop the cursor, reconnect without it and resnapshot. + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + event_cursor_expired: + value: + error: + code: event_cursor_expired + message: Event cursor is older than the retained window; reconnect without it. + details: null + request_id: request-01HZX4K8W6 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/dns/query: + get: + operationId: queryDns + summary: Execute a routed diagnostic DNS query + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - name: domain + in: query + required: true + description: DNS name within the 255-wire-octet and 63-octet-label limits. + schema: + type: string + minLength: 1 + example: example.com + - name: type + in: query + description: Unique record types; repeated query parameter. + style: form + explode: true + schema: + type: array + minItems: 1 + uniqueItems: true + items: + $ref: '#/components/schemas/DnsRecordType' + default: + - A + example: + - A + - AAAA + - name: upstream + in: query + schema: + type: string + minLength: 1 + - name: cache_mode + in: query + schema: + type: string + enum: + - normal + - bypass + default: normal + - $ref: '#/components/parameters/Detail' + responses: + '200': + description: Per-record-type DNS results, including DNS failures + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/DnsQueryResponse' + examples: + dual_stack: + value: + domain: example.com + cache_mode: normal + query_time: '2026-08-14T00:00:00Z' + results: + - type: A + cached: false + cache_entry_id: dns-entry-01HZX4K8W5 + upstream: alidns + route: + source: dns.routing + rule: domain(example.com) + status: NOERROR + elapsed_ms: 12 + question: + name: example.com. + type: A + answers: + - name: example.com. + type: A + class: IN + ttl: 600 + data: 93.184.216.34 + - type: AAAA + cached: false + cache_entry_id: null + upstream: alidns + route: + source: dns.routing + rule: domain(example.com) + status: NODATA + elapsed_ms: 11 + question: + name: example.com. + type: AAAA + answers: [] + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '413': + $ref: '#/components/responses/TooLarge' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/dns/cache: + get: + operationId: listDnsCache + summary: Read a paginated DNS cache snapshot + x-permission: observe + security: + - bearerAuth: [] + - {} + parameters: + - name: name + in: query + schema: + type: string + minLength: 1 + - name: domain + in: query + schema: + type: string + minLength: 1 + - name: type + in: query + style: form + explode: true + schema: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/DnsRecordType' + - name: include_expired + in: query + schema: + type: boolean + default: false + - $ref: '#/components/parameters/Limit1000' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Detail' + responses: + '200': + description: DNS cache page + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/DnsCacheList' + examples: + entries: + value: + observed_at: '2026-08-15T12:00:00Z' + coverage: + positive: true + negative: true + persistent: false + entries: + - entry_id: dns-entry-01HZX4K8W5 + domain: example.com. + type: A + class: IN + status: NOERROR + answers: + - name: example.com. + type: A + class: IN + data: 93.184.216.34 + ttl: 3600 + expires_at: '2026-08-15T13:00:00Z' + stale_until: null + - entry_id: dns-entry-01HZX4K8W6 + domain: missing.example.com. + type: A + class: IN + status: NXDOMAIN + answers: [] + expires_at: '2026-08-15T12:05:00Z' + stale_until: '2026-08-15T12:06:00Z' + total: 1024 + next_cursor: eyJvZmZzZXQiOjEwMH0 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/Unavailable' + delete: + operationId: deleteDnsCacheByName + summary: Delete cache entries for one exact name + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - name: name + in: query + required: true + description: Exact canonicalizable DNS name; required to prevent an accidental flush. + schema: + type: string + minLength: 1 + example: example.com. + - name: type + in: query + style: form + explode: true + schema: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/DnsRecordType' + example: + - A + - AAAA + responses: + '200': + description: Idempotent matching deletion result + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteMatchingCount' + examples: + deleted: + value: + matched: 2 + deleted: 2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/dns/cache/{entry_id}: + delete: + operationId: deleteDnsCacheEntry + summary: Delete one opaque DNS cache entry + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/DnsEntryId' + responses: + '200': + description: Idempotent deletion result + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteCount' + examples: + deleted: + value: + deleted: 1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/dns/cache/flush: + post: + operationId: flushDnsCache + summary: Flush the complete runtime DNS cache + x-permission: control + security: + - bearerAuth: [] + - {} + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyObject' + responses: + '200': + description: Cache invalidation barrier result + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteMatchingCount' + examples: + flushed: + value: + matched: 1024 + deleted: 1024 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/operations/reload: + post: + operationId: startReload + summary: Start an asynchronous reload + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyObject' + examples: + empty: + value: {} + responses: + '202': + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/OperationAccepted' + examples: + queued: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: queued + href: /api/v1/operations/op-01HZX4K8W7 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8W7 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/operations/suspend: + post: + operationId: startSuspend + summary: Start an asynchronous suspend transition + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyObject' + examples: + empty: + value: {} + responses: + '202': + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/OperationAccepted' + examples: + queued: + value: + operation_id: op-01HZX4K8W8 + kind: suspend + status: queued + href: /api/v1/operations/op-01HZX4K8W8 + x-headers: + Content-Type: application/json + Location: /api/v1/operations/op-01HZX4K8W8 + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/operations/resume: + post: + operationId: startResume + summary: Start an asynchronous resume transition + x-permission: control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyObject' + examples: + empty: + value: {} + responses: + '202': + $ref: '#/components/responses/OperationAccepted' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/Unavailable' + /api/v1/operations/{id}: + get: + operationId: getOperation + summary: Read an asynchronous operation + x-permission: observe-owner-or-control + security: + - bearerAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/OperationId' + responses: + '200': + description: Current or terminal operation state + headers: + Retry-After: + description: Positive polling floor in seconds; present while nonterminal. + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/Operation' + examples: + probe_complete: + value: + operation_id: op-01HZX4K8W9 + kind: probe + status: succeeded + created_at: '2026-08-15T09:59:59Z' + started_at: '2026-08-15T10:00:00Z' + finished_at: '2026-08-15T10:00:01Z' + result: + target: + type: group + group_id: group-proxy + selection_changed: + tcp: false + udp: false + selection_before: + tcp: node-hk-01 + udp: null + selection_after: + tcp: node-hk-01 + udp: null + results: + - member_id: node-hk-01 + resolved_leaf_node_id: node-hk-01 + kind: dns + purpose: dns + transport: udp + ip_version: ipv4 + warmth: cold + state: healthy + latency_ms: 45 + health_updated: true + error: null + observed_at: '2026-08-15T10:00:00Z' + - member_id: group-jp + resolved_leaf_node_id: node-jp-01 + kind: dns + purpose: dns + transport: udp + ip_version: ipv4 + warmth: cold + state: unavailable + latency_ms: null + health_updated: true + error: udp_probe_timeout + observed_at: '2026-08-15T10:00:00Z' + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + reload_running: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: running + created_at: '2026-08-15T09:29:59Z' + started_at: '2026-08-15T09:30:00Z' + finished_at: null + result: null + error: null + x-headers: + Content-Type: application/json + Retry-After: 1 + Cache-Control: no-store + X-Content-Type-Options: nosniff + reload_complete: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: succeeded + created_at: '2026-08-15T09:29:59Z' + started_at: '2026-08-15T09:30:00Z' + finished_at: '2026-08-15T09:30:01Z' + result: + active_generation_id: generation-42 + datapath_generation_id: generation-42 + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + reload_complete_reload: + value: + operation_id: op-01HZX4K8W7 + kind: reload + status: succeeded + created_at: '2026-08-15T09:29:59Z' + started_at: '2026-08-15T09:30:00Z' + finished_at: '2026-08-15T09:30:00Z' + result: + active_generation_id: generation-42 + datapath_generation_id: generation-42 + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + suspend_complete: + value: + operation_id: op-01HZX4K8W8 + kind: suspend + status: succeeded + created_at: '2026-08-15T10:00:59Z' + started_at: '2026-08-15T10:01:00Z' + finished_at: '2026-08-15T10:01:00Z' + result: + runtime_state: suspended + error: null + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + description: | + Bearer authentication is mandatory on every request except CORS preflights + whenever a deployment secret is set. + The anonymous security alternative is permitted only on an explicitly + secretless loopback listener, never as a fallback for invalid credentials. + Native permissions are expressed by x-permission; see API Configuration. + headers: + NoStore: + description: Native responses are not cacheable. + required: true + schema: + type: string + const: no-store + NoSniff: + description: Prevent MIME-type sniffing. + required: true + schema: + type: string + const: nosniff + ETag: + description: Quoted group configuration revision. + required: true + schema: + type: string + minLength: 1 + parameters: + Detail: + name: detail + in: query + schema: + type: string + enum: + - summary + - full + default: summary + example: full + Limit1000: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + example: 100 + Cursor: + name: cursor + in: query + description: | + Opaque cursor bound to the resource, running adapter instance, filters, + and retained snapshot. Restart, changed filters, or snapshot expiry or + eviction invalidates it. GET /flows returns 410 snapshot_expired; + GET /nodes and GET /dns/cache return 400 invalid_request for a cursor + that is unknown or no longer valid. Discard it and restart the page walk + without a cursor; never silently continue against a new snapshot. + schema: + type: string + minLength: 1 + GroupId: + name: groupId + in: path + required: true + schema: + type: string + minLength: 1 + example: group-proxy + FlowId: + name: flow_id + in: path + required: true + schema: + type: string + minLength: 1 + example: flow-23 + DnsEntryId: + name: entry_id + in: path + required: true + schema: + type: string + minLength: 1 + example: dns-entry-01HZX4K8W5 + OperationId: + name: id + in: path + required: true + schema: + type: string + minLength: 1 + example: op-01HZX4K8W7 + IfMatch: + name: If-Match + in: header + required: true + schema: + type: string + minLength: 1 + example: '"17"' + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + schema: + type: string + minLength: 1 + LastEventId: + name: Last-Event-ID + in: header + required: false + schema: + type: string + minLength: 1 + example: instance-7:123 + responses: + '404': + description: Unknown source ID or unavailable configuration readback + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + resource_not_found: + value: + error: + code: resource_not_found + message: Configuration source not found. + request_id: request-source-1 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + capability_not_supported: + value: + error: + code: capability_not_supported + message: Configuration readback is unavailable. + request_id: request-source-2 + x-headers: + Content-Type: application/json + Cache-Control: no-store + X-Content-Type-Options: nosniff + OperationAccepted: + description: Operation accepted + headers: + Location: + description: Operation status URL; equal to body href. + required: true + schema: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Retry-After: + description: Positive polling floor in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/OperationAccepted' + BadRequest: + $ref: '#/components/responses/ErrorResponseCommon' + description: Malformed parameters or request shape + Unauthorized: + $ref: '#/components/responses/ErrorResponseCommon' + description: Credentials are missing or invalid + Forbidden: + $ref: '#/components/responses/ErrorResponseCommon' + description: Authenticated caller lacks the required permission + NotFound: + $ref: '#/components/responses/ErrorResponseCommon' + description: Resource/action unavailable, absent, expired, or concealed + Conflict: + $ref: '#/components/responses/ErrorResponseCommon' + description: State, idempotency, cursor, or coherent-snapshot conflict + Gone: + $ref: '#/components/responses/ErrorResponseCommon' + description: Flow or paginated snapshot retention expired + PreconditionFailed: + $ref: '#/components/responses/ErrorResponseCommon' + description: If-Match does not equal the current configuration revision or on-disk source content hash + TooLarge: + $ref: '#/components/responses/ErrorResponseCommon' + description: Request size or advertised fan-out limit exceeded + UnsupportedMediaType: + $ref: '#/components/responses/ErrorResponseCommon' + description: Unsupported request Content-Type + Unprocessable: + $ref: '#/components/responses/ErrorResponseCommon' + description: Unsupported field, value, or transition, or error diagnostics from configuration replacement validation + PreconditionRequired: + $ref: '#/components/responses/ErrorResponseCommon' + description: Required If-Match is absent + RateLimited: + description: Advertised request or operation rate exceeded + headers: + Retry-After: + description: Retry delay in seconds. + required: true + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unavailable: + description: Bounded queue or required runtime component unavailable + headers: + Retry-After: + description: Retry delay in seconds when retryable. + schema: + type: integer + minimum: 1 + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + ErrorResponseCommon: + description: Native API error + headers: + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Content-Type-Options: + $ref: '#/components/headers/NoSniff' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + schemas: + EmptyObject: + type: object + additionalProperties: false + maxProperties: 0 + SafeUInt: + type: integer + minimum: 0 + maximum: 9007199254740991 + NullableSafeUInt: + type: + - integer + - 'null' + minimum: 0 + maximum: 9007199254740991 + UInt64: + type: string + maxLength: 20 + not: + pattern: '[^0-9]' + pattern: ^(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)$ + description: Canonical unsigned 64-bit decimal string, 0 through 18446744073709551615. Never a JSON number. + NullableUInt64: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/UInt64' + Timestamp: + type: string + format: date-time + NullableTimestamp: + type: + - string + - 'null' + format: date-time + ErrorCode: + type: string + description: Closed catalogue of HTTP error codes (docs/errors.md keeps the prose). Adding a code is a contract change; adapters never invent codes. Errors embedded in resources (operation.error, datapath.errors, lifecycle.last_error) carry an adapter-defined code and stay plain SafeError. + enum: + - invalid_request + - authentication_required + - permission_denied + - resource_not_found + - capability_not_supported + - state_conflict + - idempotency_conflict + - event_cursor_expired + - snapshot_unavailable + - snapshot_expired + - flow_expired + - stale_revision + - request_too_large + - unsupported_media_type + - unsupported_value + - precondition_required + - rate_limited + - temporarily_unavailable + SafeError: + type: object + required: + - code + - message + properties: + code: + type: string + minLength: 1 + message: + type: string + minLength: 1 + details: + type: + - object + - 'null' + description: Safe structured error; never raw engine output. + ApiError: + allOf: + - $ref: '#/components/schemas/SafeError' + - type: object + properties: + code: + $ref: '#/components/schemas/ErrorCode' + description: HTTP error body; the code comes from ErrorCode. + ErrorResponse: + type: object + required: + - error + - request_id + properties: + error: + $ref: '#/components/schemas/ApiError' + request_id: + type: + - string + - 'null' + Discovery: + type: object + required: + - name + - status + - api_major + - base_path + - links + properties: + name: + type: string + const: dae/honk-native + status: + type: string + const: draft + api_major: + type: integer + const: 1 + base_path: + type: string + const: /api/v1 + links: + type: object + required: + - version + - capabilities + - config + - config_validate + - runtime + - runtime_outbounds + - traffic_history + - operations + properties: + version: + type: string + const: /api/v1/version + capabilities: + type: string + const: /api/v1/capabilities + config: + type: string + const: /api/v1/config + config_validate: + type: string + const: /api/v1/config/validate + runtime: + type: string + const: /api/v1/runtime + runtime_outbounds: + type: string + const: /api/v1/runtime/outbounds + traffic_history: + type: string + const: /api/v1/runtime/traffic/history + operations: + type: string + const: /api/v1/operations/{id} + Version: + type: object + required: + - api + - engine + properties: + api: + type: object + required: + - name + - major + - status + properties: + name: + type: string + const: dae/honk-native + major: + type: integer + const: 1 + status: + type: string + const: draft + engine: + type: object + required: + - name + - version + properties: + name: + type: string + minLength: 1 + version: + type: string + minLength: 1 + build: + oneOf: + - type: 'null' + - type: object + required: + - revision + - target + - built_at + properties: + revision: + type: + - string + - 'null' + target: + type: + - string + - 'null' + built_at: + $ref: '#/components/schemas/NullableTimestamp' + AvailableResource: + type: object + required: + - available + properties: + available: + type: boolean + Capabilities: + type: object + required: + - observed_at + - profiles + - limits + - resources + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + profiles: + type: array + uniqueItems: true + items: + type: string + enum: + - base + - full_transparency + limits: + type: object + required: + - max_request_target_bytes + - max_header_bytes + - max_json_body_bytes + properties: + max_request_target_bytes: + type: integer + minimum: 1 + max_header_bytes: + type: integer + minimum: 1 + max_json_body_bytes: + type: integer + minimum: 1 + resources: + type: object + required: + - config + - config_validate + - runtime + - runtime_memory + - runtime_outbounds + - traffic_history + - datapath + - nodes + - groups + - probes + - connections + - flows + - routing_trace + - events + - dns_query + - dns_cache + - operations + - reload + - suspend + - resume + properties: + config: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - content + - writable + - max_bytes + - max_sources + properties: + available: + type: boolean + content: + type: boolean + default: false + description: Visibility flag permitting optional source text; not permission to disclose secrets. False by default. + writable: + type: boolean + description: | + Server-wide switch for replacing accepted sources under control; + individual sources may still be read-only. True requires full + validation and reload operations, including resources.reload.available + and resources.operations.available. Independent of content visibility + and the optional dry-run endpoint. + max_bytes: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + description: Maximum UTF-8 bytes in replacement content; the shared JSON body ceiling applies independently. + max_sources: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + description: Maximum complete effective source set the adapter can expose; never silently truncate it. + config_validate: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - modes + - max_bytes + - max_sources + properties: + available: + type: boolean + modes: + type: array + minItems: 1 + uniqueItems: true + items: + $ref: '#/components/schemas/ConfigValidationMode' + max_bytes: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + description: Maximum total UTF-8 source bytes, including local dependencies in full mode; the shared JSON body ceiling also applies. + max_sources: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + description: Maximum sources per validation, including local dependencies in full mode. + runtime: + $ref: '#/components/schemas/AvailableResource' + runtime_memory: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - metrics + properties: + available: + type: boolean + metrics: + type: array + uniqueItems: true + items: + type: string + enum: + - process.rss_bytes + - cgroup.current_bytes + - cgroup.limit_bytes + - cgroup.events.high + - cgroup.events.oom + - cgroup.events.oom_kill + - kernel.ebpf_bytes + runtime_outbounds: + $ref: '#/components/schemas/AvailableResource' + traffic_history: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - max_window_seconds + - max_points + properties: + available: + type: boolean + max_window_seconds: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + description: Maximum look-back window in seconds; not a guarantee against bounded-ring eviction. + max_points: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + description: Maximum returned samples per history request. + datapath: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - kinds + - details + properties: + available: + type: boolean + kinds: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/DatapathKind' + details: + type: array + uniqueItems: true + items: + type: string + enum: + - attachments + - maps + nodes: + $ref: '#/components/schemas/AvailableResource' + groups: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - config_patch + - selection + - max_patch_operations + properties: + available: + type: boolean + config_patch: + type: boolean + selection: + type: boolean + max_patch_operations: + type: integer + minimum: 1 + probes: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - targets + - kinds + - purposes + - transports + - ip_versions + - limits + properties: + available: + type: boolean + targets: + type: array + uniqueItems: true + items: + type: string + enum: + - node + - group + kinds: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/ProbeKind' + purposes: + type: array + uniqueItems: true + items: + type: string + enum: + - data + - dns + transports: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/Transport' + ip_versions: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/IpVersion' + limits: + $ref: '#/components/schemas/ProbeLimits' + connections: + $ref: '#/components/schemas/AvailableResource' + flows: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - recording + - scopes + - max_flows + - max_steps_per_flow + - retention_seconds + - snapshot_ttl_seconds + - max_page_size + properties: + available: + type: boolean + recording: + type: string + enum: + - 'off' + - 'on' + - sampled + scopes: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/FlowScope' + max_flows: + type: integer + minimum: 1 + max_steps_per_flow: + type: integer + minimum: 1 + retention_seconds: + type: integer + minimum: 0 + snapshot_ttl_seconds: + type: integer + minimum: 1 + max_page_size: + type: integer + minimum: 1 + maximum: 1000 + routing_trace: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - resolve_modes + - max_addresses + - max_rule_steps + - timeout_ms + - per_principal_requests_per_minute + - global_requests_per_minute + properties: + available: + type: boolean + resolve_modes: + type: array + uniqueItems: true + items: + type: string + enum: + - none + - live + max_addresses: + type: integer + minimum: 1 + max_rule_steps: + type: integer + minimum: 1 + timeout_ms: + type: integer + minimum: 1 + per_principal_requests_per_minute: + type: integer + minimum: 1 + global_requests_per_minute: + type: integer + minimum: 1 + events: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - kinds + - retention_seconds + - max_buffered_events + - max_clients + - heartbeat_seconds + properties: + available: + type: boolean + kinds: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/EventKind' + retention_seconds: + type: integer + minimum: 0 + max_buffered_events: + type: integer + minimum: 1 + max_clients: + type: integer + minimum: 1 + heartbeat_seconds: + type: integer + minimum: 1 + maximum: 15 + dns_query: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - record_types + - limits + properties: + available: + type: boolean + record_types: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/DnsRecordType' + limits: + $ref: '#/components/schemas/DnsQueryLimits' + dns_cache: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - read + - delete_entry + - delete_name + - flush + - entry_kinds + properties: + available: + type: boolean + read: + type: boolean + delete_entry: + type: boolean + delete_name: + type: boolean + flush: + type: boolean + entry_kinds: + type: array + uniqueItems: true + items: + type: string + enum: + - positive + - negative + operations: + type: object + required: + - available + if: + properties: + available: + const: true + then: + required: + - retention_seconds + properties: + available: + type: boolean + retention_seconds: + type: integer + minimum: 0 + reload: + $ref: '#/components/schemas/AvailableResource' + suspend: + $ref: '#/components/schemas/AvailableResource' + resume: + $ref: '#/components/schemas/AvailableResource' + EffectiveConfig: + type: object + required: + - generation_id + - revision + - sources + - diagnostics + - secrets_redacted + properties: + generation_id: + type: string + minLength: 1 + description: Running generation whose accepted sources and diagnostics are returned. + revision: + type: string + minLength: 1 + description: Opaque configuration revision used by Runtime.generation.config_revision and GroupSummary.config_revision; preserve without numeric parsing. + sources: + type: array + items: + $ref: '#/components/schemas/ConfigSource' + diagnostics: + type: array + items: + $ref: '#/components/schemas/ConfigDiagnostic' + secrets_redacted: + type: boolean + description: True when content is withheld or paths, text, or diagnostic messages are redacted under visibility policy. + ConfigSource: + type: object + required: + - id + - path + - kind + - content_sha256 + - bytes + - writable + - loaded_at + - line_count + properties: + id: + type: string + minLength: 1 + description: Unique opaque source ID within this configuration snapshot; never a credential-bearing path or URL. + path: + type: string + minLength: 1 + description: Display path only, replaced with when hidden by visibility policy; not a file-access capability. + kind: + type: string + enum: + - main + - include + - subscription + - generated + content_sha256: + type: string + pattern: ^[0-9a-f]{64}$ + minLength: 64 + maxLength: 64 + description: Lowercase SHA-256 of the accepted source bytes before redaction; not necessarily the digest of displayed content. + bytes: + $ref: '#/components/schemas/SafeUInt' + description: Accepted source size in bytes before redaction. + writable: + type: boolean + description: | + True only when server-wide editing is enabled and this source permits + replacement by a control caller. False for engine-written includes, + generated sources, and subscriptions; observe alone never grants writes. + loaded_at: + $ref: '#/components/schemas/Timestamp' + description: Time these source bytes were accepted, not the current file modification time. + content: + type: string + description: Optional dae text, only when resources.config.content is true; still subject to secret redaction. Use for editing only if its UTF-8 SHA-256 matches content_sha256. + line_count: + $ref: '#/components/schemas/SafeUInt' + description: Lines in the accepted source before redaction; empty text has zero lines, and a final newline does not add an empty line. + if: + properties: + kind: + enum: + - subscription + - generated + required: + - kind + then: + properties: + writable: + const: false + ConfigDiagnostic: + type: object + required: + - level + - source_id + - line + - column + - span + - code + - message + description: | + Safe diagnostic for an accepted or candidate source, never raw parser output. + Coordinates refer to the original source before redaction. Use null for + unknown locations; do not fabricate positions from setting names. + properties: + level: + type: string + enum: + - error + - warning + - info + source_id: + type: string + minLength: 1 + description: Source ID in the effective snapshot, validation request, or source set being validated for replacement. + line: + $ref: '#/components/schemas/NullableSafeUInt' + minimum: 1 + description: One-based line, or null if unknown. + column: + $ref: '#/components/schemas/NullableSafeUInt' + minimum: 1 + description: One-based UTF-8 byte column, or null if unknown; not a character or UTF-16 offset. + span: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/ConfigDiagnosticSpan' + code: + type: string + minLength: 1 + description: Adapter-defined diagnostic code, independent of the HTTP ErrorCode catalogue. + message: + type: string + minLength: 1 + description: Safe operator-facing description; never source excerpts, credentials, private paths, or raw engine errors. + ConfigDiagnosticSpan: + type: object + required: + - start_line + - start_column + - end_line + - end_column + description: | + One-based lines and UTF-8 byte columns; start inclusive, end exclusive. + The end must not precede the start. A zero-width span is permitted. + When line and column are known, they equal the span start. + properties: + start_line: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + start_column: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + end_line: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + end_column: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + ConfigValidationMode: + type: string + enum: + - syntax + - full + ConfigValidationSource: + type: object + additionalProperties: false + required: + - content + properties: + id: + type: string + minLength: 1 + description: Request-local diagnostic ID. If omitted, use source-N where N is the one-based array index; all effective IDs must be unique. Never put secrets in IDs. + path: + type: string + minLength: 1 + description: Optional engine-native source name and include-resolution base within the adapter's authorized local roots; never grants arbitrary file access. + content: + type: string + description: Candidate engine-native source text; empty text is a candidate, not a malformed request. + ConfigValidationRequest: + type: object + additionalProperties: false + required: + - sources + - mode + properties: + sources: + type: array + minItems: 1 + description: Ordered candidate sources; the first is the main source. Supplied content takes precedence over local files at the same resolved path. + items: + $ref: '#/components/schemas/ConfigValidationSource' + mode: + $ref: '#/components/schemas/ConfigValidationMode' + ConfigValidationResult: + type: object + required: + - valid + - diagnostics + - generation_id + - validated_at + properties: + valid: + type: boolean + description: True exactly when validation completed without error diagnostics; warnings and info do not invalidate the candidate. No promise that a later apply will succeed. + diagnostics: + type: array + description: Source IDs identify submitted sources. Attribute a dependency failure to the referring submitted source and its include/subscription location, not an undisclosed local path. + items: + $ref: '#/components/schemas/ConfigDiagnostic' + generation_id: + type: string + minLength: 1 + description: Running generation captured when validation starts, for context only; not a new candidate generation or an apply precondition. + validated_at: + $ref: '#/components/schemas/Timestamp' + description: Time validation completed. + if: + properties: + valid: + const: true + then: + properties: + diagnostics: + not: + contains: + properties: + level: + const: error + required: + - level + else: + properties: + diagnostics: + contains: + properties: + level: + const: error + required: + - level + ProbeLimits: + type: object + required: + - max_members_per_job + - max_results_per_job + - max_active_jobs + - max_queued_jobs + - max_concurrent_per_target + - job_timeout_ms + - per_principal_requests_per_minute + - global_requests_per_minute + properties: + max_members_per_job: + type: integer + minimum: 1 + max_results_per_job: + type: integer + minimum: 1 + max_active_jobs: + type: integer + minimum: 1 + max_queued_jobs: + type: integer + minimum: 0 + max_concurrent_per_target: + type: integer + minimum: 1 + job_timeout_ms: + type: integer + minimum: 1 + per_principal_requests_per_minute: + type: integer + minimum: 1 + global_requests_per_minute: + type: integer + minimum: 1 + DnsQueryLimits: + type: object + required: + - max_types_per_request + - query_timeout_ms + - max_response_bytes + - per_principal_requests_per_minute + - global_requests_per_minute + properties: + max_types_per_request: + type: integer + minimum: 1 + query_timeout_ms: + type: integer + minimum: 1 + max_response_bytes: + type: integer + minimum: 1 + per_principal_requests_per_minute: + type: integer + minimum: 1 + global_requests_per_minute: + type: integer + minimum: 1 + Runtime: + type: object + required: + - observed_at + - instance_id + - lifecycle + - generation + - datapath + - traffic + - process + - last_reload + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + instance_id: + type: string + minLength: 1 + lifecycle: + type: object + required: + - state + - started_at + - uptime_seconds + properties: + state: + type: string + enum: + - starting + - running + - reloading + - suspended + - draining + - degraded + - failed + started_at: + $ref: '#/components/schemas/NullableTimestamp' + uptime_seconds: + $ref: '#/components/schemas/NullableUInt64' + generation: + type: object + required: + - active_id + - config_revision + - state + - activated_at + properties: + active_id: + type: string + minLength: 1 + config_revision: + type: + - string + - 'null' + state: + type: string + enum: + - active + - reloading + activated_at: + $ref: '#/components/schemas/NullableTimestamp' + datapath: + $ref: '#/components/schemas/DatapathSummary' + traffic: + $ref: '#/components/schemas/TrafficSummary' + process: + type: object + required: + - cpu_percent + properties: + pid: + type: + - integer + - 'null' + minimum: 0 + maximum: 4294967295 + cpu_percent: + type: + - number + - 'null' + minimum: 0 + last_reload: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/LastReload' + LastReload: + type: object + required: + - operation_id + - status + - finished_at + - error + properties: + operation_id: + type: string + minLength: 1 + status: + type: string + enum: + - succeeded + - failed + finished_at: + $ref: '#/components/schemas/NullableTimestamp' + error: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/SafeError' + TrafficSummary: + type: object + required: + - scope + - observed_by + - counter_since + - sampled_at + - connections + - bytes + - rates + properties: + scope: + type: string + const: visible + observed_by: + $ref: '#/components/schemas/ObservedBy' + counter_since: + $ref: '#/components/schemas/NullableTimestamp' + sampled_at: + $ref: '#/components/schemas/NullableTimestamp' + description: Traffic sample timestamp, or null when unavailable. Never substitute the HTTP snapshot timestamp. + connections: + type: object + required: + - tcp + - udp + - total + properties: + tcp: + $ref: '#/components/schemas/NullableSafeUInt' + udp: + $ref: '#/components/schemas/NullableSafeUInt' + total: + $ref: '#/components/schemas/NullableSafeUInt' + bytes: + type: object + required: + - upload + - download + properties: + upload: + $ref: '#/components/schemas/NullableUInt64' + download: + $ref: '#/components/schemas/NullableUInt64' + rates: + oneOf: + - type: 'null' + - type: object + required: + - window_seconds + - upload_bytes_per_second + - download_bytes_per_second + properties: + window_seconds: + type: number + exclusiveMinimum: 0 + description: Duration of the rate interval ending at sampled_at, in seconds. + upload_bytes_per_second: + $ref: '#/components/schemas/NullableUInt64' + download_bytes_per_second: + $ref: '#/components/schemas/NullableUInt64' + RuntimeMemory: + type: object + required: + - observed_at + - process + - cgroup + - kernel + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + process: + oneOf: + - type: 'null' + - type: object + properties: + rss_bytes: + $ref: '#/components/schemas/NullableUInt64' + cgroup: + oneOf: + - type: 'null' + - type: object + required: + - scope + properties: + scope: + type: string + enum: + - service + - shared + - unknown + current_bytes: + $ref: '#/components/schemas/NullableUInt64' + limit_bytes: + $ref: '#/components/schemas/NullableUInt64' + events: + oneOf: + - type: 'null' + - type: object + properties: + high: + $ref: '#/components/schemas/NullableUInt64' + oom: + $ref: '#/components/schemas/NullableUInt64' + oom_kill: + $ref: '#/components/schemas/NullableUInt64' + kernel: + oneOf: + - type: 'null' + - type: object + required: + - sampled_at + properties: + ebpf_bytes: + $ref: '#/components/schemas/NullableUInt64' + sampled_at: + $ref: '#/components/schemas/NullableTimestamp' + RuntimeOutbounds: + type: object + required: + - observed_at + - counter_since + - outbounds + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + counter_since: + $ref: '#/components/schemas/Timestamp' + description: Shared cumulative counter reset boundary; changes on restart or reset. Never compute deltas across this boundary. + outbounds: + type: array + items: + $ref: '#/components/schemas/OutboundCounters' + OutboundCounters: + type: object + required: + - name + - kind + - active_connections + - total_connections + - upload_bytes + - download_bytes + - errors + properties: + name: + type: string + minLength: 1 + description: Engine-visible outbound name, retained with its counters across reloads. + kind: + type: string + enum: + - group + - node + - builtin + description: Configured group, leaf node, or engine builtin such as direct/block. + active_connections: + $ref: '#/components/schemas/SafeUInt' + description: Currently active connections attributed to this outbound. + total_connections: + $ref: '#/components/schemas/UInt64' + description: Cumulative connections attributed to this outbound since counter_since. + upload_bytes: + $ref: '#/components/schemas/UInt64' + download_bytes: + $ref: '#/components/schemas/UInt64' + errors: + $ref: '#/components/schemas/UInt64' + description: Cumulative outbound failures since counter_since; policy blocks are not errors. + TrafficHistory: + type: object + required: + - observed_at + - window_seconds + - sampled_every_seconds + - samples + description: | + Samples lie in (observed_at - window_seconds, observed_at], oldest first. + Retention is bounded by age and capacity and is cleared on process restart. + Return fewer points for short retention, or an empty array before sampling. + If needed, select every Nth stored sample backwards from the newest to + fit max_points; preserve original timestamps and rates, without interpolation. + Choose the smallest positive N that fits the point limit. A cumulative + counter reset makes the spanning rate sample null, never a spike. + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + window_seconds: + $ref: '#/components/schemas/SafeUInt' + minimum: 1 + description: Requested look-back window, not the age of the oldest retained sample. + sampled_every_seconds: + type: number + exclusiveMinimum: 0 + description: Nominal interval between returned samples after thinning, or the recorder interval for an empty result; not the rate averaging interval. + samples: + type: array + description: At most max_points retained samples; missed intervals remain gaps, never fabricated zero traffic. + items: + $ref: '#/components/schemas/TrafficHistorySample' + TrafficHistorySample: + type: object + required: + - sampled_at + - upload_bytes_per_second + - download_bytes_per_second + - connections + properties: + sampled_at: + $ref: '#/components/schemas/Timestamp' + description: Original sample timestamp, never the HTTP snapshot time. + upload_bytes_per_second: + $ref: '#/components/schemas/NullableUInt64' + download_bytes_per_second: + $ref: '#/components/schemas/NullableUInt64' + connections: + $ref: '#/components/schemas/NullableSafeUInt' + description: Visible active TCP and UDP connections at sampled_at, or null when unavailable. + DatapathKind: + type: string + enum: + - ebpf + - userspace + - mock + - unknown + DatapathState: + type: string + enum: + - active + - degraded + - detached + - failed + - disabled + - unknown + Visibility: + type: string + enum: + - full + - partial + - none + ObservedBy: + type: string + enum: + - userspace + - ebpf + - mixed + EbpfRoutingSummary: + type: object + required: + - state + - generation_id + properties: + state: + type: string + enum: + - published + - not_published + - error + - unknown + generation_id: + type: + - string + - 'null' + epoch: + type: + - string + - 'null' + EbpfSummary: + type: object + required: + - backend + - programs + - hooks + - routing + - health + - last_error + - checked_at + properties: + backend: + type: string + enum: + - real + - mock + - unknown + programs: + type: string + enum: + - loaded + - not_loaded + - error + - unknown + hooks: + type: string + enum: + - attached + - partially_attached + - detached + - unknown + routing: + $ref: '#/components/schemas/EbpfRoutingSummary' + health: + type: string + enum: + - healthy + - degraded + - failed + - unknown + last_error: + type: + - string + - 'null' + checked_at: + $ref: '#/components/schemas/Timestamp' + DatapathSummary: + type: object + required: + - kind + - state + - visibility + - ebpf + properties: + kind: + $ref: '#/components/schemas/DatapathKind' + state: + $ref: '#/components/schemas/DatapathState' + visibility: + $ref: '#/components/schemas/Visibility' + ebpf: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/EbpfSummary' + Datapath: + type: object + required: + - observed_at + - kind + - state + - visibility + - ebpf + - errors + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + kind: + $ref: '#/components/schemas/DatapathKind' + state: + $ref: '#/components/schemas/DatapathState' + visibility: + $ref: '#/components/schemas/Visibility' + ebpf: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/EbpfDetail' + errors: + type: array + items: + $ref: '#/components/schemas/SafeError' + EbpfDetail: + allOf: + - $ref: '#/components/schemas/EbpfSummary' + - type: object + required: [] + properties: + attachments: + type: array + items: + $ref: '#/components/schemas/EbpfAttachment' + maps: + $ref: '#/components/schemas/EbpfMaps' + EbpfAttachment: + type: object + required: + - name + - interface + - direction + - state + properties: + name: + type: string + minLength: 1 + interface: + type: string + minLength: 1 + direction: + type: string + enum: + - ingress + - egress + state: + type: string + enum: + - attached + - detached + - error + - unknown + EbpfMaps: + type: object + required: + - state + - conn_state + properties: + state: + type: string + enum: + - ready + - partial + - error + - unknown + conn_state: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/MapOccupancy' + MapOccupancy: + type: object + required: + - occupancy + - capacity + - occupancy_known + properties: + occupancy: + $ref: '#/components/schemas/NullableSafeUInt' + capacity: + $ref: '#/components/schemas/SafeUInt' + occupancy_known: + type: boolean + if: + properties: + occupancy_known: + const: true + then: + properties: + occupancy: + $ref: '#/components/schemas/SafeUInt' + else: + properties: + occupancy: + type: 'null' + Transport: + type: string + enum: + - tcp + - udp + IpVersion: + type: string + enum: + - ipv4 + - ipv6 + HealthObservation: + type: object + required: + - transport + - purpose + - ip_version + - warmth + - measurement + - sample_source + - state + - latency_ms + - moving_avg_ms + - avg10_ms + - observed_at + - error + properties: + transport: + $ref: '#/components/schemas/Transport' + purpose: + type: string + enum: + - data + - dns + - shared + ip_version: + $ref: '#/components/schemas/IpVersion' + warmth: + type: string + enum: + - cold + - warm + - mixed + - unknown + measurement: + type: string + enum: + - tcp_connect + - http_headers + - http_round_trip + - dns_round_trip + - quic_handshake + - mixed + - unknown + sample_source: + type: string + enum: + - probe + - traffic + - restored + - derived + - mixed + - unknown + state: + type: string + enum: + - healthy + - unavailable + - unknown + latency_ms: + type: + - number + - 'null' + minimum: 0 + moving_avg_ms: + type: + - number + - 'null' + minimum: 0 + avg10_ms: + type: + - number + - 'null' + minimum: 0 + observed_at: + $ref: '#/components/schemas/Timestamp' + error: + type: + - string + - 'null' + allOf: + - if: + properties: + state: + enum: + - unavailable + - unknown + then: + properties: + latency_ms: + type: 'null' + Node: + type: object + required: + - id + - name + - protocol + - subscription_tag + - group_ids + - health + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + protocol: + type: + - string + - 'null' + subscription_tag: + type: + - string + - 'null' + group_ids: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + health: + type: array + description: Latest observations, unique by (transport, purpose, measurement, ip_version, warmth) within this node. + items: + $ref: '#/components/schemas/HealthObservation' + NodeList: + type: object + required: + - observed_at + - nodes + - next_cursor + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + nodes: + type: array + items: + $ref: '#/components/schemas/Node' + next_cursor: + type: + - string + - 'null' + GroupPolicy: + type: object + required: + - kind + - native + properties: + kind: + type: string + enum: + - selector + - urltest + - loadbalance + - fallback + - random + - score + native: + type: string + minLength: 1 + GroupPolicyRequest: + type: object + additionalProperties: false + required: + - kind + - native + properties: + kind: + type: string + enum: + - selector + - urltest + - loadbalance + - fallback + - random + - score + native: + type: string + minLength: 1 + GroupMember: + type: object + required: + - id + - name + - kind + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + kind: + type: string + enum: + - node + - group + GroupConfig: + type: object + required: + - default_member_id + - final_outbound + - check_url + - check_interval + - tolerance + - idle_timeout + - interrupt_connections + properties: + default_member_id: + type: + - string + - 'null' + final_outbound: + type: + - string + - 'null' + check_url: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/SafeHttpUrl' + check_interval: + type: + - integer + - 'null' + minimum: 1 + tolerance: + type: + - number + - 'null' + minimum: 0 + idle_timeout: + type: + - integer + - 'null' + minimum: 0 + interrupt_connections: + type: boolean + SafeHttpUrl: + type: string + format: uri + pattern: ^https?://(?![^/?#]*@) + description: Absolute HTTP(S) URL without userinfo; server also enforces administrator SSRF policy. + GroupSelection: + type: object + required: + - member_id + - source + properties: + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: + - string + - 'null' + source: + type: string + minLength: 1 + GroupRanking: + type: object + required: + - metric + - recovery_penalty_ms + - group_offset_ms + - score + - reason + properties: + metric: + type: + - string + - 'null' + recovery_penalty_ms: + type: + - number + - 'null' + group_offset_ms: + type: + - number + - 'null' + score: + type: + - number + - 'null' + reason: + type: + - string + - 'null' + GroupHealthObservation: + allOf: + - $ref: '#/components/schemas/HealthObservation' + - type: object + required: + - member_id + - sorting_latency_ms + - ranking + properties: + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: + - string + - 'null' + sorting_latency_ms: + type: + - number + - 'null' + ranking: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/GroupRanking' + GroupRuntime: + type: object + required: + - selection + - health + properties: + selection: + type: object + required: + - tcp + - udp + properties: + tcp: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/GroupSelection' + udp: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/GroupSelection' + health: + type: array + items: + $ref: '#/components/schemas/GroupHealthObservation' + GroupCapabilities: + type: object + required: + - can_select + - supports_nested_groups + - mutable_config + - probe_transports + properties: + can_select: + type: boolean + supports_nested_groups: + type: boolean + mutable_config: + type: array + uniqueItems: true + items: + type: string + enum: + - policy + - default_member_id + - final_outbound + - check_url + - check_interval + - tolerance + - idle_timeout + - interrupt_connections + probe_transports: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/Transport' + Group: + type: object + required: + - id + - name + - config_revision + - policy + - members + - config + - runtime + - capabilities + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + config_revision: + type: string + minLength: 1 + policy: + $ref: '#/components/schemas/GroupPolicy' + members: + type: array + items: + $ref: '#/components/schemas/GroupMember' + config: + $ref: '#/components/schemas/GroupConfig' + runtime: + $ref: '#/components/schemas/GroupRuntime' + capabilities: + $ref: '#/components/schemas/GroupCapabilities' + GroupSummary: + type: object + required: + - id + - name + - config_revision + - policy + - member_count + - selection + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + config_revision: + type: string + minLength: 1 + description: Same opaque configuration revision as Group.config_revision; preserve without numeric parsing. + policy: + $ref: '#/components/schemas/GroupPolicy' + member_count: + type: integer + minimum: 0 + selection: + type: object + required: + - tcp_member_id + - udp_member_id + properties: + tcp_member_id: + type: + - string + - 'null' + udp_member_id: + type: + - string + - 'null' + GroupList: + type: array + items: + $ref: '#/components/schemas/GroupSummary' + JsonPatch: + type: array + minItems: 1 + items: + oneOf: + - $ref: '#/components/schemas/PolicyPatch' + - $ref: '#/components/schemas/MemberIdPatch' + - $ref: '#/components/schemas/OutboundPatch' + - $ref: '#/components/schemas/CheckUrlPatch' + - $ref: '#/components/schemas/PositiveIntegerPatch' + - $ref: '#/components/schemas/TolerancePatch' + - $ref: '#/components/schemas/IdleTimeoutPatch' + - $ref: '#/components/schemas/InterruptPatch' + - $ref: '#/components/schemas/RemovePatch' + - $ref: '#/components/schemas/CopyMovePatch' + description: Bounded by resources.groups.max_patch_operations. + PolicyPatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + const: /policy + value: + $ref: '#/components/schemas/GroupPolicyRequest' + MemberIdPatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + const: /config/default_member_id + value: + type: + - string + - 'null' + OutboundPatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + const: /config/final_outbound + value: + type: + - string + - 'null' + CheckUrlPatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + const: /config/check_url + value: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/SafeHttpUrl' + PositiveIntegerPatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + enum: + - /config/check_interval + value: + type: + - integer + - 'null' + minimum: 1 + TolerancePatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + const: /config/tolerance + value: + type: + - number + - 'null' + minimum: 0 + IdleTimeoutPatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + const: /config/idle_timeout + value: + type: + - integer + - 'null' + minimum: 0 + InterruptPatch: + type: object + additionalProperties: false + required: + - op + - path + - value + properties: + op: + type: string + enum: + - add + - replace + - test + path: + type: string + const: /config/interrupt_connections + value: + type: boolean + MutableGroupPath: + type: string + enum: + - /policy + - /config/default_member_id + - /config/final_outbound + - /config/check_url + - /config/check_interval + - /config/tolerance + - /config/idle_timeout + - /config/interrupt_connections + RemovePatch: + type: object + additionalProperties: false + required: + - op + - path + properties: + op: + const: remove + path: + $ref: '#/components/schemas/MutableGroupPath' + CopyMovePatch: + type: object + additionalProperties: false + required: + - op + - path + - from + properties: + op: + enum: + - copy + - move + path: + $ref: '#/components/schemas/MutableGroupPath' + from: + $ref: '#/components/schemas/MutableGroupPath' + GroupSelectionRequest: + type: object + additionalProperties: false + required: + - member_id + - network + properties: + member_id: + type: string + minLength: 1 + network: + type: string + enum: + - tcp + - udp + - both + GroupSelectionResult: + type: object + required: + - group_id + - member_id + - network + - source + - selection_revision + - connections_interrupted + properties: + group_id: + type: string + minLength: 1 + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: + - string + - 'null' + network: + type: string + enum: + - tcp + - udp + - both + source: + type: string + const: runtime + selection_revision: + type: string + minLength: 1 + connections_interrupted: + type: boolean + ProbeKind: + type: string + enum: + - tcp_connect + - http + - dns + ProbeTarget: + unevaluatedProperties: false + oneOf: + - $ref: '#/components/schemas/NodeProbeTarget' + - $ref: '#/components/schemas/GroupProbeTarget' + NodeProbeTarget: + type: object + required: + - type + - node_id + properties: + type: + type: string + const: node + node_id: + type: string + minLength: 1 + GroupProbeTarget: + type: object + required: + - type + - group_id + properties: + type: + type: string + const: group + group_id: + type: string + minLength: 1 + ProbeTargetResponse: + oneOf: + - $ref: '#/components/schemas/NodeProbeTarget' + - $ref: '#/components/schemas/GroupProbeTarget' + ProbeMembers: + oneOf: + - type: string + enum: + - direct + - leaves + - type: array + minItems: 1 + uniqueItems: true + items: + type: string + minLength: 1 + ProbeRequest: + type: object + additionalProperties: false + required: + - target + - kind + - purpose + - transport + - ip_version + - warmth + properties: + target: + $ref: '#/components/schemas/ProbeTarget' + kind: + $ref: '#/components/schemas/ProbeKind' + purpose: + type: string + enum: + - data + - dns + transport: + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + items: + $ref: '#/components/schemas/Transport' + ip_version: + type: string + enum: + - ipv4 + - ipv6 + - any + members: + $ref: '#/components/schemas/ProbeMembers' + default: direct + warmth: + type: string + enum: + - cold + - warm + allOf: + - if: + properties: + target: + properties: + type: + const: node + required: + - type + then: + not: + required: + - members + - oneOf: + - properties: + kind: + const: tcp_connect + purpose: + const: data + transport: + type: array + minItems: 1 + maxItems: 1 + items: + const: tcp + - properties: + kind: + const: http + purpose: + const: data + transport: + type: array + minItems: 1 + maxItems: 1 + items: + const: tcp + - properties: + kind: + const: dns + purpose: + const: dns + ProbeResultItem: + type: object + required: + - member_id + - resolved_leaf_node_id + - kind + - purpose + - transport + - ip_version + - warmth + - state + - latency_ms + - health_updated + - error + - observed_at + properties: + member_id: + type: string + minLength: 1 + resolved_leaf_node_id: + type: + - string + - 'null' + kind: + $ref: '#/components/schemas/ProbeKind' + purpose: + type: string + enum: + - data + - dns + transport: + $ref: '#/components/schemas/Transport' + ip_version: + $ref: '#/components/schemas/IpVersion' + warmth: + type: string + enum: + - cold + - warm + - unknown + state: + type: string + enum: + - healthy + - unavailable + - unknown + latency_ms: + type: + - number + - 'null' + minimum: 0 + health_updated: + type: boolean + error: + type: + - string + - 'null' + observed_at: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + state: + enum: + - unavailable + - unknown + then: + properties: + latency_ms: + type: 'null' + ProbeResult: + type: object + required: + - target + - selection_changed + - selection_before + - selection_after + - results + properties: + target: + $ref: '#/components/schemas/ProbeTargetResponse' + selection_changed: + $ref: '#/components/schemas/TransportBooleanMap' + selection_before: + $ref: '#/components/schemas/TransportSelectionMap' + selection_after: + $ref: '#/components/schemas/TransportSelectionMap' + results: + type: array + items: + $ref: '#/components/schemas/ProbeResultItem' + TransportBooleanMap: + type: object + required: + - tcp + - udp + properties: + tcp: + type: boolean + udp: + type: boolean + TransportSelectionMap: + type: object + required: + - tcp + - udp + properties: + tcp: + type: + - string + - 'null' + udp: + type: + - string + - 'null' + ConnectionState: + type: string + enum: + - observed + - routing + - dialing + - active + - closed + - blocked + - failed + - unknown + Connection: + type: object + required: + - id + - flow_id + - pname + - state + - outbound + - chain + - chain_source + - rule_id + - rule_expression + - rule_source + - ingress + - domain_source + - started_at + - observed_by + - upload_bytes + - download_bytes + - upload_bytes_per_second + - download_bytes_per_second + properties: + id: + type: string + minLength: 1 + flow_id: + type: + - string + - 'null' + pname: + type: + - string + - 'null' + state: + $ref: '#/components/schemas/ConnectionState' + src: + type: string + minLength: 1 + dst: + type: string + minLength: 1 + domain: + type: + - string + - 'null' + outbound: + type: + - string + - 'null' + chain: + type: array + items: + type: string + description: Application outbound selection_path group IDs followed by the leaf node ID, in order; empty for direct/block or an unknown path. + chain_source: + type: string + enum: + - evaluation + - reconstructed + - unknown + description: Selection captured at evaluation, reconstructed from retained evidence, or unavailable; never a current group snapshot. + rule_id: + type: + - string + - 'null' + description: Generation-scoped traffic rule ID, or null when unavailable. + rule_expression: + type: + - string + - 'null' + description: Sanitized display expression for that rule, or null when unavailable. + rule_source: + type: string + enum: + - kernel + - recomputed + - unknown + description: Deciding kernel rule, recomputed userspace evidence, or unavailable provenance; see honk-mapping's matched_rule row. + ingress: + type: + - string + - 'null' + enum: + - lan + - wan + - null + domain_source: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/DomainSource' + started_at: + $ref: '#/components/schemas/NullableTimestamp' + observed_by: + $ref: '#/components/schemas/ObservedBy' + upload_bytes: + $ref: '#/components/schemas/NullableUInt64' + download_bytes: + $ref: '#/components/schemas/NullableUInt64' + upload_bytes_per_second: + $ref: '#/components/schemas/NullableUInt64' + download_bytes_per_second: + $ref: '#/components/schemas/NullableUInt64' + ConnectionList: + type: object + required: + - observed_at + - instance_id + - visibility + - truncated + - tcp + - udp + - total_tcp + - total_udp + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + instance_id: + type: string + minLength: 1 + visibility: + $ref: '#/components/schemas/Visibility' + truncated: + type: boolean + description: Whether limit omitted visible entries matching type and src. + tcp: + type: array + items: + $ref: '#/components/schemas/Connection' + udp: + type: array + items: + $ref: '#/components/schemas/Connection' + total_tcp: + $ref: '#/components/schemas/SafeUInt' + description: Visible live TCP entries matching type and src before limit; zero when type excludes TCP. + total_udp: + $ref: '#/components/schemas/SafeUInt' + description: Visible live UDP entries matching type and src before limit; zero when type excludes UDP. + FlowScope: + type: string + enum: + - userspace_tcp + - userspace_udp + - kernel_direct + - kernel_block + - dns_intercept + - kernel_bypass + TraceStatus: + type: string + enum: + - complete + - partial + - disabled + FlowInput: + type: object + required: + - src + - dst + - domain + - domain_source + - pid + - process_path + - src_mac + - ingress + - domain_rule_ids + - dscp + - mark + properties: + src: + type: + - string + - 'null' + dst: + type: + - string + - 'null' + domain: + type: + - string + - 'null' + domain_source: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/DomainSource' + pid: + $ref: '#/components/schemas/NullableSafeUInt' + process_path: + type: + - string + - 'null' + src_mac: + type: + - string + - 'null' + ingress: + type: + - string + - 'null' + enum: + - lan + - wan + - null + domain_rule_ids: + oneOf: + - type: 'null' + - type: array + uniqueItems: true + items: + type: string + minLength: 1 + dscp: + type: + - integer + - 'null' + minimum: 0 + maximum: 63 + mark: + type: + - integer + - 'null' + minimum: 0 + maximum: 4294967295 + TrafficRoutingInput: + type: object + required: + - network + - src_ip + - src_port + - dst_ip + - dst_port + - domain + - pname + - src_mac + - dscp + - mark + - ingress + - domain_rule_ids + properties: + network: + $ref: '#/components/schemas/Transport' + src_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + src_port: + type: + - integer + - 'null' + minimum: 0 + maximum: 65535 + dst_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + dst_port: + type: + - integer + - 'null' + minimum: 0 + maximum: 65535 + domain: + type: + - string + - 'null' + pname: + type: + - string + - 'null' + src_mac: + type: + - string + - 'null' + dscp: + type: + - integer + - 'null' + minimum: 0 + maximum: 63 + mark: + type: + - integer + - 'null' + minimum: 0 + maximum: 4294967295 + ingress: + type: + - string + - 'null' + enum: + - lan + - wan + - null + domain_rule_ids: + oneOf: + - type: 'null' + - type: array + items: + type: string + minLength: 1 + uniqueItems: true + DnsRequestRoutingInput: + type: object + required: + - name + - qtype + - source_ip + - original_dst + properties: + name: + type: string + minLength: 1 + qtype: + $ref: '#/components/schemas/DnsRecordType' + source_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + original_dst: + type: + - string + - 'null' + DnsResponseRoutingInput: + type: object + required: + - name + - qtype + - answer_ips + - from_upstream + properties: + name: + type: string + minLength: 1 + qtype: + $ref: '#/components/schemas/DnsRecordType' + answer_ips: + type: array + items: + $ref: '#/components/schemas/IpAddress' + from_upstream: + type: string + minLength: 1 + DomainSource: + type: string + enum: + - tls_sni + - http_host + - quic_sni + - dns_mapping + - explicit + - unknown + FlowSummary: + type: object + required: + - id + - instance_id + - revision + - network + - state + - pname + - connection_id + - outbound + - chain + - chain_source + - rule_id + - rule_expression + - rule_source + - ingress + - domain_source + - observed_by + - started_at + - ended_at + - trace_status + properties: + id: + type: string + minLength: 1 + instance_id: + type: string + minLength: 1 + revision: + type: integer + minimum: 1 + maximum: 9007199254740991 + network: + $ref: '#/components/schemas/Transport' + state: + $ref: '#/components/schemas/ConnectionState' + pname: + type: + - string + - 'null' + connection_id: + type: + - string + - 'null' + outbound: + type: + - string + - 'null' + chain: + type: array + items: + type: string + description: Application outbound selection_path group IDs followed by the leaf node ID, in order; empty for direct/block or an unknown path. + chain_source: + type: string + enum: + - evaluation + - reconstructed + - unknown + description: Selection captured at evaluation, reconstructed from retained evidence, or unavailable; never a current group snapshot. + rule_id: + type: + - string + - 'null' + description: Generation-scoped traffic rule ID, or null when unavailable. + rule_expression: + type: + - string + - 'null' + description: Sanitized display expression for that rule, or null when unavailable. + rule_source: + type: string + enum: + - kernel + - recomputed + - unknown + description: Deciding kernel rule, recomputed userspace evidence, or unavailable provenance; see honk-mapping's matched_rule row. + ingress: + type: + - string + - 'null' + enum: + - lan + - wan + - null + domain_source: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/DomainSource' + observed_by: + $ref: '#/components/schemas/ObservedBy' + started_at: + $ref: '#/components/schemas/NullableTimestamp' + ended_at: + $ref: '#/components/schemas/NullableTimestamp' + trace_status: + $ref: '#/components/schemas/TraceStatus' + input: + $ref: '#/components/schemas/FlowInput' + FlowCoverage: + type: object + required: + - userspace_tcp + - userspace_udp + - kernel_direct + - kernel_block + - dns_intercept + - kernel_bypass + properties: + userspace_tcp: + $ref: '#/components/schemas/Visibility' + userspace_udp: + $ref: '#/components/schemas/Visibility' + kernel_direct: + $ref: '#/components/schemas/Visibility' + kernel_block: + $ref: '#/components/schemas/Visibility' + dns_intercept: + $ref: '#/components/schemas/Visibility' + kernel_bypass: + $ref: '#/components/schemas/Visibility' + FlowList: + type: object + required: + - instance_id + - observed_at + - coverage + - dropped_records + - flows + - next_cursor + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + coverage: + $ref: '#/components/schemas/FlowCoverage' + dropped_records: + $ref: '#/components/schemas/NullableUInt64' + flows: + type: array + items: + $ref: '#/components/schemas/FlowSummary' + next_cursor: + type: + - string + - 'null' + FlowDetail: + allOf: + - $ref: '#/components/schemas/FlowSummary' + - type: object + required: + - input + - trace + properties: + input: + $ref: '#/components/schemas/FlowInput' + trace: + $ref: '#/components/schemas/FlowTrace' + not: + required: + - mode + - oneOf: + - properties: + trace_status: + const: complete + trace: + properties: + status: + const: complete + - properties: + trace_status: + const: partial + trace: + properties: + status: + const: partial + - properties: + trace_status: + const: disabled + trace: + properties: + status: + const: disabled + description: Recorded evidence identified by id and instance_id; never a simulation. + FlowTrace: + type: object + required: + - status + - missing + - steps + properties: + status: + $ref: '#/components/schemas/TraceStatus' + missing: + type: array + uniqueItems: true + items: + type: string + enum: + - not_instrumented + - started_late + - buffer_overflow + - sampled + - redacted + - evicted + steps: + type: array + items: + $ref: '#/components/schemas/FlowStep' + if: + properties: + status: + const: complete + then: + properties: + missing: + maxItems: 0 + steps: + items: + properties: + evidence: + const: observed + if: + properties: + stage: + const: route + then: + properties: + data: + properties: + input: + type: object + else: + properties: + missing: + minItems: 1 + FlowStep: + oneOf: + - $ref: '#/components/schemas/InputStep' + - $ref: '#/components/schemas/RouteStep' + - $ref: '#/components/schemas/DatapathStep' + - $ref: '#/components/schemas/DialModeStep' + - $ref: '#/components/schemas/DnsStep' + - $ref: '#/components/schemas/RerouteStep' + - $ref: '#/components/schemas/OutboundStep' + - $ref: '#/components/schemas/ConnectionStep' + discriminator: + propertyName: stage + mapping: + input: '#/components/schemas/InputStep' + route: '#/components/schemas/RouteStep' + datapath: '#/components/schemas/DatapathStep' + dial_mode: '#/components/schemas/DialModeStep' + dns: '#/components/schemas/DnsStep' + reroute: '#/components/schemas/RerouteStep' + outbound: '#/components/schemas/OutboundStep' + connection: '#/components/schemas/ConnectionStep' + InputStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: input + data: + $ref: '#/components/schemas/InputStepData' + InputStepData: + type: object + required: + - values + - source + properties: + values: + allOf: + - $ref: '#/components/schemas/FlowInput' + - type: object + required: + - pname + properties: + pname: + type: + - string + - 'null' + source: + type: string + enum: + - kernel + - socket + - sniffer + - dns_mapping + RouteStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: route + data: + $ref: '#/components/schemas/RouteStepData' + RouteStepData: + type: object + required: + - evaluation_id + - chain + - plane + - rule_id + - rules + - outbound + - must + - mark + - input + - dns_action + properties: + evaluation_id: + type: string + minLength: 1 + chain: + type: string + enum: + - traffic + - dns_request + - dns_response + - dns_upstream + plane: + type: string + enum: + - kernel + - userspace + rule_id: + type: + - string + - 'null' + rules: + type: array + items: + $ref: '#/components/schemas/RuleEvaluation' + outbound: + type: + - string + - 'null' + description: Traffic outbound, or DNS upstream tag for upstream/requery; null for other DNS actions. + must: + type: + - boolean + - 'null' + mark: + type: + - integer + - 'null' + minimum: 0 + maximum: 4294967295 + input: + type: + - object + - 'null' + additionalProperties: true + description: Immutable inputs consumed by this evaluation, typed by chain below; null means missing capture. + dns_action: + type: + - string + - 'null' + enum: + - upstream + - asis + - accept + - reject + - requery + - null + allOf: + - oneOf: + - properties: + chain: + enum: + - traffic + - dns_upstream + input: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/TrafficRoutingInput' + dns_action: + type: 'null' + - properties: + chain: + const: dns_request + input: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/DnsRequestRoutingInput' + dns_action: + enum: + - upstream + - asis + - reject + - null + must: + type: 'null' + mark: + type: 'null' + - properties: + chain: + const: dns_response + input: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/DnsResponseRoutingInput' + dns_action: + enum: + - accept + - reject + - requery + - null + must: + type: 'null' + mark: + type: 'null' + - if: + properties: + dns_action: + enum: + - asis + - accept + - reject + then: + properties: + outbound: + type: 'null' + DatapathStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: datapath + data: + $ref: '#/components/schemas/DatapathStepData' + DatapathStepData: + type: object + required: + - plane + - action + - reason + - error + properties: + plane: + type: string + enum: + - kernel + - userspace + action: + type: string + enum: + - pass + - redirect + - hold + - arm_direct + - activate_direct + - activate_proxy + - drop + reason: + type: string + minLength: 1 + error: + type: + - string + - 'null' + DialModeStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: dial_mode + data: + $ref: '#/components/schemas/DialModeStepData' + DialModeStepData: + type: object + required: + - configured + - effective_target + - domain + - domain_source + - verification + - reason + properties: + configured: + type: string + minLength: 1 + effective_target: + type: string + enum: + - ip + - domain + - none + - unknown + domain: + type: + - string + - 'null' + domain_source: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/DomainSource' + verification: + type: string + enum: + - matched + - other_family_trusted + - failed + - not_required + - unavailable + reason: + type: string + minLength: 1 + DnsStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: dns + data: + $ref: '#/components/schemas/DnsStepData' + DnsStepData: + type: object + required: + - lookup_id + - parent_lookup_id + - attempt_id + - purpose + - name + - qtype + - source + - upstream_transport + - carrier_transport + - cache + - cache_entry_id + - upstream + - route_evaluation_ids + - status + - addresses + - selected_ip + - error + properties: + lookup_id: + type: string + minLength: 1 + parent_lookup_id: + type: + - string + - 'null' + attempt_id: + type: + - string + - 'null' + purpose: + type: string + enum: + - domain_verification + - dial_target + - proxy_server + - intercepted_query + - family_preference + - refresh + name: + type: string + minLength: 1 + qtype: + $ref: '#/components/schemas/DnsRecordType' + source: + type: string + enum: + - hosts + - cache + - upstream + - coalesced + - unknown + upstream_transport: + type: + - string + - 'null' + enum: + - udp + - tcp + - dot + - doh + - doq + - doh3 + - null + carrier_transport: + type: + - string + - 'null' + enum: + - tcp + - udp + - null + cache: + type: string + enum: + - hit + - miss + - stale + - bypass + - unknown + cache_entry_id: + type: + - string + - 'null' + upstream: + type: + - string + - 'null' + route_evaluation_ids: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + status: + type: string + minLength: 1 + addresses: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/IpAddress' + selected_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + error: + type: + - string + - 'null' + RerouteStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: reroute + data: + $ref: '#/components/schemas/RerouteStepData' + RerouteStepData: + type: object + required: + - performed + - reason + - from_evaluation_id + - to_evaluation_id + properties: + performed: + type: + - boolean + - 'null' + reason: + type: string + minLength: 1 + from_evaluation_id: + type: + - string + - 'null' + to_evaluation_id: + type: + - string + - 'null' + OutboundStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: outbound + data: + $ref: '#/components/schemas/OutboundStepData' + OutboundStepData: + type: object + required: + - attempt_id + - parent_attempt_id + - kind + - evaluation_id + - routing_source + - routed_outbound + - effective_outbound + - mode_override + - selection_path + - leaf_node_id + - leaf_node_name + - target + - target_kind + - dial_ip + - server_addr + - resolution_location + - status + - error + properties: + attempt_id: + type: string + minLength: 1 + parent_attempt_id: + type: + - string + - 'null' + kind: + type: string + enum: + - leaf + - transport + evaluation_id: + type: + - string + - 'null' + minLength: 1 + routing_source: + type: string + enum: + - evaluation + - forced + - builtin + - unknown + routed_outbound: + type: + - string + - 'null' + effective_outbound: + type: + - string + - 'null' + mode_override: + type: string + enum: + - none + - direct + - global + - unknown + selection_path: + type: array + items: + $ref: '#/components/schemas/SelectionPathItem' + leaf_node_id: + type: + - string + - 'null' + leaf_node_name: + type: + - string + - 'null' + description: Sanitized leaf name captured at decision time, or null when unavailable; leaf_node_id remains authoritative. + target: + type: + - string + - 'null' + target_kind: + type: string + enum: + - ip + - domain + - none + - unknown + dial_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + server_addr: + type: + - string + - 'null' + resolution_location: + type: string + enum: + - original_ip + - local_dns + - outbound_remote + - not_applicable + - unknown + status: + type: string + enum: + - started + - succeeded + - failed + - cancelled + error: + type: + - string + - 'null' + if: + properties: + routing_source: + const: evaluation + then: + properties: + evaluation_id: + type: string + else: + properties: + evaluation_id: + type: 'null' + SelectionPathItem: + type: object + required: + - group_id + - member_id + - member_name + - policy + - reason + - selection + properties: + group_id: + type: string + minLength: 1 + member_id: + type: + - string + - 'null' + minLength: 1 + member_name: + type: + - string + - 'null' + description: Sanitized member name captured at decision time, or null when unavailable; member_id remains authoritative. + policy: + type: string + minLength: 1 + reason: + type: string + minLength: 1 + selection: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/SelectionDecision' + SelectionDecision: + type: object + required: + - previous_member_id + - metric + - tolerance_ms + - candidates + properties: + previous_member_id: + type: + - string + - 'null' + metric: + type: + - string + - 'null' + tolerance_ms: + type: + - number + - 'null' + candidates: + type: array + items: + $ref: '#/components/schemas/SelectionCandidate' + SelectionCandidate: + type: object + required: + - member_id + - member_name + - leaf_node_id + - leaf_node_name + - eligible + - sorting_latency_ms + - score + - selected + - reason + properties: + member_id: + type: string + minLength: 1 + member_name: + type: + - string + - 'null' + description: Sanitized member name captured at decision time, or null when unavailable; member_id remains authoritative. + leaf_node_id: + type: + - string + - 'null' + leaf_node_name: + type: + - string + - 'null' + description: Sanitized leaf name captured at decision time, or null when unavailable; leaf_node_id remains authoritative. + eligible: + type: + - boolean + - 'null' + sorting_latency_ms: + type: + - number + - 'null' + score: + type: + - number + - 'null' + selected: + type: boolean + reason: + type: string + minLength: 1 + ConnectionStep: + allOf: + - $ref: '#/components/schemas/StepCommon' + - type: object + required: + - stage + - data + properties: + stage: + const: connection + data: + $ref: '#/components/schemas/ConnectionStepData' + ConnectionStepData: + type: object + required: + - state + - reason + - milestone + - attempt_id + - reply_received + - error + properties: + state: + $ref: '#/components/schemas/ConnectionState' + reason: + type: string + minLength: 1 + milestone: + type: string + enum: + - transport_ready + - target_request_sent + - target_confirmed + - first_reply + - terminal + - unknown + attempt_id: + type: + - string + - 'null' + reply_received: + type: + - boolean + - 'null' + error: + type: + - string + - 'null' + RuleResult: + type: string + enum: + - matched + - not_matched + - skipped + - indeterminate + RuleCondition: + type: object + required: + - id + - expression + - result + - missing_inputs + properties: + id: + type: string + minLength: 1 + expression: + type: + - string + - 'null' + result: + $ref: '#/components/schemas/RuleResult' + missing_inputs: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + RuleEvaluation: + type: object + required: + - rule_id + - expression + - result + - missing_inputs + - conditions + properties: + rule_id: + type: string + minLength: 1 + expression: + type: + - string + - 'null' + result: + $ref: '#/components/schemas/RuleResult' + missing_inputs: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + conditions: + type: array + items: + $ref: '#/components/schemas/RuleCondition' + IpAddress: + oneOf: + - type: string + format: ipv4 + - type: string + format: ipv6 + RoutingTraceInput: + type: object + additionalProperties: false + required: + - network + - dst_port + properties: + network: + $ref: '#/components/schemas/Transport' + domain: + type: + - string + - 'null' + minLength: 1 + dst_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + dst_port: + type: integer + minimum: 1 + maximum: 65535 + src_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + src_port: + type: + - integer + - 'null' + minimum: 1 + maximum: 65535 + pname: + type: + - string + - 'null' + dscp: + type: + - integer + - 'null' + minimum: 0 + maximum: 63 + mark: + type: + - integer + - 'null' + minimum: 0 + maximum: 4294967295 + anyOf: + - required: + - domain + properties: + domain: + type: string + minLength: 1 + - required: + - dst_ip + properties: + dst_ip: + $ref: '#/components/schemas/IpAddress' + RoutingTraceRequest: + type: object + additionalProperties: false + required: + - input + properties: + input: + $ref: '#/components/schemas/RoutingTraceInput' + resolve: + type: string + enum: + - none + - live + default: none + allOf: + - if: + required: + - resolve + properties: + resolve: + const: live + then: + properties: + input: + allOf: + - required: + - domain + properties: + domain: + type: string + minLength: 1 + - properties: + dst_ip: + type: 'null' + RoutingEvaluation: + type: object + required: + - dst_ip + - decision + - outbound + - missing_inputs + - rules + properties: + dst_ip: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/IpAddress' + decision: + type: string + enum: + - determinate + - indeterminate + outbound: + type: + - string + - 'null' + missing_inputs: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + rules: + type: array + items: + $ref: '#/components/schemas/RuleEvaluation' + SimulationDnsData: + allOf: + - $ref: '#/components/schemas/DnsStepData' + - type: object + properties: + lookup_id: + type: string + minLength: 1 + description: Simulation-local lookup ID; never a recorded-flow identity. + purpose: + const: dial_target + attempt_id: + type: 'null' + description: Simulations never create outbound attempts. + RoutingTraceResponse: + type: object + required: + - mode + - instance_id + - generation_id + - observed_at + - evaluations + - dns + properties: + mode: + type: string + const: simulation + instance_id: + type: string + minLength: 1 + generation_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + evaluations: + type: array + items: + $ref: '#/components/schemas/RoutingEvaluation' + dns: + type: array + items: + $ref: '#/components/schemas/SimulationDnsData' + not: + anyOf: + - required: + - id + - required: + - flow_id + - required: + - connection_id + description: Hypothetical output. It never identifies or claims history for a live flow. + DnsRecordType: + type: string + pattern: ^(?:[A-Z][A-Z0-9-]*|TYPE[0-9]{1,5}|[0-9]{1,5})$ + DnsAnswer: + type: object + required: + - name + - type + - class + - ttl + - data + properties: + name: + type: string + minLength: 1 + type: + $ref: '#/components/schemas/DnsRecordType' + class: + type: string + minLength: 1 + ttl: + type: integer + minimum: 0 + maximum: 4294967295 + data: + type: string + DnsQuestion: + type: object + required: + - name + - type + properties: + name: + type: string + minLength: 1 + type: + $ref: '#/components/schemas/DnsRecordType' + DnsRoute: + type: object + required: + - source + - rule + properties: + source: + type: string + enum: + - forced + - dns.routing + - default + rule: + type: + - string + - 'null' + DnsQueryResult: + type: object + required: + - type + - cached + - cache_entry_id + - upstream + - route + - status + - elapsed_ms + - question + properties: + type: + $ref: '#/components/schemas/DnsRecordType' + cached: + type: boolean + cache_entry_id: + type: + - string + - 'null' + upstream: + type: + - string + - 'null' + route: + $ref: '#/components/schemas/DnsRoute' + status: + type: string + minLength: 1 + elapsed_ms: + type: integer + minimum: 0 + maximum: 9007199254740991 + question: + $ref: '#/components/schemas/DnsQuestion' + answers: + type: array + items: + $ref: '#/components/schemas/DnsAnswer' + DnsQueryResponse: + type: object + required: + - domain + - cache_mode + - query_time + - results + properties: + domain: + type: string + minLength: 1 + cache_mode: + type: string + enum: + - normal + - bypass + query_time: + $ref: '#/components/schemas/Timestamp' + results: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/DnsQueryResult' + DnsCacheCoverage: + type: object + required: + - positive + - negative + - persistent + properties: + positive: + type: boolean + negative: + type: boolean + persistent: + type: boolean + DnsCacheEntry: + type: object + required: + - entry_id + - domain + - type + - class + - status + - expires_at + - stale_until + properties: + entry_id: + type: string + minLength: 1 + domain: + type: string + minLength: 2 + pattern: \.$ + type: + $ref: '#/components/schemas/DnsRecordType' + class: + type: string + minLength: 1 + status: + type: string + minLength: 1 + answers: + type: array + items: + $ref: '#/components/schemas/DnsAnswer' + expires_at: + $ref: '#/components/schemas/Timestamp' + stale_until: + $ref: '#/components/schemas/NullableTimestamp' + DnsCacheList: + type: object + required: + - observed_at + - coverage + - entries + - total + - next_cursor + properties: + observed_at: + $ref: '#/components/schemas/Timestamp' + coverage: + $ref: '#/components/schemas/DnsCacheCoverage' + entries: + type: array + items: + $ref: '#/components/schemas/DnsCacheEntry' + total: + $ref: '#/components/schemas/SafeUInt' + next_cursor: + type: + - string + - 'null' + DeleteCount: + type: object + required: + - deleted + properties: + deleted: + type: integer + minimum: 0 + DeleteMatchingCount: + type: object + required: + - matched + - deleted + properties: + matched: + type: integer + minimum: 0 + deleted: + type: integer + minimum: 0 + OperationKind: + type: string + enum: + - probe + - reload + - suspend + - resume + - group_update + OperationAccepted: + type: object + required: + - operation_id + - kind + - status + - href + properties: + operation_id: + type: string + minLength: 1 + kind: + $ref: '#/components/schemas/OperationKind' + status: + type: string + const: queued + href: + type: string + pattern: ^/api/v1/operations/[^/]+$ + Operation: + oneOf: + - $ref: '#/components/schemas/QueuedOperation' + - $ref: '#/components/schemas/RunningOperation' + - $ref: '#/components/schemas/FailedOperation' + - $ref: '#/components/schemas/ProbeSucceededOperation' + - $ref: '#/components/schemas/ReloadSucceededOperation' + - $ref: '#/components/schemas/SuspendSucceededOperation' + - $ref: '#/components/schemas/ResumeSucceededOperation' + - $ref: '#/components/schemas/GroupUpdateSucceededOperation' + QueuedOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + status: + const: queued + started_at: + type: 'null' + finished_at: + type: 'null' + result: + type: 'null' + error: + type: 'null' + RunningOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + status: + const: running + finished_at: + type: 'null' + result: + type: 'null' + error: + type: 'null' + FailedOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + status: + const: failed + finished_at: + $ref: '#/components/schemas/Timestamp' + result: + type: 'null' + error: + $ref: '#/components/schemas/SafeError' + ProbeSucceededOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + kind: + const: probe + status: + const: succeeded + finished_at: + $ref: '#/components/schemas/Timestamp' + result: + $ref: '#/components/schemas/ProbeResult' + error: + type: 'null' + ReloadSucceededOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + kind: + const: reload + status: + const: succeeded + finished_at: + $ref: '#/components/schemas/Timestamp' + result: + $ref: '#/components/schemas/ReloadResult' + error: + type: 'null' + ReloadResult: + type: object + required: + - active_generation_id + - datapath_generation_id + properties: + active_generation_id: + type: + - string + - 'null' + datapath_generation_id: + type: + - string + - 'null' + SuspendSucceededOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + kind: + const: suspend + status: + const: succeeded + finished_at: + $ref: '#/components/schemas/Timestamp' + result: + type: object + required: + - runtime_state + properties: + runtime_state: + type: + - string + - 'null' + enum: + - suspended + - null + error: + type: 'null' + ResumeSucceededOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + kind: + const: resume + status: + const: succeeded + finished_at: + $ref: '#/components/schemas/Timestamp' + result: + type: object + required: + - runtime_state + properties: + runtime_state: + type: + - string + - 'null' + enum: + - running + - null + error: + type: 'null' + GroupUpdateSucceededOperation: + allOf: + - $ref: '#/components/schemas/OperationCommon' + - type: object + properties: + kind: + const: group_update + status: + const: succeeded + finished_at: + $ref: '#/components/schemas/Timestamp' + result: + $ref: '#/components/schemas/GroupUpdateResult' + error: + type: 'null' + GroupUpdateResult: + type: object + required: + - group_id + - config_revision + properties: + group_id: + type: string + minLength: 1 + config_revision: + type: string + minLength: 1 + EventKind: + type: string + enum: + - stream.ready + - runtime.updated + - flow.updated + - flow.gap + - operation.updated + - generation.changed + StreamReadyEvent: + type: object + required: + - instance_id + - observed_at + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + not: + anyOf: + - required: + - href + - required: + - resource_id + - required: + - revision + - required: + - status + - required: + - reason + - required: + - previous_generation_id + - required: + - generation_id + RuntimeUpdatedEvent: + type: object + required: + - instance_id + - observed_at + - href + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + href: + const: /api/v1/runtime + FlowUpdatedEvent: + type: object + required: + - instance_id + - observed_at + - resource_id + - revision + - href + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + resource_id: + type: string + minLength: 1 + revision: + type: integer + minimum: 1 + maximum: 9007199254740991 + href: + type: string + pattern: ^/api/v1/flows/[^/]+$ + FlowGapEvent: + type: object + required: + - instance_id + - observed_at + - resource_id + - reason + - dropped_records + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + resource_id: + type: + - string + - 'null' + reason: + type: string + enum: + - buffer_overflow + - sampled + - evicted + - recording_changed + dropped_records: + $ref: '#/components/schemas/NullableUInt64' + OperationUpdatedEvent: + type: object + required: + - instance_id + - observed_at + - resource_id + - status + - href + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + resource_id: + type: string + minLength: 1 + status: + type: string + enum: + - queued + - running + - succeeded + - failed + href: + type: string + pattern: ^/api/v1/operations/[^/]+$ + GenerationChangedEvent: + type: object + required: + - instance_id + - observed_at + - previous_generation_id + - generation_id + properties: + instance_id: + type: string + minLength: 1 + observed_at: + $ref: '#/components/schemas/Timestamp' + previous_generation_id: + type: string + minLength: 1 + generation_id: + type: string + minLength: 1 + StepCommon: + type: object + required: + - seq + - observed_at + - elapsed_us + - generation_id + - evidence + properties: + seq: + type: integer + minimum: 1 + maximum: 9007199254740991 + observed_at: + $ref: '#/components/schemas/NullableTimestamp' + elapsed_us: + $ref: '#/components/schemas/NullableSafeUInt' + generation_id: + type: + - string + - 'null' + evidence: + type: string + enum: + - observed + - reconstructed + OperationCommon: + type: object + required: + - operation_id + - kind + - status + - created_at + - started_at + - finished_at + - result + - error + properties: + operation_id: + type: string + minLength: 1 + kind: + $ref: '#/components/schemas/OperationKind' + status: + type: string + enum: + - queued + - running + - succeeded + - failed + created_at: + $ref: '#/components/schemas/Timestamp' + started_at: + $ref: '#/components/schemas/NullableTimestamp' + finished_at: + $ref: '#/components/schemas/NullableTimestamp' + result: + type: + - object + - 'null' + additionalProperties: true + error: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/SafeError' + examples: + FlowUpdated: + summary: Flow revision notification + x-event: flow.updated + x-event-id: instance-7:124 + value: + instance_id: instance-7 + observed_at: '2026-09-14T10:00:00Z' + resource_id: flow-23 + revision: 8 + href: /api/v1/flows/flow-23 diff --git a/source/v0.1.0/en/docs/api-config.md b/source/v0.1.0/en/docs/api-config.md index f573b3a..ebd7d6a 100644 --- a/source/v0.1.0/en/docs/api-config.md +++ b/source/v0.1.0/en/docs/api-config.md @@ -4,111 +4,85 @@ title: API Configuration # API Configuration -The API module is configured independently of the rest of the dae configuration. Its settings live in a top-level `api { }` block of the `.dae` file, separate from `global { }`. The block may also be placed in a separate included file (see the `include` section of the dae configuration) if you prefer to keep it isolated. +> This page is retained as a configuration draft for the proposed native +> contract. Current honk uses +> `experimental.clash_api.external_controller` and `secret`; the referenced +> dae/kdae branch has no general REST listener. A top-level `api { }` block is +> therefore a proposed adapter configuration, not an existing dae feature. -## Example `.dae` +The shared adapter should use a single listen address, an opaque bearer secret, +and explicit CORS origins. Interface-name wildcards and regexes are not part of +the native contract because they make binding and authorization ambiguous. -``` +## Proposed native listener fields + +```dae api { - # Port the HTTP API listens on. - port: 9527 - - # Interfaces to listen on. Manually enter interface names. - # Supports comma-separated names, `*` wildcards and regular expressions. - # If omitted, the API listens on the loopback interface only. - interfaces: 'lo, eth*, ^(enp|eth)[0-9]+' - - # Bearer token. Generate one with: - # openssl rand -base64 16 - # openssl rand -base64 32 - # Listening on any interface other than the loopback requires a valid token. - token: 'q/RWNF0nPm2v3eD5LxD5VA==' + listen: '127.0.0.1:9527' + secret: 'replace-with-a-random-secret' + allow_origins: ['http://127.0.0.1:3000'] } ``` -## Fields - | Field | Type | Required | Description | |-------|------|----------|-------------| -| port | int | yes | Port the HTTP API listens on | -| interfaces | string | no | Comma-separated interface names. Names are entered manually and may contain `*` wildcards or regular expressions (e.g. `lo`, `eth*`, `^(enp|eth)[0-9]+`). If omitted, the loopback interface is used | -| token | string | no | Bearer token. Required for listening on any interface other than the loopback | - -## Interface Examples - -`interfaces` is a comma-separated list. Each entry is a manually entered interface name that may contain `*` wildcards or a regular expression: - -| Example | Type | Matches | -|---------|------|---------| -| `lo` | exact name | only the `lo` interface | -| `eth0` | exact name | only the `eth0` interface | -| `eth*` | wildcard | `eth0`, `eth1`, `eth10` | -| `en*` | wildcard | `enp0s3`, `eno1`, `ens33` | -| `^(eth\|enp)[0-9]+` | regex | `eth0`, `enp0`, `enp12` | -| `^(wl\|wlan)[0-9]+` | regex | `wlan0`, `wlan1` | -| `lo, eth*, ^(enp)[0-9].*` | mixed | `lo`, `eth0`, `enp0s3`, ... | - -Example configurations: - -``` -# Listen on the loopback and every ethernet interface. -api { - port: 9527 - interfaces: 'lo, eth*' - token: 'q/RWNF0nPm2v3eD5LxD5VA==' -} -``` - -``` -# Listen on the loopback and every wireless interface (regex). -api { - port: 9527 - interfaces: 'lo, ^(wl|wlan)[0-9]+' - token: 'q/RWNF0nPm2v3eD5LxD5VA==' -} -``` - -## Token Generation - -Generate a token with: - -```bash -openssl rand -base64 16 -openssl rand -base64 32 -``` - -Alternatively, dae provides a built-in generator (`dae gen-token`) that produces a token in the same format. - -## Token Validity - -A token is considered **valid** only if it matches the format produced by `openssl rand -base64 16` or `openssl rand -base64 32`, i.e. the standard base64 encoding of exactly 16 or 32 random bytes: - -| Source | Base64 length | Example | -|--------|---------------|---------| -| `openssl rand -base64 16` | 24 chars (ends with `==`) | `q/RWNF0nPm2v3eD5LxD5VA==` | -| `openssl rand -base64 32` | 44 chars (ends with `=`) | `0w5Vl0xR/mQY7r2tJzH3eFkC9qDxS+uN1vLbGPaQcXo=` | - -Any other value is rejected. - -## Interface Listening Rule - -The API only listens on the loopback interface (`127.0.0.1` / `::1`) unless all of the following hold: - -1. An `interfaces` list is configured, **and** -2. The configured `token` is valid. - -Behavior: - -| `interfaces` | `token` | Listened interfaces | -|--------------|---------|---------------------| -| omitted | any | loopback only | -| configured | missing or invalid | loopback only (a warning is logged) | -| configured | valid | all matching interfaces | - -## Cookie Security - -If a frontend stores the token in a cookie, developers must make sure the token is not leaked: - -- Serve the API over HTTPS and set the cookie with `Secure`, `HttpOnly` and `SameSite=Strict`. -- Never include the token in URLs, logs, error messages, or client-side scripts. -- Prefer keeping the token in memory or sending it via the `Authorization: Bearer` header over cookies. +| listen | address | yes | One explicit host and port. Loopback is the default deployment. | +| secret | string | no | Opaque bearer secret; required for non-loopback exposure. | +| allow_origins | string array | no | Explicit browser origins. Empty means browser CORS is disabled. | + +The exact configuration section is engine-owned: honk currently uses +`experimental.clash_api.external_controller` and `secret`, while dae/kdae +needs an adapter implementation before this block becomes active. + +## Listener and authentication rules + +- Omitting `listen` binds only to loopback. +- A non-loopback listener requires `secret`; otherwise startup fails closed. +- `secret` is opaque. Implementations may enforce a minimum entropy policy but + must not require one specific textual encoding. +- Authentication uses `Authorization: Bearer `. Secrets must not appear + in URLs, responses, or logs. +- Browser access is disabled unless the exact request origin is listed in + `allow_origins`; wildcard origins are not valid with bearer credentials. +- Browsers send CORS preflights without credentials. The server validates the + origin, requested method, and requested headers, then answers the preflight + without bearer authentication; the actual request is authenticated as usual. +- Non-loopback bearer transport MUST use TLS at the listener or a trusted + local reverse proxy; a secret sent over untrusted cleartext is not secure. +- Reject unapproved browser Origins on all native requests, including + mutation POSTs with simple content types; require the documented JSON + content types. Check Host against configured listener/proxy hostnames to + prevent DNS rebinding of an unauthenticated loopback listener. +- On a secretless loopback listener, reject browser requests marked + `Sec-Fetch-Site: cross-site`, even without Origin. Cross-site GET navigation + must not trigger a control action such as a live DNS query. + +The native and Clash-compatible surfaces may share one socket, but their route, +authentication, and CORS middleware remain independent. They may also use +separate listeners without changing native `/api/v1/*` paths. + +## Permissions + +The native API defines two permissions: + +| Permission | Access | +|------------|--------| +| `observe` | Runtime, memory, datapath, nodes, groups, connections, recorded flows, permitted events, DNS cache, and operation results owned by the caller. | +| `control` | Probes, routing simulations, live DNS queries, group mutations, DNS cache mutations, reload, suspend, and resume. Includes `observe`. | + +The proposed single `secret` grants `control`. Implementations may support +additional observe-only credentials, but must preserve these permission names. +Missing or invalid credentials return `401`; an authenticated credential +without the required permission returns `403` without revealing whether the +target exists. Discovery, version, and capabilities require no permission once +the caller has reached the listener. + +Under the current loopback-compatible default, omitting `secret` grants local +callers both permissions. Capability flags describe engine support, not caller +authorization. + +`detail=summary` only reduces response size. It does not redact data for a +less-privileged user. Fine-grained privacy filters must apply consistently to +snapshots, recorded steps, errors and replayed events, and mark a trace +partial when they hide required evidence. Do not grant ordinary `observe` +access to raw configuration; credentials can be embedded in it. diff --git a/source/v0.1.0/en/docs/capabilities.md b/source/v0.1.0/en/docs/capabilities.md new file mode 100644 index 0000000..d966c82 --- /dev/null +++ b/source/v0.1.0/en/docs/capabilities.md @@ -0,0 +1,118 @@ +--- +title: Capabilities +--- + +# GET /api/v1/capabilities + +> Draft endpoint. This is the authoritative coarse-grained feature declaration +> for the running adapter. Resource responses may further narrow capabilities +> for an individual node or group. + +## Request + +{% api_request getCapabilities %} + +## Response + +### Success (200 OK) + +{% api_example getCapabilities 200 available %} + +### Rules + +- Every advertised resource key contains `available`. +- Top-level `limits` apply to every native route before resource-specific work + is dispatched. +- A resource with `available: false` may omit its remaining fields. +- Optional metrics, enum values, and operation limits are explicit arrays or + objects; clients must not infer them from the engine version. +- Unknown resource keys and fields must be ignored by clients. +- Requesting an unavailable resource or action returns `404` with + `capability_not_supported`. +- Per-group and per-node capabilities may be stricter than this response. +- Limits are server-advertised ceilings. Exceeding a request rate returns + `429`; exceeding fan-out or size returns `413`; a full bounded queue returns + `503`. Traffic-history window and point limits instead return + `400 invalid_request`. + +`runtime_memory.metrics` contains canonical response field paths. An +implementation must not advertise a metric that it always reports as `null`. +`dns_cache.entry_kinds` declares which positive or negative cache entries can +be read and mutated without silently hiding another cache class. + +`runtime_outbounds.available` declares the per-outbound cumulative counter +snapshot. `traffic_history.available` declares the bounded traffic ring; +when true, `max_window_seconds` and `max_points` are required positive safe +integers. They bound the look-back window and returned sample count, not a +retention guarantee. Both resources are optional and require `observe`. + +## Configuration visibility + +`resources.config.available` gates effective configuration readback under +`observe`, including single-source GET. When available, the adapter must declare +`content`, `writable`, `max_bytes`, and `max_sources`. +`content` is a visibility flag, false by default: false forbids source text in +the response; true permits optional text subject to secret redaction. It does +not grant access to raw secrets. `max_sources` is a positive safe-integer bound +on the complete source set, not permission to truncate it. + +`writable` is the server-wide switch for source replacement under `control`. +A source's own `writable` field can further restrict writes. A false switch or +read-only source returns `403 permission_denied`; neither grants write access +through `observe`. Generated and subscription sources are never writable. +`max_bytes` is a positive safe-integer limit on UTF-8 replacement content, not +character count. The shared JSON body limit also applies; excess returns +`413 request_too_large`. + +Advertising `writable: true` requires full validation and asynchronous reload +support, with `resources.reload.available` and `resources.operations.available` +both true. Editing is independent of the optional dry-run endpoint and of +content visibility. It does not grant permission to read secrets. + +Path redaction follows the [shared visibility rules](api-config.html#Permissions). +Use `` for hidden display paths. Apply privacy filters consistently +to paths, source text, and diagnostics; `detail=summary` is not a privacy tier. +The adapter sets `secrets_redacted` when it withholds content or redacts data. + +`resources.config_validate.available` independently gates dry-run validation +under `control`; the request body may contain secrets. When available, the +adapter must declare `modes` as a nonempty unique subset of `syntax` and `full`. +It must also declare `max_bytes` and `max_sources` as positive safe integers. +The limits bound total UTF-8 source bytes and source count, including locally +resolved dependencies in `full` mode. The shared JSON body ceiling also applies. Exceeding a size or +source-count limit returns `413 request_too_large`; an unadvertised mode returns +`422 unsupported_value`. Neither mode permits network access or state changes. +See [Configuration](configuration.html) for request and diagnostic semantics. + +## Conformance profiles + +`profiles` is an array, not a feature inferred from engine identity. The +example is an illustrative partial adapter, **not honk's current response**. + +- **`base`** requires discovery, version, capabilities, runtime, the shared + authentication/error/visibility rules, and honest capability declarations. + Every resource key in this page's `resources` object MUST have an entry, + even when unavailable; discovery/version/capabilities themselves are mandatory. + Operations are required whenever an advertised action is asynchronous; + events and mutations are otherwise optional. dae can implement this + profile without claiming honk-only features. +- **`full_transparency`** additionally requires nodes, groups, connections, + recorded flows and events; observed rule inputs/short-circuit decisions, + dial-mode verification, DNS linkage, reroute reasons, actual member/leaf + attempts, and lifecycle outcomes for managed traffic, including direct and + blocked decisions. It requires all recorded-flow acceptance scenarios, + no intentional sampling in these scopes, and explicit loss/retention + accounting. Early bypass scope may remain uninstrumented only if declared + as an exclusion; this is not a claim to observe all host traffic. + +Profile support describes implemented instrumentation, not losslessness of +every snapshot. Buffer loss, disabled recording or redaction downgrades the +current coverage and affected traces even on a conforming engine. A +userspace-only adapter MUST NOT advertise `full_transparency`. A simulator, +Clash connection list, log parser, or map snapshot cannot satisfy it. + +## Example + +```bash +curl http://localhost:9527/api/v1/capabilities +``` diff --git a/source/v0.1.0/en/docs/check-nodes.md b/source/v0.1.0/en/docs/check-nodes.md index 968a091..0e9fe21 100644 --- a/source/v0.1.0/en/docs/check-nodes.md +++ b/source/v0.1.0/en/docs/check-nodes.md @@ -1,39 +1,87 @@ --- -title: Check Nodes +title: Probes --- -# POST /api/nodes/check +# POST /api/v1/probes -Triggers an immediate latency check for all nodes. +> Proposed bounded asynchronous probe resource. `/nodes` reads existing +> observations; it never probes. There is no separate check-nodes or +> node-latency action. A probe is not evidence that a client flow succeeded. ## Request -```http -POST /api/nodes/check HTTP/1.1 -Host: localhost:9527 -Content-Length: 0 -``` +{% api_example createProbe request dns_udp http %} -## Response +| Field | Required | Contract | +|-------|----------|----------| +| target | yes | Exactly `{type: node, node_id}` or `{type: group, group_id}`. | +| kind | yes | `tcp_connect`, `http`, or `dns`; supported kinds are advertised. | +| purpose | yes | `data` or `dns`; the health domain being tested, not inferred from UDP alone. | +| transport | yes | Nonempty unique array of `tcp`/`udp`, restricted by kind. | +| ip_version | yes | `ipv4`, `ipv6`, or `any`. `any` expands to advertised families. | +| members | no | Group-only: `direct` (default), `leaves`, or nonempty unique direct-member IDs. | +| warmth | yes | `cold` or `warm`. An unimplementable reuse constraint returns 422, not mislabeled results. | -### Success (200 OK) +`tcp_connect` tests TCP reachability of the configured node server, not a +proxy handshake or application latency; it requires `transport: [tcp]` and +`purpose: data`. `http` tests the configured HTTP(S) check through the target +outbound, requires TCP/data, and measures through the response headers. +`dns` tests the configured DNS check through the target outbound and requires +`purpose: dns`; TCP and/or UDP describe that DNS query's transport. The IP +family refers to the check destination (node server for `tcp_connect`), not +necessarily the tunnel's network. There is no arbitrary UDP echo or generic +`latency` kind whose success criterion is unspecified. -```json -{ - "ok": true, - "message": "Latency check triggered" -} -``` +A group `direct` target preserves direct members and policy-authorized nested +resolution; no eligible leaf produces `unavailable`, never an arbitrary +sibling. `leaves` is an explicit diagnostic expansion. Deduplicate identical +leaf/kind/transport/purpose/family/warmth executions while retaining every +member-to-leaf association in the results. `cold` excludes reusable check +connections; `warm` permits but does not require reuse. Result `warmth` states +what actually happened (`cold`, `warm`, or `unknown`). Do not claim a cold +physical tunnel merely because a new logical stream was opened. -### Fields +The request cannot specify arbitrary URLs, names, IPs or ports. Use +administrator-configured check destinations, with the SSRF policy in +[Groups](groups.html): validate and pin resolved addresses, revalidate every +redirect, bound redirects/body/time, and do not let an API caller rewrite the +administrator allowlist. Invalid kind/transport/purpose combinations and +unsupported target capabilities return `422 unsupported_value` before work. -| Field | Type | Description | -|-------|------|-------------| -| ok | bool | Whether the operation succeeded | -| message | string | Human-readable message | +Probes may update native health and automatic selections. The adapter must +preserve native side-effect semantics and report `health_updated` per result +and `selection_changed` per transport. A reachability measurement MUST NOT be +injected into an application-latency collection as an equivalent sample. -## Example +## Accepted (202) -```bash -curl -X POST http://localhost:9527/api/nodes/check -``` +{% api_example createProbe 202 queued http %} + +Poll [Operations](operations.html) or follow `operation.updated` events. + +## Completed result + +{% api_example getOperation 200 probe_complete %} + +Each result identifies the requested member, actual leaf (nullable), probe +kind and dimensions, observed state (`healthy`, `unavailable`, `unknown`), +nullable measured latency and safe error. `succeeded` means the job completed, +not that every target was healthy. The example illustrates two result rows; +a completed job MUST include all requested dimension/member combinations. +Unstarted/cancelled work is `unknown` with a safe cancellation/deadline code +and `health_updated: false`, not an unhealthy node. + +## Limits + +`resources.probes.limits` bounds fan-out, projected results, active/queued jobs, +per-target concurrency, deadline, and principal/global request rates. Reject +oversized fan-out/results with `413` before dispatch; use `429` for concurrency +or rate excess and the dedicated full-queue `503` response below. Both require +`Retry-After` as a positive number of seconds. Enforce one job deadline +including preparation; cancel and drain started work at expiry. +Already completed real errors keep their native health effects; cancellation +and never-started candidates are health-neutral. + +### Full queue (503) + +{% api_example createProbe 503 queue_full http %} diff --git a/source/v0.1.0/en/docs/configuration.md b/source/v0.1.0/en/docs/configuration.md index 3c6d18d..01266ef 100644 --- a/source/v0.1.0/en/docs/configuration.md +++ b/source/v0.1.0/en/docs/configuration.md @@ -2,49 +2,247 @@ title: Configuration --- -# GET /api/config +# Configuration -Returns the current active configuration (read-only). +The native API exposes accepted configuration sources, dry-run validation, and +single-source replacement followed by a reload. Source text uses dae syntax; +the API rejects partial patches and multi-source writes. -## Request +## GET /api/v1/config -```http -GET /api/config HTTP/1.1 -Host: localhost:9527 -``` +Requires `observe` and `capabilities.resources.config.available`. Returns the +accepted configuration, not a fresh read of files that may have changed on disk. +Sources, diagnostics, `generation_id`, and `revision` belong to one coherent +snapshot. The source set is complete, not silently truncated to `max_sources`. -## Response +### Request + +{% api_request getConfig %} ### Success (200 OK) -```json -{ - "api": { - "port": 9527 - }, - "global": { - "log_level": "info" - }, - "groups": [ - { - "name": "proxy", - "policy": "random" - } - ], - "routing": { - "rules": [] - } -} -``` +{% api_example getConfig 200 redacted %} + +The all-zero SHA-256 in this example is a placeholder, not a digest of a real +configuration. ### Fields -The response mirrors the dae configuration file structure. See [dae documentation](https://dae.universe.ingress/) for full field details. +| Field | Type | Description | +|-------|------|-------------| +| generation_id | string | Running generation for this accepted configuration. | +| revision | string | Opaque configuration revision used by `Runtime.generation.config_revision` and `GroupSummary.config_revision`; never parse it as a number. | +| sources | array | Complete accepted source set, bounded by `resources.config.max_sources`. | +| sources[].id | string | Unique opaque ID within the snapshot; never a private path or credential-bearing URL. | +| sources[].path | string | Display path, or `` when hidden by visibility policy. | +| sources[].kind | string | `main`, `include`, `subscription`, or `generated`. | +| sources[].content_sha256 | string | Lowercase 64-hex SHA-256 of accepted bytes before redaction. | +| sources[].bytes | integer | Accepted byte count before redaction; nonnegative safe integer. | +| sources[].writable | boolean | Whether a `control` caller may replace this source under the server-wide write switch. Engine-written sources are read-only. | +| sources[].loaded_at | string | RFC 3339 time when the engine accepted these bytes, not file modification time. | +| sources[].content | string, optional | Engine-native text, only when `resources.config.content` is true; secret redaction still applies. | +| sources[].line_count | integer | Lines before redaction; empty text has zero lines, and a final newline adds no empty line. | +| diagnostics | array | Retained diagnostics for the accepted configuration, using the shared shape below. | +| secrets_redacted | boolean | True when the adapter withholds content or redacts paths, text, or diagnostic messages. | + +### Visibility + +`capabilities.resources.config.content` is a visibility flag, false by default. +When false, every source omits `content`; it must not return an empty string as a +substitute. When true, content remains optional and must not expose secrets to +ordinary `observe` callers. Path redaction follows the existing visibility rules +in [Capabilities](capabilities.html#Configuration-visibility) and +[API Configuration](api-config.html#Permissions): apply privacy filters +consistently, not only to one endpoint or detail tier. Diagnostics must not echo +source excerpts, credentials, private paths, or raw engine errors. Hashes, byte +counts, line counts, and positions describe the accepted source before redaction; +they need not match displayed text. Redacted text is not an editing representation. +Never save it over the source. + +## GET /api/v1/config/sources/{source_id} + +Requires `observe` and `resources.config.available`. Returns one `ConfigSource` +with the same fields and visibility rules as an entry in `GET /config`. It reads +the accepted snapshot, not current disk contents. An unknown ID returns +`404 resource_not_found`; unavailable readback returns +`404 capability_not_supported`. + +{% api_request getConfigSource %} + +{% api_example getConfigSource 200 editable %} + +This content-bearing example assumes `resources.config.content: true`. The +default visibility setting withholds `content`. To use returned text for +editing, first verify that its UTF-8 SHA-256 equals `content_sha256`. A mismatch +means the text is not the complete accepted source. Do not save redacted text. + +## Editing + +`PUT /api/v1/config/sources/{source_id}` replaces one accepted source. It requires +`control`, `resources.config.available`, `resources.config.writable`, and +`writable: true` on that source. The server-wide switch does not make every +source writable. Includes and subscriptions written by the engine, including +`kind: generated` and `kind: subscription`, are read-only. + +### Editor flow + +1. Read `GET /config` and retain the source ID and `content_sha256`. Load the + full text from that snapshot or the single-source GET. If content is absent + or its digest differs, obtain the complete source through an authorized + channel; never replace it with redacted text. +2. Edit the complete dae text. +3. Optionally call `POST /config/validate` in `full` mode with the resulting + source set, if the adapter advertises that mode. The server repeats the same checks + before writing; a successful dry run does not bypass them or pin disk state. +4. PUT `{content: string}` as `application/json`, with the retained SHA-256 + enclosed in double quotes in `If-Match`. This precondition uses source bytes, + not the top-level configuration `revision`. +5. Poll the operation at `Location`, respecting the positive `Retry-After` + polling floor, until it succeeds or fails. A `202` means the server wrote the + file and queued reload, not that the new configuration is active. +6. After successful reload, refetch `GET /config` for the new generation and + `content_sha256`. When events are available, `generation.changed` announces + the new generation; it does not waive the polling floor. + +### Request + +{% api_example replaceConfigSource request replacement http %} + +The body accepts only `content`. It replaces the full file as UTF-8 text, including +its final newline if supplied. Empty text is a validation candidate, not a +malformed request. `resources.config.max_bytes` limits replacement UTF-8 bytes; +`limits.max_json_body_bytes` independently limits the encoded JSON body. +Exceeding either returns `413 request_too_large`. + +`If-Match` accepts one quoted strong tag, not a wildcard, weak tag, or tag list. +The optional `Idempotency-Key` follows the [operation rules](operations.html): +within the running instance's retention window, the same caller, method, path, +key, and body return the original operation without another write or hash +check. Reusing the key with a different body returns `409 idempotency_conflict`. + +### Validation and atomic write + +For a new write, the server checks `If-Match` against the current on-disk content +hash, then validates the resulting source set in `full` mode with the replacement +substituted for the selected source. The check includes syntax, semantics, and +dependencies, using authorized local files and cached data only. Missing or +inaccessible dependencies produce errors. Validation performs no network access +or cache refresh. + +If diagnostics contain any `error`, the server never writes a file or starts a +reload. It returns `422 unsupported_value` in the shared `{error, request_id}` +envelope, with `ConfigDiagnostic` entries in `error.details.diagnostics`. +Warnings and info alone do not prevent a write. + +Otherwise, the server writes a temporary file in the source directory and +atomically renames it over the source, preserving the file mode. Concurrent API +writes serialize the hash check, validation, and replacement. The server checks +the on-disk hash again before replacement and rejects a changed hash with `412`. +After writing, it starts a reload operation with `kind: reload`. + +{% api_example replaceConfigSource 202 queued http %} + +Reload failure leaves the previous generation active, but does not roll back +the file write. Until successful reload, readback still describes the previously +accepted bytes, not the newly written file. Inspect the operation error and +reconcile disk state before retrying. + +### Errors + +| Status and code | Meaning and action | +|-----------------|--------------------| +| `403 permission_denied` | Missing `control`, disabled server-wide editing, or a read-only source. Do not offer writes for that source. | +| `404 resource_not_found` | Unknown source ID. Refetch the accepted source set. | +| `412 stale_revision` | The on-disk hash differs from `If-Match`; the server writes nothing. Reconcile the changed file before retrying. Refetching the accepted snapshot alone may still return the old hash. | +| `422 unsupported_value` | Full validation found error diagnostics; the server writes nothing and starts no reload. Display diagnostics and correct the candidate. | +| `428 precondition_required` | `If-Match` is missing; the server writes nothing. Supply the retained source hash. | + +{% api_example replaceConfigSource 422 invalid %} + +The editable GET example hashes to +`d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1`. +The PUT replacement hashes to +`92fe71cacbc73458f2da2a62363cec2e1cfae3ee0e3838acd7a90562e64f242a`. +Both include the final newline. After successful reload of that replacement, +the source's accepted hash becomes the latter. + +## POST /api/v1/config/validate + +Requires `control` and `capabilities.resources.config_validate.available` because +the body may contain secrets. Validation never writes files, refreshes caches, +applies configuration, publishes a generation, or starts an operation. + +### Request + +{% api_example validateConfig request syntax_error http %} + +| Field | Type | Description | +|-------|------|-------------| +| sources | array | Nonempty ordered candidate source set; the first source is the main source. | +| sources[].id | string, optional | Request-local diagnostic ID; omitted IDs become `source-N`, with a one-based array index. All effective IDs must be unique and must not contain secrets. | +| sources[].path | string, optional | Engine-native source name and include-resolution base within authorized local roots; not permission to read arbitrary files. | +| sources[].content | string | Candidate engine-native text; empty text is a candidate, not a malformed request. | +| mode | string | Required `syntax` or `full`, selected from `resources.config_validate.modes`. | + +`syntax` parses only submitted text. `full` also checks semantics and resolves +includes/subscriptions from submitted sources or adapter-authorized local files +and cached data. Submitted content takes precedence at the same resolved path. +Neither mode accesses the network. Missing or inaccessible dependencies produce +error diagnostics, not a successful partial validation. + +`max_bytes` bounds the sum of UTF-8 source bytes, not JavaScript string length. +`max_sources` bounds the source count. Both include locally resolved dependencies +in `full` mode. The shared `limits.max_json_body_bytes` separately bounds the +encoded HTTP body. Exceeding any size or source-count limit returns +`413 request_too_large`, without truncation or partial success. + +### Success (200 OK) + +{% api_example validateConfig 200 invalid %} + +| Field | Type | Description | +|-------|------|-------------| +| valid | boolean | True exactly when validation completed without error diagnostics; warnings and info do not invalidate the candidate. | +| diagnostics | array | Shared diagnostic shape below, with IDs referring to submitted sources. Attribute dependency failures to the referring submitted source and include/subscription location. | +| generation_id | string | Running generation captured when validation starts, for context only. | +| validated_at | string | RFC 3339 time when validation completed. | + +A completed validation returns `200` even when the candidate is invalid. +`valid: true` does not guarantee that a later apply will succeed. A concurrent +reload may change the running generation; this result neither pins it for a +later apply nor changes the effective configuration or revision. + +Malformed JSON, invalid request shape, or duplicate effective source IDs returns +`400 invalid_request`. An unadvertised mode returns `422 unsupported_value`. +Unavailable resources return `404 capability_not_supported`. Authentication, +permission, media-type, and rate failures use the [shared errors](errors.html). + +## Diagnostic fields + +Readback, dry-run validation, and rejected writes use `ConfigDiagnostic`. +Diagnostic codes are adapter-defined, not members of the HTTP `ErrorCode` catalogue. + +| Field | Type | Description | +|-------|------|-------------| +| level | string | `error`, `warning`, or `info`. | +| source_id | string | Source ID in the effective snapshot, validation request, or replacement's resulting source set. | +| line | integer or null | One-based source line; null when unknown. | +| column | integer or null | One-based UTF-8 byte column, not a character or UTF-16 offset; null when unknown. | +| span | object or null | `start_line`, `start_column`, `end_line`, `end_column`; one-based, start inclusive and end exclusive. End must not precede start; adapters may return zero-width spans. | +| code | string | Nonempty adapter-defined diagnostic code. | +| message | string | Safe operator-facing description, never raw parser output. | -> **Note:** This endpoint returns a sanitized view of the configuration. Sensitive fields like `api.token` are not included in the response. +Coordinates refer to the original source before redaction. When known, `line` +and `column` equal the span start. Unknown locations stay null; adapters must +not invent positions from setting names. -## Example +## honk mapping -```bash -curl http://localhost:9527/api/config -``` +These are new native resources, not aliases of honk's Clash `/configs`. +That GET exposes compatibility settings and metadata diagnostics; its PUT is a +no-op. Honk already retains accepted diagnostics, but native readback needs +accepted-source bytes and metadata captured with the configuration. Candidate +validation needs a separate bounded, side-effect-free path through the parser. +Editing requires a new atomic source writer with hash preconditions and full +validation before the existing reload machinery. See the +[source evidence](honk-mapping.html) and [reload semantics](reload.html). diff --git a/source/v0.1.0/en/docs/connections.md b/source/v0.1.0/en/docs/connections.md index f332c62..006d5e7 100644 --- a/source/v0.1.0/en/docs/connections.md +++ b/source/v0.1.0/en/docs/connections.md @@ -2,91 +2,111 @@ title: Connections --- -# GET /api/connections +# GET /api/v1/connections -Returns a list of active TCP and UDP connections, including real-time per-connection network speeds. +> Draft endpoint. Real-direct flows can bypass userspace, so this list is not +> necessarily a complete packet-flow inventory. Native responses label +> `observed_by` and use `null` when a counter is unavailable. + +Returns a list of visible TCP and UDP connections, including per-connection +network speeds where the observation plane provides them. ## Request -```http -GET /api/connections HTTP/1.1 -Host: localhost:9527 -``` +{% api_request listConnections %} ## Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | type | string | all | Filter: `tcp`, `udp`, or `all` | -| limit | int | 100 | Max connections to return | +| src | string | - | Exact source IP literal without a port; applied with `type` before `limit`. | +| limit | int | 100 | Max connections to return across both arrays; capped at 1000. | +| detail | string | summary | `summary` omits `src`, `dst`, and `domain`; `full` includes them when observable. | ## Response ### Success (200 OK) -```json -{ - "tcp": [ - { - "id": 1, - "src": "192.168.1.100:12345", - "dst": "1.2.3.4:443", - "domain": "example.com", - "outbound": "proxy", - "started": "2026-08-13T12:00:00Z", - "upload_bytes": 20480, - "download_bytes": 1048576, - "upload_rate": 4096, - "download_rate": 32768 - } - ], - "udp": [ - { - "id": 2, - "src": "192.168.1.100:5353", - "dst": "8.8.8.8:53", - "domain": "dns.google", - "outbound": "direct", - "started": "2026-08-13T12:00:05Z", - "upload_bytes": 128, - "download_bytes": 512, - "upload_rate": 0, - "download_rate": 0 - } - ], - "total_tcp": 42, - "total_udp": 128 -} -``` +{% api_example listConnections 200 visible %} ### Fields | Field | Type | Description | |-------|------|-------------| +| observed_at | string | Snapshot timestamp (RFC3339). | +| instance_id | string | Running adapter instance; resets on process restart. | +| visibility | string | `full`, `partial`, or `none`. | +| truncated | bool | Whether `limit` omitted visible entries matching `type` and `src`. | | tcp | array | Active TCP connections | | udp | array | Active UDP sessions | -| total_tcp | int | Total active TCP count | -| total_udp | int | Total active UDP count | +| total_tcp | int | Visible active TCP count matching `type` and `src`, before `limit`. | +| total_udp | int | Visible active UDP count matching `type` and `src`, before `limit`. | ### Connection Object | Field | Type | Description | |-------|------|-------------| -| id | uint64 | Connection identifier | -| src | string | Source address (ip:port) | -| dst | string | Destination address (ip:port) | -| domain | string | Sniffed domain name (empty if unknown) | -| outbound | string | Outbound group name | -| started | string | Connection start time (RFC3339) | -| upload_bytes | uint64 | Bytes uploaded by this connection | -| download_bytes | uint64 | Bytes downloaded by this connection | -| upload_rate | uint64 | Real-time upload speed (bytes/sec) | -| download_rate | uint64 | Real-time download speed (bytes/sec) | - -> **Note:** Overall real-time network speed and connection totals are available from [`GET /api/runtime/status`](runtime-status.md). +| id | string | Opaque connection identifier | +| flow_id | string or null | Related recorded-flow ID, if correlation is known; not derived from a tuple. | +| pname | string or null | Captured process name; null without process context. Included in summary. | +| state | string | Observed lifecycle state from the flow contract, or unknown. | +| src | string, optional | Source address (ip:port), present with `detail=full` | +| dst | string, optional | Destination address (ip:port), present with `detail=full` | +| domain | string or null, optional | Sniffed domain with `detail=full`, or `null` when unknown | +| outbound | string or null | Effective routed outbound, not a leaf name masquerading as a group. | +| chain | array of strings | Application outbound `selection_path` group IDs followed by the leaf node ID, in order; empty for direct/block or an unknown path. | +| chain_source | string | `evaluation`: captured at selection; `reconstructed`: recovered from retained evidence; `unknown`: unavailable. | +| rule_id | string or null | Generation-scoped traffic rule ID, or null when unavailable. | +| rule_expression | string or null | Sanitized display expression for that rule, or null when unavailable. | +| rule_source | string | `kernel`: deciding kernel rule; `recomputed`: userspace recomputation, not the deciding kernel rule; `unknown`: unavailable provenance. | +| ingress | string or null | `lan` or `wan` when captured; null when unavailable. | +| domain_source | string or null | `tls_sni`, `http_host`, `quic_sni`, `dns_mapping`, `explicit`, or `unknown`; null without domain evidence. | +| started_at | string or null | Actual start time if recorded; null if only post-dial registration time is known. | +| observed_by | string | `userspace`, `ebpf`, or `mixed` | +| upload_bytes | decimal uint64 string or null | Visible uploaded bytes | +| download_bytes | decimal uint64 string or null | Visible downloaded bytes | +| upload_bytes_per_second | decimal uint64 string or null | Visible upload rate | +| download_bytes_per_second | decimal uint64 string or null | Visible download rate | + +> **Note:** Visible network speed and connection totals are available +> from [`GET /api/v1/runtime`](runtime-status.html). The datapath may observe only a +> subset of host traffic. + +When the matching entries across both arrays exceed `limit`, the server +returns the most recently observed entries first with a stable tie-breaker +and sets `truncated: true`. Totals are the complete matching counts visible +at `observed_at`, not only the returned array sizes. + +Summary reduces payload; it does not confer less-sensitive access. `pname`, +addresses, domains and list-view evidence all require `observe`. `chain`, +`chain_source`, `rule_id`, `rule_expression`, `rule_source`, `ingress`, and +`domain_source` are required in both detail tiers and share the +[flow-summary contract](flows.html#List-view-fields). The list carries the +application selection, not a DNS helper's path. The full decision timeline +and DNS provenance remain at [`GET /api/v1/flows/{flow_id}`](flows.html). + +As [honk's `matched_rule` evidence](honk-mapping.html#matched-rule) shows, +a recomputed rule can differ from the deciding kernel rule. Do not relabel +it as `kernel` or use today's group selection to fill an unknown chain. +Inbound identity beyond `ingress` is out of scope for this draft. + +Totals count visible live entries after the `type` and exact source-IP `src` +filters (the excluded transport has count zero); absence from this snapshot +is not evidence of a clean close. `/flows` records failed/blocked attempts +and recently terminated flows. This draft has no close/terminate action +because [tracker deletion](honk-mapping.html#tracker-deletion) removes an +observation, not proof that the transport was cancelled. + +The supported client/device view groups the source IP from `src` over +`detail=full` entries, ignoring the source port. This derivation is bounded +by `limit`; use the `src` filter for a per-address drill-down, not tuple +guessing or device identity inference. MAC addresses and client/device +first-seen timestamps are not on the `/connections` wire. `started_at` +describes a connection, not when the client/device was first seen. ## Example ```bash -curl "http://localhost:9527/api/connections?type=tcp&limit=10" +curl "http://localhost:9527/api/v1/connections?type=tcp&limit=10&detail=full" ``` diff --git a/source/v0.1.0/en/docs/datapath.md b/source/v0.1.0/en/docs/datapath.md new file mode 100644 index 0000000..1f90918 --- /dev/null +++ b/source/v0.1.0/en/docs/datapath.md @@ -0,0 +1,89 @@ +--- +title: Datapath +--- + +# GET /api/v1/datapath + +> Draft endpoint. Returns detailed datapath and eBPF state. The summary is +> also included in [`GET /api/v1/runtime`](runtime-status.html). + +The endpoint reports whether the datapath is loaded, attached, published, and +usable. `programs: loaded` alone does not mean that traffic is being handled. + +## Request + +{% api_request getDatapath %} + +`detail=summary` is the default and omits interface names, attachments, and map +occupancy. `detail=full` includes the documented `attachments` and `maps` +objects when available. + +## Response + +### Success (200 OK) + +{% api_example getDatapath 200 active %} + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| observed_at | string | Snapshot timestamp (RFC3339). | +| kind | string | `ebpf`, `userspace`, `mock`, or `unknown`. | +| state | string | `active`, `degraded`, `detached`, `failed`, `disabled`, or `unknown`. | +| visibility | string | `full`, `partial`, or `none`. | +| ebpf.backend | string | `real`, `mock`, or `unknown`. | +| ebpf.programs | string | `loaded`, `not_loaded`, `error`, or `unknown`. | +| ebpf.hooks | string | `attached`, `partially_attached`, `detached`, or `unknown`. | +| ebpf.routing.state | string | `published`, `not_published`, `error`, or `unknown`. | +| ebpf.routing.generation_id | string or null | Generation currently published to eBPF. | +| ebpf.routing.epoch | string or null | Engine routing epoch when exposed. | +| ebpf.attachments | array | Engine-visible hook attachments. It may be empty when details are unavailable. | +| ebpf.maps.state | string | `ready`, `partial`, `error`, or `unknown`. | +| ebpf.maps.conn_state | object or null | Conntrack occupancy when the backend exposes it. | +| ebpf.health | string | `healthy`, `degraded`, `failed`, or `unknown`. | +| ebpf.last_error | string or null | Latest safe machine-readable error code. | +| ebpf.checked_at | string | Time at which eBPF state was checked. | +| errors | array | Current safe errors using `code`, `message`, and optional `details`. | + +Map `capacity` and known `occupancy` are bounded numeric counts. +`occupancy_known: false` requires `occupancy: null`, not a fabricated zero. + +`ebpf.routing.generation_id` identifies the actual published kernel policy, +which must be valid for the active runtime generation. It need not equal +`generation.active_id`: unchanged policies can be reused across reloads. +A staged/pending publication is never reported as active. `epoch` is an +engine-native routing epoch, not a substitute for configuration identity or +the active double-buffer slot. Independently timed GETs may straddle reload; +compare their observation times before diagnosing a mismatch. + +## State rules + +- For `kind: ebpf`, `active` requires loaded programs, required hooks attached, + and published routing for the active generation. +- For `kind: ebpf`, `degraded` means the datapath can operate only partially, + or an important map/counter cannot be read. +- `failed` means initialization or a required runtime operation failed. +- `unknown` means the adapter cannot verify the state; it must not infer + `active` from configuration alone. + +For `kind: userspace`, `active` means the required listeners and forwarding +workers are running with the active routing configuration and can handle +traffic. `degraded` means forwarding remains partially usable but a required +listener, worker, or observation is impaired. eBPF programs, hooks, and +publication are not prerequisites for a userspace-only datapath; `ebpf` is null. + +For `kind: mock` or `ebpf.backend: mock`, states describe the simulated path +only. A mock may report `active` for verified simulated readiness, but this +does not claim real packet handling, kernel hooks, routing publication, or +host-traffic visibility. Unverified state remains `unknown`; clients must +not present mock readiness as a healthy production datapath. + +This endpoint is read-only. Reload and lifecycle actions use +`/api/v1/operations/*`. + +## Example + +```bash +curl "http://localhost:9527/api/v1/datapath?detail=full" +``` diff --git a/source/v0.1.0/en/docs/discovery.md b/source/v0.1.0/en/docs/discovery.md new file mode 100644 index 0000000..cf346f5 --- /dev/null +++ b/source/v0.1.0/en/docs/discovery.md @@ -0,0 +1,44 @@ +--- +title: Discovery +--- + +# GET /api + +> Draft endpoint. This resource identifies the native API surface and points to +> the bootstrap resources. Engine version information remains exclusively at +> `GET /api/v1/version`. + +## Request + +{% api_request getDiscovery %} + +## Response + +### Success (200 OK) + +{% api_example getDiscovery 200 draft %} + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| name | string | Stable name of the native API surface. | +| status | string | API design status; currently `draft`. | +| api_major | integer | Selected wire major, currently 1; independent of document/engine version. | +| base_path | string | Versioned native resource prefix. | +| links | object | Stable bootstrap links. This is not a capability declaration. | +| links.config | string | Stable `/api/v1/config` link; capabilities declare availability, content visibility, and source limits. | +| links.config_validate | string | Stable `/api/v1/config/validate` link for POST; capabilities declare availability, modes, and limits. | +| links.runtime_outbounds | string | Stable `/api/v1/runtime/outbounds` link; availability is declared by capabilities. | +| links.traffic_history | string | Stable `/api/v1/runtime/traffic/history` link; availability and limits are declared by capabilities. | + +Clients use `links.version` for engine identity and `links.capabilities` to +discover which optional resources and actions the running adapter implements. +The discovery response must not copy the engine version or the +Clash-compatible `/version` payload. + +## Example + +```bash +curl http://localhost:9527/api +``` diff --git a/source/v0.1.0/en/docs/dns-cache.md b/source/v0.1.0/en/docs/dns-cache.md index 298fde1..7d07ac9 100644 --- a/source/v0.1.0/en/docs/dns-cache.md +++ b/source/v0.1.0/en/docs/dns-cache.md @@ -2,69 +2,152 @@ title: DNS Cache --- -# GET /api/dns/cache +# DNS Cache -Returns DNS cache entries. +> Draft endpoints: `GET /api/v1/dns/cache`, `DELETE /api/v1/dns/cache/{entry_id}`, +> filtered `DELETE /api/v1/dns/cache`, and `POST /api/v1/dns/cache/flush`. +> Cache introspection and mutations are independently declared under +> `resources.dns_cache` by `GET /api/v1/capabilities`. -## Request +These endpoints operate on the engine's runtime DNS cache only. They do not +flush the kernel conntrack table, the host stub resolver, an upstream DNS +server, or any configured DNS routing rule. They also do not change +`fixed_domain_ttl`, optimistic-cache settings, or the cache size limit. -```http -GET /api/dns/cache HTTP/1.1 -Host: localhost:9527 -``` +An implementation that does not expose a capability must return `404` with +`capability_not_supported`; it must not return an empty successful result. + +## List entries + +### `GET /api/v1/dns/cache` + +The response is a paginated snapshot. The cache can change while the client +walks the pages, so `cursor` is opaque and must not be manufactured by a +client. +The server binds the cursor to the running adapter instance, filters, and +retained snapshot. Restart, changed filters, or snapshot expiry/eviction invalidates +it. An unknown or invalidated cursor returns `400 invalid_request`; discard +it and restart without a cursor, never silently continue a different snapshot. + +{% api_request listDnsCache %} ## Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| domain | string | - | Filter by domain (partial match) | -| limit | int | 100 | Max entries to return | +| name | string | - | Exact DNS name after canonicalization; matches one question name | +| domain | string | - | Listing-only partial-match convenience filter; never accepted by a delete request | +| type | string | - | Record type filter; may be repeated, for example `type=A&type=AAAA` | +| include_expired | bool | false | Include expired entries that have not yet been lazily evicted | +| limit | int | 100 | Max entries to return; servers cap this value at 1000 | +| cursor | string | - | Opaque cursor returned as `next_cursor` | +| detail | string | summary | `summary` omits answer RDATA; `full` includes `answers`. | ## Response ### Success (200 OK) -```json -{ - "entries": [ - { - "domain": "example.com", - "type": "A", - "answer": "93.184.216.34", - "ttl": 3600, - "deadline": "2026-08-13T13:00:00Z" - }, - { - "domain": "google.com", - "type": "A", - "answer": "142.250.80.46", - "ttl": 300, - "deadline": "2026-08-13T12:05:00Z" - } - ], - "total": 1024 -} -``` +{% api_example listDnsCache 200 entries %} ### Fields | Field | Type | Description | |-------|------|-------------| +| observed_at | string | Snapshot timestamp (RFC3339). | +| coverage | object | Cache classes represented by this endpoint. | | entries | array | DNS cache entries | -| total | int | Total cached entries | +| total | int | Number of entries matching the filters at snapshot time | +| next_cursor | string | Opaque cursor for the next page, or `null` when complete | + +`coverage.positive` and `coverage.negative` must match the advertised +`entry_kinds`. `coverage.persistent` declares whether entries outside the +runtime in-memory cache are included. Implementations must not silently omit a +cache class they claim to expose. ### Entry Object | Field | Type | Description | |-------|------|-------------| -| domain | string | Domain name | -| type | string | Record type (A, AAAA, CNAME, etc.) | -| answer | string | DNS answer | -| ttl | int | Time to live (seconds) | -| deadline | string | Cache expiration time (RFC3339) | +| entry_id | string | Opaque runtime entry ID; not stable across restart or full flush | +| domain | string | Canonical DNS name, lower-case A-label with a trailing dot | +| type | string | Question record type, such as `A`, `AAAA`, or `HTTPS` | +| class | string | DNS question class, normally `IN` | +| status | string | `NOERROR`, `NXDOMAIN`, `NODATA`, `SERVFAIL`, or another DNS result | +| answers | array, optional | Complete cached RRset with `detail=full`; an entry is not one individual answer value | +| expires_at | string | Time at which the normal cache lifetime ends (RFC3339) | +| stale_until | string or null | Required end of optimistic stale-answer eligibility (RFC3339); null when stale serving is disabled or the boundary is unavailable. | + +Negative results such as `NXDOMAIN` and `NODATA` are cache entries too. A +delete operation removes the complete question/type entry, including every +answer and negative state; deleting one RDATA value from an RRset is not +supported because it would create a response that was never validated by an +upstream. + +## Delete one entry + +### `DELETE /api/v1/dns/cache/{entry_id}` + +Deletes exactly one cache entry identified by the opaque `entry_id` returned +by the list endpoint. The ID must be URL-encoded as a path segment. + +{% api_request deleteDnsCacheEntry %} + +Deletion is idempotent and returns `200` whether the entry existed: + +{% api_example deleteDnsCacheEntry 200 deleted %} + +A retry after the entry is gone returns `deleted: 0`. + +## Delete matching entries + +### `DELETE /api/v1/dns/cache` + +Deletes all entries matching an exact name and optional record-type filters. +`name` is required for this endpoint; a partial `domain` filter is never +accepted for deletion. Omitting `type` deletes every type and both positive +and negative entries for that name. + +{% api_request deleteDnsCacheByName %} + +The response is successful even when no entries matched, which makes retries +safe: + +{% api_example deleteDnsCacheByName 200 deleted %} + +## Flush the complete runtime cache + +### `POST /api/v1/dns/cache/flush` + +Flushes all runtime DNS cache entries. This is deliberately an action endpoint +so an unfiltered `DELETE /api/v1/dns/cache` cannot accidentally erase the entire +cache. The request body is empty or `{}`. + +{% api_request flushDnsCache %} + +The server returns only after the invalidation barrier has been installed: + +{% api_example flushDnsCache 200 flushed %} + +Queries already in flight may still return their upstream result to their +caller, but a result started before the barrier must not repopulate an entry +that was flushed or deleted. A later normal DNS query may populate the cache +again. + +## Mutation errors + +| Status | Code | Meaning | +|--------|------|---------| +| 400 | `invalid_name` | The name is missing, malformed, or not canonicalizable | +| 400 | `filter_required` | A collection delete did not include the required exact `name` | +| 404 | `capability_not_supported` | The running engine does not expose this cache operation | +| 503 | `cache_unavailable` | The DNS cache cannot be inspected or mutated at this time | ## Example ```bash -curl "http://localhost:9527/api/dns/cache?domain=google" +curl "http://localhost:9527/api/v1/dns/cache?domain=google&limit=20&detail=full" +curl -X DELETE \ + "http://localhost:9527/api/v1/dns/cache?name=example.com.&type=A" +curl -X POST \ + "http://localhost:9527/api/v1/dns/cache/flush" ``` diff --git a/source/v0.1.0/en/docs/dns-query.md b/source/v0.1.0/en/docs/dns-query.md index dd10d05..b3fb0f7 100644 --- a/source/v0.1.0/en/docs/dns-query.md +++ b/source/v0.1.0/en/docs/dns-query.md @@ -2,83 +2,59 @@ title: DNS Query --- -# GET /api/dns/query +# GET /api/v1/dns/query -Performs a live DNS query through the dae DNS module for debugging. The query is resolved using the configured `dns.upstream` servers and evaluated against the `dns.routing` rules, exactly like a real DNS request handled by dae. +> Draft endpoint. Use `GET /api/v1/dns/query`. +> Each requested record type has its own DNS status, upstream, route source, +> cache state, and elapsed time. + +Performs a live DNS query through the configured DNS module for debugging. +Successful responses include `Cache-Control: no-store`. ## Request -```http -GET /api/dns/query?domain=example.com&type=A&type=AAAA HTTP/1.1 -Host: localhost:9527 -``` +{% api_request queryDns %} ## Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | domain | string | - | Domain name to query (required) | -| type | string | A | Record type: `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SOA`, `PTR`. May be specified multiple times (e.g. `&type=A&type=AAAA`) to query several record types in a single request | +| type | string | A | IANA record type mnemonic such as `A`, `AAAA`, `HTTPS`, `SVCB`, `SRV`, `CNAME`, `MX`, `TXT`, `NS`, `SOA`, or `PTR`; numeric types are allowed when unknown qtypes are advertised. May be specified multiple times (e.g. `&type=A&type=AAAA`) | | upstream | string | - | Force a specific upstream defined in `dns.upstream` (e.g. `alidns`). If omitted, the upstream is chosen by `dns.routing` | +| cache_mode | string | normal | `normal` reads and writes the runtime cache; `bypass` reads from upstream and neither reads nor writes the cache | +| detail | string | summary | `summary` omits answer RDATA; `full` includes `answers`. | ## Response ### Success (200 OK) -```json -{ - "domain": "example.com", - "types": [ - "A", - "AAAA" - ], - "cached": false, - "upstream": "alidns", - "status": "NOERROR", - "elapsed_ms": 12, - "query_time": "2026-08-14T00:00:00Z", - "question": [ - { - "name": "example.com.", - "type": "A" - }, - { - "name": "example.com.", - "type": "AAAA" - } - ], - "answers": [ - { - "name": "example.com.", - "type": "A", - "class": "IN", - "ttl": 600, - "data": "93.184.216.34" - }, - { - "name": "example.com.", - "type": "AAAA", - "class": "IN", - "ttl": 600, - "data": "2606:2800:220:1:248:1893:25c8:1946" - } - ] -} -``` +{% api_example queryDns 200 dual_stack %} ### Fields | Field | Type | Description | |-------|------|-------------| | domain | string | Query domain name | -| types | array | Requested record types | -| cached | bool | Whether the answer was served from the DNS cache | -| upstream | string | Upstream that served the query (after routing) | -| status | string | DNS response code: `NOERROR`, `NXDOMAIN`, `SERVFAIL`, `FORMERR`, `REFUSED`, etc. | -| elapsed_ms | int | Query round-trip time (ms) | +| cache_mode | string | Cache behavior used for this query | | query_time | string | Timestamp of the query (RFC3339) | -| question | array | DNS question section | -| answers | array | DNS answer records | +| results | array | One result for each requested record type. | +| results[].type | string | Requested record type. | +| results[].cached | bool | Whether this type was served from cache. | +| results[].cache_entry_id | string or null | Cache entry ID, if one exists. | +| results[].upstream | string or null | Upstream used; `null` for a cache hit. | +| results[].route.source | string | `forced`, `dns.routing`, or `default`. | +| results[].route.rule | string or null | Safe identifier or summary of the matched route. | +| results[].status | string | DNS response code such as `NOERROR`, `NXDOMAIN`, or `SERVFAIL`. | +| results[].elapsed_ms | int | Per-type elapsed time in milliseconds. | +| results[].question | object | DNS question. | +| results[].answers | array, optional | DNS answer records with `detail=full`. | + +This is a new diagnostic query, not the DNS history of an existing flow. +`route` describes the request-side choice only; response requeries, +resolver-server routing, actual carriers and exact flow correlations belong +to recorded DNS/route steps. Cache hits may not retain the origin upstream; +null is not permission to reconstruct it from the current configuration. ### Question Object @@ -97,34 +73,22 @@ Host: localhost:9527 | ttl | int | Time to live (seconds) | | data | string | Record data | -### Errors - -#### 400 Bad Request - -Returned when `domain` is missing or `type` is invalid. +### Errors and limits -```json -{ - "ok": false, - "error": "Invalid domain name" -} -``` - -#### 502 Bad Gateway - -Returned when all matched upstreams failed to resolve the domain. - -```json -{ - "ok": false, - "error": "All DNS upstreams failed" -} -``` +A syntactically valid DNS execution returns `200` even when a per-type DNS +status is `NXDOMAIN` or `SERVFAIL`. Invalid names and types use the +[shared error envelope](errors.html). Canonical names are limited to 255 DNS +wire octets and 63 octets per label. Requested types must be unique; duplicates +return `400 invalid_request`. More types than +`resources.dns_query.limits.max_types_per_request` returns `413`. The adapter +enforces the advertised timeout, response-size, principal-rate, and global-rate +limits before dispatch; rate excess returns `429` with `Retry-After`, while an +unavailable DNS subsystem returns `503`. ## Example ```bash -curl "http://localhost:9527/api/dns/query?domain=example.com&type=A" -curl "http://localhost:9527/api/dns/query?domain=example.com&type=A&type=AAAA" -curl "http://localhost:9527/api/dns/query?domain=example.com&upstream=googledns" +curl "http://localhost:9527/api/v1/dns/query?domain=example.com&type=A&detail=full" +curl "http://localhost:9527/api/v1/dns/query?domain=example.com&type=A&type=AAAA" +curl "http://localhost:9527/api/v1/dns/query?domain=example.com&upstream=googledns" ``` diff --git a/source/v0.1.0/en/docs/errors.md b/source/v0.1.0/en/docs/errors.md new file mode 100644 index 0000000..f97551c --- /dev/null +++ b/source/v0.1.0/en/docs/errors.md @@ -0,0 +1,62 @@ +--- +title: Errors +--- + +# Error responses + +All native API errors use one JSON envelope: + +{% api_example patchGroup 412 stale_revision %} + +| Field | Type | Description | +|-------|------|-------------| +| error.code | string | Stable machine-readable code. | +| error.message | string | Short safe description for an operator. | +| error.details | object or null | Optional structured details; never raw engine output. | +| request_id | string or null | Identifier for correlating server-side logs. | + +## Shared status semantics + +The `ErrorCode` schema in the OpenAPI document enumerates exactly the codes below +for HTTP error bodies (`ApiError`); adding one is a contract change. Errors embedded +in resources (`operation.error`, `datapath.errors`, `lifecycle.last_error`) carry an +adapter-defined code. + +| Status | Typical code | Meaning | +|--------|--------------|---------| +| 400 | `invalid_request` | Malformed parameter or request shape. | +| 401 | `authentication_required` | Credentials are missing or invalid. | +| 403 | `permission_denied` | The authenticated caller cannot perform the action. | +| 404 | `resource_not_found` | The requested resource does not exist. | +| 404 | `capability_not_supported` | The running adapter does not expose the resource or action. | +| 409 | `state_conflict` | Current runtime state prevents the requested transition. | +| 409 | `idempotency_conflict` | An idempotency key was reused with a different request body. | +| 409 | `event_cursor_expired` | SSE cursor cannot be replayed; open a fresh stream and resnapshot. | +| 409 | `snapshot_unavailable` | Routing simulation could not pin a consistent generation. | +| 410 | `snapshot_expired` | Paginated flow snapshot expired; restart the page walk. | +| 410 | `flow_expired` | Flow evidence was evicted/expired and a tombstone still exists. | +| 412 | `stale_revision` | `If-Match` does not match the current resource revision or on-disk source content hash. | +| 413 | `request_too_large` | Request or requested fan-out exceeds an advertised limit. | +| 415 | `unsupported_media_type` | Request `Content-Type` is unsupported. | +| 422 | `unsupported_value` | Unsupported field, value, or transition, or error diagnostics from full validation of a source replacement. | +| 428 | `precondition_required` | A required `If-Match` header is missing. | +| 429 | `rate_limited` | A request or operation limit was reached. | +| 503 | `temporarily_unavailable` | A bounded queue or required runtime component is unavailable. | + +Responses with `429` or retryable `503` include `Retry-After`. Errors must not +contain bearer secrets, proxy credentials, private keys, raw configuration, +stack traces, local file paths, or unredacted chained engine errors. + +Configuration source replacement uses existing codes: + +- `403 permission_denied` also covers disabled server-wide editing and read-only + sources, including generated and subscription sources. +- `412 stale_revision` compares the source's current on-disk SHA-256, not the + accepted snapshot revision; the server writes nothing. +- `422 unsupported_value` carries `error.details.diagnostics` with the shared + `ConfigDiagnostic` shape and at least one `error` diagnostic. The JSON request + may be well-formed even when dae syntax is invalid. The server writes nothing + and starts no reload. +- `428 precondition_required` rejects a missing `If-Match` before writing. + +See the [editing flow](configuration.html#Editing) for recovery steps. diff --git a/source/v0.1.0/en/docs/events.md b/source/v0.1.0/en/docs/events.md new file mode 100644 index 0000000..3311205 --- /dev/null +++ b/source/v0.1.0/en/docs/events.md @@ -0,0 +1,87 @@ +--- +title: Events +--- + +# GET /api/v1/events + +> Proposed bounded SSE feed. Polling remains supported. This feed notifies +> clients of state changes; it is neither a packet stream nor durable storage. + +Requires `observe`; operation notifications additionally obey operation +ownership/control visibility. Accept `text/event-stream`. Successful streams +use `Content-Type: text/event-stream`, `Cache-Control: no-store`, and disabled +proxy buffering where supported. Errors before streaming use the shared JSON +envelope. Heartbeat comments are sent at most 15 seconds apart while idle. + +| Parameter | Default | Meaning | +|-----------|---------|---------| +| kinds | all permitted advertised kinds | Comma-separated event kinds below. | +| flow_id | absent | Limit flow notifications to one flow. | + +{% api_request streamEvents %} + +{% api_event FlowUpdated %} + +Event `id` is opaque and unique within an instance. The example's textual +shape is not a parsing contract. Resume with `Last-Event-ID`; bearer secrets +never go in URLs. Native browser `EventSource` cannot set Authorization: +use streaming `fetch` with the header and an SSE parser, or a same-origin +server-side credential boundary. Do not add query tokens or weaken auth to +accommodate `EventSource`. + +For explicitly allowed origins, CORS permits `Authorization`, `Last-Event-ID`, +`Content-Type`, `If-Match`, `Idempotency-Key`, and `Accept` request headers, +and exposes `Location`, `Retry-After`, and `ETag`. The server validates the +origin, requested method, and requested headers before answering a CORS +preflight without bearer authentication; the actual request keeps its normal +authentication and permission checks. + +## Event kinds + +Each named event has one JSON `data` object. Unknown kinds/fields are ignored. + +| Kind | Payload | +|------|---------| +| `stream.ready` | `instance_id`, `observed_at`. Signals that replay is attached and new events are buffered. No resource ID. Sent on every connection, even if not in `kinds`. | +| `runtime.updated` | `instance_id`, `observed_at`, `href` (`/api/v1/runtime`). Coalesced invalidation; fetch the current snapshot. | +| `flow.updated` | `instance_id`, `observed_at`, `resource_id` (flow ID), `revision`, `href`. Includes first observation, decisions, attempts, status changes, and terminal state. Fetch the retained record; coalescing must preserve the latest revision. | +| `flow.gap` | `instance_id`, `observed_at`, nullable `resource_id`, `reason` (`buffer_overflow`, `sampled`, `evicted`, `recording_changed`), nullable `dropped_records` (the same decimal uint64 string counter used by flow coverage). No invented close event. | +| `operation.updated` | `instance_id`, `observed_at`, `resource_id`, `status`, `href`. Only operations visible to this caller. The normal GET operation envelope is authoritative. | +| `generation.changed` | `instance_id`, `observed_at`, `previous_generation_id`, `generation_id`. Sent only after successful publication/promotion, never just on reload acceptance. | + +All advertised events use the same instance and generation identities as +GET resources. The stream publishes only IDs, state, and safe reason codes; +sensitive rule inputs, DNS answers, process names and configuration do not +appear in notification payloads. Flow detail requires a separate authorized +GET. Operation IDs must not leak through events to other observe principals. + +## Replay and recovery + +- Replay retained events strictly after `Last-Event-ID` and then switch to + live delivery without an unobserved gap. Duplicate delivery is permitted; + deduplicate by event ID, and flow snapshots by `(instance_id, id, revision)`. +- A fresh connection sends `stream.ready` with a replay cursor **before** the + client fetches its baseline snapshots. Buffer notifications during GETs, + apply them after the snapshots, and ignore older/equal flow revisions. + This closes the snapshot/subscribe race without requiring a database log. +- An unknown, expired, or previous-instance cursor returns + `409 event_cursor_expired` **before** a `200` stream begins. The client + discards the cursor, opens a new stream, waits for ready, and refetches + snapshots. Never silently resume at the present or pretend lost history + was recovered. Changing filters requires a new baseline; filtered-out + events are not replayed under a different filter set. +- `stream.ready` after a valid resume is sent after retained replay, with + its own cursor. Filtered-out IDs may leave gaps; clients must not infer + dropped events by subtracting IDs. +- If a slow client exceeds its bounded queue, close the stream. Reconnection + replays from its last acknowledged event; if that is no longer retained, + return the cursor-expired error. No unbounded queues and no blocking + datapath writers. Loss before the replay buffer is separately reported by + `flow.gap` and flow coverage, not hidden as a successful replay. + +Capabilities advertise `kinds`, `retention_seconds`, `max_buffered_events`, +`max_clients`, and `heartbeat_seconds`. Retention is an upper bound subject +to buffer pressure; no at-least-once durable guarantee. Recheck credentials +on reconnect and terminate a live stream when its authorization is revoked. +A stream proves event delivery, not that the underlying engine captured all +routing decisions. diff --git a/source/v0.1.0/en/docs/flows.md b/source/v0.1.0/en/docs/flows.md new file mode 100644 index 0000000..3bf7cd5 --- /dev/null +++ b/source/v0.1.0/en/docs/flows.md @@ -0,0 +1,353 @@ +--- +title: Recorded Flows +--- + +# Recorded flows + +> Proposed native API, not an existing honk endpoint. The target is full +> per-flow transparency: **rule input → dial mode → IP/DNS → reroute? → +> outbound → connection status**. This is a causal chain, not a mandated +> execution order: DNS may run before a rule, during domain verification, +> or inside an outbound dial. Record the order the engine actually executed. + +A flow is one engine-observed TCP connection incarnation or UDP session +incarnation, including attempts that are blocked or fail before a connection +exists. `/connections` is the live connection view; it cannot substitute for +this retained decision record. Reading a flow MUST NOT re-evaluate rules, +resolve DNS, probe nodes, or dial anything. + +## Identity and lifetime + +- `id` is an opaque, instance-scoped flow ID, allocated at the first decision + hook, before sniffing, DNS, or dialing can fail. Together with `instance_id` + it is never reused. Neither a five-tuple, PID, socket cookie, outbound index, + nor honk's UDP decision token alone is an API identity. +- Kernel and userspace observations join only through an incarnation-safe + handoff. If correlation cannot be proved, return separate partial records; + never join by IP, name, five-tuple, or a nearby timestamp alone. +- `connection_id` is nullable: blocked, failed, and kernel-direct flows may + never have a userspace tracker entry. One UDP endpoint may carry several + packets; a retired/recreated session gets a new flow ID. +- `generation_id` on a step names the configuration/routing generation + actually used at that step. A flow can cross reload generations. Do not + relabel old steps with the current generation, group name, or selected leaf. + `rule_id` is meaningful only with that generation and rule chain. +- A new engine process gets a new `instance_id`. API IDs are not BPF map ABI; + do not change the persisted UDP token allocator to implement them. + +## GET /api/v1/flows + +Requires `observe`. Returns active **and retained terminal** flows. + +| Parameter | Default | Meaning | +|-----------|---------|---------| +| network | all | `tcp`, `udp`, or `all`. | +| state | all | One lifecycle state below, or `all`. | +| connection_id | absent | Exact opaque connection ID within the current adapter instance; includes retained terminal flows, never tuple matching. | +| limit | 100 | 1–1000, additionally bounded by the advertised limit. | +| cursor | absent | Opaque snapshot cursor; includes the original filters. | +| detail | summary | `full` adds source/destination/domain inputs; not the trace. | + +{% api_example listFlows 200 visible %} + +`revision` increases whenever the retained flow changes. Cursors preserve a +bounded point-in-time list, ordered newest-first with ID as tie-breaker. An +expired snapshot returns `410 snapshot_expired`; do not silently restart a +page walk. `pname` is the captured process name or `null`, including for LAN +traffic without process context. It is available in summary; summary is a +payload-size tier, **not an authorization or privacy boundary**. + +Use `connection_id` to find retained traces after a connection leaves the +live snapshot. Correlate IDs only within the same `instance_id`; a restart +does not authorize a tuple-based fallback. + +### List-view fields + +`Connection` and `FlowSummary` carry the same required list-view evidence, +including with `detail=summary`; no per-row trace fetch is needed for these +columns. Nullable fields remain present when unavailable. + +| Field | Type | Description | +|-------|------|-------------| +| chain | array of strings | Application outbound `selection_path` group IDs followed by the leaf node ID, in order; empty for direct/block or an unknown path. | +| chain_source | string | `evaluation`: captured at selection; `reconstructed`: recovered from retained evidence; `unknown`: unavailable. | +| rule_id | string or null | Generation-scoped traffic rule ID, or null when unavailable. | +| rule_expression | string or null | Sanitized display expression for that rule, or null when unavailable. | +| rule_source | string | `kernel`: deciding kernel rule; `recomputed`: userspace recomputation, not the deciding kernel rule; `unknown`: unavailable provenance. | +| ingress | string or null | `lan` or `wan` when captured; null when unavailable. | +| domain_source | string or null | `tls_sni`, `http_host`, `quic_sni`, `dns_mapping`, `explicit`, or `unknown`; null without domain evidence. | + +`chain` describes the effective application selection, not an interleaved +DNS lookup or a transport retry. Use an empty chain with `chain_source: +unknown` when the path was not captured; an empty chain alone does not prove +direct/block. Never join today's group registry to claim an old selection. +The source label describes evidence, not the outbound step's `routing_source`. + +The [honk `matched_rule` row](honk-mapping.html#matched-rule) distinguishes +the deciding kernel rule from recomputed userspace evidence. Preserve that +distinction in `rule_source`; GET must not re-run routing to populate it. +`rule_expression` is display text, never executable configuration. +Rule IDs retain their generation and traffic-chain scope; use the detail +trace for that context rather than merging identical IDs across reloads. +These columns do not upgrade a partial trace to complete. + +### Full inputs + +`detail=full` adds an `input` object with `src`, `dst`, `domain`, `domain_source`, +`pid`, `process_path`, `src_mac`, `ingress`, `domain_rule_ids`, `dscp`, and +`mark`; each is nullable. `ingress` is `lan` or `wan` when known; +`domain_rule_ids` is the consumed domain-predicate ID set when routing used +an IP bitmap rather than a known domain. Addresses use +`ip:port` or `[ipv6]:port`. `dst` is the original destination, never the proxy +server. `domain_source` is `tls_sni`, `http_host`, `quic_sni`, `dns_mapping`, +`explicit`, or `unknown`. A DNS IP-to-domain association is not proof of what +name this client requested. PID/path are optional observations, not inferred +from a later lookup of a reused PID. All full fields require the same +`observe` permission and MUST be sanitized; packet bodies and credentials +are never returned. + +## GET /api/v1/flows/{flow_id} + +Requires `observe`. Returns the full flow summary, `input`, and `trace`. +There is no second trace ID or independent trace store to correlate. + +{% api_example getFlow 200 partial_handoff %} + +This example records the kernel's handed-off decision, **not its unrecorded +rule path**; it is intentionally partial. It also illustrates domain dialing +without locally resolving the destination. Do not manufacture an IP or DNS +step when the remote proxy resolves the name. + +## Step contract + +Every step has `seq` (positive integer, strictly increasing within the flow), +`stage`, `observed_at` (RFC3339 or null), `elapsed_us` (monotonic offset from +first observation or null), `generation_id` (string or null), `evidence` +(`observed` or `reconstructed`), and the stage-specific `data` below. Sequence +is collector order, not proof of causality between concurrent attempts; +`attempt_id` and evaluation references carry that relationship. Sequence, +flow revisions and monotonic offsets are bounded JSON integers in +0–9007199254740991; cumulative `uint64` counters use decimal strings instead. + +| stage | data contract | +|-------|---------------| +| `input` | Client-flow observation changes: `values` uses the display `input` fields plus nullable `pname`; `source` is `kernel`, `socket`, `sniffer`, or `dns_mapping`. This event does not implicitly supply a later route's inputs. | +| `route` | `evaluation_id`, `chain` (`traffic`, `dns_request`, `dns_response`, `dns_upstream`), `plane` (`kernel`, `userspace`), immutable chain-specific `input` (or null for missing capture), nullable `dns_action`, nullable `rule_id`, `rules` (below), nullable `outbound`, nullable `must`, nullable `mark`. One record per actual evaluation/pass. | +| `datapath` | `plane` (`kernel`, `userspace`), `action` (`pass`, `redirect`, `hold`, `arm_direct`, `activate_direct`, `activate_proxy`, `drop`), safe `reason`, nullable `error`. Record enforcement separately from the policy verdict. | +| `dial_mode` | `configured` (engine-native mode), `effective_target` (`ip`, `domain`, `none`, `unknown`), nullable `domain` and `domain_source`, `verification` (`matched`, `other_family_trusted`, `failed`, `not_required`, `unavailable`), safe `reason`. Rejected SNI remains evidence here, not an accepted routing input. Other-family trust is not an exact IP match. | +| `dns` | `lookup_id`, nullable `parent_lookup_id`, nullable `attempt_id`, `purpose` (`domain_verification`, `dial_target`, `proxy_server`, `intercepted_query`, `family_preference`, `refresh`), `name`, `qtype`, `source` (`hosts`, `cache`, `upstream`, `coalesced`, `unknown`), nullable `upstream_transport` (`udp`, `tcp`, `dot`, `doh`, `doq`, `doh3`), nullable `carrier_transport` (`tcp`, `udp`), `cache` (`hit`, `miss`, `stale`, `bypass`, `unknown`), nullable `cache_entry_id`, nullable `upstream`, `route_evaluation_ids`, `status`, `addresses`, nullable `selected_ip`, nullable `error`. One step per question/result; include failed and rejected response attempts. | +| `reroute` | `performed` (bool or null), safe `reason`, nullable `from_evaluation_id` and `to_evaluation_id`. `false` means deliberately not rerouted; `null` means not observed. Examples: final must/block, preserved IP route, verified domain, missing domain. | +| `outbound` | `attempt_id`, nullable `parent_attempt_id`, `kind` (`leaf`, `transport`), nullable `evaluation_id`, `routing_source` (`evaluation`, `forced`, `builtin`, `unknown`), nullable `routed_outbound` and `effective_outbound`, `mode_override` (`none`, `direct`, `global`, `unknown`), ordered `selection_path`, nullable `leaf_node_id` and `leaf_node_name`, nullable `target`, `target_kind` (`ip`, `domain`, `none`, `unknown`), nullable `dial_ip`, nullable `server_addr`, `resolution_location` (`original_ip`, `local_dns`, `outbound_remote`, `not_applicable`, `unknown`), `status` (`started`, `succeeded`, `failed`, `cancelled`), nullable safe `error`. Emit attempt transitions, including failed/cancelled losers, without changing old steps. | +| `connection` | `state`, safe `reason`, `milestone` (`transport_ready`, `target_request_sent`, `target_confirmed`, `first_reply`, `terminal`, `unknown`), nullable `attempt_id`, nullable `reply_received`, nullable safe `error`. Includes failures before registration and terminal cleanup. | + +Each selection-path item also has nullable `member_name` and `selection`. +`selection` is a decision-time object with nullable `previous_member_id`, +`metric`, `tolerance_ms`, and a `candidates` array. Each candidate requires `member_id`, `selected` (boolean), and safe +`reason`; its required nullable fields are `member_name`, `leaf_node_id`, +`leaf_node_name`, `eligible`, `sorting_latency_ms`, and `score`. Preserve the actual considered +candidates and eligibility/demotion/exploration reasons, not every configured +node. Manual selection can use null; an automatic decision whose context +was not captured makes the trace partial. These values come from the actual +selection computation, not a later `/groups` snapshot. An unstarted candidate +can be considered for selection without becoming a dial attempt. +Path `member_id` is nullable when an empty/ineligible group has no selected +member. Preserve that group and its failure/final-fallback reason. A final +group adds another path item; a builtin/node terminal uses the outbound/leaf +fields, not a fabricated declared group member. +Names are sanitized decision-time captures, required but null when +unavailable. IDs remain authoritative; never replace a retained name by +joining the current node or group registry. + +DNS `addresses` is the observed IP answer set, not the list of addresses +actually dialed. A proxy server IP is not the flow's destination IP. Shared +DNS/singleflight work can use the same `lookup_id` in multiple flows; a cache +hit references the consumed cache entry only when its identity is known. +Never retroactively attach an unrelated later DNS query to a flow. + +`parent_lookup_id` links a real family-preference/helper/refresh operation +to its trigger, not to a guessed client transaction. A stale response can +have a failed fresh attempt; distinguish answer source from attempted +upstream. `upstream_transport` is configured DNS protocol, while +`carrier_transport` is what actually carried the exchange (for example, +configured UDP DNS carried over TCP through a proxy). + +A kernel `drop` used to complete proxy handoff is not a policy `block`. +Likewise direct activation ends userspace setup, not the native connection. +Record NFQUEUE hold/arm/verdict/publication order in `datapath` steps without +exposing mutable verdict tokens. Static port-53 interception and early +bypasses are enforcement reasons, not invented configured rule matches. + +`server_addr` is the physical proxy server/socket peer if observed; `dial_ip` +is the destination chosen for the application target. They may differ or be +unknown. Internal address races/session retries are `transport` attempts +under their owning `leaf` attempt, not invented alternative group selections. +Speculative pool warming/probes are not client-flow attempts merely because +they dial the same node. Reused transports must not claim a new handshake. + +### Inputs belong to an evaluation + +`route.data.input` is the immutable input **consumed by that evaluation**, +not a reference to the most recent client `input` event. Its shape follows +`chain`; the client flow's display tuple and `network` remain unchanged: + +| Chain | Required input fields | +|-------|-----------------------| +| `traffic`, `dns_upstream` | `network` (`tcp` or `udp`), nullable `src_ip`, `src_port`, `dst_ip`, `dst_port`, `domain`, `pname`, `src_mac`, `dscp`, `mark`, `ingress`, `domain_rule_ids`. IPs are literals, ports are 0–65535; zero preserves internal resolver source ports. | +| `dns_request` | `name`, `qtype`, nullable `source_ip` and `original_dst` (`ip:port` or `[ipv6]:port`). The original DNS destination is needed by `asis`. | +| `dns_response` | `name`, `qtype`, `answer_ips` (the consumed address list), `from_upstream`. These are the response being evaluated, before any requery replaces it. | + +All listed keys are present in a captured input. A nullable field denotes an +observed absent value; missing capture uses `input: null` and a partial trace, +never a fabricated zero/empty context. In particular, a TCP client flow can +contain a DNS-upstream evaluation of +`0.0.0.0:0 → 192.0.2.53:53 / udp`, with no process or MAC context. +The evaluation's transport is the router input, not necessarily the DNS +carrier after proxy conversion; do not derive it from the top-level flow. + +For traffic/DNS-upstream evaluations, `dns_action` is null and `outbound` is +the router's outbound. For DNS request rules, `dns_action` is `upstream`, +`asis`, or `reject`; for response rules it is `accept`, `reject`, or `requery`. +`outbound` then names the selected upstream only for `upstream`/`requery`, +and is null otherwise; `must` and `mark` are null for these DNS-policy chains. +Unknown DNS action is null and makes the trace partial. An upstream named +`accept` is therefore distinguishable from the accept action. + +### Causal references, not adjacency + +`evaluation_id` identifies exactly one route evaluation within this flow. +An outbound with `routing_source: evaluation` references the evaluation that +caused the attempt; the reference is non-null even if another evaluation +selected the same outbound. Forced/builtin choices have a null reference +because no router ran for that choice; `unknown` means missing evidence and +requires a partial trace. Internal transport attempts retain the owning +evaluation reference and use `parent_attempt_id` for their leaf attempt. + +Evaluation IDs are unique; repeated attempt IDs describe transitions of the +same attempt, never another attempt. A complete trace resolves every +evaluation, attempt-parent, DNS route/attempt, and reroute reference to the +corresponding records in that flow; parent links cannot cycle. A partial +trace may retain references whose evidence was lost, but must label the loss +and must not substitute another record with a matching name or address. + +For example, two DNS families may complete out of order: + +| Collector order | Record | Recorded input / causal edge | +|-----------------|--------|------------------------------| +| 1 | traffic evaluation `app-1` | Client TCP tuple; its own immutable input. | +| 2 | request evaluation `request-a` | `name: example.com`, `qtype: A`, client source. | +| 3 | request evaluation `request-aaaa` | Same name/source, `qtype: AAAA`; not an update to `request-a`. | +| 4 | upstream evaluation `up-a` | Resolver IPv4 tuple and router transport `udp`. | +| 5 | upstream evaluation `up-aaaa` | Resolver IPv6 tuple and router transport `udp`. | +| 6 | outbound attempt `dial-aaaa` | `evaluation_id: up-aaaa`; completing first changes no other edge. | +| 7 | response evaluation `response-aaaa` | AAAA response address list and actual `from_upstream`; action `accept` or `requery`. | +| 8 | DNS result `lookup-aaaa` | `attempt_id: dial-aaaa`, `route_evaluation_ids: [request-aaaa, up-aaaa, response-aaaa]`. | +| 9 | outbound attempt `dial-a` | `evaluation_id: up-a`, regardless of the most recent route record. | + +An application's later outbound references its own deciding traffic +evaluation, not the most recently completed DNS evaluation. Following +`requery` performs and records new evaluations/attempts; it never overwrites +the earlier response input. Reads do not reconstruct any of these edges. + +### Rule evaluations + +Each `rules[]` item has `rule_id`, nullable `expression`, `result`, +`missing_inputs`, and `conditions`. Each condition has `id`, nullable +`expression`, `result`, and `missing_inputs`; IDs identify nodes in the +compiled predicate tree (including AND/OR/negation), not flattened +independent booleans. `expression` is a sanitized display of that rule or +predicate, including its configured operands; it is not a second executable +rule language. Keep the generation's rule dictionary while records refer to +it, and expand compact IDs at serialization, not on the packet path. +Compiler-inserted rules use a distinct namespace. Never expose raw config or +credentials through expressions; redacted required evidence marks a trace +partial. Consumed bitmap predicate IDs are valid recorded inputs even when +the client's domain string was never observable. + +`result` is `matched`, `not_matched`, `skipped`, or `indeterminate`. `skipped` +means not executed after a final rule or short-circuit; it is not a negative +verdict. `indeterminate` means evidence/input is missing, not a guessed +non-match. `missing_inputs` lists names such as `dst_ip`, `pname`, or +`kernel_rule_trace`. A result with missing evidence MUST NOT be upgraded to +`observed` by replaying today's router. A replay can be attached only as a +`reconstructed` step, and cannot fill a completeness gap. + +A full trace captures the inputs each rule consumed inside its own route +record, the actual short-circuit path and deciding rule/fallback. Empty +`rules` plus an outbound is useful partial evidence, not full rule tracing. +An input absent at runtime is different from an input absent in the recorder: +record the engine's real absent-value evaluation in the former case. + +### Connection states + +`observed → routing → dialing → active → closed` is a common TCP path, not a +required sequence. `blocked` and `failed` can occur before `dialing` or +`active`; a kernel-direct decision need not dial. All terminal states are +`closed`, `blocked`, or `failed`. `unknown` is observational uncertainty, not +an engine verdict; a later observation may resolve it. + +For TCP, `active` and outbound attempt `succeeded` mean a usable local stream +was returned, not necessarily that the remote proxy accepted the target. +Some protocols defer their target request/response until first I/O. +`milestone` distinguishes transport readiness, request transmission, actual +target confirmation (only when the protocol provides it), and first reply. +Never infer `target_confirmed` from tracker registration or a completed QUIC +connection to the proxy server. + +For UDP, `active` means the local endpoint/transport is usable, not that the +remote application responded. Record first reply with `reply_received: true`; +idle expiry, no-reply expiry, transport death, intentional retirement, and +shutdown have distinct safe `reason` codes. A health-neutral cancellation is +not an outbound failure. LRU/map eviction or lost observation is not a clean +TCP FIN and MUST NOT be fabricated as `closed`. + +## Completeness, retention, and cost + +`trace.status` and `trace_status` agree: `complete` means every decision so far +in the declared flow scope was captured with observed evidence; `partial` +means evidence is missing; `disabled` means recording was off. Complete does +not imply terminal or successful. `trace.missing` is empty only for complete +traces; otherwise it lists `not_instrumented`, `started_late`, `buffer_overflow`, +`sampled`, `redacted`, or `evicted`. No applicable DNS/reroute/dial work is a +recorded not-applicable decision, not missing evidence. + +List `coverage` reports each observation scope as `full`, `partial`, or `none`, +independently of individual trace completeness. It is bounded to managed +interfaces/ingress and the advertised recording interval, not all host +packets. `dropped_records` is a canonical unsigned 64-bit decimal string +counting losses since this instance started, or null when unavailable; +`"0"` must be measured. Sampling or loss downgrades coverage. +Unobserved kernel-direct/blocked flows cannot be hidden behind a full +userspace list. Kernel bypasses (multicast, own traffic, local services, closed +admission) must be declared even where no connection exists. + +Capabilities advertise `recording` (`off`, `on`, `sampled`), `scopes`, +`max_flows`, `max_steps_per_flow`, `retention_seconds`, `snapshot_ttl_seconds`, +and `max_page_size`. Retention is a **maximum age after termination**, not a +durable guarantee under the bounded memory limit. Eviction, recording toggles, +and losses produce `flow.gap` events; per-flow loss also marks the retained +record partial. Known expired IDs return `410 flow_expired` while a bounded +tombstone exists; otherwise unknown/unauthorized IDs return `404 flow_not_found`. + + +All snapshots, rule dictionaries and variable-length step data share bounded +recorder memory. Admission to a new snapshot may return `503` rather than +allocate without limit; oversized candidate/rule evidence marks the trace +partial. Neither longer retention nor pagination permits unbounded metadata. +Do not stream packets, format rule strings, walk all maps on each GET, or make +forwarding await a dashboard. Capture compact decision IDs into bounded +buffers at existing decision boundaries; serialize on the control plane. +The full-transparency profile requires the missing decision hooks, not a +slower approximation that re-executes routing during a read. + +## Acceptance scenarios + +An implementation claiming full transparency MUST demonstrate actual records +for: TCP dial failure before tracker insertion; UDP no-reply expiry versus +reply-then-idle; kernel direct and block; must/block resisting mode override; +each supported dial mode; accepted and rejected domain verification; DNS hit, +stale hit, upstream failure and remote name resolution; nested group selection +and cancelled/retried dials; interleaved A/AAAA evaluation/attempt references; +TCP application versus UDP resolver inputs; DNS response-policy requery; +reload between steps; five-tuple reuse; buffer loss, eviction, and an SSE +reconnect gap. Simulation output alone proves none +of these runtime observations. diff --git a/source/v0.1.0/en/docs/groups.md b/source/v0.1.0/en/docs/groups.md index 013dbcd..b977063 100644 --- a/source/v0.1.0/en/docs/groups.md +++ b/source/v0.1.0/en/docs/groups.md @@ -2,66 +2,176 @@ title: Groups --- -# GET /api/groups +# Groups -Returns information about all configured node groups and their nodes. +> Draft endpoint. A group contains direct node or group members, configuration, +> runtime selection, and typed health observations. Nested groups are kept as +> group members and must not be flattened into the primary member list. -## Request +The group API has four separate responsibilities: -```http -GET /api/groups HTTP/1.1 -Host: localhost:9527 -``` - -## Response +- `GET` reads the current group state. +- `PATCH` changes group configuration only. +- `PUT` changes runtime selection when the policy supports manual selection. +- `POST /api/v1/probes` starts a typed probe job whose target can be a group. -### Success (200 OK) +## Group resource -```json -[ - { - "name": "proxy", - "policy": "random", - "nodes": [ - { - "name": "node1", - "alive": true, - "latency_ms": 45 - }, - { - "name": "node2", - "alive": true, - "latency_ms": 120 - } - ] - }, - { - "name": "direct", - "policy": "fixed", - "nodes": [ - { - "name": "direct", - "alive": true, - "latency_ms": 5 - } - ] - } -] -``` +{% api_example getGroup 200 current %} -### Fields +### Resource fields | Field | Type | Description | |-------|------|-------------| -| name | string | Group name | -| policy | string | Selection policy (random, fixed, etc.) | -| nodes | array | List of nodes in this group | -| nodes[].name | string | Node name | -| nodes[].alive | bool | Whether node is reachable | -| nodes[].latency_ms | int64 | Node latency (ms) | +| id | string | Opaque stable group identifier. Do not derive API identity from `name`. | +| name | string | Engine-visible group name. | +| config_revision | string | Revision used for optimistic configuration updates. | +| policy.kind | string | Canonical behavior: `selector`, `urltest`, `loadbalance`, `fallback`, `random`, or `score`. | +| policy.native | string | Effective engine policy, not a configuration alias that the runtime implements differently. | +| members | array | Direct group members, in declaration order. | +| members[].id | string | Opaque node or group member identifier. | +| members[].kind | string | `node` or `group`. | +| config | object | Configured group options. It is separate from runtime state. | +| runtime.selection | object | Current selection by transport. A value may be `null`. | +| runtime.health | array | Member-context observations using the shared node health dimensions and metrics. Unknown latency is null; zero is never a failure sentinel. | +| capabilities | object | Operations and fields supported by the current engine. | + +`resolved_leaf_node_id` is optional. It is present when a member resolves to an +actual node, and is separate from `member_id` because a honk group can select a +nested group tag while dialing its selected leaf node. + +The API must not assume that every group has one current node: + +- `random` and `loadbalance` may select per connection and have no stable pick. +- URLTest may have no selection before its first usable measurement. +- TCP and UDP selections may differ. +- A selected group member may resolve to a different leaf node later. + +`runtime.selection` is a coarse transport summary, not an authoritative pick +for every address family/purpose or future flow. `resolved_leaf_node_id` is an +observation, not a promise to dial that leaf. Actual per-flow selection paths +and failed/cancelled candidates belong to the recorded flow. + +Group health adds nullable `sorting_latency_ms`: the actual latency used for +ranking **this member in this group**, including engine recovery penalty and +group offset. It is not defined for random/selector or non-latency Score +ranking. Nullable `ranking` describes `metric` (native metric name), +`recovery_penalty_ms`, `group_offset_ms`, `score`, and a safe `reason`. +Unknown components are null; do not reverse-engineer them from a group winner. +Health fields use [Nodes](node-latency.html)'s raw metric definitions. + +`tolerance` is path-dependent switching hysteresis, not an additive latency. +Nested groups, eligibility, retained choices, concurrency, and Score evidence +also influence selection. Even a complete health snapshot cannot replay a +past decision; it must not replace decision-time flow evidence. Score is a +distinct policy, not URLTest with its score mislabeled as milliseconds. + +## GET /api/v1/groups + +Returns group summaries for discovery, including the same opaque +`config_revision` as the detail resource; preserve it without numeric parsing. +Use the detail endpoint for members and health observations. + +### Request + +{% api_request listGroups %} + +### Success (200 OK) + +{% api_example listGroups 200 groups %} + +## GET /api/v1/groups/{groupId} + +Returns the complete current group resource described above. +The response includes an `ETag` whose value matches `config_revision`. + +### Request + +{% api_request getGroup %} + +## PATCH /api/v1/groups/{groupId} + +Updates group configuration only. It does not change runtime selection. + +Use RFC 6902 JSON Patch and send the revision returned by `GET` in +`If-Match`. + +{% api_example patchGroup request tolerance http %} + +Only fields listed in `capabilities.mutable_config` may be patched. Group +membership sources are intentionally not part of this operation: +`members`, `filters`, and nested group relationships require an engine-owned +configuration reload and must not be silently changed at runtime. +More operations than `resources.groups.max_patch_operations` returns +`413 request_too_large` before any change is applied. A successful synchronous +update returns the new `ETag`; a rejected patch changes nothing. + +When `check_url` is mutable, it accepts only absolute `http` or `https` URLs +without userinfo. Only default ports are allowed unless an administrator +configures an explicit port allowlist. After resolution, loopback, link-local, +multicast, unspecified, private, and cloud-metadata destinations are rejected +unless present in an administrator-owned destination allowlist. The validated +address is pinned for the connection. The adapter reapplies these checks after +every redirect and enforces bounded redirects, response size, and timeout. + +### Responses + +| Status | Meaning | +|--------|---------| +| 200 | Configuration was applied and the response contains the updated group. | +| 202 | The update was accepted and returns the shared `group_update` operation summary. | +| 412 | `If-Match` does not match the current `config_revision`. | +| 409 | Current runtime state prevents the requested transition. | +| 422 | The patch is syntactically valid but the field or value is unsupported. | +| 428 | Required `If-Match` is missing. | + +An asynchronous response uses the [shared operation contract](operations.html): + +{% api_example patchGroup 202 queued %} + +## PUT /api/v1/groups/{groupId}/selection + +Replaces the runtime selection when `capabilities.can_select` is `true`. +Selection is separate from configuration so a runtime choice is not confused +with the group's default member or policy. + +{% api_example selectGroupMember request tcp_udp http %} + +`network` is `tcp`, `udp`, or `both`. The member must be a direct member of the +group. For a nested group, `member_id` identifies the group member; the response +may also include its resolved leaf node. + +Manual selection is not emulated for policies that do not support it. An +unsupported member or policy returns `422 selection_not_supported`; a +temporarily invalid runtime transition returns `409 state_conflict`. + +### Success (200 OK) + +{% api_example selectGroupMember 200 selected %} + +## Group latency tests + +Use the shared `POST /api/v1/probes` endpoint with a group target. See +[Probes](check-nodes.html) for the request and result contract. + +For a group target, the probe implementation must preserve both identifiers: + +- `member_id` is the direct group member requested by the caller. +- `resolved_leaf_node_id` is the actual node that was tested, when applicable. + +The default member scope is `direct`. A nested group is tested through its +policy-authorized selected/resolved leaf. If none is eligible, report +unavailable; do not silently select a sibling or the first configured leaf. +`leaves` explicitly requests expanded leaf diagnostics, not a simulation of +the group's normal selection. Duplicate leaves are measured once per probe +dimension; every direct-member-to-leaf association remains represented. + +Probe results use typed state and nullable latency. A failed result is not +represented as `latency_ms: 0`. -## Example +## Examples ```bash -curl http://localhost:9527/api/groups +curl http://localhost:9527/api/v1/groups +curl http://localhost:9527/api/v1/groups/group-proxy ``` diff --git a/source/v0.1.0/en/docs/honk-mapping.md b/source/v0.1.0/en/docs/honk-mapping.md new file mode 100644 index 0000000..45f1481 --- /dev/null +++ b/source/v0.1.0/en/docs/honk-mapping.md @@ -0,0 +1,165 @@ +--- +title: honk Implementation Evidence +--- + +# Design decisions and honk implementation evidence + +This revision responds to [PR #1's discussion](https://github.com/daeuniverse/api-standardize/pull/1) +and the [routing-trace proposal](https://github.com/daeuniverse/api-standardize/pull/2). +The objective is **full per-flow transparency**, not merely a richer connection +list. The native endpoints remain proposed; this repository does not implement +them in honk or dae. + +Source inspection used the local honk checkout at HEAD +`780c3f158bbe6e69c02327d5e7ce46d1b594cf4e`. Links below pin that revision rather +than drifting with `main`. This is source evidence, not a real-kernel runtime +verification. No dae source checkout was audited, so honk-specific behavior +must not be advertised as a shared dae guarantee. + +## Disposition of PR #1 comments + +| Comment | Decision | +|---------|----------| +| Node subscription provenance | Add nullable `subscription_tag`, using current subscription name via node provenance, never name heuristics or credential-bearing URLs. | +| Health averages and ranking | Define last real sample, real-only halving average and real-only last-ten average; add purpose/warmth/measurement/source and group-context effective ranking. Unsupported formulas stay null. Tolerance is hysteresis, not an offset. | +| Process attribution | Add nullable `pname` to connection/flow summaries. Summary is a size tier, not a privacy permission. Delayed process-path lookup is not durable process identity. | +| JSON versus GraphQL | Keep native HTTP JSON, HTTP status/preconditions and operation envelopes. No second query language. | +| Separate panel, embedded deployment | Keep API independent of asset packaging. An embedded/static UI and LuCI can share it; preserve Clash compatibility rather than replace it. No UI repository/build pipeline is added here. | +| Machine-readable contract | Resource-owned OpenAPI sources publish one generated bundle; native named examples also render the documentation snippets. Standard schema/example validation and focused header, framing and flow-invariant tests replace reverse-parsing Markdown; semantic narrative remains hand-authored. | +| Streaming | One bounded SSE feed with resume, authorization, loss and resnapshot rules. Flow details remain GET resources, not duplicated into every event. | +| Version path | `/api/v1` for resources, `/api` for discovery; document revision and engine version are independent. No unversioned resource aliases. | +| Effective configuration and validation | Add capability-gated native `GET /config` and `POST /config/validate` with shared safe diagnostics. Readback uses accepted sources, not current disk contents; content is hidden by default. Validation requires `control`, performs no network access or state changes, and obeys byte/source limits. | +| Configuration editing | Add single-source GET and PUT. PUT requires `control`, server/source writability, and an on-disk SHA-256 `If-Match` precondition. Full validation precedes atomic replacement and a reload operation; any error diagnostic prevents the write. Engine-written sources remain read-only. | +| Probe overlap | One `/probes` resource, explicit `tcp_connect`/`http`/`dns` semantics and health dimensions; `/nodes` is read-only. | +| Required capabilities | Define `base` and `full_transparency` profiles. Userspace-only snapshots cannot claim the latter. | +| `202 Retry-After` | Mandatory alongside Location; clients obey a positive-seconds polling floor, including after an SSE invalidation. | +| Connection/flow list columns | Denormalise the application chain, rule ID/expression, ingress and domain provenance onto both summaries. Current handoff/tracking omits deciding-rule context; producers must retain selection IDs and label evaluation, reconstruction and recomputation honestly. | +| Per-outbound usage | Add `/runtime/outbounds`, mirroring the current Clash `/stats` counters. Producers must retain outbound kind and a shared reset timestamp, and serialize full-width counters without the current snapshot's uint32 narrowing. | +| Traffic history | Add `/runtime/traffic/history` with advertised window/point ceilings. Current Clash traffic streaming supplies live rate deltas, not timestamped queryable history; producers must sample into a bounded ring independently of subscribers and preserve gaps/reset boundaries. | + +## What the current code actually retains + +| Area | Existing source evidence | Consequence for this API | +|------|--------------------------|--------------------------| +| Kernel route | [RoutingInput/Decision](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-ebpf-common/src/routing_policy.rs#L12-L68) contains normalized tuple, MAC, pname, DSCP, ingress context, rule ordinal and verdict. [Descriptor](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-ebpf-common/src/routing_policy.rs#L101-L109) has a real publication generation. | Capture these at evaluation. Rule ordinal alone is not stable identity. | +| Handoff | [HandoffResult](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/connection/handoff.rs#L42-L66) omits rule and generation; kernel callers project the decision into forwarding metadata. | Today's handoff cannot reconstruct the old rule path. Keep a generation-scoped rule/outbound dictionary at the producer. API GETs must not consume a routing handoff map. | +| `matched_rule` | [prepare_routing](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/connection/routing.rs#L190-L269) may run the current userspace router for tracking while preserving the kernel's actual outbound/mark/must. | A rule string on a connection can be recomputed evidence, not the deciding kernel rule. Label reconstruction and never upgrade it to a complete trace. | +| TCP tracking | [TCP registration](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/connection/tcp.rs#L304-L446) occurs after successful candidate dialing. | Create the observation ID earlier; retain blocked/empty-plan/dial-failed outcomes that never become connections. | +| UDP tracking | [UDP initialization](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/connection/udp.rs#L499-L603) registers after transport preparation but before ready publication and first-send acknowledgement. | Transport prepared, first send accepted and first reply are different milestones. Track endpoint incarnations, not individual packets. | +| Tracker deletion | [ConnectionTracker::remove](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/connection_tracker.rs#L129-L172) erases a map entry. [Clash DELETE](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/clash_api.rs#L1259-L1268) calls that method. | Disappearance is not proof of transport cancellation. Do not build a native close action on this behavior. | +| Deferred protocol setup | [Hysteria2 stream](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-outbound/src/proxy/hysteria2/mod.rs#L226-L278) can return before its buffered target request is sent/accepted. | `active` is not automatically remote-target-confirmed. | +| DNS provenance | [DnsOutcome](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/dns/outcome.rs#L63-L93) has transient outcome/upstream metadata; [ResolvedAddr](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/dns/resolver.rs#L13-L18) retains addresses/TTL, not flow correlation. | Add lookup references at consumers, not by matching DNS transaction IDs or nearby names/IPs. | +| Native health | [URLTest ranking](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-outbound/src/group/policy.rs#L300-L387) uses a real-only EMA plus a separate failure-demotion tier; [parser aliases](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-config/src/parser/groups.rs#L161-L190) map `min_avg10` and `min_last_delay` to URLTest too. | Do not choose a displayed ranking metric from the raw policy spelling, or turn a demotion tier into invented milliseconds. | +| `GET /api/v1/config` | [Clash GET/PUT `/configs`](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/clash_api.rs#L332-L355) returns compatibility settings and accepted diagnostics under the config lock; PUT is a no-op. [ActiveDiagnostics and snapshot rows](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/config_diagnostics.rs#L80-L125) retain generation, source IDs, severity, nullable byte spans, line/byte-column positions, code, and safe message. | Gate with `resources.config`. Map retained diagnostics to `ConfigDiagnostic`; convert byte spans to line/byte-column spans at load time, or return null when unknown. Capture the complete accepted source set, hashes, byte/line counts, load times, and native revision with publication. The existing metadata-only snapshot does not retain source text. | +| `POST /api/v1/config/validate` | [DetailedDiagnostic and Severity](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-config/src/diagnostic.rs#L167-L189) already distinguish info, warning, and error with source ownership and nullable locations. The Clash PUT above does not validate a candidate. | Gate independently with `resources.config_validate`. Add bounded syntax/full validation through the engine parser without writes, network access, cache refresh, or generation publication. Reuse safe diagnostics; preserve source attribution and report inaccessible dependencies as errors. This is a new HTTP contract, not a rename of PUT `/configs`. | +| `GET /api/v1/config/sources/{source_id}` | The accepted-source snapshot required by native `GET /config` supplies this representation; the Clash GET above is not a raw-source reader. | Return one accepted source with the same content visibility and metadata. Unknown IDs return `404 resource_not_found`; never turn an opaque ID into arbitrary file access. | +| `PUT /api/v1/config/sources/{source_id}` | The Clash PUT above is a no-op; the [reload transaction](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/reload/transaction.rs#L484-L625) handles generation publication, not this native source-edit contract. | Add full validation before any write, compare the on-disk hash with `If-Match`, then use a mode-preserving temporary-file rename and start a `reload` operation. Reject read-only sources and return safe diagnostics on `422`; do not claim the existing Clash PUT implements editing. | +| List-view evidence | Kernel route inputs retain ingress; selection has group/leaf context, but the handoff and `matched_rule` evidence above do not preserve a complete deciding path. | Capture application selection IDs, generation-scoped rule ID/expression and domain source at their producer boundaries. Reuse them in `Connection` and `FlowSummary`; do not recompute on GET or join the current registry. | +| Outbound counters | [Clash `/stats`](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/clash_api.rs#L859-L907) exposes name, total/active connections, upload/download and errors. [OutboundTracker](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/stats.rs#L13-L67) retains uint64 atomics but narrows connection/error snapshots to uint32. | Read full-width producer counters for the native resource; capture kind and shared `counter_since`, preserve closed-connection totals and original attribution, and do not infer kernel-wide coverage. | +| Traffic history | [Clash traffic sampler](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/clash_api.rs#L1319-L1350) broadcasts live byte deltas while subscribers exist, without sample timestamps or a history query. | Add periodic timestamped rate/connection capture into a bounded ring, with age/capacity eviction, reset-aware gaps and restart clearing. Enforce advertised window/point limits before reading it; SSE replay remains separate. | + +## Dial mode is not Clash mode + +The four configured dial modes have different rule-input and target effects. +The following describes **honk's current intercepted-flow path**, not a rule +that other engines must emulate: + +| Dial mode | Sniff/verification | Domain in routing | Proxy target | +|-----------|--------------------|-------------------|--------------| +| `ip` | Skip sniff. | No sniffed domain. | Original IP. | +| `domain` | Resolve sniffed name; accept exact original-IP match or trust other-family-only answers; otherwise discard. | Accepted domain, only where handoff finality permits. | Accepted domain, otherwise original IP. | +| `domain+` | Retain sniffed name without reality verification. | Preserve IP-only routing input/eligible initial handoff. | Domain when available. | +| `domain++` | Retain sniffed name without reality verification. | Domain participates where eligible; not an unconditional reroute. | Domain when available. | + +[Verification and reroute gates](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/connection/routing.rs#L53-L177) +exclude `must` and reserved handoffs from sniff replacement. Control-plane +routing already delegates the evaluation; it is not a second reroute merely +because userspace runs. `domain`'s other-family trust must be distinguished +from an exact IP match; the current bool loses that distinction. + +[Clash mode override](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/connection/handoff.rs#L450-L475) +is a later, separate choice. `must` and `block` resist it; a non-must direct +result may still change under Global mode. Direct outbounds use the original +IP even if a domain was sniffed. A fixed routed group does not freeze its +selected leaf or prohibit that group's configured final fallback. + +## Boundaries the full-transparency implementation must cover + +### Kernel versus userspace + +[LAN ingress](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-ebpf/src/ingress.rs#L473-L910) +contains closed-admission, special-address, cached-decision, local-socket, +static port-53, direct-offload and staging paths. [WAN](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-ebpf/src/egress.rs#L353-L547) +can enforce direct and block without a userspace connection. LAN block may +instead be redirected for userspace enforcement. Outbound name alone cannot +identify the enforcement plane or whether a packet was delivered. + +[NFQUEUE transitions](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/nfqueue/transition.rs#L4-L250) +require direct arm → marked accept verdicts → direct activation; proxy +publication happens before canonical dialing/sending, and the held originals +are dropped as part of that handoff. An NF_DROP is therefore not necessarily +a routing block. Observability must not alter this safety ordering. + +The existing [event consumer](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/ebpf/real/events.rs#L18-L96) +feeds rate-limited logs, not retained per-flow events. The kernel producers +in `honk-ebpf/src/contrack.rs` emit overflow/token-exhaustion diagnostics; +the `Blocked` enum variant alone is not evidence of block-event coverage. +Add producer loss accounting and bounded retention. Do not infer completeness +from a quiet log, map occupancy, or an attached program. + +### DNS, addresses, and groups + +[DNS pipeline](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/dns/engine/pipeline.rs#L165-L269) +handles hosts before request routing, then cache/singleflight/upstream work. +[Cache outcomes](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/dns/engine/pipeline/cache.rs#L12-L99) +do not retain the original upstream history. Request route, response requery, +resolver-server route, family-preference helper, refresh and application +reroute are separate decisions. A domain passed to a remote proxy need not +have a locally known selected destination IP. + +[DNS upstream routing](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/dns/upstream_pool/routing.rs#L20-L46) +can carry configured UDP DNS over TCP through a proxy. Record the actual +carrier and leaf instead of copying the client's UDP label. Health has TCP, +DNS-UDP and data-UDP domains; existing probe histories also mix warm-up, +restored and derived samples. New API labels must be captured at production, +not reconstructed as independent measurements. + +[Score evidence](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-outbound/src/group/score.rs#L82-L179) +is group/target/family/node contextual. It is not a global latency score. +Nested selection, final fallback, demotion, hysteresis and actual cancelled +or failed candidates must be recorded at selection/dial boundaries. A later +`/groups` snapshot cannot explain the historical winner exactly. + +### Reload and identity + +[Kernel publication](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/ebpf/real/routing.rs#L314-L376) +has its own generation; `active_routing_generation()` currently returns a +slot in [the backend](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/ebpf/real/mod.rs#L523-L524). +[Reload](https://github.com/daeuniverse/honk/blob/780c3f158bbe6e69c02327d5e7ce46d1b594cf4e/crates/honk-core/src/control/reload/transaction.rs#L484-L625) +can reuse the unchanged kernel policy while publishing userspace state. +DNS/registry/diagnostic generations and UDP allocator token bits are different +namespaces. None is a universal flow/config revision by itself. + +Keep tuple/token/endpoint-generation safety contracts intact. Add observation +identity before the first potentially failing decision, with per-step producer +generation and immutable dictionaries. Retain terminal evidence before guards +or endpoint retirement remove live entries. Unknown/lost/expired evidence +must remain visible as such, rather than becoming a fabricated clean close. + +Serialize sanitized decision-time `member_name` beside selection-path and +candidate `member_id`, and `leaf_node_name` beside candidate and outbound +`leaf_node_id`. These name fields are required but null when unavailable. +IDs remain authoritative. Retained traces must not acquire new names by +joining the current registry after a reload renames or removes a node/group. + +## Implementation order, without reducing the target + +1. Introduce compact observation ownership at existing TCP guards, UDP leases, + kernel decision and DNS boundaries; no parallel routing engine. +2. Capture rule inputs/results, domain verification, enforcement, DNS lineage, + actual member/leaf/transport attempts and lifecycle milestones where produced. +3. Publish a bounded read-only flow store and SSE replay with explicit loss, + retention, authorization and generation dictionaries. +4. Pass the recorded-flow acceptance scenarios before advertising + `full_transparency`. A useful partial adapter may advertise `base` meanwhile, + but is not completion of the full per-flow objective. diff --git a/source/v0.1.0/en/docs/node-latency.md b/source/v0.1.0/en/docs/node-latency.md index 2489f16..a258821 100644 --- a/source/v0.1.0/en/docs/node-latency.md +++ b/source/v0.1.0/en/docs/node-latency.md @@ -1,60 +1,97 @@ --- -title: Node Latency +title: Nodes --- -# GET /api/nodes/latency +# GET /api/v1/nodes -Returns latency information for all configured nodes. +> Draft endpoint. Returns node identity and the latest typed health samples. +> New measurements are started with `POST /api/v1/probes`; reading this resource +> never starts network traffic. ## Request -```http -GET /api/nodes/latency HTTP/1.1 -Host: localhost:9527 -``` +{% api_request listNodes %} + +## Query parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| group_id | string | - | Return direct members of one group. | +| limit | int | 100 | Maximum nodes to return; capped at 1000. | +| cursor | string | - | Opaque cursor returned by `next_cursor`. | + +The server binds cursors to the running adapter instance, filters, and +retained snapshot. Restart, changed filters, or snapshot expiry/eviction invalidates +them. The server rejects unknown or invalidated cursors with +`400 invalid_request`; discard the cursor and restart the page walk without it. ## Response ### Success (200 OK) -```json -[ - { - "node": "node1", - "group": "proxy", - "latency_ms": 45, - "alive": true, - "last_check": "2026-08-13T12:00:00Z" - }, - { - "node": "node2", - "group": "proxy", - "latency_ms": 120, - "alive": true, - "last_check": "2026-08-13T12:00:00Z" - }, - { - "node": "node3", - "group": "proxy", - "latency_ms": 0, - "alive": false, - "last_check": "2026-08-13T11:59:30Z" - } -] -``` +{% api_example listNodes 200 nodes %} ### Fields | Field | Type | Description | |-------|------|-------------| -| node | string | Node name | -| group | string | Group name | -| latency_ms | int64 | Latency in milliseconds (0 if unknown) | -| alive | bool | Whether node is reachable | -| last_check | string | Last check timestamp (RFC3339) | +| observed_at | string | Snapshot timestamp (RFC3339). | +| nodes | array | Nodes visible to the adapter. | +| nodes[].id | string | Opaque stable node identifier. | +| nodes[].name | string | Engine-visible node name. | +| nodes[].protocol | string or null | Protocol label when safely available. | +| nodes[].subscription_tag | string or null | Current subscription provenance, using the engine's `subtag(...)` name; null for manual nodes or unavailable provenance. Never expose subscription URLs or credentials. | +| nodes[].group_ids | array | Direct group memberships. | +| nodes[].health | array | Latest observations keyed by transport, purpose, measurement, destination IP family, and warmth. | +| next_cursor | string or null | Cursor for the next page. | + +Health `state` is `healthy`, `unavailable`, or `unknown`. Failed or unknown +latency is `null`, never `0`; `error` is a safe machine-readable code. Clients +must not derive node IDs from names. +Missing measurements and optimistically-alive native state are `unknown`, +not evidence of a healthy probe. A restored/derived sample retains its source +label and must not be displayed as a fresh independent measurement. + +A single-latency column MUST pick and label one fixed +`(transport, purpose, measurement, ip_version, warmth)` tuple. Show unknown +when that tuple has no usable sample; observations with other tuples are not +substitutes. Each tuple is unique within `node.health`. + +## Shared health dimensions and metrics + +Node health and group `runtime.health[]` use the same observation fields: + +| Field | Meaning | +|-------|---------| +| transport | `tcp` or `udp`; not the proxy protocol's underlying tunnel transport. | +| purpose | `data`, `dns`, or `shared`. DNS-UDP and data-UDP MUST NOT collapse into one key. `shared` means the engine genuinely shares an observation collection; do not duplicate it as independently measured DNS/data. | +| ip_version | `ipv4` or `ipv6`, for the check destination, not necessarily the proxy server. | +| warmth | `cold`, `warm`, `mixed`, or `unknown`. Existing undifferentiated histories are `mixed`/`unknown`, never guessed cold. | +| latency_ms | Last real completed sample, excluding all synthetic timeout placeholders and ranking offsets. Null when that observation failed or is unavailable. Zero is valid only for an actual measured zero, not a failure sentinel. | +| moving_avg_ms | Recursive halving average of real successful samples: first sample initializes it; each next value is `(previous + sample) / 2`. Null if that exact metric is unavailable. | +| avg10_ms | Arithmetic mean of the last up to ten real successful samples; null before a real sample or when that exact metric is unavailable. | +| observed_at | Time of the latest observation, not the HTTP request time. | +| measurement | `tcp_connect`, `http_headers`, `http_round_trip`, `dns_round_trip`, `quic_handshake`, `mixed`, or `unknown`. Never label warm HTTP RTT or QUIC setup as a cold full connection measurement. | +| sample_source | `probe`, `traffic`, `restored`, `derived`, `mixed`, or `unknown`. Copying one measurement across health domains/families is derived evidence, not independent probes. | +| error | Safe machine-readable failure code or null. | + +Neither average includes failure penalties, synthetic timeout samples, +group `add_latency`, or tolerance. Historical averages may survive a failed +latest probe; `state` and `observed_at` still describe the latest observation. +Engines with different formulas must return null for the canonical average, +not relabel their native statistic. In particular, parsing `min_avg10` does +not prove that an engine ranks by an average of ten. + +Latency history is out of scope for this draft; clients sample +`moving_avg_ms`/`avg10_ms` to maintain their own series. + +`subscription_tag` is current node provenance, not identity. A stable node ID +can remain unchanged across subscription refresh/tag changes. Unknown and +manual provenance both yield null; the API must not infer tags from names. +Group-specific ranking belongs to the group, not a mutated node health copy. ## Example ```bash -curl http://localhost:9527/api/nodes/latency +curl "http://localhost:9527/api/v1/nodes?group_id=group-proxy" ``` diff --git a/source/v0.1.0/en/docs/operations.md b/source/v0.1.0/en/docs/operations.md new file mode 100644 index 0000000..f8c653d --- /dev/null +++ b/source/v0.1.0/en/docs/operations.md @@ -0,0 +1,83 @@ +--- +title: Operations +--- + +# GET /api/v1/operations/{id} + +> Draft endpoint. Reload, suspend, resume, probes, and asynchronous group +> updates use one operation envelope. + +An operation ID is opaque, unguessable, and unique for the lifetime of the +running adapter. Clients must not derive its kind or creation time from the ID. + +## Accepted operation + +Every `202 Accepted` response MUST include `Location` and `Retry-After`. +`Location` agrees with the envelope's `href`. `Retry-After` is a positive +integer number of seconds; clients MUST wait at least that long before the +next status poll. A running GET also includes it; a terminal GET need not. +An event can prompt a new GET, but does not waive the polling floor. Polling +too soon may return `429` with a fresh `Retry-After`. + +{% api_example startReload 202 queued http %} + +## Request + +{% api_request getOperation %} + +## Response + +### Running (200 OK) + +{% api_example getOperation 200 reload_running %} + +### Completed (200 OK) + +{% api_example getOperation 200 reload_complete %} + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| operation_id | string | Opaque operation identifier. | +| kind | string | `probe`, `reload`, `suspend`, `resume`, or `group_update`. | +| status | string | `queued`, `running`, `succeeded`, or `failed`. | +| created_at | string | Creation timestamp (RFC3339). | +| started_at | string or null | Execution start timestamp. | +| finished_at | string or null | Terminal timestamp. | +| result | object or null | Kind-specific result, present only after success. | +| error | object or null | Safe machine-readable error after failure. | + +A successful `group_update` result contains `group_id` and the applied +`config_revision`; fetch the group for its current full representation. +The operation's revision records that mutation's result even if another +update has already advanced the live resource. + +`error` uses the same `code`, `message`, and optional `details` object defined +by the [native error contract](errors.html). Raw engine errors, stack traces, +configuration fragments, credentials, and local paths must not be returned. + +Completed operations remain queryable for at least the +`resources.operations.retention_seconds` value advertised by +`GET /api/v1/capabilities`. Unknown or expired IDs return `404 operation_not_found`. +Cancellation is not part of the current draft. + +Operation status is visible to the principal that created it and to callers +with `control`; unknown, expired, or unauthorized IDs all return +`404 operation_not_found` to avoid leaking existence. + +Operation-start endpoints accept an optional `Idempotency-Key` header. During +the advertised operation retention window, the key is scoped to the caller, +method, and path. Reusing it with the same body returns the original operation; +reusing it with a different body returns `409 idempotency_conflict`. Without a +key, a retried POST may create another operation. + +Operation idempotency is scoped to the running instance; it is not a durable +retry guarantee across process restart. A client with an uncertain result +must re-observe runtime state rather than replay a mutation blindly. + +## Example + +```bash +curl http://localhost:9527/api/v1/operations/op-01HZX4K8W7 +``` diff --git a/source/v0.1.0/en/docs/reload.md b/source/v0.1.0/en/docs/reload.md index 30a6c52..b9d1c16 100644 --- a/source/v0.1.0/en/docs/reload.md +++ b/source/v0.1.0/en/docs/reload.md @@ -2,50 +2,48 @@ title: Reload --- -# POST /api/reload +# POST /api/v1/operations/reload -Triggers a configuration reload. This is equivalent to running `dae reload` from the command line. +> Draft endpoint. Reload is capability-gated and asynchronous. Queueing a +> reload is not proof that a new generation was validated and published. + +Starts a configuration reload operation. ## Request -```http -POST /api/reload HTTP/1.1 -Host: localhost:9527 -Content-Length: 0 -``` +{% api_example startReload request empty http %} ## Response -### Success (200 OK) +### Accepted (202 Accepted) -```json -{ - "ok": true, - "message": "Reload triggered" -} -``` +{% api_example startReload 202 queued http %} -### Error (500 Internal Server Error) +Poll [`GET /api/v1/operations/{id}`](operations.html) for completion. -```json -{ - "ok": false, - "error": "Failed to reload configuration" -} -``` +### Completed result + +{% api_example getOperation 200 reload_complete_reload %} ### Fields | Field | Type | Description | |-------|------|-------------| -| ok | bool | Whether the operation succeeded | -| message | string | Human-readable message (on success) | -| error | string | Error message (on failure) | +| operation_id | string | Reload operation identifier. | +| status | string | `queued`, `running`, `succeeded`, or `failed`. | +| result.active_generation_id | string or null | Generation active after completion. | +| result.datapath_generation_id | string or null | Generation published to the datapath. | +| finished_at | string or null | Completion timestamp (RFC3339). | +| error | object or null | Shared safe error object, when present. | + +An operation may report `succeeded` only after configuration validation, +datapath routing publication, and active-generation promotion all complete. +When reload fails, the previous active generation remains active. ## Example ```bash -curl -X POST http://localhost:9527/api/reload +curl -X POST http://localhost:9527/api/v1/operations/reload \ + -H 'Content-Type: application/json' \ + -d '{}' ``` - -> **Note:** Reload is an asynchronous operation. Use `GET /api/runtime/status` or `GET /api/config` to verify the new configuration is active. diff --git a/source/v0.1.0/en/docs/routing-trace.md b/source/v0.1.0/en/docs/routing-trace.md new file mode 100644 index 0000000..8aa1d36 --- /dev/null +++ b/source/v0.1.0/en/docs/routing-trace.md @@ -0,0 +1,71 @@ +--- +title: Routing Simulation +--- + +# POST /api/v1/routing/trace + +> Proposed diagnostic endpoint. This is a **simulation**, never the history +> of a live flow. Actual decisions belong to [Recorded Flows](flows.html). +> It incorporates [PR #2](https://github.com/daeuniverse/api-standardize/pull/2) +> without treating a dry-run match as full per-flow transparency. + +Use a JSON body rather than GET query parameters: process/domain/source inputs +should not be copied into access-log URLs, and live DNS must be explicit. +Requires `control` because `resolve: live` can create DNS traffic. Neither +mode dials the hypothetical flow, alters routing, updates node health, +changes group selection, or publishes domain bitmaps to the datapath. + +{% api_example traceRouting request hypothetical http %} + +`input.network` (`tcp` or `udp`) and `dst_port` (1–65535) are required. At +least `domain` or `dst_ip` is required. Other input fields are optional and +nullable; missing source IP/port, process name, DSCP, and mark are **unknown**, +not empty strings, zero, or the API caller's own metadata. IPs must be literal +IPv4/IPv6; DSCP is 0–63, mark 0–4294967295, and source port 1–65535. Domain +validation uses the DNS query limits. Unknown fields return `400 invalid_request`. +The supplied domain is explicit input, not a claim that SNI verification passed. + +`resolve` is `none` (default) or `live`. With `none`, do not resolve even from +cache; a supplied destination IP yields exactly one evaluation, while a +name-only input yields one evaluation with `dst_ip: null`. With `live`, require +a domain and no destination IP; use the engine's own DNS routing chain and +produce one evaluation per unique A/AAAA address. Do not synthesize a routing +outbound if DNS fails or returns no addresses: return an empty `evaluations` +array and the actual DNS results. DNS is the only permitted network activity. +Use an isolated, non-publishing query context: it may read existing cache +entries but must not fill/refresh/invalidate runtime caches, health, selection, +or domain-routing maps. An engine unable to isolate this must not advertise +`live` resolution; its regular state-mutating resolver is not a dry-run API. + +{% api_example traceRouting 200 indeterminate %} + +The example's later match is conditional on the earlier unknown rule not +matching. It does **not** authorize returning that later outbound as certain. +`decision` is `determinate` or `indeterminate`; `outbound` is non-null only +when all feasible paths agree on it. A deciding fallback is a rule with its +own generation-scoped ID. `rules` uses the recorded-flow rule/condition +contract, with `skipped` for actual short-circuiting. Unknowns propagate +through AND/OR/negation; a missing input irrelevant to a proven decision need +not make the result indeterminate. Do not convert `indeterminate` to a miss +and continue as though every input were known. + +`dns` contains the same data objects as recorded `dns` steps, with +`purpose: dial_target`; their lookup IDs are simulation-only and never link +this request to a live flow. A `200` response can contain DNS errors or +indeterminate evaluations; these are diagnostic results, not HTTP failures. + +Pin the router, assets, DNS policy, and any consulted selection state for the +request. `generation_id` is the pinned routing generation, not the current +generation at response serialization. If the adapter cannot obtain a +consistent snapshot, return `409 snapshot_unavailable`, rather than combine +old rules with new group IDs. Automatic policy state is still time-dependent: +the result predicts a **rule outbound**, not a future dial, remote IP, leaf +node, reroute, connection success, or actual kernel short-circuit path. + +Capabilities under `routing_trace` advertise `resolve_modes`, `max_addresses`, +`max_rule_steps`, `timeout_ms`, and per-principal/global requests per minute. +Enforce limits before fan-out, stop at the common deadline, and reject a +result exceeding address/rule bounds with `413 trace_limit_exceeded` instead +of truncating into a determinate answer. Timeout returns `503` with +`Retry-After`; rate excess returns `429`. Incompatible input/resolve mode or +unadvertised modes return `422 unsupported_value`. diff --git a/source/v0.1.0/en/docs/runtime-memory.md b/source/v0.1.0/en/docs/runtime-memory.md new file mode 100644 index 0000000..d0604cd --- /dev/null +++ b/source/v0.1.0/en/docs/runtime-memory.md @@ -0,0 +1,68 @@ +--- +title: Runtime Memory +--- + +# GET /api/v1/runtime/memory + +> Draft endpoint. This is a lightweight, read-only memory snapshot intended for +> frequent dashboard polling. It does not enumerate connections or eBPF map +> entries. + +Process, cgroup, and kernel memory are different scopes. Their values must not +be added together. An unsupported or unobservable metric is `null`, and an +unadvertised metric may be omitted or `null`. Decimal string `"0"` is a real measurement, never absence. + +## Request + +{% api_request getRuntimeMemory %} + +Successful responses include `Cache-Control: no-store`. + +## Response + +### Success (200 OK) + +{% api_example getRuntimeMemory 200 snapshot %} + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| observed_at | string | Snapshot timestamp (RFC3339). | +| process | object or null | Memory attributed to the engine process. | +| process.rss_bytes | decimal uint64 string or null, optional | Resident set size reported by the operating system. | +| cgroup | object or null | Effective cgroup memory accounting when available. | +| cgroup.scope | string | `service`, `shared`, or `unknown`. | +| cgroup.current_bytes | decimal uint64 string or null, optional | Current cgroup memory usage. | +| cgroup.limit_bytes | decimal uint64 string or null, optional | Effective hard limit; `null` when unlimited or unknown. | +| cgroup.events | object or null, optional | Counters from the effective cgroup memory controller; the container may be omitted when no event metrics are advertised. | +| cgroup.events.high | decimal uint64 string or null, optional | Number of times the high boundary was reached. | +| cgroup.events.oom | decimal uint64 string or null, optional | Number of observed allocation failures caused by cgroup OOM. | +| cgroup.events.oom_kill | decimal uint64 string or null, optional | Number of processes killed by the cgroup OOM killer. | +| kernel | object or null | Kernel memory attributable to the engine when observable. | +| kernel.ebpf_bytes | decimal uint64 string or null, optional | Memory attributable to eBPF maps and programs. | +| kernel.sampled_at | string or null | Timestamp of the cached kernel-memory sample. | + +`process.rss_bytes`, `cgroup.current_bytes`, and `kernel.ebpf_bytes` have +different accounting scopes and may overlap. Clients must display them +separately. + +An implementation must not walk every eBPF map entry in the request path. +Kernel memory may be sampled asynchronously and reused across requests; +`kernel.sampled_at` lets clients show that it is older than the process and +cgroup sample. + +Go heap statistics, Rust allocator statistics, and the Clash-compatible +`memory` field are implementation-specific and are not canonical native fields. +Feature availability is advertised by `GET /api/v1/capabilities`. + +Clients should not poll this resource more than once per second. Servers may +return `429` with `Retry-After` when the advertised rate limit is exceeded. +The `runtime_memory.metrics` capability lists every supported metric path; +unadvertised metrics may be omitted or `null`. Consumers must not substitute `"0"` for either case. + +## Example + +```bash +curl http://localhost:9527/api/v1/runtime/memory +``` diff --git a/source/v0.1.0/en/docs/runtime-status.md b/source/v0.1.0/en/docs/runtime-status.md index 5b691ef..6a90333 100644 --- a/source/v0.1.0/en/docs/runtime-status.md +++ b/source/v0.1.0/en/docs/runtime-status.md @@ -1,93 +1,199 @@ --- -title: Runtime Status +title: Runtime --- -# GET /api/runtime/status +# GET /api/v1/runtime -Returns the current runtime status of the dae instance, including memory usage, connection statistics, and real-time network speed. +> Draft endpoint. This read-only snapshot describes the running process, active +> configuration generation, eBPF datapath summary, and traffic visible to the +> engine. Detailed eBPF state is available from [`GET /api/v1/datapath`](datapath.html), +> and the independently pollable memory snapshot is available from +> [`GET /api/v1/runtime/memory`](runtime-memory.html). + +Runtime values are observations, not a promise that the engine can see every +packet on the host. Unsupported or unobservable values are `null`; bounded +counts use numeric `0`, while uint64 quantities use decimal string `"0"`. ## Request -```http -GET /api/runtime/status HTTP/1.1 -Host: localhost:9527 -``` +{% api_request getRuntime %} + +`detail=summary` is the default and omits `process.pid`. `detail=full` includes +it when the adapter can observe it. ## Response ### Success (200 OK) -```json -{ - "time": "2026-08-14T00:00:00Z", - "uptime_seconds": 86400, - "goroutines": 42, - "memory": { - "alloc_bytes": 33554432, - "total_alloc_bytes": 134217728, - "sys_bytes": 67108864, - "heap_alloc_bytes": 33554432, - "heap_sys_bytes": 41943040, - "heap_inuse_bytes": 33554432, - "stack_inuse_bytes": 1048576, - "num_gc": 321, - "last_gc_time": "2026-08-14T00:00:00Z" - }, - "connections": { - "active": { - "tcp": 42, - "udp": 128, - "total": 170 - }, - "total": { - "tcp": 1337, - "udp": 4096, - "grand_total": 5433 - } - }, - "rates": { - "upload_rate": 102400, - "download_rate": 512000 - }, - "traffic": { - "upload_bytes": 123456789, - "download_bytes": 987654321 - } -} -``` +{% api_example getRuntime 200 snapshot %} ### Fields | Field | Type | Description | |-------|------|-------------| -| time | string | Snapshot timestamp (RFC3339) | -| uptime_seconds | uint64 | Seconds since dae started | -| goroutines | int | Number of running goroutines | -| memory | object | Memory usage statistics | -| memory.alloc_bytes | uint64 | Bytes of allocated heap objects | -| memory.total_alloc_bytes | uint64 | Cumulative bytes allocated since start | -| memory.sys_bytes | uint64 | Total bytes obtained from the OS | -| memory.heap_alloc_bytes | uint64 | Bytes of currently live heap objects | -| memory.heap_sys_bytes | uint64 | Bytes of heap memory obtained from the OS | -| memory.heap_inuse_bytes | uint64 | Bytes of in-use heap spans | -| memory.stack_inuse_bytes | uint64 | Bytes of in-use stack memory | -| memory.num_gc | uint64 | Number of completed garbage collection cycles | -| memory.last_gc_time | string | Time of the last GC cycle (RFC3339) | -| connections.active.tcp | uint32 | Currently active TCP connections | -| connections.active.udp | uint32 | Currently active UDP sessions | -| connections.active.total | uint32 | Total active connections (TCP + UDP) | -| connections.total.tcp | uint64 | Total TCP connections since dae started | -| connections.total.udp | uint64 | Total UDP sessions since dae started | -| connections.total.grand_total | uint64 | Total connections (TCP + UDP) since dae started | -| rates.upload_rate | uint64 | Current upload speed (bytes/sec) | -| rates.download_rate | uint64 | Current download speed (bytes/sec) | -| traffic.upload_bytes | uint64 | Total bytes uploaded | -| traffic.download_bytes | uint64 | Total bytes downloaded | - -> **Note:** Per-connection real-time network speeds are available from [`GET /api/connections`](connections.md). +| observed_at | string | Snapshot timestamp (RFC3339). | +| instance_id | string | Unique adapter process incarnation; changes on restart. | +| lifecycle.state | string | `starting`, `running`, `reloading`, `suspended`, `draining`, `degraded`, or `failed`. | +| lifecycle.started_at | string or null | Process start time, when known. | +| lifecycle.uptime_seconds | decimal uint64 string or null | Process uptime. | +| generation.active_id | string | Opaque active runtime generation. | +| generation.config_revision | opaque string or null | Configuration revision used by the active generation; preserve it without numeric parsing. | +| generation.state | string | `active` or `reloading`. A pending generation is not active. | +| datapath.kind | string | `ebpf`, `userspace`, `mock`, or `unknown`. | +| datapath.state | string | `active`, `degraded`, `detached`, `failed`, `disabled`, or `unknown`. | +| datapath.visibility | string | `full`, `partial`, or `none` for traffic visible to the datapath. | +| datapath.ebpf | object or null | eBPF state summary when the datapath uses eBPF. | +| traffic.scope | string | Scope of the counters, normally `visible`. | +| traffic.observed_by | string | `userspace`, `ebpf`, or `mixed`. | +| traffic.counter_since | string or null | Start time of the reported cumulative counters. | +| traffic.sampled_at | string or null | Traffic sample timestamp (RFC3339), or null when unavailable. Never substitute the HTTP snapshot timestamp. | +| traffic.connections | object | Currently visible TCP, UDP, and total connection counts. Each count is a bounded JSON integer or `null` when unobservable. | +| traffic.bytes | object | Cumulative visible bytes. Each value is a decimal uint64 string or `null` when unobservable. | +| traffic.rates | object or null | Current rates. `null` when unavailable; `window_seconds` stays numeric and byte rates are decimal uint64 strings or `null`. | +| process.pid | uint32 or null, optional | Engine process ID with `detail=full`. | +| process.cpu_percent | number or null | Process CPU usage when available. | +| last_reload | object or null | Most recent reload operation and its result. | + +`traffic.rates.window_seconds` is the duration of the sampling interval +ending at `traffic.sampled_at`. A cached sample retains its original +timestamp; `counter_since` instead marks the cumulative counter reset +boundary. A null sample timestamp does not establish freshness. + +The `datapath.ebpf` summary uses these states: + +| Field | Values | Meaning | +|-------|--------|---------| +| backend | `real`, `mock`, `unknown` | Backend used by the engine. | +| programs | `loaded`, `not_loaded`, `error`, `unknown` | Whether eBPF programs are loaded. | +| hooks | `attached`, `partially_attached`, `detached`, `unknown` | Whether required hooks are mounted. | +| routing.state | `published`, `not_published`, `error`, `unknown` | Whether routing is visible to eBPF. | +| routing.generation_id | string or null | Generation currently published to eBPF. | +| health | `healthy`, `degraded`, `failed`, `unknown` | Combined operational result. | + +For `datapath.kind: ebpf`, `datapath.state` may be `active` only when the +required programs, hooks, and active routing publication are all valid. +A loaded program alone is not an active datapath. Userspace and mock state +rules are defined in [Datapath](datapath.html). + +> **Note:** Per-connection details and byte counters are available from +> [`GET /api/v1/connections`](connections.html). They carry the same visibility limits. + +Per-outbound cumulative counters and bounded traffic history are separate +resources below. Summing live connection bytes by `outbound` omits closed, +truncated, and unobserved connections; it is not a usage total. + +Memory metrics are intentionally excluded from this snapshot so a dashboard +can poll [`GET /api/v1/runtime/memory`](runtime-memory.html) without repeatedly fetching +generation, datapath, traffic, and reload state. + +During reload, the old active generation remains reported until the new +generation has passed configuration validation and datapath publication. A +failed reload therefore leaves `generation.active_id` unchanged and is exposed +through `last_reload`. + +Generation identifiers are adapter-owned, instance-scoped opaque references +with distinct namespaces for runtime commits and kernel policy publications. +DNS cache epochs, outbound-registry generations, diagnostic generations and +eBPF double-buffer slot numbers are not interchangeable configuration +revisions. A reload that reuses an unchanged kernel policy can promote a new +runtime generation while retaining the old datapath generation ID. The +adapter must retain that relationship, not forge equal strings. + +Runtime fields are a coherent control-plane snapshot; independently sampled +kernel/traffic counters retain their own timestamps. Reads of two separate +HTTP resources are not an atomic transaction. Flow steps capture their +actual producer generation and may legitimately span multiple generations. ## Example ```bash -curl http://localhost:9527/api/runtime/status +curl "http://localhost:9527/api/v1/runtime?detail=full" ``` + +## GET /api/v1/runtime/outbounds + +Requires `observe` and `resources.runtime_outbounds.available`. This snapshot +mirrors honk's Clash-surface [`/stats` outbound counters](honk-mapping.html#outbound-counters), +not a sum of the current `/connections` page. + +{% api_request getRuntimeOutbounds %} + +{% api_example getRuntimeOutbounds 200 snapshot %} + +| Field | Type | Description | +|-------|------|-------------| +| observed_at | string | Counter snapshot timestamp (RFC3339). | +| counter_since | string | Shared start/reset boundary for all cumulative counters (RFC3339). | +| outbounds | array | Counters attributed to engine-visible outbounds. | +| outbounds[].name | string | Outbound name retained with the counters, not a stable node/group ID. | +| outbounds[].kind | string | `group`: configured group; `node`: leaf node; `builtin`: engine builtin such as direct/block. | +| outbounds[].active_connections | safe unsigned integer | Currently active connections attributed to this outbound. | +| outbounds[].total_connections | decimal uint64 string | Cumulative connections attributed to this outbound since `counter_since`. | +| outbounds[].upload_bytes | decimal uint64 string | Cumulative visible uploaded bytes since `counter_since`. | +| outbounds[].download_bytes | decimal uint64 string | Cumulative visible downloaded bytes since `counter_since`. | +| outbounds[].errors | decimal uint64 string | Cumulative outbound failures since `counter_since`; policy blocks are not errors. | + +Restart or counter reset changes `counter_since`; clients must not compute +deltas across that boundary. A reload changes it only if the counters reset. +Closed connections remain in cumulative totals. Newly observed outbounds +start at zero within the same interval; retain old names and kinds with +their counters rather than relabelling them from today's registry. +Rows reflect the producer's attribution, not every group and node on a +selection path; do not duplicate counters across `chain` entries. +These counters cover visible traffic only, not all kernel-direct or blocked +traffic. Zero denotes a measured zero, never unsupported accounting. + +## GET /api/v1/runtime/traffic/history + +Requires `observe` and `resources.traffic_history.available`. The producer +samples visible runtime rates and active connection counts into a bounded +in-memory ring independently of HTTP reads. + +### Request + +{% api_request getTrafficHistory %} + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| window_seconds | positive safe integer | Advertised `max_window_seconds` | Look-back window ending at `observed_at`. | +| max_points | positive safe integer | Advertised `max_points` | Maximum returned sample count. | + +Both limits are under `resources.traffic_history`. Invalid, zero, negative, +or non-integer values and requests above either advertised limit return +`400 invalid_request`; the server must not silently clamp them. + +{% api_example getTrafficHistory 400 window_too_large %} + +{% api_example getTrafficHistory 400 too_many_points %} + +### Response + +{% api_example getTrafficHistory 200 recent %} + +| Field | Type | Description | +|-------|------|-------------| +| observed_at | string | History snapshot timestamp (RFC3339), not a replacement for sample timestamps. | +| window_seconds | positive safe integer | Requested look-back window, even when retention is shorter. | +| sampled_every_seconds | positive number | Nominal interval between returned samples after thinning; recorder interval for an empty result. | +| samples | array | At most `max_points` samples, oldest first, in `(observed_at - window_seconds, observed_at]`. | +| samples[].sampled_at | string | Original sample timestamp (RFC3339). | +| samples[].upload_bytes_per_second | decimal uint64 string or null | Sampled visible upload rate, or null when unavailable. | +| samples[].download_bytes_per_second | decimal uint64 string or null | Sampled visible download rate, or null when unavailable. | +| samples[].connections | safe unsigned integer or null | Visible active TCP and UDP connections at the sample, or null when unavailable. | + +When the window holds too many points, select every Nth stored sample +backwards from the newest to fit `max_points`, then return them oldest +first. Choose the smallest positive N that fits the limit. Preserve original +timestamps and rates; `sampled_every_seconds` describes the resulting +cadence, not a new rate averaging interval. +Missed intervals remain timestamp gaps; unavailable measurements are null, +not zero. A cumulative counter reset makes the spanning rate sample null, +not a negative rate or a fabricated spike. + +The ring is bounded by age and capacity and is cleared on process restart. +It may return fewer samples than requested, or an empty array before +sampling; a requested window is not a retention guarantee. +This is the only sampled-metric history the native API serves; retained +flow traces and operation results remain separate records. SSE does not +replay traffic history, even with `Last-Event-ID`; fetch this resource on +first open or reconnect rather than treating invalidations as samples. diff --git a/source/v0.1.0/en/docs/suspend.md b/source/v0.1.0/en/docs/suspend.md index aec9eff..0ddacd5 100644 --- a/source/v0.1.0/en/docs/suspend.md +++ b/source/v0.1.0/en/docs/suspend.md @@ -2,50 +2,55 @@ title: Suspend --- -# POST /api/suspend +# POST /api/v1/operations/suspend -Suspends the proxy service. This is equivalent to running `dae suspend` from the command line. +> Draft endpoint. Suspension is capability-gated and asynchronous. It is not +> a universal dae/honk operation. + +Starts suspension for an adapter that implements a no-load lifecycle. ## Request -```http -POST /api/suspend HTTP/1.1 -Host: localhost:9527 -Content-Length: 0 -``` +{% api_example startSuspend request empty http %} ## Response -### Success (200 OK) +### Accepted (202 Accepted) -```json -{ - "ok": true, - "message": "Service suspended" -} -``` +{% api_example startSuspend 202 queued http %} -### Error (500 Internal Server Error) +Poll [`GET /api/v1/operations/{id}`](operations.html) for completion. -```json -{ - "ok": false, - "error": "Failed to suspend service" -} -``` +### Completed result + +{% api_example getOperation 200 suspend_complete %} ### Fields | Field | Type | Description | |-------|------|-------------| -| ok | bool | Whether the operation succeeded | -| message | string | Human-readable message (on success) | -| error | string | Error message (on failure) | +| operation_id | string | Suspension operation identifier. | +| status | string | `queued`, `running`, `succeeded`, or `failed`. | +| result.runtime_state | string or null | `suspended` after a successful operation. | +| finished_at | string or null | Completion timestamp (RFC3339). | +| error | object or null | Shared safe error object, when present. | + +If the adapter advertises `resources.resume.available`, resume uses: + +{% api_example startResume request empty http %} + +Resume returns the same operation envelope with `kind: resume`. On success, +`result.runtime_state` is `running`, or null when the adapter cannot observe +the resulting state, consistent with suspension's nullable state field. +Acceptance alone must never be reported as successful resumption. + +An unavailable suspend or resume operation returns `404 capability_not_supported`. +A lifecycle state that prevents the transition returns `409 state_conflict`. ## Example ```bash -curl -X POST http://localhost:9527/api/suspend +curl -X POST http://localhost:9527/api/v1/operations/suspend \ + -H 'Content-Type: application/json' \ + -d '{}' ``` - -> **Note:** To resume the service, use the `dae resume` command from the command line or restart the dae service. diff --git a/source/v0.1.0/en/docs/version.md b/source/v0.1.0/en/docs/version.md index 92669a9..060a0f4 100644 --- a/source/v0.1.0/en/docs/version.md +++ b/source/v0.1.0/en/docs/version.md @@ -2,39 +2,50 @@ title: Version --- -# GET /api/version +# GET /api/v1/version -Returns version information about the dae instance. +> Draft endpoint. This is the canonical native API version resource. Use +> `GET /api` only for general API discovery, `GET /api/v1/capabilities` for +> feature negotiation, and `GET /api/v1/runtime` for live process state. + +Returns the native API identity and the version of the running engine. The +response is independent of the engine implementation language. ## Request -```http -GET /api/version HTTP/1.1 -Host: localhost:9527 -``` +{% api_request getVersion %} ## Response ### Success (200 OK) -```json -{ - "version": "0.1.0", - "go_version": "go1.26", - "build_time": "2026-08-13T00:00:00Z" -} -``` +{% api_example getVersion 200 build %} ### Fields | Field | Type | Description | |-------|------|-------------| -| version | string | dae version | -| go_version | string | Go version used to build | -| build_time | string | Build timestamp (RFC3339) | +| api.name | string | Stable name of the native API surface. | +| api.status | string | Current API design status. The draft value is `draft`. | +| api.major | integer | Wire major selected by `/api/v1`, independent of engine release. | +| engine.name | string | Running engine name, such as `dae` or `honk`. | +| engine.version | string | Engine release or build version. It may be `unknown` when the build does not provide one. | +| build | object or null | Optional generic build metadata. | +| build.revision | string or null | Source revision when embedded at build time. | +| build.target | string or null | Build target triple or platform identifier. | +| build.built_at | string or null | Build timestamp (RFC3339), when reproducibility policy allows it. | + +Build metadata is optional and must not be required by clients. If an engine +exposes it, the generic fields are `build.revision`, `build.target`, and +`build.built_at`; `build.built_at` uses RFC3339. Implementation-specific fields +such as `go_version` are not part of the native contract. + +The native response must not reuse the Clash-compatible `/version` response. +For example, honk keeps its existing `version` string with the `honk ` prefix +and its dashboard compatibility flags on that separate endpoint. ## Example ```bash -curl http://localhost:9527/api/version +curl http://localhost:9527/api/v1/version ``` diff --git a/source/v0.1.0/en/index.md b/source/v0.1.0/en/index.md index c3f6c09..72bff25 100644 --- a/source/v0.1.0/en/index.md +++ b/source/v0.1.0/en/index.md @@ -1,72 +1,109 @@ --- -title: dae API Documentation +title: dae/honk Native API Documentation --- -# dae API Documentation +# dae/honk Native API Documentation -Welcome to the dae API documentation. This provides a RESTful HTTP JSON API for monitoring and controlling the dae transparent proxy. +Welcome to the dae/honk API documentation. This site defines a proposed native +HTTP JSON control plane for Linux transparent-proxy engines and documents the +existing Clash-compatible surface separately. ## Overview -dae is a high-performance transparent proxy based on Linux eBPF. The API allows you to: +dae and honk are Linux eBPF transparent-proxy engines. The native API allows a +client to: - Monitor real-time traffic statistics -- Report runtime status including memory usage and connection totals -- Query node latency and health status -- View active connections with per-connection network speeds -- Debug DNS resolution -- Reload configuration -- Suspend/resume the proxy - -All requests and responses use **JSON** (`Content-Type: application/json`). No other formats are supported. +- Discover engine capabilities and datapath visibility +- Read sanitized runtime, routing, node, group, and DNS state +- Start typed probes without conflating TCP reachability with proxy latency +- Track asynchronous reload/suspend operations +- Explain each recorded flow from rule inputs through dialing/DNS/rerouting, + actual outbound attempts, and connection outcome +- Simulate hypothetical routing without confusing predictions with history +- Follow bounded, resumable server-sent events + +Resource bodies use **JSON**; group patches use JSON Patch and the event +feed uses `text/event-stream` with JSON data. Native responses send +`Cache-Control: no-store` and `X-Content-Type-Options: nosniff`; bearer +credentials are never accepted in a URL query parameter. + +Unsigned 64-bit quantities are canonical decimal JSON strings from `"0"` through +`"18446744073709551615"`: no leading zeros (except `"0"`), sign, decimal point, +or exponent. Clients must preserve them as strings or parse them as arbitrary- +precision integers (`BigInt`), never `Number`; bounded counts and limits, flow +revisions, and step `seq` values remain JSON numbers. Configuration and selection +revision identifiers remain opaque strings and must not be parsed. ## Quick Start ### Configuration -The API is an independent module. Add the `api { }` block to your dae configuration file: +The draft native listener uses `/api/v1`, with unversioned `/api` discovery. honk currently +configures its Clash-compatible listener with `experimental.clash_api`; the +referenced dae/kdae branch currently has no general REST listener and exposes +reload/suspend through CLI and signals. See [API Configuration](docs/api-config.html) +for the proposed shared listener contract. -``` +```dae api { - port: 9527 - token: 'q/RWNF0nPm2v3eD5LxD5VA==' # Generate with `openssl rand -base64 16` or `openssl rand -base64 32` + listen: '127.0.0.1:9527' + secret: 'replace-with-a-random-secret' + allow_origins: ['http://127.0.0.1:3000'] } ``` -By default the API listens on the loopback interface only. To listen on other interfaces, configure `interfaces` and a valid token. See [API Configuration](docs/api-config.md) for the full `api { }` reference, token rules and security notes. +The native listener is loopback-only by default. See [API Configuration](docs/api-config.html) +for the shared listener, authentication, and CORS contract. ### Base URL ``` -http://localhost:9527 +http://localhost:9527/api/v1 # native API draft, wire major 1 +http://localhost:9090 # honk Clash compatibility API ``` ### Authentication -If a `token` is configured in the `api` module, include it in requests: +If a bearer secret is configured, include it in requests: ``` Authorization: Bearer ``` -> **Note for frontend developers:** If you store the token in a cookie, make sure the token is not leaked. Serve the API over HTTPS and set the cookie with `Secure`, `HttpOnly` and `SameSite=Strict`, and never expose the token in URLs, logs, or client-side scripts. - ## API Version -Current version: **v0.1.0** +Native API status: **draft**. `/api/v1` identifies the wire major, not a claim +that honk 1.0 or this API is released. The `v0.1.0` site directory is the +document revision. Breaking wire changes require a new major path; additive +features are negotiated through capabilities, never inferred from engine +versions. Unversioned resource routes are not aliases. + +The [OpenAPI contract](/openapi.yaml) is the generated public bundle. Its +authoring sources live under `api/`, grouped by resource, with native named +examples owned by their operations. Edit those sources and run `npm run build`; +do not edit the bundle or copy example payloads into this prose. + +`npm run check:contract` bundles and lints the specification, validates its +structured examples, headers and flow invariants, and runs independent +regressions. Markdown references stable example names through project-owned +Hexo tags; it is rendered from the contract, not parsed back into one. +The human explanations of permissions, visibility and lifecycle remain +hand-authored. Generated clients and an embedded UI remain outside this spec. + +Builds clear Hexo's rendered-page cache so changed contract examples cannot +leave stale documentation behind. Restart `npm run server` after changing +`api/` sources; ordinary prose edits still use Hexo's normal development loop. + +See [honk implementation evidence](docs/honk-mapping.html) for the current +instrumentation gaps and the disposition of PR #1's comments. Native JSON +and the existing Clash API stay side by side. A separate panel or LuCI +client can use the native API; whether its assets ship embedded or as an +external UI does not change this contract. ## Endpoints -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/version` | Version information | -| GET | `/api/runtime/status` | Runtime status: memory, connection totals, network speed | -| GET | `/api/dns/query` | Debug DNS domain queries | -| GET | `/api/dns/cache` | DNS cache | -| GET | `/api/connections` | Active connections with per-connection speeds | -| GET | `/api/nodes/latency` | Node latency | -| POST | `/api/nodes/check` | Trigger latency checks | -| GET | `/api/groups` | Node groups | -| GET | `/api/config` | Configuration | -| POST | `/api/reload` | Reload configuration | -| POST | `/api/suspend` | Suspend service | +{% api_endpoints %} + +The separate honk Clash-compatible surface remains at `/version`, `/configs`, +`/proxies`, and its other compatibility routes; it is not part of this native table. diff --git a/tools/check-contract.mjs b/tools/check-contract.mjs new file mode 100644 index 0000000..8b916b9 --- /dev/null +++ b/tools/check-contract.mjs @@ -0,0 +1,86 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse } from "yaml"; +import { createContract } from "./contract.mjs"; +import { validateFlowTrace } from "./validate-flow.mjs"; + +const OPENAPI_FILE = new URL("../source/openapi.yaml", import.meta.url); + +function validateConfigExample(example) { + const errors = []; + const body = example.body; + const diagnostics = body.diagnostics ?? body.error?.details?.diagnostics ?? []; + if (example.operationId === "getConfig" || example.kind === "request") { + const ids = new Set(); + for (const [index, source] of body.sources.entries()) { + const id = source.id ?? `source-${index + 1}`; + if (ids.has(id)) errors.push(`duplicate source ID ${id}`); + ids.add(id); + } + for (const diagnostic of body.diagnostics ?? []) { + if (!ids.has(diagnostic.source_id)) errors.push(`unknown diagnostic source ${diagnostic.source_id}`); + } + } + for (const diagnostic of diagnostics) { + const span = diagnostic.span; + if (span === null) continue; + if (span.end_line < span.start_line || + (span.end_line === span.start_line && span.end_column < span.start_column)) { + errors.push("diagnostic span ends before its start"); + } + if ((diagnostic.line !== null && diagnostic.line !== span.start_line) || + (diagnostic.column !== null && diagnostic.column !== span.start_column)) { + errors.push("diagnostic location differs from its span start"); + } + } + return errors; +} + +export function checkContract(spec) { + const context = createContract(spec); + const errors = [...context.errors]; + let flowCount = 0; + for (const example of context.examples.values()) { + if (context.errors.length === 0 && + ((example.operationId === "getConfig" && example.status === 200) || + (example.operationId === "validateConfig" && (example.kind === "request" || example.status === 200)) || + (example.operationId === "replaceConfigSource" && example.status === 422))) { + for (const error of validateConfigExample(example)) errors.push(`${example.id}: ${error}`); + } + if (example.kind !== "response" || example.operationId !== "getFlow" || example.status !== 200) continue; + flowCount += 1; + for (const error of validateFlowTrace(example.body)) errors.push(`${example.id}: ${error}`); + } + return { context, examples: context.examples, errors, flowCount }; +} + +export async function main() { + let spec; + try { + spec = parse(await readFile(OPENAPI_FILE, "utf8")); + } catch (error) { + console.error(`Contract check failed: source/openapi.yaml: ${error.message}`); + process.exitCode = 1; + return; + } + + const result = checkContract(spec); + if (result.errors.length > 0) { + console.error(`Contract check failed with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}:`); + for (const error of result.errors) console.error(`- ${error}`); + process.exitCode = 1; + return; + } + + console.log( + `Contract OK: ${result.examples.size} examples, ${result.flowCount} flow traces, ${Object.keys(spec.components?.schemas ?? {}).length} schemas, ${Object.keys(spec.paths ?? {}).length} paths.`, + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/tools/check-contract.test.mjs b/tools/check-contract.test.mjs new file mode 100644 index 0000000..8ff17c8 --- /dev/null +++ b/tools/check-contract.test.mjs @@ -0,0 +1,950 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { parse } from "yaml"; +import { createContract, renderExample, validateExample } from "./contract.mjs"; +import { validateFlowTrace } from "./validate-flow.mjs"; +import { checkContract } from "./check-contract.mjs"; + +const spec = parse(await readFile(new URL("../source/openapi.yaml", import.meta.url), "utf8")); +const contract = createContract(spec); + +function example(key) { + const value = contract.examples.get(key); + assert.ok(value, `missing canonical example ${key}`); + return structuredClone(value); +} + +function assertValid(errors, label = "expected valid data") { + assert.deepEqual(errors, [], label); +} + +function assertInvalid(errors, label = "invalid data passed") { + assert.ok(errors.length > 0, label); +} + +function step(flow, stage, predicate = () => true) { + const value = flow.trace.steps.find((candidate) => candidate.stage === stage && predicate(candidate.data)); + assert.ok(value, `missing ${stage} step`); + return value; +} + +function setPath(value, dottedPath, replacement) { + const parts = dottedPath.split("."); + const owner = parts.slice(0, -1).reduce((current, part) => current[part], value); + owner[parts.at(-1)] = replacement; +} + +test("the bundled contract and every named example are valid", () => { + assertValid(contract.errors, "invalid bundled contract"); + for (const [key, value] of contract.examples) { + assertValid(validateExample(contract, value), key); + } +}); + +test("refined nullable objects stay open for client generators", () => { + assert.deepEqual([ + spec.components.schemas.OperationCommon.properties.result.additionalProperties, + spec.components.schemas.RouteStepData.properties.input.additionalProperties, + ], [true, true]); +}); + +test("connection examples preserve totals and linked flow identity", () => { + const connections = example("listConnections:200:visible").body; + if (!connections.truncated) { + assert.equal(connections.total_tcp, connections.tcp.length); + assert.equal(connections.total_udp, connections.udp.length); + } + const connection = connections.tcp.find((entry) => entry.flow_id !== null); + assert.ok(connection); + const listed = example("listFlows:200:visible").body; + const summary = listed.flows.find((flow) => flow.id === connection.flow_id); + assert.ok(summary); + const detail = example("getFlow:200:partial_handoff").body; + assert.equal(listed.instance_id, connections.instance_id); + for (const flow of [summary, detail]) { + assert.equal(flow.id, connection.flow_id); + assert.equal(flow.instance_id, connections.instance_id); + assert.equal(flow.connection_id, connection.id); + assert.equal(flow.started_at, connection.started_at); + } + assert.equal(detail.input.src, connection.src); + assert.equal(detail.input.dst, connection.dst); + assert.equal(detail.input.domain, connection.domain); + const unrelated = example("getFlow:200:interleaved_dns").body; + assert.notEqual(unrelated.id, detail.id); + assert.notEqual(unrelated.connection_id, connection.id); +}); + +test("connection and flow-summary examples carry required list-view evidence", () => { + const fields = ["chain", "chain_source", "rule_id", "rule_expression", "rule_source", "ingress", "domain_source"]; + const sources = { + chain_source: ["evaluation", "reconstructed", "unknown"], + rule_source: ["kernel", "recomputed", "unknown"], + }; + const selectors = { + listConnections: (body) => [...body.tcp, ...body.udp], + listFlows: (body) => body.flows, + getFlow: (body) => [body], + }; + const seen = new Set(); + for (const response of contract.examples.values()) { + const select = selectors[response.operationId]; + if (response.kind !== "response" || response.status !== 200 || !select) continue; + const schema = { $ref: `#/components/schemas/${response.operationId === "listConnections" ? "Connection" : "FlowSummary"}` }; + for (const row of select(response.body)) { + seen.add(response.operationId); + for (const field of fields) { + assert.ok(Object.hasOwn(row, field), `${response.id} ${row.id} lacks ${field}`); + const missing = structuredClone(row); + delete missing[field]; + assertInvalid(contract.validate(schema, missing), `${field} must be required`); + } + assert.ok(Array.isArray(row.chain) && row.chain.every((id) => typeof id === "string")); + for (const [field, values] of Object.entries(sources)) { + assert.ok(values.includes(row[field]), `${response.id} has invalid ${field}`); + assertInvalid(contract.validate(schema, { ...row, [field]: "invalid" })); + } + assert.ok(["lan", "wan", null].includes(row.ingress)); + assert.ok(row.domain_source === null || spec.components.schemas.DomainSource.enum.includes(row.domain_source)); + } + } + assert.deepEqual([...seen].sort(), Object.keys(selectors).sort()); +}); + +test("linked list evidence agrees with the application flow rather than DNS attempts", () => { + const connection = example("listConnections:200:visible").body.tcp[0]; + const summary = example("listFlows:200:visible").body.flows[0]; + const detail = example("getFlow:200:partial_handoff").body; + for (const field of ["chain", "chain_source", "rule_id", "rule_expression", "rule_source", "ingress", "domain_source"]) { + assert.deepEqual(connection[field], summary[field], field); + assert.deepEqual(summary[field], detail[field], field); + } + for (const key of ["getFlow:200:partial_handoff", "getFlow:200:interleaved_dns"]) { + const flow = example(key).body; + const outbound = step(flow, "outbound", (data) => data.target === flow.input.domain + ":443").data; + assert.deepEqual(flow.chain, [...outbound.selection_path.map((item) => item.group_id), outbound.leaf_node_id]); + assert.equal(flow.ingress, flow.input.ingress); + assert.equal(flow.domain_source, flow.input.domain_source); + const route = step(flow, "route", (data) => data.evaluation_id === outbound.evaluation_id).data; + assert.equal(flow.rule_id, route.rule_id); + } +}); + +test("outbound counters retain uint64 totals and a numeric active count", () => { + const response = example("getRuntimeOutbounds:200:snapshot"); + const row = response.body.outbounds[0]; + for (const field of ["total_connections", "upload_bytes", "download_bytes", "errors"]) { + row[field] = "18446744073709551615"; + assertValid(validateExample(contract, response)); + row[field] = 0; + assertInvalid(validateExample(contract, response), `${field} accepted a JSON number`); + row[field] = "0"; + } + row.active_connections = 9007199254740991; + assertValid(validateExample(contract, response)); + row.active_connections = "0"; + assertInvalid(validateExample(contract, response)); + row.active_connections = 0; + row.kind = "invalid"; + assertInvalid(validateExample(contract, response)); + row.kind = "builtin"; + delete response.body.counter_since; + assertInvalid(validateExample(contract, response)); +}); + +test("traffic history preserves gaps, timestamps, and safe numeric boundaries", () => { + const response = example("getTrafficHistory:200:recent"); + const sample = response.body.samples[0]; + sample.upload_bytes_per_second = null; + sample.download_bytes_per_second = null; + sample.connections = null; + assertValid(validateExample(contract, response)); + sample.upload_bytes_per_second = "18446744073709551615"; + sample.download_bytes_per_second = "18446744073709551615"; + sample.connections = 9007199254740991; + assertValid(validateExample(contract, response)); + for (const field of ["upload_bytes_per_second", "download_bytes_per_second", "connections"]) { + const valid = sample[field]; + sample[field] = field === "connections" ? "0" : 0; + assertInvalid(validateExample(contract, response), `${field} accepted the wrong numeric type`); + sample[field] = valid; + } + delete sample.sampled_at; + assertInvalid(validateExample(contract, response)); + response.body.samples = []; + assertValid(validateExample(contract, response)); + response.body.sampled_every_seconds = 0; + assertInvalid(validateExample(contract, response)); +}); + +test("traffic history advertises usable limits and rejects invalid query shapes", () => { + const capabilities = example("getCapabilities:200:available"); + const limits = capabilities.body.resources.traffic_history; + assert.equal(typeof capabilities.body.resources.runtime_outbounds.available, "boolean"); + assert.equal(limits.available, true); + for (const field of ["max_window_seconds", "max_points"]) { + const value = limits[field]; + delete limits[field]; + assertInvalid(validateExample(contract, capabilities), `${field} must be advertised when available`); + limits[field] = 0; + assertInvalid(validateExample(contract, capabilities)); + limits[field] = value; + } + const request = example("getTrafficHistory:request"); + for (const name of ["window_seconds", "max_points"]) { + const parameter = request.parameters.find((item) => item.definition.name === name); + assert.ok(parameter, `missing ${name} query parameter`); + const value = parameter.value; + parameter.value = 0; + assertInvalid(validateExample(contract, request)); + parameter.value = value; + } + const history = example("getTrafficHistory:200:recent").body; + assert.ok(history.window_seconds <= limits.max_window_seconds); + assert.ok(history.samples.length <= limits.max_points); + for (const key of ["window_too_large", "too_many_points"]) { + const rejected = example(`getTrafficHistory:400:${key}`); + assert.equal(rejected.body.error.code, "invalid_request"); + assertValid(validateExample(contract, rejected)); + } +}); + +test("effective configuration preserves opaque revisions and source metadata types", () => { + const response = example("getConfig:200:redacted"); + response.body.revision = "revision:not-a-number"; + response.body.sources[0].content = ""; + response.body.extension = { supported: true }; + assertValid(validateExample(contract, response)); + for (const [field, invalid] of [ + ["content_sha256", "sha256:not-a-digest"], + ["bytes", "128"], + ["line_count", -1], + ["loaded_at", "yesterday"], + ["kind", "remote"], + ["writable", "true"], + ]) { + const changed = example("getConfig:200:redacted"); + changed.body.sources[0][field] = invalid; + assertInvalid(validateExample(contract, changed), `invalid ${field} passed`); + } + delete response.body.revision; + assertInvalid(validateExample(contract, response)); +}); + +test("both configuration results allow unknown locations but reject zero-based positions", () => { + for (const key of ["getConfig:200:redacted", "validateConfig:200:invalid"]) { + const response = example(key); + const diagnostic = response.body.diagnostics[0]; + diagnostic.code = "adapter_specific_diagnostic"; + diagnostic.line = null; + diagnostic.column = null; + diagnostic.span = null; + assertValid(validateExample(contract, response)); + diagnostic.line = 0; + assertInvalid(validateExample(contract, response)); + diagnostic.line = 1; + diagnostic.column = 0; + assertInvalid(validateExample(contract, response)); + diagnostic.column = 1; + diagnostic.span = { start_line: 1, start_column: 1, end_line: 1, end_column: 1 }; + assertValid(validateExample(contract, response)); + delete diagnostic.span.end_column; + assertInvalid(validateExample(contract, response)); + } +}); + +test("validation validity distinguishes errors from warnings and info", () => { + const response = example("validateConfig:200:invalid"); + response.body.valid = true; + assertInvalid(validateExample(contract, response), "errors cannot be valid"); + for (const level of ["warning", "info"]) { + response.body.diagnostics[0].level = level; + assertValid(validateExample(contract, response)); + } + response.body.valid = false; + assertInvalid(validateExample(contract, response), "invalid candidates need an error diagnostic"); + response.body.diagnostics = []; + assertInvalid(validateExample(contract, response)); + response.body.valid = true; + assertValid(validateExample(contract, response)); +}); + +test("validation accepts unnamed and empty text candidates but closes request objects", () => { + const request = example("validateConfig:request:syntax_error"); + request.body.sources = [{ content: "" }]; + assertValid(validateExample(contract, request)); + request.body.mode = "full"; + request.body.sources[0].path = ""; + assertValid(validateExample(contract, request)); + request.body.sources[0].apply = true; + assertInvalid(validateExample(contract, request)); + delete request.body.sources[0].apply; + request.body.apply = true; + assertInvalid(validateExample(contract, request)); + delete request.body.apply; + request.body.mode = "live"; + assertInvalid(validateExample(contract, request)); + request.body.mode = "syntax"; + request.body.sources = []; + assertInvalid(validateExample(contract, request)); +}); + +test("configuration capabilities require usable limits only when available", () => { + for (const [resource, fields] of [ + ["config", ["content", "writable", "max_bytes", "max_sources"]], + ["config_validate", ["modes", "max_bytes", "max_sources"]], + ]) { + const response = example("getCapabilities:200:available"); + for (const field of fields) { + const changed = structuredClone(response); + delete changed.body.resources[resource][field]; + assertInvalid(validateExample(contract, changed), `missing ${resource}.${field} passed`); + if (field.startsWith("max_")) { + for (const invalid of [0, 9007199254740992]) { + changed.body.resources[resource][field] = invalid; + assertInvalid(validateExample(contract, changed)); + } + } + } + response.body.resources[resource] = { available: false }; + assertValid(validateExample(contract, response)); + delete response.body.resources[resource]; + assertInvalid(validateExample(contract, response), "unavailable resource keys must still be present"); + } + const response = example("getCapabilities:200:available"); + for (const modes of [[], ["syntax", "syntax"], ["live"]]) { + response.body.resources.config_validate.modes = modes; + assertInvalid(validateExample(contract, response)); + } +}); + +test("configuration examples obey visibility, runtime identity, and advertised limits", () => { + const resources = example("getCapabilities:200:available").body.resources; + const runtime = example("getRuntime:200:snapshot").body; + const config = example("getConfig:200:redacted").body; + assert.equal(config.generation_id, runtime.generation.active_id); + assert.equal(config.revision, runtime.generation.config_revision); + assert.ok(config.sources.length <= resources.config.max_sources); + for (const source of config.sources) { + if (!resources.config.content) { + assert.equal(Object.hasOwn(source, "content"), false); + assert.equal(config.secrets_redacted, true); + } + assert.ok(Date.parse(source.loaded_at) >= Date.parse(runtime.lifecycle.started_at)); + } + for (const value of contract.examples.values()) { + if (value.operationId !== "validateConfig") continue; + if (value.kind === "request") { + assert.ok(resources.config_validate.modes.includes(value.body.mode)); + assert.ok(value.body.sources.length <= resources.config_validate.max_sources); + const bytes = value.body.sources.reduce((sum, source) => sum + Buffer.byteLength(source.content, "utf8"), 0); + assert.ok(bytes <= resources.config_validate.max_bytes); + } else if (value.status === 200) { + assert.equal(value.body.generation_id, runtime.generation.active_id); + assert.ok(Date.parse(value.body.validated_at) >= Date.parse(runtime.generation.activated_at)); + } + } + const request = example("validateConfig:request:syntax_error").body; + const result = example("validateConfig:200:invalid").body; + for (const diagnostic of result.diagnostics) { + assert.ok(request.sources.some((source, index) => (source.id ?? `source-${index + 1}`) === diagnostic.source_id)); + } + for (const name of ["too_many_bytes", "too_many_sources"]) { + const rejected = example(`validateConfig:413:${name}`); + assert.equal(rejected.body.error.code, "request_too_large"); + assertValid(validateExample(contract, rejected)); + } +}); +test("source editing examples preserve exact bytes and use the accepted hash as a precondition", () => { + const snapshot = example("getConfig:200:editable").body; + const source = example("getConfigSource:200:editable").body; + const request = example("replaceConfigSource:request:replacement"); + assert.deepEqual(source, snapshot.sources.find(({ id }) => id === source.id)); + assert.equal(createHash("sha256").update(source.content, "utf8").digest("hex"), source.content_sha256); + assert.equal(Buffer.byteLength(source.content, "utf8"), source.bytes); + assert.equal(request.parameters.find(({ definition }) => definition.name === "source_id").value, source.id); + assert.equal(request.headers["If-Match"], `"${source.content_sha256}"`); + assert.equal( + createHash("sha256").update(request.body.content, "utf8").digest("hex"), + "92fe71cacbc73458f2da2a62363cec2e1cfae3ee0e3838acd7a90562e64f242a", + ); + assert.ok(Buffer.byteLength(request.body.content, "utf8") <= + example("getCapabilities:200:available").body.resources.config.max_bytes); + assert.match(renderExample(request, "http"), /^PUT \/api\/v1\/config\/sources\/source-main HTTP\/1\.1/m); +}); + +test("source replacement accepts only complete text with a single hash precondition", () => { + const request = example("replaceConfigSource:request:replacement"); + request.body.content = ""; + assertValid(validateExample(contract, request)); + for (const body of [{}, { content: null }, { content: "", path: "other.dae" }, + { content: "", mode: "syntax" }, { sources: [{ content: "" }] }, [{ op: "replace", path: "/content", value: "" }]]) { + const changed = structuredClone(request); + changed.body = body; + assertInvalid(validateExample(contract, changed)); + } + const missing = structuredClone(request); + delete missing.headers["If-Match"]; + assertInvalid(validateExample(contract, missing)); + for (const value of ["*", `W/${request.headers["If-Match"]}`, '"17"', request.headers["If-Match"].slice(1, -1), + `${request.headers["If-Match"]}, ${request.headers["If-Match"]}`]) { + const changed = structuredClone(request); + changed.headers["If-Match"] = value; + assertInvalid(validateExample(contract, changed)); + } +}); + +test("source readback permits withheld text but never advertises writable engine output", () => { + const source = example("getConfigSource:200:redacted"); + assert.equal(Object.hasOwn(source.body, "content"), false); + assertValid(validateExample(contract, source)); + for (const kind of ["subscription", "generated"]) { + source.body.kind = kind; + source.body.writable = false; + assertValid(validateExample(contract, source)); + source.body.writable = true; + assertInvalid(validateExample(contract, source)); + } + delete source.body.writable; + assertInvalid(validateExample(contract, source)); + const unavailable = example("getConfigSource:404:resource_not_found"); + assert.equal(unavailable.body.error.code, "resource_not_found"); + assertValid(validateExample(contract, unavailable)); +}); + +test("rejected source writes carry structured errors without pretending validation succeeded", () => { + for (const [status, name, code] of [ + [403, "permission_denied", "permission_denied"], + [412, "stale_revision", "stale_revision"], + [428, "precondition_required", "precondition_required"], + ]) { + const response = example(`replaceConfigSource:${status}:${name}`); + assert.equal(response.body.error.code, code); + assertValid(validateExample(contract, response)); + } + const rejected = example("replaceConfigSource:422:invalid"); + assert.equal(rejected.body.error.code, "unsupported_value"); + assert.equal(rejected.body.error.details.diagnostics[0].source_id, + example("getConfigSource:200:editable").body.id); + for (const mutate of [ + (body) => { delete body.request_id; }, + (body) => { delete body.error.details; }, + (body) => { body.error.details.diagnostics = []; }, + (body) => { body.error.details.diagnostics[0].level = "warning"; }, + (body) => { body.error.details.diagnostics[0].column = 0; }, + (body) => { body.error.code = "invalid_request"; }, + ]) { + const changed = structuredClone(rejected); + mutate(changed.body); + assertInvalid(validateExample(contract, changed)); + } +}); + +test("source writes accept only reload operations with causal polling headers", () => { + const accepted = example("replaceConfigSource:202:queued"); + assert.equal(accepted.body.kind, "reload"); + assertValid(validateExample(contract, accepted)); + for (const mutate of [ + (value) => { value.body.kind = "group_update"; }, + (value) => { value.body.status = "succeeded"; }, + (value) => { delete value.headers.Location; }, + (value) => { value.headers.Location = "/api/v1/operations/other"; }, + (value) => { delete value.headers["Retry-After"]; }, + (value) => { value.headers["Retry-After"] = 0; }, + ]) { + const changed = structuredClone(accepted); + mutate(changed); + assertInvalid(validateExample(contract, changed)); + } +}); + +test("rejected-write diagnostics retain ordered source coordinates", () => { + const changed = structuredClone(spec); + const diagnostic = changed.paths["/api/v1/config/sources/{source_id}"].put.responses["422"] + .content["application/json"].examples.invalid.value.error.details.diagnostics[0]; + diagnostic.span.end_column = diagnostic.span.start_column - 1; + assert.ok(checkContract(changed).errors.some((error) => /span ends before/.test(error))); + diagnostic.span.end_column = diagnostic.span.start_column; + diagnostic.column += 1; + assert.ok(checkContract(changed).errors.some((error) => /location differs/.test(error))); +}); + + +test("configuration checker rejects duplicate source IDs and dangling diagnostic references", () => { + for (const [mutate, message] of [ + [(body) => body.sources.push({ ...body.sources[0], path: "" }), /duplicate source ID/], + [(body) => { body.diagnostics[0].source_id = "absent"; }, /unknown diagnostic source/], + ]) { + const changed = structuredClone(spec); + mutate(changed.paths["/api/v1/config"].get.responses["200"].content["application/json"].examples.redacted.value); + assert.ok(checkContract(changed).errors.some((error) => message.test(error))); + } + const changed = structuredClone(spec); + changed.paths["/api/v1/config/validate"].post.requestBody.content["application/json"].examples.full.value.sources = + [{ id: "source-2", content: "" }, { content: "" }]; + assert.ok(checkContract(changed).errors.some((error) => /duplicate source ID source-2/.test(error))); +}); + +test("configuration checker preserves ordered spans and their point locations", () => { + const changed = structuredClone(spec); + const diagnostic = changed.paths["/api/v1/config/validate"].post.responses["200"] + .content["application/json"].examples.invalid.value.diagnostics[0]; + diagnostic.span.end_column = diagnostic.span.start_column; + assertValid(checkContract(changed).errors, "zero-width span rejected"); + diagnostic.span.end_line = 2; + diagnostic.span.end_column = 1; + assertValid(checkContract(changed).errors, "multiline span rejected"); + diagnostic.span.end_line = 1; + assert.ok(checkContract(changed).errors.some((error) => /span ends before/.test(error))); + diagnostic.span.end_column = 9; + diagnostic.column = 7; + assert.ok(checkContract(changed).errors.some((error) => /location differs/.test(error))); +}); + +test("examples remain bound to their operation schema", () => { + const changed = structuredClone(spec); + changed.paths["/api/v1/flows/{flow_id}"].get.responses["200"].content[ + "application/json" + ].schema = { $ref: "#/components/schemas/Runtime" }; + assertInvalid(createContract(changed).errors); +}); + +test("required path, query, and header parameters need native examples", () => { + const mutations = [ + (changed) => delete changed.components.parameters.FlowId.example, + (changed) => { + const parameter = changed.paths["/api/v1/dns/query"].get.parameters.find( + (candidate) => candidate.name === "domain", + ); + assert.ok(parameter); + delete parameter.example; + }, + (changed) => delete changed.components.parameters.IfMatch.example, + ]; + + for (const mutate of mutations) { + const changed = structuredClone(spec); + mutate(changed); + assertInvalid(createContract(changed).errors); + } +}); + +test("unsupported parameter serialization is rejected explicitly", () => { + const changed = structuredClone(spec); + const parameter = changed.paths["/api/v1/dns/query"].get.parameters.find( + (candidate) => candidate.name === "domain", + ); + assert.ok(parameter); + parameter.style = "deepObject"; + assertInvalid(createContract(changed).errors); +}); + +test("required request headers are checked case-insensitively", () => { + const missing = example("patchGroup:request:tolerance"); + delete missing.headers["If-Match"]; + assertInvalid(validateExample(contract, missing)); + + const lowerCase = example("patchGroup:request:tolerance"); + lowerCase.headers["if-match"] = lowerCase.headers["If-Match"]; + delete lowerCase.headers["If-Match"]; + assertValid(validateExample(contract, lowerCase)); +}); + +test("202 response headers must be declared, present, typed, and causal", () => { + const undeclared = structuredClone(spec); + delete undeclared.paths["/api/v1/probes"].post.responses["202"].headers["Retry-After"]; + assertInvalid(createContract(undeclared).errors, "missing Retry-After declaration passed"); + + const missing = example("createProbe:202:queued"); + delete missing.headers["Retry-After"]; + assertInvalid(validateExample(contract, missing), "missing Retry-After value passed"); + assert.throws(() => renderExample(missing, "http")); + + const wrongType = example("createProbe:202:queued"); + wrongType.headers["Retry-After"] = "1"; + assertInvalid(validateExample(contract, wrongType), "string Retry-After passed"); + + const wrongLocation = example("createProbe:202:queued"); + wrongLocation.headers.Location = "/api/v1/operations/different-operation"; + assertInvalid(validateExample(contract, wrongLocation), "unrelated Location passed"); +}); + +test("probe queue-full responses require a positive Retry-After", () => { + const response = example("createProbe:503:queue_full"); + assertValid(validateExample(contract, response)); + assert.match(renderExample(response, "http"), /^HTTP\/1\.1 503 Service Unavailable\n/u); + + const missing = structuredClone(response); + delete missing.headers["Retry-After"]; + assertInvalid(validateExample(contract, missing)); + assert.throws(() => renderExample(missing, "http")); + + const zero = structuredClone(response); + zero.headers["Retry-After"] = 0; + assertInvalid(validateExample(contract, zero)); +}); + +test("response status and media type cannot be rebound", () => { + const wrongStatus = example("createProbe:202:queued"); + wrongStatus.status = 200; + assertInvalid(validateExample(contract, wrongStatus)); + + const wrongMedia = example("createProbe:202:queued"); + wrongMedia.mediaType = "text/plain"; + assertInvalid(validateExample(contract, wrongMedia)); +}); + +test("an ordinary response property named schema is not contract metadata", () => { + const response = example("getRuntime:200:snapshot"); + response.body.schema = { future_adapter: true }; + assertValid(validateExample(contract, response)); +}); + +test("SSE event names select their authoritative payload schema", () => { + const event = example("event:FlowUpdated"); + + const wrongBinding = structuredClone(event); + wrongBinding.event = "flow.gap"; + assertInvalid(validateExample(contract, wrongBinding), "event/schema binding mismatch passed"); + + const flowGap = { + instance_id: "instance-7", + observed_at: "2026-09-14T10:00:00Z", + resource_id: null, + reason: "buffer_overflow", + dropped_records: "1", + }; + const flowGapSchema = spec.paths["/api/v1/events"].get.responses["200"].content[ + "text/event-stream" + ]["x-event-data-schemas"]["flow.gap"]; + assertValid(contract.validate({ $ref: flowGapSchema }, flowGap)); + + const wrongPayload = structuredClone(event); + wrongPayload.body = flowGap; + assertInvalid(validateExample(contract, wrongPayload), "wrong selected payload passed"); +}); + +test("rendering rejects header and SSE field injection", () => { + const response = example("createProbe:202:queued"); + response.headers.Location += "\r\nInjected: true"; + assert.throws(() => renderExample(response, "http")); + + const badEvent = example("event:FlowUpdated"); + badEvent.event += "\nevent: flow.gap"; + assert.throws(() => renderExample(badEvent, "http")); + + const badId = example("event:FlowUpdated"); + badId.eventId += "\r\nretry: 0"; + assert.throws(() => renderExample(badId, "http")); + + const nulId = example("event:FlowUpdated"); + nulId.eventId += "\u0000ignored"; + assertInvalid(validateExample(contract, nulId)); + assert.throws(() => renderExample(nulId, "sse")); +}); + +test("JSON and HTTP renderers expose the canonical wire values", () => { + const body = example("createProbe:request:dns_udp"); + assert.deepEqual(JSON.parse(renderExample(body)), body.body); + + const query = renderExample(example("queryDns:request"), "http"); + assert.match(query, /^GET \/api\/v1\/dns\/query\?/u); + assert.match(query, /(?:\?|&)domain=example\.com(?:&| )/u); + assert.match(query, /(?:\?|&)type=A&type=AAAA(?:&| )/u); + + const pathRequest = renderExample(example("getFlow:request"), "http"); + assert.match(pathRequest, /^GET \/api\/v1\/flows\/flow-23 HTTP\/1\.1(?:\r?\n|$)/u); + + const accepted = renderExample(example("createProbe:202:queued"), "http"); + assert.match(accepted, /^HTTP\/1\.1 202 Accepted(?:\r?\n)/u); + assert.match(accepted, /(?:^|\r?\n)Location: \/api\/v1\/operations\/op-01HZX4K8W9(?:\r?\n)/u); + assert.match(accepted, /(?:^|\r?\n)Retry-After: 1(?:\r?\n)/u); + assert.match(accepted, /\r?\n\r?\n/u); +}); + +test("native EventSource observes emitted and legal alternate SSE framing", { timeout: 3_000 }, async () => { + assert.equal(typeof EventSource, "function", "run Node with --experimental-eventsource"); + const event = example("event:FlowUpdated"); + const emitted = renderExample(event, "http"); + assert.equal( + emitted, + `id: ${event.eventId}\nevent: ${event.event}\ndata: ${JSON.stringify(event.body)}\n\n`, + ); + + const lateEvent = `data:${JSON.stringify(event.body)}\nevent:${event.event}\nid: late\n\n`; + const multiData = [ + `event: ${event.event}`, + "id: multiline", + ...JSON.stringify(event.body, null, 2) + .split("\n") + .map((line, index) => `data:${index % 2 ? " " : ""}${line}`), + "", + "", + ].join("\r\n"); + + const server = createServer((_request, response) => { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "close", + }); + response.end(emitted + lateEvent + multiData); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + let source; + try { + const address = server.address(); + assert.notEqual(address, null); + source = new EventSource(`http://127.0.0.1:${address.port}/events`); + const received = await new Promise((resolve, reject) => { + const values = []; + const timer = setTimeout(() => reject(new Error("timed out waiting for SSE frames")), 2_000); + source.addEventListener(event.event, (message) => { + values.push({ id: message.lastEventId, body: JSON.parse(message.data) }); + if (values.length === 3) { + clearTimeout(timer); + resolve(values); + } + }); + source.addEventListener("error", (error) => { + clearTimeout(timer); + reject(error); + }, { once: true }); + }); + assert.deepEqual(received, [ + { id: event.eventId, body: event.body }, + { id: "late", body: event.body }, + { id: "multiline", body: event.body }, + ]); + } finally { + source?.close(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("request targets stay closed while response targets remain additive", () => { + const request = example("createProbe:request:dns_udp"); + request.body.target.display_name = "future response metadata"; + assertInvalid(validateExample(contract, request)); + + const response = example("getOperation:200:probe_complete"); + response.body.result.target.display_name = "adapter-provided label"; + assertValid(validateExample(contract, response)); + + const tcp = example("createProbe:request:dns_udp"); + Object.assign(tcp.body, { kind: "tcp_connect", purpose: "data", transport: ["tcp"] }); + assertValid(validateExample(contract, tcp)); + + const wrongPurpose = structuredClone(tcp); + wrongPurpose.body.purpose = "dns"; + assertInvalid(validateExample(contract, wrongPurpose)); + + const wrongTransport = structuredClone(tcp); + wrongTransport.body.transport = ["udp"]; + assertInvalid(validateExample(contract, wrongTransport)); +}); + +test("runtime counters preserve nullable and numeric connection semantics", () => { + const nullable = example("getRuntime:200:snapshot"); + for (const name of ["tcp", "udp", "total"]) nullable.body.traffic.connections[name] = null; + nullable.body.traffic.bytes.upload = null; + nullable.body.traffic.bytes.download = null; + nullable.body.traffic.rates.upload_bytes_per_second = null; + nullable.body.traffic.rates.download_bytes_per_second = null; + assertValid(validateExample(contract, nullable)); + + const zero = example("getRuntime:200:snapshot"); + for (const name of ["tcp", "udp", "total"]) zero.body.traffic.connections[name] = 0; + assertValid(validateExample(contract, zero)); + + const wrongType = example("getRuntime:200:snapshot"); + wrongType.body.traffic.connections.tcp = "0"; + assertInvalid(validateExample(contract, wrongType)); +}); + +test("runtime memory may omit unadvertised metrics", () => { + const memory = example("getRuntimeMemory:200:snapshot"); + delete memory.body.process.rss_bytes; + delete memory.body.cgroup.current_bytes; + delete memory.body.cgroup.limit_bytes; + delete memory.body.cgroup.events; + delete memory.body.kernel.ebpf_bytes; + assertValid(validateExample(contract, memory)); +}); + +test("flow snapshots advertise a schema-valid 503 response", () => { + assertValid(validateExample(contract, example("listFlows:503:snapshot_full"))); +}); + +test("uint64 decimal strings preserve exact limits at every current consumer", () => { + const uint64 = { $ref: "#/components/schemas/UInt64" }; + const nullable = { $ref: "#/components/schemas/NullableUInt64" }; + for (const value of ["0", "9007199254740992", "18446744073709551615"]) { + assertValid(contract.validate(uint64, value)); + } + assertValid(contract.validate(nullable, null)); + for (const value of [ + 0, + 9007199254740992, + "-1", + "+1", + "01", + "1e3", + "1 ", + "1\t", + "1\n", + "18446744073709551616", + ]) { + assertInvalid(contract.validate(uint64, value), `${JSON.stringify(value)} passed as uint64`); + } + + const max = "18446744073709551615"; + const consumers = [ + ["getRuntime:200:snapshot", [ + "lifecycle.uptime_seconds", + "traffic.bytes.upload", + "traffic.bytes.download", + "traffic.rates.upload_bytes_per_second", + "traffic.rates.download_bytes_per_second", + ]], + ["getRuntimeMemory:200:snapshot", [ + "process.rss_bytes", + "cgroup.current_bytes", + "cgroup.limit_bytes", + "cgroup.events.high", + "cgroup.events.oom", + "cgroup.events.oom_kill", + "kernel.ebpf_bytes", + ]], + ["listConnections:200:visible", [ + "tcp.0.upload_bytes", + "tcp.0.download_bytes", + "tcp.0.upload_bytes_per_second", + "tcp.0.download_bytes_per_second", + ]], + ["listFlows:200:visible", ["dropped_records"]], + ]; + for (const [key, fields] of consumers) { + const response = example(key); + for (const field of fields) setPath(response.body, field, max); + assertValid(validateExample(contract, response), key); + } + + assertValid(contract.validate( + { $ref: "#/components/schemas/FlowGapEvent" }, + { + instance_id: "instance-7", + observed_at: "2026-09-14T10:00:00Z", + resource_id: null, + reason: "buffer_overflow", + dropped_records: max, + }, + )); +}); + +test("the interleaved DNS flow preserves chain-specific input and port zero", () => { + const response = example("getFlow:200:interleaved_dns"); + assertValid(validateExample(contract, response)); + assertValid(validateFlowTrace(response.body)); + const upstream = step(response.body, "route", (data) => data.chain === "dns_upstream"); + assert.equal(upstream.data.input.src_port, 0); + + const missingNetwork = example("getFlow:200:interleaved_dns"); + delete step(missingNetwork.body, "route", (data) => data.chain === "dns_upstream").data.input.network; + assertInvalid(validateExample(contract, missingNetwork)); + + const nonPort = example("getFlow:200:interleaved_dns"); + step(nonPort.body, "route", (data) => data.chain === "dns_upstream").data.input.src_port = 65_536; + assertInvalid(validateExample(contract, nonPort)); + + const swapped = example("getFlow:200:interleaved_dns"); + const request = step(swapped.body, "route", (data) => data.chain === "dns_request"); + const responseRoute = step(swapped.body, "route", (data) => data.chain === "dns_response"); + [request.data.input, responseRoute.data.input] = [responseRoute.data.input, request.data.input]; + assertInvalid(validateExample(contract, swapped)); +}); + +test("partial traces retain unresolved references while complete traces reject them", () => { + const complete = example("getFlow:200:interleaved_dns"); + step(complete.body, "outbound", (data) => data.attempt_id === "attempt-app").data.evaluation_id = + "evaluation-missing"; + assertValid(contract.validate(complete.schema, complete.body)); + assertInvalid(validateFlowTrace(complete.body)); + + const partial = structuredClone(complete); + partial.body.trace_status = "partial"; + partial.body.trace.status = "partial"; + partial.body.trace.missing = ["not_instrumented"]; + assertValid(validateExample(contract, partial)); + assertValid(validateFlowTrace(partial.body)); + + const unexplainedLoss = example("getFlow:200:interleaved_dns"); + unexplainedLoss.body.trace_status = "partial"; + unexplainedLoss.body.trace.status = "partial"; + assertInvalid(validateFlowTrace(unexplainedLoss.body)); +}); + +test("flow IDs, ownership, and parent graphs remain causal", () => { + const duplicate = example("getFlow:200:interleaved_dns").body; + const duplicateRoute = structuredClone(step(duplicate, "route", (data) => data.chain === "traffic")); + duplicateRoute.seq = 8; + duplicate.trace.steps.push(duplicateRoute); + assertInvalid(validateFlowTrace(duplicate)); + + const changedOwner = example("getFlow:200:interleaved_dns").body; + const repeatedAttempt = structuredClone( + step(changedOwner, "outbound", (data) => data.attempt_id === "attempt-dns"), + ); + repeatedAttempt.seq = 8; + repeatedAttempt.data.evaluation_id = "eval-traffic"; + changedOwner.trace.steps.push(repeatedAttempt); + assertInvalid(validateFlowTrace(changedOwner)); + + const attemptCycle = example("getFlow:200:interleaved_dns").body; + step(attemptCycle, "outbound", (data) => data.attempt_id === "attempt-dns").data.parent_attempt_id = + "attempt-app"; + step(attemptCycle, "outbound", (data) => data.attempt_id === "attempt-app").data.parent_attempt_id = + "attempt-dns"; + assertInvalid(validateFlowTrace(attemptCycle)); + + const lookupCycle = example("getFlow:200:interleaved_dns").body; + const dns = step(lookupCycle, "dns"); + dns.data.parent_lookup_id = dns.data.lookup_id; + assertInvalid(validateFlowTrace(lookupCycle)); +}); + +test("complete traces require known routing sources and DNS actions", () => { + const unknownSource = example("getFlow:200:interleaved_dns").body; + step(unknownSource, "outbound", (data) => data.attempt_id === "attempt-app").data.routing_source = + "unknown"; + assertInvalid(validateFlowTrace(unknownSource)); + + const missingAction = example("getFlow:200:interleaved_dns").body; + step(missingAction, "route", (data) => data.chain === "dns_request").data.dns_action = null; + assertInvalid(validateFlowTrace(missingAction)); +}); + +test("partial reroutes may report an uncaptured source", () => { + const response = example("getFlow:200:partial_handoff"); + const reroute = step(response.body, "reroute"); + Object.assign(reroute.data, { + performed: true, + reason: "sniffed_domain", + from_evaluation_id: null, + to_evaluation_id: "eval-1", + }); + step(response.body, "route").data.plane = "userspace"; + const mode = step(response.body, "dial_mode"); + mode.data.configured = "domain++"; + mode.data.reason = "sniffed_domain"; + assertValid(validateExample(contract, response)); + assertValid(validateFlowTrace(response.body)); +}); diff --git a/tools/contract.mjs b/tools/contract.mjs new file mode 100644 index 0000000..dedac9b --- /dev/null +++ b/tools/contract.mjs @@ -0,0 +1,704 @@ +import { STATUS_CODES } from "node:http"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +const DIALECT = "https://json-schema.org/draft/2020-12/schema"; +const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]); +const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u; +const METHOD = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u; + +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const own = (value, key) => Object.hasOwn(value, key); + +/** + * Resolve an OpenAPI carrier Reference Object. Schema Objects are deliberately + * left to AJV, so a payload property named `$ref` or `schema` is never visited. + */ +function resolve(spec, value, errors, label) { + const seen = new Set(); + while (isObject(value) && own(value, "$ref")) { + const ref = value.$ref; + if (typeof ref !== "string" || (ref !== "#" && !ref.startsWith("#/"))) { + errors.push(`${label}: only local $ref values are supported`); + return undefined; + } + if (seen.has(ref)) { + errors.push(`${label}: local $ref cycle at ${ref}`); + return undefined; + } + seen.add(ref); + + let pointer; + try { + pointer = decodeURIComponent(ref.slice(1)); + } catch { + errors.push(`${label}: malformed local $ref ${ref}`); + return undefined; + } + let current = spec; + if (pointer !== "") { + for (const rawToken of pointer.slice(1).split("/")) { + if (/~(?![01])/u.test(rawToken)) { + errors.push(`${label}: malformed JSON pointer ${ref}`); + return undefined; + } + const token = rawToken.replaceAll("~1", "/").replaceAll("~0", "~"); + if ((!isObject(current) && !Array.isArray(current)) || !own(current, token)) { + errors.push(`${label}: unresolved local $ref ${ref}`); + return undefined; + } + current = current[token]; + } + } + value = current; + } + return value; +} + +function headerEntry(headers, name) { + if (!isObject(headers)) return undefined; + const expected = name.toLowerCase(); + return Object.entries(headers).find(([candidate]) => candidate.toLowerCase() === expected); +} + +function readHeaders(example, label, errors) { + if (!own(example, "x-headers")) return {}; + if (!isObject(example["x-headers"])) { + errors.push(`${label}: x-headers must be an object`); + return {}; + } + return Object.fromEntries(Object.entries(example["x-headers"])); +} + +function operations(spec, errors) { + const result = []; + const ids = new Map(); + if (!isObject(spec.paths)) { + errors.push("source/openapi.yaml: paths must be an object"); + return result; + } + for (const [path, rawPathItem] of Object.entries(spec.paths)) { + const pathItem = resolve(spec, rawPathItem, errors, `path ${path}`); + if (!isObject(pathItem)) continue; + for (const [method, rawOperation] of Object.entries(pathItem)) { + if (!HTTP_METHODS.has(method)) continue; + const operation = resolve(spec, rawOperation, errors, `${method.toUpperCase()} ${path}`); + if (!isObject(operation)) continue; + const operationId = operation.operationId; + if (typeof operationId !== "string" || operationId.length === 0) { + errors.push(`${method.toUpperCase()} ${path}: operationId must be a nonempty string`); + continue; + } + if (ids.has(operationId)) { + errors.push(`duplicate operationId ${operationId}: ${ids.get(operationId)} and ${method.toUpperCase()} ${path}`); + continue; + } + ids.set(operationId, `${method.toUpperCase()} ${path}`); + result.push({ operationId, method: method.toUpperCase(), path, pathItem, operation }); + } + } + return result; +} + +function parameterStyle(definition) { + return definition.style ?? (definition.in === "query" || definition.in === "cookie" ? "form" : "simple"); +} + +function parameterKey(definition) { + const name = definition.in === "header" ? definition.name.toLowerCase() : definition.name; + return `${definition.in}:${name}`; +} + +function normalizeParameters(spec, record, errors) { + const merged = new Map(); + for (const [scope, values] of [ + ["path", record.pathItem.parameters], + ["operation", record.operation.parameters], + ]) { + if (values === undefined) continue; + if (!Array.isArray(values)) { + errors.push(`${record.operationId}: ${scope} parameters must be an array`); + continue; + } + for (const [index, raw] of values.entries()) { + const label = `${record.operationId}: ${scope} parameter ${index}`; + const definition = resolve(spec, raw, errors, label); + if (!isObject(definition) || typeof definition.name !== "string" || typeof definition.in !== "string") { + errors.push(`${label}: invalid Parameter Object`); + continue; + } + if (!["path", "query", "header"].includes(definition.in)) { + errors.push(`${record.operationId}: unsupported ${definition.in} parameter ${definition.name}`); + continue; + } + const style = parameterStyle(definition); + const supported = definition.in === "query" ? style === "form" : style === "simple"; + if (!supported) { + errors.push(`${record.operationId}: unsupported ${style} serialization for ${definition.in} parameter ${definition.name}`); + } + if (definition.schema === undefined) { + errors.push(`${record.operationId}: parameter ${definition.name} has no schema`); + } + merged.set(parameterKey(definition), definition); + } + } + + const normalized = []; + for (const definition of merged.values()) { + if (!own(definition, "example")) { + if (definition.required === true) { + errors.push(`${record.operationId}: required ${definition.in} parameter ${definition.name} has no example`); + } + continue; + } + normalized.push({ definition, value: definition.example }); + } + return normalized; +} + +function normalizeExample(spec, raw, label, errors) { + const example = resolve(spec, raw, errors, label); + if (!isObject(example)) { + errors.push(`${label}: invalid Example Object`); + return undefined; + } + if (!own(example, "value")) { + errors.push(`${label}: Example Object must contain value`); + return undefined; + } + return example; +} + +function addCase(examples, value, errors) { + if (examples.has(value.id)) errors.push(`${value.id}: duplicate native example key`); + else examples.set(value.id, value); +} +function requestHeaders(spec, example, parameters, label, errors) { + const headers = readHeaders(example, label, errors); + if (!headerEntry(headers, "Host")) { + try { + headers.Host = new URL(spec.servers[0].url).host; + } catch { + errors.push(`${label}: request examples require an absolute server URL`); + } + } + for (const { definition, value } of parameters) { + if (definition.in !== "header" || headerEntry(headers, definition.name)) continue; + headers[definition.name] = value; + } + return headers; +} + + +function normalizeRequests(spec, record, parameters, examples, errors) { + const rawBody = record.operation.requestBody; + const requestBody = rawBody === undefined ? undefined : resolve(spec, rawBody, errors, `${record.operationId}: request body`); + let named = 0; + if (requestBody !== undefined && !isObject(requestBody)) { + errors.push(`${record.operationId}: invalid Request Body Object`); + } else if (isObject(requestBody)) { + if (!isObject(requestBody.content)) errors.push(`${record.operationId}: request body content must be an object`); + for (const [mediaType, rawMedia] of Object.entries(requestBody.content ?? {})) { + const media = resolve(spec, rawMedia, errors, `${record.operationId}: request media ${mediaType}`); + if (!isObject(media)) continue; + if (media.examples !== undefined && !isObject(media.examples)) { + errors.push(`${record.operationId}: ${mediaType} examples must be an object`); + continue; + } + for (const [name, rawExample] of Object.entries(media.examples ?? {})) { + named += 1; + const id = `${record.operationId}:request:${name}`; + const example = normalizeExample(spec, rawExample, id, errors); + if (!example) continue; + if (media.schema === undefined) errors.push(`${id}: request media type has no schema`); + addCase(examples, { + id, + kind: "request", + operationId: record.operationId, + method: record.method, + path: record.path, + mediaType, + schema: media.schema, + body: example.value, + headers: requestHeaders(spec, example, parameters, id, errors), + parameters, + }, errors); + } + } + } + + if (requestBody?.required === true && named === 0) { + errors.push(`${record.operationId}: required request body has no named native example`); + } + if (requestBody?.required !== true) { + const id = `${record.operationId}:request`; + addCase(examples, { + id, + kind: "request", + operationId: record.operationId, + method: record.method, + path: record.path, + mediaType: undefined, + schema: undefined, + body: undefined, + headers: requestHeaders(spec, {}, parameters, id, errors), + parameters, + }, errors); + } +} + +function responseHeaderDefinitions(spec, response, label, errors) { + if (response.headers === undefined) return {}; + if (!isObject(response.headers)) { + errors.push(`${label}: response headers must be an object`); + return {}; + } + const definitions = {}; + for (const [name, raw] of Object.entries(response.headers)) { + const header = resolve(spec, raw, errors, `${label}: header ${name}`); + if (isObject(header)) definitions[name] = header; + else errors.push(`${label}: header ${name} is not a Header Object`); + } + return definitions; +} + +function checkAcceptedResponse(record, status, definitions, errors) { + if (status !== "202") return; + for (const name of ["Location", "Retry-After"]) { + const entry = headerEntry(definitions, name); + if (!entry || entry[1].required !== true) { + errors.push(`${record.operationId}: 202 response requires ${name} with required: true`); + } + } +} + +function normalizeResponses(spec, record, examples, errors) { + if (!isObject(record.operation.responses)) { + errors.push(`${record.operationId}: responses must be an object`); + return; + } + for (const [statusKey, rawResponse] of Object.entries(record.operation.responses)) { + const label = `${record.operationId}:${statusKey}`; + const response = resolve(spec, rawResponse, errors, `${label}: response`); + if (!isObject(response)) continue; + const headerDefinitions = responseHeaderDefinitions(spec, response, label, errors); + checkAcceptedResponse(record, statusKey, headerDefinitions, errors); + if (response.content !== undefined && !isObject(response.content)) { + errors.push(`${label}: response content must be an object`); + continue; + } + for (const [mediaType, rawMedia] of Object.entries(response.content ?? {})) { + const media = resolve(spec, rawMedia, errors, `${label}: response media ${mediaType}`); + if (!isObject(media)) continue; + if (media.examples !== undefined && !isObject(media.examples)) { + errors.push(`${label}: ${mediaType} examples must be an object`); + continue; + } + for (const [name, rawExample] of Object.entries(media.examples ?? {})) { + const id = `${record.operationId}:${statusKey}:${name}`; + const example = normalizeExample(spec, rawExample, id, errors); + if (!example) continue; + if (media.schema === undefined) errors.push(`${id}: response media type has no schema`); + addCase(examples, { + id, + kind: "response", + operationId: record.operationId, + status: /^\d{3}$/u.test(statusKey) ? Number(statusKey) : statusKey, + mediaType, + schema: media.schema, + body: example.value, + headers: readHeaders(example, id, errors), + headerDefinitions, + }, errors); + } + } + } +} + +function operationRecord(spec, operationId, errors) { + const record = operations(spec, []).find((candidate) => candidate.operationId === operationId); + if (!record) errors.push(`operation ${operationId} does not exist`); + return record; +} + +function requestBinding(spec, example, errors) { + const record = operationRecord(spec, example.operationId, errors); + if (!record) return undefined; + if (example.method !== record.method) errors.push(`method ${example.method} does not match ${record.method}`); + if (example.path !== record.path) errors.push(`path ${example.path} does not match ${record.path}`); + const parameters = normalizeParameters(spec, record, errors); + const requestBody = record.operation.requestBody === undefined + ? undefined + : resolve(spec, record.operation.requestBody, errors, `${record.operationId}: request body`); + if (example.body === undefined) { + if (requestBody?.required === true) errors.push("required request body is missing"); + if (example.mediaType !== undefined) errors.push("bodyless request must not select a media type"); + return { parameters, schema: undefined }; + } + if (typeof example.mediaType !== "string") { + errors.push("request body has no media type"); + return { parameters, schema: undefined }; + } + const media = resolve(spec, requestBody?.content?.[example.mediaType], errors, `${record.operationId}: request media ${example.mediaType}`); + if (!isObject(media)) { + errors.push(`request media type ${example.mediaType} is not declared by ${record.operationId}`); + return { parameters, schema: undefined }; + } + return { parameters, schema: media.schema }; +} + +function responseBinding(spec, example, errors) { + const record = operationRecord(spec, example.operationId, errors); + if (!record) return undefined; + const status = String(example.status); + const response = resolve(spec, record.operation.responses?.[status], errors, `${record.operationId}:${status} response`); + if (!isObject(response)) { + errors.push(`response status ${status} is not declared by ${record.operationId}`); + return undefined; + } + const media = resolve(spec, response.content?.[example.mediaType], errors, `${record.operationId}:${status} media ${example.mediaType}`); + if (!isObject(media)) { + errors.push(`response media type ${example.mediaType} is not declared for ${record.operationId}:${status}`); + return undefined; + } + return { + schema: media.schema, + headerDefinitions: responseHeaderDefinitions(spec, response, `${record.operationId}:${status}`, errors), + }; +} + +function streamBinding(spec, event, errors) { + const stream = operationRecord(spec, "streamEvents", errors); + if (!stream) return undefined; + const response = resolve(spec, stream.operation.responses?.["200"], errors, "streamEvents:200 response"); + const media = resolve(spec, response?.content?.["text/event-stream"], errors, "streamEvents:200 event media"); + const bindings = media?.["x-event-data-schemas"]; + if (!isObject(bindings)) { + errors.push("streamEvents:200: text/event-stream has no x-event-data-schemas map"); + return undefined; + } + const ref = bindings[event]; + if (typeof ref !== "string") { + errors.push(`streamEvents: event ${event} has no schema binding`); + return undefined; + } + const schema = resolve(spec, { $ref: ref }, errors, `streamEvents: event ${event}`); + if (schema === undefined) return undefined; + return { schema, stream }; +} + +function normalizeEvents(spec, examples, errors) { + const componentExamples = spec.components?.examples; + if (componentExamples === undefined) return; + if (!isObject(componentExamples)) { + errors.push("components.examples must be an object"); + return; + } + for (const [name, rawExample] of Object.entries(componentExamples)) { + const id = `event:${name}`; + const example = normalizeExample(spec, rawExample, id, errors); + if (!example) continue; + const event = example["x-event"]; + if (typeof event !== "string" || event.length === 0) { + errors.push(`${id}: x-event must be a nonempty string`); + continue; + } + const binding = streamBinding(spec, event, errors); + if (!binding) continue; + addCase(examples, { + id, + kind: "event", + operationId: binding.stream.operationId, + method: binding.stream.method, + path: binding.stream.path, + status: 200, + mediaType: "text/event-stream", + event, + eventId: example["x-event-id"], + schema: binding.schema, + body: example.value, + }, errors); + } +} + +function schemaValidator(spec) { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + const cache = new Map(); + return (schema, value) => { + if (schema === undefined) return ["schema is missing"]; + let compiled = cache.get(schema); + if (!compiled) { + try { + compiled = { + validate: ajv.compile({ ...spec, $schema: spec.jsonSchemaDialect ?? DIALECT, allOf: [schema] }), + }; + } catch (error) { + compiled = { error: `invalid schema: ${error.message}` }; + } + cache.set(schema, compiled); + } + if (compiled.error) return [compiled.error]; + if (compiled.validate(value)) return []; + return compiled.validate.errors.map((error) => { + const where = error.instancePath || "/"; + return `${where} ${error.message}`; + }); + }; +} + +function scalar(value) { + return value === null || ["string", "number", "boolean"].includes(typeof value); +} + +function serializableParameter({ definition, value }) { + if (scalar(value)) return []; + if (Array.isArray(value) && value.every(scalar)) return []; + return [`parameter ${definition.name} uses unsupported object or nested-array serialization`]; +} + +function safeHeaders(headers) { + const errors = []; + if (!isObject(headers)) return ["headers must be an object"]; + for (const [name, value] of Object.entries(headers)) { + if (!HEADER_NAME.test(name)) errors.push(`invalid header name ${JSON.stringify(name)}`); + const values = Array.isArray(value) ? value : [value]; + if (!values.every(scalar)) errors.push(`header ${name} must be a scalar or flat array`); + else if (values.some((item) => /[\r\n]/u.test(String(item)))) errors.push(`header ${name} contains a line break`); + } + return errors; +} + +function mediaTypeOf(value) { + return typeof value === "string" ? value.split(";", 1)[0].trim().toLowerCase() : undefined; +} + +/** + * @typedef {{definition: Record, value: unknown}} ExampleParameter + * @typedef {{id:string, kind:"request", operationId:string, method:string, path:string, mediaType:string|undefined, schema:unknown, body:unknown, headers:Record, parameters:ExampleParameter[]}} RequestExample + * @typedef {{id:string, kind:"response", operationId:string, status:number|string, mediaType:string, schema:unknown, body:unknown, headers:Record, headerDefinitions:Record>}} ResponseExample + * @typedef {{id:string, kind:"event", operationId:string, method:string, path:string, status:number, mediaType:"text/event-stream", event:string, eventId?:unknown, schema:unknown, body:unknown}} EventExample + * @typedef {RequestExample|ResponseExample|EventExample} ContractExample + */ + +/** Create one normalized, schema-validator-backed view of an immutable OpenAPI document. */ +export function createContract(spec) { + const errors = []; + const examples = new Map(); + if (!isObject(spec)) { + return { spec, examples, errors: ["source/openapi.yaml: document must be an object"], validate: () => ["schema is unavailable"] }; + } + if (spec.openapi !== "3.1.0") errors.push("source/openapi.yaml: openapi must be 3.1.0"); + const records = operations(spec, errors); + for (const record of records) { + const parameters = normalizeParameters(spec, record, errors); + normalizeRequests(spec, record, parameters, examples, errors); + normalizeResponses(spec, record, examples, errors); + } + normalizeEvents(spec, examples, errors); + + const context = { spec, examples, errors, validate: schemaValidator(spec) }; + for (const example of examples.values()) { + errors.push(...validateExample(context, example)); + } + return context; +} + +/** Validate one normalized example without interpreting rendered HTTP or SSE text. */ +export function validateExample(context, example) { + const prefix = typeof example?.id === "string" ? `${example.id}: ` : "example: "; + const errors = []; + const fail = (message) => errors.push(prefix + message); + if (!isObject(example) || !["request", "response", "event"].includes(example.kind)) { + return [prefix + "invalid normalized example"]; + } + + if (example.kind === "request") { + if (!METHOD.test(example.method) || typeof example.path !== "string" || /[\r\n]/u.test(example.path)) { + fail("invalid HTTP request line fields"); + } + for (const message of safeHeaders(example.headers)) fail(message); + const bindingErrors = []; + const binding = requestBinding(context.spec, example, bindingErrors); + for (const message of bindingErrors) fail(message); + if (example.body !== undefined && binding?.schema !== undefined) { + for (const message of context.validate(binding.schema, example.body)) fail(`body ${message}`); + } + if (!Array.isArray(example.parameters)) fail("parameters must be an array"); + else if (binding) { + const actual = new Map(); + for (const parameter of example.parameters) { + if (isObject(parameter?.definition)) actual.set(parameterKey(parameter.definition), parameter); + else fail("invalid normalized parameter"); + } + for (const expected of binding.parameters) { + const definition = expected.definition; + let parameter = actual.get(parameterKey(definition)); + if (definition.in === "header") { + const present = headerEntry(example.headers, definition.name); + if (!present) { + if (definition.required === true) fail(`missing required request header ${definition.name}`); + continue; + } + parameter = { definition, value: present[1] }; + } else if (!parameter) { + if (definition.required === true) fail(`missing required ${definition.in} parameter ${definition.name}`); + continue; + } + for (const message of serializableParameter(parameter)) fail(message); + if (definition.schema === undefined) fail(`parameter ${definition.name} has no schema`); + else for (const message of context.validate(definition.schema, parameter.value)) fail(`parameter ${definition.name} ${message}`); + } + } + const contentType = headerEntry(example.headers, "Content-Type")?.[1]; + if (contentType !== undefined && mediaTypeOf(contentType) !== example.mediaType?.toLowerCase()) { + fail(`Content-Type ${contentType} does not match ${example.mediaType}`); + } + } else if (example.kind === "response") { + const bindingErrors = []; + const binding = responseBinding(context.spec, example, bindingErrors); + for (const message of bindingErrors) fail(message); + if (binding?.schema !== undefined) { + for (const message of context.validate(binding.schema, example.body)) fail(`body ${message}`); + } + for (const message of safeHeaders(example.headers)) fail(message); + const contentType = headerEntry(example.headers, "Content-Type")?.[1]; + if (contentType === undefined) fail("response example is missing Content-Type"); + else if (typeof example.mediaType !== "string" || mediaTypeOf(contentType) !== example.mediaType.toLowerCase()) { + fail(`Content-Type ${contentType} does not match ${example.mediaType}`); + } + for (const [name, definition] of Object.entries(binding?.headerDefinitions ?? {})) { + const present = headerEntry(example.headers, name); + if (definition.required === true && !present) fail(`missing required response header ${name}`); + if (!present) continue; + if (definition.schema === undefined) fail(`response header ${name} has no schema`); + else for (const message of context.validate(definition.schema, present[1])) fail(`response header ${name} ${message}`); + } + if (example.status === 202) { + const retry = headerEntry(example.headers, "Retry-After")?.[1]; + if (!Number.isInteger(retry) || retry < 1) fail("Retry-After must be a positive integer"); + const location = headerEntry(example.headers, "Location")?.[1]; + if (location !== example.body?.href) fail("Location must equal response body href"); + } + } else { + if (example.operationId !== "streamEvents" || example.status !== 200) fail("event is not bound to streamEvents:200"); + if (example.mediaType !== "text/event-stream") fail("event media type must be text/event-stream"); + if (typeof example.event !== "string" || example.event.length === 0 || /[\r\n]/u.test(example.event)) { + fail("event must be a nonempty single-line string"); + } + if (example.eventId !== undefined && (typeof example.eventId !== "string" || example.eventId.length === 0 || /[\u0000\r\n]/u.test(example.eventId))) { + fail("eventId must be a nonempty string without NUL, CR or LF"); + } + const bindingErrors = []; + const binding = streamBinding(context.spec, example.event, bindingErrors); + for (const message of bindingErrors) fail(message); + if (binding) { + if (example.method !== binding.stream.method || example.path !== binding.stream.path) fail("event operation identity changed"); + for (const message of context.validate(binding.schema, example.body)) fail(`body ${message}`); + } + } + return errors; +} + +function headerValue(value) { + const values = Array.isArray(value) ? value : [value]; + if (!values.every(scalar) || values.some((item) => /[\r\n]/u.test(String(item)))) { + throw new TypeError("header values must be line-safe scalars or flat arrays"); + } + return values.map(String).join(", "); +} + +function encoded(value) { + if (!scalar(value)) throw new TypeError("URI parameter values must be scalars"); + return encodeURIComponent(String(value)); +} + +function requestParts(example) { + let target = example.path; + const query = []; + const headers = { ...example.headers }; + for (const parameter of example.parameters) { + const { definition, value } = parameter; + const values = Array.isArray(value) ? value : [value]; + if (!values.every(scalar)) throw new TypeError(`unsupported serialization for parameter ${definition.name}`); + if (definition.in === "path") { + const rendered = values.map(encoded).join(","); + const marker = `{${definition.name}}`; + if (!target.includes(marker)) throw new TypeError(`path has no placeholder for ${definition.name}`); + target = target.replaceAll(marker, rendered); + } else if (definition.in === "query") { + const name = encodeURIComponent(definition.name); + if (definition.explode !== false && Array.isArray(value)) { + for (const item of values) query.push(`${name}=${encoded(item)}`); + } else { + query.push(`${name}=${values.map(encoded).join(",")}`); + } + } else if (definition.in === "header") { + if (!headerEntry(headers, definition.name)) headers[definition.name] = value; + } else { + throw new TypeError(`unsupported parameter location ${definition.in}`); + } + } + if (/\{[^{}]+\}/u.test(target)) throw new TypeError("request path has an unresolved parameter"); + if (query.length > 0) target += `${target.includes("?") ? "&" : "?"}${query.join("&")}`; + if (example.body !== undefined && !headerEntry(headers, "Content-Type")) headers["Content-Type"] = example.mediaType; + return { target, headers }; +} + +function renderedHeaders(headers) { + const errors = safeHeaders(headers); + if (errors.length > 0) throw new TypeError(errors.join("; ")); + return Object.entries(headers).map(([name, value]) => `${name}: ${headerValue(value)}`).join("\n"); +} +function responseRenderErrors(example) { + const errors = safeHeaders(example.headers); + const contentType = headerEntry(example.headers, "Content-Type")?.[1]; + if (contentType === undefined) errors.push("response example is missing Content-Type"); + for (const [name, definition] of Object.entries(example.headerDefinitions ?? {})) { + if (definition.required === true && !headerEntry(example.headers, name)) { + errors.push(`missing required response header ${name}`); + } + } + if (example.status === 202) { + const retry = headerEntry(example.headers, "Retry-After")?.[1]; + if (!Number.isInteger(retry) || retry < 1) errors.push("Retry-After must be a positive integer"); + if (headerEntry(example.headers, "Location")?.[1] !== example.body?.href) { + errors.push("Location must equal response body href"); + } + } + return errors; +} + + +/** Render a normalized case as JSON, a complete HTTP message, or one SSE event. */ +export function renderExample(example, format = "json") { + if (format === "json") return example.body === undefined ? "" : JSON.stringify(example.body, null, 2); + if (format === "sse" || (format === "http" && example.kind === "event")) { + if (example.kind !== "event") throw new TypeError("only event examples can be rendered as SSE"); + if (typeof example.event !== "string" || example.event.length === 0 || /[\r\n]/u.test(example.event)) { + throw new TypeError("event must be a nonempty single-line string"); + } + if (example.eventId !== undefined && (typeof example.eventId !== "string" || example.eventId.length === 0 || /[\u0000\r\n]/u.test(example.eventId))) { + throw new TypeError("eventId must be a nonempty string without NUL, CR or LF"); + } + if (example.body === undefined) throw new TypeError("event body is required"); + const lines = []; + if (example.eventId !== undefined) lines.push(`id: ${example.eventId}`); + lines.push(`event: ${example.event}`, `data: ${JSON.stringify(example.body)}`); + return `${lines.join("\n")}\n\n`; + } + if (format !== "http") throw new TypeError(`unsupported example format ${format}`); + if (example.kind === "request") { + if (!METHOD.test(example.method) || /[\r\n]/u.test(example.path)) throw new TypeError("invalid HTTP request line fields"); + const { target, headers } = requestParts(example); + const body = example.body === undefined ? "" : JSON.stringify(example.body, null, 2); + return `${example.method} ${target} HTTP/1.1\n${renderedHeaders(headers)}\n\n${body}`; + } + const responseErrors = responseRenderErrors(example); + if (responseErrors.length > 0) throw new TypeError(responseErrors.join("; ")); + if (!Number.isInteger(example.status) || example.status < 100 || example.status > 599) { + throw new TypeError("HTTP response status must be an integer from 100 to 599"); + } + const reason = STATUS_CODES[example.status] ?? ""; + return `HTTP/1.1 ${example.status}${reason ? ` ${reason}` : ""}\n${renderedHeaders(example.headers)}\n\n${JSON.stringify(example.body, null, 2)}`; +} diff --git a/tools/validate-flow.mjs b/tools/validate-flow.mjs new file mode 100644 index 0000000..16c862b --- /dev/null +++ b/tools/validate-flow.mjs @@ -0,0 +1,112 @@ +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** Validate causal invariants that JSON Schema cannot express for one FlowDetail value. */ +export function validateFlowTrace(flow) { + const errors = []; + const trace = flow?.trace; + if (!isObject(trace) || !Array.isArray(trace.steps)) return errors; + + const complete = trace.status === "complete"; + if (trace.status === "partial" && (!Array.isArray(trace.missing) || trace.missing.length === 0)) { + errors.push("partial trace must name at least one missing evidence source"); + } + if (complete && Array.isArray(trace.missing) && trace.missing.length > 0) { + errors.push("complete trace must not name missing evidence sources"); + } + + const evaluations = new Map(); + const attempts = new Set(); + const lookups = new Set(); + const attemptParents = new Map(); + const lookupParents = new Map(); + const attemptOwners = new Map(); + + const addParent = (parents, id, parent) => { + if (typeof id !== "string" || typeof parent !== "string") return; + const values = parents.get(id) ?? new Set(); + values.add(parent); + parents.set(id, values); + }; + + for (const step of trace.steps) { + const data = isObject(step?.data) ? step.data : {}; + if (step?.stage === "route" && typeof data.evaluation_id === "string") { + if (evaluations.has(data.evaluation_id)) errors.push(`duplicate route evaluation ID ${data.evaluation_id}`); + else evaluations.set(data.evaluation_id, step); + } else if (step?.stage === "outbound" && typeof data.attempt_id === "string") { + attempts.add(data.attempt_id); + const owner = JSON.stringify([data.parent_attempt_id, data.routing_source, data.evaluation_id]); + if (attemptOwners.has(data.attempt_id) && attemptOwners.get(data.attempt_id) !== owner) { + errors.push(`outbound attempt ${data.attempt_id} changes its parent or route evaluation`); + } else { + attemptOwners.set(data.attempt_id, owner); + } + addParent(attemptParents, data.attempt_id, data.parent_attempt_id); + } else if (step?.stage === "dns" && typeof data.lookup_id === "string") { + lookups.add(data.lookup_id); + addParent(lookupParents, data.lookup_id, data.parent_lookup_id); + } + } + + const requireReference = (ids, id, kind, step) => { + if (complete && id !== null && id !== undefined && !ids.has(id)) { + errors.push(`complete trace ${step.stage} step ${step.seq} has unresolved ${kind} ${id}`); + } + }; + + for (const step of trace.steps) { + const data = isObject(step?.data) ? step.data : {}; + if (step?.stage === "route") { + if (complete && data.input === null) { + errors.push(`complete trace route evaluation ${data.evaluation_id ?? ""} has no captured input context`); + } + if (complete && ["dns_request", "dns_response"].includes(data.chain) && data.dns_action === null) { + errors.push(`complete trace route evaluation ${data.evaluation_id ?? ""} has no observed DNS action`); + } + } else if (step?.stage === "outbound") { + if (complete && data.routing_source === "unknown") { + errors.push(`complete trace outbound attempt ${data.attempt_id ?? ""} has unknown routing source`); + } + requireReference(evaluations, data.evaluation_id, "route evaluation", step); + requireReference(attempts, data.parent_attempt_id, "parent attempt", step); + } else if (step?.stage === "dns") { + requireReference(attempts, data.attempt_id, "attempt", step); + requireReference(lookups, data.parent_lookup_id, "parent DNS lookup", step); + if (Array.isArray(data.route_evaluation_ids)) { + for (const id of data.route_evaluation_ids) requireReference(evaluations, id, "route evaluation", step); + } + } else if (step?.stage === "reroute") { + if (complete && data.performed === true && (data.from_evaluation_id === null || data.to_evaluation_id === null)) { + errors.push(`performed reroute step ${step.seq} requires both route evaluation endpoints`); + } + if (data.performed === true && typeof data.from_evaluation_id === "string" && data.from_evaluation_id === data.to_evaluation_id) { + errors.push(`performed reroute step ${step.seq} cannot reuse one evaluation for both passes`); + } + requireReference(evaluations, data.from_evaluation_id, "source route evaluation", step); + requireReference(evaluations, data.to_evaluation_id, "target route evaluation", step); + } else if (step?.stage === "connection") { + requireReference(attempts, data.attempt_id, "attempt", step); + } + } + + const hasCycle = (ids, parents) => { + const visited = new Set(); + const active = new Set(); + const visit = (id) => { + if (active.has(id)) return true; + if (visited.has(id)) return false; + active.add(id); + for (const parent of parents.get(id) ?? []) { + if (ids.has(parent) && visit(parent)) return true; + } + active.delete(id); + visited.add(id); + return false; + }; + return [...ids].some(visit); + }; + + if (hasCycle(attempts, attemptParents)) errors.push("outbound attempt parent links contain a cycle"); + if (hasCycle(lookups, lookupParents)) errors.push("DNS lookup parent links contain a cycle"); + return errors; +} diff --git a/yarn.lock b/yarn.lock index f828ad0..afa4c32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46,6 +46,11 @@ resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz#333fedabc3fd1a8e5d0100013731cf19e6a8c5d3" integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw== +"@redocly/cli@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@redocly/cli/-/cli-2.53.0.tgz#83002e5fb5b28de6c3a6b6ec31add29a5eb137eb" + integrity sha512-lMtzH4YF2RFW5+pV9oSlkoj2MkvTSAceV6/Qgn7l56PwzEWM1To62qWjlCWaZcMr9AxOpvWuS0XoE942Mzug3A== + "@types/trusted-types@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -71,6 +76,23 @@ agent-base@^7.1.0, agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + +ajv@^8.0.0, ajv@^8.17.1: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-regex@^6.2.2: version "6.3.0" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.3.0.tgz#247c8e7b70a1a43b10ce14c0226fcbf58e8815d5" @@ -442,6 +464,11 @@ fast-archy@^1.0.0: resolved "https://registry.yarnpkg.com/fast-archy/-/fast-archy-1.1.0.tgz#ce2bcc2657bfcc38d22e95790c647fd9dc3c7597" integrity sha512-CECNuPvHraaAhtvyEj73We9z/sd+IjNvsnxdqvASSGoG6vdb1phgFw+YnP0VFcv47/jUEIH7Pt51vpldr6sM3A== +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + fast-equals@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-3.0.3.tgz#8e6cb4e51ca1018d87dd41982ef92758b3e4197f" @@ -452,6 +479,11 @@ fast-text-table@^1.0.1: resolved "https://registry.yarnpkg.com/fast-text-table/-/fast-text-table-1.1.0.tgz#40126866a611845e6900eca130257af9a2cfff65" integrity sha512-Sy8/ESTt2nBEU45NGdgFzraZZAMH3C/pFpwsMdKOqRAlTMr6qbn66E8YTxsC4bkJaOhWqaBpEYfrN3CT8N/phw== +fast-uri@^3.0.1: + version "3.1.7" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a" + integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg== + filelist@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.6.tgz#1e8870942a7c636c862f7c49b9394937b6a995a3" @@ -958,6 +990,11 @@ jsdom@^25.0.1: ws "^8.18.0" xml-name-validator "^5.0.0" +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + jsonparse@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" @@ -1253,6 +1290,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + resolve@^1.20.0: version "1.22.12" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f" @@ -1565,3 +1607,8 @@ xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +yaml@^2.8.1: + version "2.9.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.1.tgz#c16233fb31944e705cfefaff38795587f57588ce" + integrity sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==