RedNet Cloud/Docs 中文 EN
RedNet Cloud is an enterprise Web protection management platform. Go + Gin REST API backend, Vue 3 SPA frontend, with the zcloud CLI for automation.

This document belongs to RedNet Cloud — Enterprise Web Protection Management Platform
CLI tool: zcloud · 5 modules: guard / sys / analytics / cli_release / auth
Full API index: /api/openapi.json · Sitemap: /sitemap.xml · AI Quick Read: /llms.txt


API Documentation

Production-grade REST API documentation · 57 endpoints · 80+ chart-key data interfaces · Dual-channel authentication
Audience: integration engineers, SREs, SaaS integrators, AI agents
Reading order: §0 Conventions§1 Authentication → jump to your business module


Table of Contents

Must Read

Public Endpoints (no auth)

Business Endpoints (dual-channel auth)

Plan Catalog

Node Operations

Analytics (92 chart-keys · single-shape contract)

Reference

Full OpenAPI: /api/openapi.json · AI Quick Read: /llms.txt · Error Codes: /docs/errors · CLI: /docs/cli · Permissions: /docs/permissions


§0 Overall Conventions

The RedNet Cloud backend is a Gin-based RESTful service.

0.1 Basic Protocol

Item Value
Protocol HTTPS (recommended) / HTTP
Data format Requests and responses are application/json (exceptions: POST /api/guard/certs is still JSON with PEM as string fields; export/download endpoints return text/csv, application/pdf, etc.)
Charset UTF-8
Path prefix All business APIs live under /api/
Time format Unix milliseconds (int64), not ISO strings
i18n Accept-Language: zh-CN or en-US localizes error messages and permission names

0.2 Unified Response Envelope

Every JSON response follows this three-field shape:

{
  "code": 0,
  "message": "ok",
  "data": { /* business payload, type depends on the endpoint */ }
}
Field Type Meaning
code number 0 = success; non-zero = business error code
message string Error description (honors Accept-Language)
data any Payload; list endpoints use { list, total, page, size }

Exceptions: file-export endpoints (POST /api/analytics/overview/export, GET /api/analytics/reports/:id/download, POST /api/analytics/logs/export) return raw binary or CSV/JSON streams, not wrapped in the envelope.

0.3 HTTP Status Codes

Code When emitted
200 Business success (still inspect code)
201 Resource created
400 Bad request (missing param, invalid format, out-of-range)
401 Not logged in / session expired / API Key revoked
403 Authenticated but insufficient permission / cross-OEM forbidden (see Permission Matrix)
404 Resource not found or out of visible scope
429 Rate-limited (default 100 RPS per Key)
5xx Server error

0.4 Pagination Convention

All list endpoints use page + size (not page_size):

Field Type Default Range
page int 1 ≥ 1
size int 20 1 - 100

Response shape:

{
  "code": 0,
  "data": {
    "list": [ /* ... */ ],
    "total": 42,
    "page": 1,
    "size": 20
  }
}

0.5 Cross-Module Design Markers (D*)

A few D* markers in this document come from cross-module design decisions. They warn integrators not to use fields that do not exist or have different semantics:

Marker Meaning
D4 percentile / p50 / p95 / p99 are not exposed as common fields; only time windows ≤ 24h trigger real-time ES percentile calculation
D7 Report template names are a closed enum; templates outside this enum are not callable
D8 Cache-value fields use total_cache_*; single-field names such as cache_count / cache_bytes / cache_hit do not exist
D10 Alert/risk closure uses process_uid / process_time / status / level; old field names handle_user / handle_time / risk_score / alert_status are not supported

0.6 Three Audiences

Audience Entry Prefer
Human integrators This doc + Quickstart curl / Postman with single endpoints
Scripts / CI / third-party systems This doc + API Key Management API Key + narrowed scopes
Machines / AI agents /api/openapi.json / /llms.txt / /llms-full.txt OpenAPI v3 schema

§1 Authentication (read first)

RedNet Cloud supports two authentication channels. Pick exactly one per request:

Scenario Header Best for Notes
Human login / Web Console / interactive CLI login Authorization: Bearer <token> People Token is issued by POST /api/auth/login; expires; best for short-lived sessions
Scripts / CI / third-party integrations Authorization: ApiKey zck_<prefix>.<secret> Machine calls The plaintext API Key is returned only once at issuance; best for long-running automation

Public endpoints (no auth) — only 5:

Every other endpoint must include one of the headers above. Examples in this doc default to Bearer; switch to API Key by replacing the header with Authorization: ApiKey zck_<prefix>.<secret> and ensure the key scopes cover the required permission.

1.1 API Key Permission Rule

effective_perms = user.RBAC ∩ key.scope

An API Key can never exceed the issuing user's current RBAC; scope can only narrow, not expand. After authentication, middleware injects the same user_id / role_id context as the session channel, so downstream RBAC/OEM isolation works identically.

Security guarantees:

Full API Key management endpoints are documented in §4.2.


§2 CLI Release (public endpoints)

Used for zcloud CLI self-update and one-line install. No authentication required.

GET /api/cli/version — Query latest CLI version

Purpose: clients self-check on startup; the install script /api/cli/install.sh calls this internally to decide which binary to download.

Authentication: none (public)

Input parameters: none

Output fields:

Field Type Description
data.version string Like v0.1.0-31, aligned with git tag
data.binaries[] array Download URLs for the four os/arch combinations
data.binaries[].os string linux / darwin
data.binaries[].arch string amd64 / arm64
data.binaries[].download_url string Append to your service URL to download

Visualization recommendation: plain text (version badge); not suitable for charts. Frontend may use this on a "System Settings - CLI version" page as a KPI card.

Example response:

{
  "code": 0,
  "message": "ok",
  "data": {
    "version": "v0.1.0-31",
    "binaries": [
      { "os": "linux",  "arch": "amd64", "download_url": "/api/cli/download/linux-amd64" },
      { "os": "linux",  "arch": "arm64", "download_url": "/api/cli/download/linux-arm64" },
      { "os": "darwin", "arch": "amd64", "download_url": "/api/cli/download/darwin-amd64" },
      { "os": "darwin", "arch": "arm64", "download_url": "/api/cli/download/darwin-arm64" }
    ]
  }
}

GET /api/cli/install.sh — One-line installer script

Purpose: install CLI on Linux/macOS in one command. Returns text/x-shellscript, suitable for curl ... | sh.

Authentication: none (public)

Input parameters: none

Output fields: a raw shell script (text); not wrapped in JSON envelope.

Visualization recommendation: not a chart; render as a code snippet.

Example:

curl -fsSL https://waf.example.com/api/cli/install.sh | sh

GET /api/cli/download/{filename} — Download a specific binary

Purpose: fetch the zcloud binary (signed) for a specific platform. filename matches the last segment of download_url returned by /api/cli/version (e.g. linux-amd64).

Authentication: none (public)

Input parameters:

Field Type Required Description
filename path yes one of linux-amd64 / linux-arm64 / darwin-amd64 / darwin-arm64

Output fields: binary stream (application/octet-stream).

Visualization recommendation: not a chart.


GET /api/cli/checksums.txt — Binary checksums

Purpose: SHA256 integrity check for /api/cli/download/*. The install script fetches this before downloading the binary.

Authentication: none (public)

Input parameters: none

Output fields: plain text/plain with one <sha256> <filename> per line.

Visualization recommendation: not a chart.


§3 User Authentication

POST /api/auth/login — Login

Purpose: exchange username/password for a session token. Used by Web Console, interactive CLI login, mobile clients.

Authentication: none (public)

Input parameters (request body):

Field Type Required Description
username string yes Login name
password string yes Password is base64-encoded by the frontend before sending; the backend decodes then bcrypt-compares (legacy compatibility)

Output fields:

Field Type Description
data.token string 32-char session token; use as Authorization: Bearer <token>
data.user_id string Unique user ID
data.user_name string Login name
data.nick_name string Display name
data.need_change_password bool true means first-login forced password change required

Visualization recommendation: login response is not charted; if need_change_password=true, the frontend should redirect to the change-password page.

Example request:

curl -X POST https://waf.example.com/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"'$(echo -n 'your_password' | base64)'"}'

Example response:

{
  "code": 0,
  "message": "ok",
  "data": {
    "token": "550e8400-e29b-41d4-a716-446655440000",
    "user_id": "u-admin",
    "user_name": "admin",
    "nick_name": "System Administrator",
    "need_change_password": false
  }
}

Common pitfalls:


POST /api/auth/logout — Logout

Purpose: actively invalidate the current Bearer token; subsequent requests with that token return 401.

Authentication: Bearer <token> (API Key has no logout concept; revoke via DELETE /api/sys/api-keys/:id)

Input parameters: none

Output fields: data is null.

Visualization recommendation: not a chart.

Example request:

curl -X POST https://waf.example.com/api/auth/logout \
  -H "Authorization: Bearer $TOKEN"

§4 System Management

4.1 User Management

Use case: CRUD users in the "System Settings - Users" page. All user endpoints are OEM-scoped; cross-OEM operations return 403.

GET /api/sys/users — User list (paged)

Purpose: render the user table on the "Users" page with keyword search and pagination.

Authentication: sys.user.list

Input parameters:

Field Type Required Example Description
page int no 1 Page number (1-based)
size int no 20 Per-page count, 1-100
keyword string no admin Fuzzy search on username / nick_name

Output fields (data.list[]):

Field Type Description
user_id string Unique user ID
user_name string Login name
nick_name string Display name
email string Email
mobile string Phone
locked int 0 = normal, non-zero = locked
role_ids int64[] Role ID array (multi-role)
roles[] array Role brief {role_id, name, level}
ctime int64 Creation time, Unix ms

Visualization recommendation: table. locked column uses badges (green/red); roles rendered as chips.

Example request:

curl -H "Authorization: Bearer $TOKEN" \
  "https://waf.example.com/api/sys/users?page=1&size=20&keyword=admin"

Example response:

{
  "code": 0,
  "message": "ok",
  "data": {
    "list": [
      {
        "user_id": "u-001",
        "user_name": "admin",
        "nick_name": "System Administrator",
        "email": "admin@example.com",
        "mobile": "",
        "locked": 0,
        "role_ids": [1],
        "roles": [{ "role_id": 1, "name": "Super Administrator", "level": 1 }],
        "ctime": 1714521600000
      }
    ],
    "total": 42,
    "page": 1,
    "size": 20
  }
}

Common pitfalls:


POST /api/sys/users — Create user

Purpose: backend for the "Add User" form on the user management page.

Authentication: sys.user.create

Input parameters (request body):

Field Type Required Example Description
user_name string yes u1 2-255 chars
password string yes InitPassw0rd! 6-72 chars (bcrypt limit)
nick_name string no Operator A ≤ 100 chars
email string no u1@x.com RFC email format
mobile string no 13800138000 ≤ 20 chars
comment string no On-call colleague Note

Output fields: returns the new user object with the same shape as a list item.

Visualization recommendation: not a chart; one-shot write. Frontend should refresh the list after success.

Example request:

curl -X POST https://waf.example.com/api/sys/users \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"user_name":"u1","password":"InitPassw0rd!","nick_name":"Operator A","email":"u1@example.com"}'

DELETE /api/sys/users/{id} — Delete user

Purpose: backend for the "Delete" button on user management. Deletion cascades to sessions, API Keys, and role bindings.

Authentication: sys.user.delete

Input parameters:

Field Type Required Description
id path yes User user_id

Output fields: data is null.

Visualization recommendation: not a chart.


PUT /api/sys/users/{id}/password — Reset password

Purpose: an admin resets a user's password. The user is forced to change the password on next login.

Authentication: sys.user.resetpwd

Input parameters:

Field Type Required Description
id path yes User user_id
password string yes New password (plaintext, 6-72 chars; backend bcrypts)

Output fields: data is null.

Visualization recommendation: not a chart.

Example request:

curl -X PUT https://waf.example.com/api/sys/users/u-001/password \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"password":"NewPassw0rd!"}'

Common pitfalls:


4.2 API Key Management

Use case: issue/revoke machine credentials in the "System Settings - API Keys" page. Pair with /api/sys/api-keys/:id/logs|stats|audit-actions for call auditing.

POST /api/sys/api-keys — Issue a new API Key

Purpose: issue a machine credential for scripts/CI/third-party systems. The plaintext api_key field is returned exactly once; the frontend must let the user copy and store it immediately.

Authentication: sys.apikey.create

Input parameters (request body):

Field Type Required Example Description
name string yes prod-integration ≤ 100 chars; for audit identification
scopes string[] no ["guard.domain.list"] Permission full key list; empty = full inheritance
expires_in_days int no 90 Default 90, max 365
allowed_ip_cidrs string[] no ["203.0.113.0/24"] E14 IP allowlist CIDRs; empty means no IP restriction

Output fields:

Field Type Description
data.key_id string Unique API Key ID (used for revoke / log lookup)
data.name string Same as request
data.api_key string Full plaintext prefix.secret, returned exactly once
data.prefix string Like zck_abc12345, safe to log
data.last4 string Last 4 chars of secret, frontend uses to identify "the one I just created"
data.expires_at int64 Expiration time, Unix ms

Error codes:

Visualization recommendation: issuance is a one-shot write; frontend should use a modal with a one-time copy button + masked display (industry pattern: GitHub/Stripe).

Example request:

curl -X POST https://waf.example.com/api/sys/api-keys \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"prod-integration","scopes":["guard.domain.list"],"expires_in_days":90}'

Example response:

{
  "code": 0,
  "data": {
    "key_id": "8f21c0c5-55ae-4cbd-a60a-8e64a6e2b1d0",
    "name": "prod-integration",
    "api_key": "zck_abc12345.A1b2C3d4E5f6G7h8I9j0K1L2M3n4O5p6Q7r8S9t0",
    "prefix": "zck_abc12345",
    "last4": "5t0",
    "expires_at": 1732982400000
  }
}

Common pitfalls:


GET /api/sys/api-keys — List API Keys

Purpose: render the API Keys list page with status for the current user / OEM.

Authentication: sys.apikey.list

Input parameters:

Field Type Required Description
page int no Default 1
size int no Default 20, max 100

Output fields (data.list[]):

Field Type Description
key_id string Unique API Key ID
name string Name
prefix string zck_xxx prefix
last4 string Last 4 of secret
user_id string Owner
oem_id string OEM scope boundary
scopes string[] Narrowed permission list
allowed_ip_cidrs string[] E14 IP allowlist
status int 1 = active, 2 = revoked
expires_at int64 Expiration time
last_used_at int64 Last call time; 0 if never used
last_used_ip string Last call IP; "" if never used
ctime int64 Issuance time

Visible scope:

Visualization recommendation: table. status rendered as a badge (green=active / gray=revoked); highlight rows where expires_at is < 7d. Combined with /stats, you can plot a "Top 5 Keys by call volume" bar chart.

Example request:

curl -H "Authorization: Bearer $TOKEN" \
  "https://waf.example.com/api/sys/api-keys?page=1&size=20"

DELETE /api/sys/api-keys/{id} — Revoke an API Key

Purpose: soft-revoke (statusrevoked); audit trail is preserved. Middleware rejects subsequent requests where status != active.

Authentication: sys.apikey.delete

Input parameters:

Field Type Required Description
id path yes API Key key_id

Output fields: data is null.

Idempotency: re-revoking returns 200; the frontend can call repeatedly without errors.

Operable scope:

Error code: 1051 API Key not found or out of visible scope.

Visualization recommendation: not a chart; trigger from a "Revoke" button with a confirmation dialog.

Example request (using API Key as caller):

curl -X DELETE https://waf.example.com/api/sys/api-keys/8f21c0c5-55ae-4cbd-a60a-8e64a6e2b1d0 \
  -H "Authorization: ApiKey zck_abc12345.A1b2C3d4..."

GET /api/sys/api-keys/{id}/logs — Call audit log for a Key (E12)

Purpose: audit the call history of an API Key. Returns events from the audit table api_key_audit_logs.

Authentication: sys.apikey.logs

Input parameters:

Field Type Required Description
id path yes API Key key_id
event string no call (default, call audit) / manage (management actions)
page int no Default 1
size int no Default 20, max 100

Output fields (per record):

Field Type Description
id int Auto-increment primary key
event_type string call / manage
key_id string Associated API Key ID
user_id string Subject (call = key holder; manage = operator)
auth_mode string apikey / session; the channel the request came through
action string call = <METHOD> <PATH>; manage = create / revoke / renew / revoke-all
status_code int HTTP response status (call type only)
biz_code int Business code (0 = success; call type only)
client_ip string Client IP
user_agent string UA (≤ 255 chars, truncated)
extra string JSON string with renew / batch action structured extensions
ctime int Unix ms

Visualization recommendation:

Visible scope:

Error code: 1051 API Key not found or out of visible scope.


GET /api/sys/api-keys/{id}/stats — Aggregated statistics for a Key

Purpose: on the API Key detail page, show aggregate KPIs (total calls, success rate, QPS, top endpoints). Based only on event_type=call records.

Authentication: sys.apikey.stats

Input parameters:

Field Type Required Description
id path yes API Key key_id
since string/int no Aggregation window start; supports 24h / 7d / 30m relative or pure integer ms timestamp; empty = all history

Output fields:

Field Type Description
total_calls int Total calls in window
success int Count of 200 ≤ status_code < 400
client_err int Count of 400 ≤ status_code < 500
server_err int Count of status_code ≥ 500
top_endpoints array Top 5 actions by count ({action, count}, descending)
last_1h_qps float Last 1h QPS (count / 3600)

Visualization recommendation:

Error codes: 400 since parse failed; 1051 API Key not found or out of visible scope.

Example request:

curl -H "Authorization: Bearer $TOKEN" \
  "https://waf.example.com/api/sys/api-keys/8f21c0c5-55ae-4cbd-a60a-8e64a6e2b1d0/stats?since=24h"

GET /api/sys/api-keys/audit-actions — Management action audit log (E13)

Purpose: cross-Key audit view; returns the management actions (create / revoke / renew / revoke-all) audit stream.

Authentication: sys.apikey.audit

Input parameters: page / size, standard pagination.

Output fields: same as GET /api/sys/api-keys/{id}/logs; event_type is always manage.

Visible scope:

Visualization recommendation:


4.3 Permission Tree

GET /api/sys/permissions/tree — Permission tree

Purpose: returns the full permission tree (module / resource / action three layers + i18n names) for the current OEM. The frontend "Role Permissions" page renders the checkbox tree from this; the API Key scope picker also uses the same tree.

Authentication: authenticated session (no specific permission required)

Input parameters: optionally append Accept-Language: en-US to localize permission names.

Output fields (excerpt, data[]):

Field Type Description
module string Module, e.g. guard / sys / analytics
resources[] array Resources under this module
resources[].prefix string Resource prefix, e.g. guard.domain
resources[].name string Resource i18n display name
resources[].actions[] array Actions under this resource
resources[].actions[].key string Short action key, e.g. list / create
resources[].actions[].name string Action i18n display name
resources[].actions[].full_key string Full permission key, e.g. guard.domain.list

Visualization recommendation:

Example response (excerpt):

{
  "code": 0,
  "data": [
    {
      "module": "guard",
      "resources": [
        {
          "prefix": "guard.domain",
          "name": "Domains",
          "actions": [
            { "key": "list",   "name": "List domains",  "full_key": "guard.domain.list" },
            { "key": "view",   "name": "View domain",   "full_key": "guard.domain.view" },
            { "key": "create", "name": "Create domain", "full_key": "guard.domain.create" }
          ]
        }
      ]
    }
  ]
}

4.4 Operation Audit /api/sys/audit-logs · /api/sys/login-records

The console page "Log Center › Operation Audit" renders both endpoints as two tabs: operation logs and login records. Both are gated by sys.audit.view.

Data visibility (important) — narrowed in SQL at the repo layer, three tiers keyed on role_id (scopeOemID / scopeUserID in the handler):

Role tier Whose records are visible
Platform (role_id < 10: super admin / ops / auditor) Everything, no OEM filter
Business root (role_id >= 10 with empty users.first_id, typically a top-level reseller) Own OEM: self plus the whole downstream creation chain
Business downstream (sub-reseller / customer) Only self, plus rows where staff acted on their behalf

No request parameter can target another user. keyword does LIKE against user_id / user_name, but it is ANDed with the scope clause and cannot widen it.

GET /api/sys/audit-logs — Operation logs

Purpose: audit trail of console writes — who changed what, before/after values, success or failure, and whether it was done on a customer's behalf.

Auth: sys.audit.view

Query parameters:

Parameter Type Notes
page / size int Pagination, size capped at 100
keyword string Broad LIKE across 14 columns (action / actor / IP / resource / delegation context)
channel string Actor channel filter, alias actor_type; one of user / apikey / oauth / aegeon_staff / internal_service, unknown values are ignored silently
start_time / end_time int64 Unix timestamp, seconds or milliseconds both accepted (normalizeLogTime in the service layer converts to millis, because the frontend date pickers have always sent seconds while storage is millis). Inclusive bounds; omit for an open end

Response fields (excerpt of data.list[]; see the AuditLog schema in OpenAPI for the full set):

Field Type Notes
ctime int64 When it happened (millisecond timestamp)
action string Business action code, e.g. guard.domain.update
message string Human-readable description, with the failure reason appended when it failed
resource_type / resource_label string Resource type and human name
actor_type / actor_name string Actor channel and display name
remote_ip string Source IP of the operation
status / http_status / biz_code int Business result (1 ok, 2 failed) plus HTTP / business codes
detail string Structured JSON: changes (field old→new) and params (business context)
on_behalf_* / delegation_* string Delegation context when staff acted for a customer

Label source of truth: a write route needs an entry in productionAuditSpecs (internal/middleware/audit_spec.go) to get a human label; query-style POSTs belong in productionReadOnlyAuditPosts and are not recorded at all. A write route in neither list lands as action=unregistered.business_operation with message=未登记业务操作; audit_spec_coverage_test.go guards against new ones.

GET /api/sys/login-records — Login records

Purpose: sign-in and staff-delegated sign-in trail — who signed in when, from which IP, using which method, and whether it succeeded.

Auth: sys.audit.view

Query parameters:

Parameter Type Notes
page / size int Pagination, size capped at 100
keyword string Broad LIKE across 15 columns (username / IP / device / UA / auth channel / failure reason / delegation context)
status int Result: 1 success, 2 failure; omit or 0 for all
start_time / end_time int64 Same semantics as operation logs (seconds or millis, inclusive)

Response fields (excerpt of data.list[]; see the LoginRecord schema in OpenAPI for the full set):

Field Type Notes
ctime int64 Sign-in time (millisecond timestamp); login_time is the same instant pre-formatted
user_name / user_id string Account that signed in
ip / login_district string Source IP and resolved region (empty or a placeholder for private IPs)
auth_channel string password / apikey / oauth / aegeon_staff
login_status / failure_reason int / string Result and failure reason
ttl_seconds int64 Lifetime of the resulting session, in seconds
staff_* / on_behalf_* / delegation_* string Delegation context for staff-assisted sign-in

§5 Guard Resource Management

📦 Guard Resource Management · 30 endpoints · used to configure protection objects (domains/certs/policies/CC&ACL rules/bwlist/forwards/schedules/WAF rules); the "configuration plane" of WAF protection.
Full schemas in /api/openapi.json. This section gives the most critical fields, enums, and common pitfalls for integration.

5.1 Domains /api/guard/domains

The domain is the core resource of Guard — every protection policy, certificate binding, and analytics aggregation anchors on domain_id.

GET /api/guard/domains — Domain list

Purpose: render the domain table on the "Protection - Domains" page; supports filtering by audit_status and keyword search.

Authentication: guard.domain.list

Input parameters:

Field Type Required Example Description
page int no 1 Page number
size int no 20 Per-page count, 1-100 (not page_size)
keyword string no api.example Fuzzy search on domain / asset_name
audit_status int no 4 1 = unaudited, 2 = auditing, 3 = rejected, 4 = approved

Output fields (data.list[] = DomainVO):

Field Type Description
domain_id string Unique domain ID
domain string The domain itself, e.g. api.example.com
asset_name string Asset note / alias
user_id string Owner UUID (kept for legacy platform compatibility)
user_name string Owner display name (P1.3 added, sourced from cloud sys.users)
policy_id string Bound policy ID
cname string CNAME assigned by the backend
auto_cert bool Whether auto-issuance of certificates is enabled
mode int32 Onboarding mode (1 = reverse-proxy, etc.; see ops doc)
audit_status int32 1=unaudited 2=auditing 3=rejected 4=approved
switches map<string,int32> Protection switches; keys from waf/cc/acl/bot/cache; 1=on 0=off
ctime / utime int64 Creation / update time

Visualization recommendation:

Example request:

curl -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains?page=1&size=20&audit_status=4"

Example response:

{
  "code": 0,
  "data": {
    "list": [
      {
        "domain_id": "d_8a3b1c",
        "domain": "api.example.com",
        "asset_name": "Production API gateway",
        "user_id": "u_abc",
        "policy_id": "p_default",
        "cname": "api.example.com.cname.zcloud.io",
        "auto_cert": false,
        "mode": 1,
        "audit_status": 4,
        "switches": { "waf": 1, "cc": 1, "acl": 1, "bot": 0, "cache": 1 },
        "ctime": 1714521600000,
        "utime": 1714608000000
      }
    ],
    "total": 8,
    "page": 1,
    "size": 20
  }
}

Common pitfalls:


POST /api/guard/domains — Create domain

Purpose: backend for the "Add Domain" form, creating a new protected domain.

Authentication: guard.domain.create

Input parameters (request body DomainCreateReq):

Field Type Required Description
domain string yes The domain itself, e.g. api.example.com
policy_id string yes Protection policy ID; list them with GET /api/guard/policies
src_sites array yes Origin configuration, 1-20 entries
src_sites[].addr string yes Origin address (IP or hostname, no wildcards)
src_sites[].port int yes Origin port, 1-65535
src_sites[].protocol string yes Origin protocol: http / https
src_sites[].weight int no Weight, defaults to 1
asset_name string no Asset note
cert_id int no Bind a certificate at creation time; it must exist, belong to the current user and cover this domain

Why policy_id is required: a domain's WAF, CC, access-control, IP-list and BOT rules all live on the policy. A domain without one is proxied normally but enforces nothing — onboarded yet unprotected.

src_sites entries with the same address, port and protocol are deduplicated silently, without an error.

Either way works for certificates: pass cert_id here (domain, origins and certificate binding all happen in one transaction, rolled back as a whole on any failure), or call POST /api/guard/certs/:id/bind afterwards.

Output fields: returns DomainVO (same as a list item) with the assigned domain_id and cname.

Visualization recommendation: not a chart. After success, jump to the domain detail page or refresh the list.


GET /api/guard/domains/{id} — Domain detail

Purpose: fetch a single domain when entering the detail page.

Authentication: guard.domain.view

Input parameters: path id = domain_id.

Output fields: DomainVO, identical to a list item.

Visualization recommendation: form display. switches rendered as a switch group; audit_status as a badge.


PUT /api/guard/domains/{id} — Update domain

Purpose: edit mutable fields like asset_name, policy_id, auto_cert.

Authentication: guard.domain.edit

Input parameters (request body DomainUpdateReq):

Field Type Required Description
asset_name string no Asset note
policy_id string no Switch policy
auto_cert bool no Toggle auto-issuance
mode int32 no Onboarding mode

Output fields: returns the updated DomainVO.

Visualization recommendation: not a chart.


DELETE /api/guard/domains/{id} — Delete domain

Purpose: remove the domain from the protection list. Deletion cascades to settings, cert bindings, analytics snapshots.

Authentication: guard.domain.delete

Input parameters: path id = domain_id.

Output fields: data is null.

Visualization recommendation: not a chart. Confirm twice in the UI; warn that "analytics and bindings will be cleaned up".


GET /api/guard/domains/{id}/settings — Get domain settings

Purpose: render the "Advanced Settings" tab on the domain detail page; shows current effective settings (protection module switches, cache policy, CC limits).

Authentication: guard.domain.view

Input parameters: path id = domain_id.

Output fields:

Field Type Description
data.settings map<string,string> keys taken from the backend settings.* dictionary, typically waf/cc/acl/bot/cache; values are stringified config JSON

Visualization recommendation: form display; one config card per key.


GET /api/guard/domains/{id}/src-check-peers — Peers probing the same origin (read-only)

Purpose: Probes run per origin IP on the node, not per domain. When several domains of the same user point at the same origin IP, the node runs a single probe and uses the smallest interval among them — so a domain may show 60s in the UI while the origin is actually probed every 10s. This endpoint surfaces the peer configuration so the effective value is explainable.

Auth: guard.domain.settings (it reads the same domain settings; no separate permission).

Input: path id = domain_id.

Output:

Field Type Notes
data.shared bool Whether any peer shares the origin and has probing enabled; the UI hides the hint when false
data.domains string[] Peer domain names (empty array, never null)
data.ping_config / tcp_configs / http_configs object Peer settings for that probe; the field is omitted entirely when no peer enabled it

Peer probe fields: detection_time_intervals (seconds, already reduced to the minimum), action (4 = alert only, 5 = switch traffic and alert), ports, fail_check_type, serial_failure_count, failure_ratio_windos, failure_ratio (integer percent 0-100).

Semantics: Strictly read-only — writes nothing and does not alter this domain's stored value. Scoped to the same user (matching zmod's where user_id = (...)); other tenants sharing the IP never appear here.


PUT /api/guard/domains/{id}/settings — Update domain settings

Purpose: modify the settings map for a domain.

Authentication: guard.domain.edit

Input parameters (request body DomainSettingsUpdateReq):

Field Type Required Description
settings map<string,string> yes Keys must come from settings.* returned by GET; unknown keys rejected

Output fields: data is null; the caller should issue a GET to fetch the new state.

Visualization recommendation: not a chart.

Common pitfalls:


PUT /api/guard/domains/{id}/origins/{service_id}/status — Toggle a service or origin

Permission: guard.domain.origin_status

Input: path id (domain ID) and service_id (src_configs[].service_id in the origin config).

Field Type Required Values Notes
status int32 yes 1 / 2 1=disabled 2=enabled
source object no Omit to toggle the whole service; provide it to toggle a single origin inside that service
source.ip string yes* 10.0.0.9 Required when source is present
source.port int32 yes* 80 Required when source is present
source.line string no Line_1_Default Left out of matching when omitted

Origin config carries two independent status levels: the public service (listen port + scheme) and each origin
inside it. "Service enabled, one origin individually disabled" is a state that exists in production. Origins are
located by ip+port (+optional line), never by index: an index depends on caller and stored order matching
exactly, so any add/remove in between would disable the wrong machine.

How this differs from PUT /api/guard/domains/{id}/settings: that endpoint takes the whole JSON blob, read
modified and written back by the caller. Origin config lives in the shared guard_domain_settings.service_config_setting
row that the legacy platform also reads and writes; rebuilding the blob and dropping any single key corrupts it.
This endpoint only states which entry to toggle — the JSON edit happens server-side and every other key (including
keys this platform does not recognise) is preserved verbatim. Values written are the string enums
CFGOPTION_2_ENABLE / CFGOPTION_1_DISABLE.

Still enforced: tenant isolation, and the domain must keep at least one enabled ordinary HTTP/HTTPS origin.
A status change triggers config delivery.

Output: none.


GET /api/guard/domains/{id}/dns/advance/config — Get DNS advanced settings

Purpose: query the DNS scheduling advanced settings: auto return-to-origin, minimum active node records, IPv6 checks. Fields mirror the zmod "DNS Advanced Settings" dialog and gen-server DnsAdvanceConfig.

Auth: guard.domain.settings (disp_config_setting is already on the generic settings whitelist — same data through another door, no separate permission).

Input: path id = domain_id.

Output fields (data is DnsAdvanceConfigVO):

Field Type Description
auto_return_source string Auto return-to-origin: CFGOPTION_1_DISABLE | CFGOPTION_2_ENABLE
auto_switch_case int Minimum active node records (legacy data may be 0; UI displays 1)
src_ipv6_check string Monitor origin IPv6 (same enum)
default_ipv6_check string Check default-line IPv6 records (same enum)
auto_switch string Auto-switch master toggle (read-only echo)
total_count / ava_ratio / max_count int / float / int Monitoring thresholds, read-only

Semantics: when either src_ipv6_check or default_ipv6_check is enabled, the records rebuild generates AAAA records for IPv6-capable nodes/origins — this is the switch for IPv4/IPv6 dual-stack access.

Example:

curl -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/dns/advance/config"

PUT /api/guard/domains/{id}/dns/advance/config — Update DNS advanced settings

Purpose: update the DNS advanced settings. Mirrors the zmod dialog: only the four fields below are written; every other key stored in disp_config_setting (total_count/ava_ratio/max_count/auto_switch/cname, etc.) is preserved as-is.

Auth: guard.domain.settings

Input (request body DnsAdvanceConfigUpdateReq, all four required):

Field Type Required Description
auto_switch_case int yes Minimum active node records, 1-1000
auto_return_source string yes CFGOPTION_1_DISABLE | CFGOPTION_2_ENABLE
src_ipv6_check string yes Same enum
default_ipv6_check string yes Same enum

Output: data is null.

Semantics: saving does not touch existing DNS records; IPv6 toggles apply on the next records rebuild (POST /api/guard/schedules/domains/{id}/init), including AAAA record add/remove. Enums must be string names — numeric forms are rejected by gen-server's types.CFGOPTION.

Example:

curl -X PUT -H "Authorization: ApiKey $ZCLOUD_API_KEY" -H "Content-Type: application/json" \
  -d '{"auto_switch_case":1,"auto_return_source":"CFGOPTION_1_DISABLE","src_ipv6_check":"CFGOPTION_2_ENABLE","default_ipv6_check":"CFGOPTION_2_ENABLE"}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/dns/advance/config"

Domain Onboarding UX Revamp · Backend Interface Audit (2026-06-26)

The frontend is upgrading the domain management page into a three-stage UX: "inline row jumps → domain workbench → onboarding wizard". Audit conclusion: mostly frontend orchestration of existing endpoints; after the domain workbench (Phase 2) shipped, two backend supplements were found in practice (see "Backend supplements found in practice" below). Mapping:

Frontend capability Reused existing endpoint Notes
Inline jump pre-filter (forwards / schedules / logs / certs) GET /forwards?keyword= · GET /schedules/domains?keyword= · GET /analytics/logs?host= · cert list keyword All support per-domain filtering; frontend reads ?keyword= to pre-fill. Shipped.
Workbench · settings packages GET /domains/{id}/settings (separate endpoint) ⚠️ GET /domains/{id} does NOT include settings; must call this separately. Frontend aligned.
Workbench · origin list GET /forwards?domain_id= Precise filter by domain_id
Workbench · DNS / onboarding status GET /schedules/domains?keyword= (incl. parsing_state / node·src count) + /records ⚠️ parsing_state is NOT in Detail; frontend reads it from this endpoint
Workbench · monitoring summary POST /analytics/batch with domain_id Per-domain stats already supported
Workbench · bound certificate GET /domains/{id} returns cert_id Detail now includes the bound cert id; 0 means unbound
Workbench · protection nodes GET /domains/{id} returns nodes[] Detail now includes protection nodes
Wizard · create domain / origin / cert / DNS POST /domainsPOST /forwardsPOST /certs/{id}/bind/{domainId}POST /schedules/domains/{id}/init Full write chain already exists

Backend supplement ① (detail fields shipped)

GET /api/guard/domains/{id} now adds cert_id + nodes + shadow_cache_addr onto the existing DomainVO. settings and parsing_state still come from their existing separate endpoints (GET /domains/{id}/settings, GET /schedules/domains).

Field Type Source
cert_id uint64 (0 = unbound) reverse-lookup the cert↔domain binding by domain_id (the inverse of POST /certs/{id}/bind/{domainId}) for the most recently bound cert (kept for compatibility)
certs []DomainCertVO (omitted when unbound) all certificates bound to this domain (the binding is many-to-many — dual certs / old+new coexistence during rotation). Each item carries id / name / certificate_type / common_name / issuer / expired_at / auto_cert; PEM is not included
nodes []DomainNodeVO the domain's protection nodes (fields ip_addr / node_id / enabled / line)
shadow_cache_addr string (omitted when unset) Cloud Shadow cache node IP, read from guard_platforms.cache_addr (a single platform-wide row, not per-domain). The workbench shows it in the Cloud Shadow tooltip so operators can allowlist it on their local security appliance; the list endpoint does not return it

Target response shape (add 2 fields onto the existing DomainVO, aligning with frontend DomainDetailVO):

{
  "domain_id": "...", "domain": "...", "cname": "...", "switches": {},
  "cert_id": 12,
  "shadow_cache_addr": "192.168.14.103",
  "nodes": [
    { "id": 1, "ip_addr": "1.2.3.4", "node_id": "n-001", "enabled": 1, "line": 0 }
  ]
}

The frontend can directly read detail.cert_id (then GET /certs/{id} for common_name/expiry/cert_type) and detail.nodes (node table).

Wizard usage (①.5): POST /domains now consumes src_sites + cert_id directly — the domain, its origin configuration and the certificate binding all happen in one transaction, rolled back as a whole on any failure. The wizard creates everything in that single call and no longer needs the "create domain → POST /forwards per origin → POST /certs/{id}/bind/{domainId}" workaround. The step-by-step endpoints still exist, for adding origins to or rebinding certificates on an existing domain.

POST /api/guard/domains/{id}/verify — Active domain onboarding probe

Purpose: actively check origin reachability and whether public DNS points to the assigned CNAME for the onboarding wizard.

Authentication: guard.domain.verify

Input parameters: path id = domain_id.

Output fields:

Field Type Description
origin_reachable bool Backend reads the first origin from service_config_setting and probes TCP/80
origin_latency_ms int64 TCP connect latency; 0 on probe failure
dns_effective bool Whether the public CNAME equals the domain's assigned cname
resolved_cname string Actual resolved CNAME
checked_at int64 Probe time, Unix milliseconds

Origin Groups /api/guard/domains/{id}/origin-groups

Origin group: one group = a set of protection nodes bound to the subset of origin servers they pull from, making "which node pulls from which origins" controllable (e.g. telecom nodes pull from telecom origins). Each domain has exactly one default origin group (holds all origins, cannot be deleted, fallback for ungrouped nodes); a node belongs to exactly one group within a domain; if any origin in a group is IPv6, the group must contain at least one IPv6-capable node. All 3 endpoints require guard.domain.origin_group.

GET /api/guard/domains/{id}/origin-groups — List origin groups

Purpose: list origin groups of the domain (default group first, then custom groups).

Authentication: guard.domain.origin_group

Input parameters: path id = domain_id.

Output fields (data.list[] is OriginGroupVO):

Field Type Description
group_id int64 Group ID
name string Group name
is_default bool Whether this is the default origin group (holds all origins, cannot be deleted)
node_list[] array Protection nodes in the group; item fields node_id / name / line / ipv6
src_list[] array Origins in the group; item fields src_ip / line / ipv6
utime int64 Update time (Unix milliseconds)

Example request:

curl -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/origin-groups"

POST /api/guard/domains/{id}/origin-groups — Create origin group

Purpose: regroup operation — the selected nodes are pulled out of their previous groups into the new one; any custom group left empty is deleted automatically and its members fall back to the default group. Node/origin metadata is backfilled by the server; clients only send identifiers.

Authentication: guard.domain.origin_group

Input parameters (path id = domain_id; request body OriginGroupCreateReq):

Field Type Required Description
name string yes Group name, max 100; must not clash with an existing group or use a reserved name
node_ids []string yes Protection node ID list, min 1; nodes must be bound to the domain
src_ips []string yes Origin IP list, min 1; origins must exist in the domain's origin config

Output fields: data is the created group's OriginGroupVO (same shape as list items).

Errors (400): duplicate/reserved group name, missing default group, node not bound to the domain, origin not in the domain's origin config, group contains an IPv6 origin but no IPv6-capable node.

Example request:

curl -X POST -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"telecom","node_ids":["n-a","n-b"],"src_ips":["10.0.0.1","10.0.0.2"]}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/origin-groups"

DELETE /api/guard/domains/{id}/origin-groups/{group_id} — Delete custom origin group

Purpose: delete a custom origin group; its nodes and origins are merged back into the default group. The default origin group cannot be deleted.

Authentication: guard.domain.origin_group

Input parameters: path id = domain_id, path group_id = group ID (int64).

Output fields:

Field Type Description
group_id int64 ID of the deleted group

Errors: 404 group not found; 400 the default group cannot be deleted.

Example request:

curl -X DELETE -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/origin-groups/5301"

PUT /api/guard/domains/{id}/audit — Domain Audit

Purpose: audit a domain. Newly created domains default to audit_status=1 (unaudited) and are not pushed to protection nodes until approved; setting 4 (approved) automatically triggers config distribution.

Authentication: platform-level roles only (role_id < 10) with guard.domain.audit; business roles receive 403 even if this permission was granted accidentally.

Input parameters (path id = domain_id; request body):

Field Type Required Description
audit_status int yes Audit status: 2=in review, 3=rejected, 4=approved (only these 3 values allowed)

Output fields:

Field Type Description
domain_id string Domain ID
audit_status int Audit status after the update

Errors: 400 invalid audit_status; 403 non-platform role or insufficient permission; 404 domain not found or not accessible.

Example request:

curl -X PUT -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"audit_status":4}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/audit"

Domain Node Assignment /api/guard/domains/{id}/nodes

Node assignment: which protection nodes a domain is bound to determines which nodes carry and scrub its traffic. All 5 per-domain endpoints require guard.domain.node; successful assign/remove calls trigger config re-distribution, while lock only changes state without re-distribution. There is also an ops reconciliation entry POST /api/guard/domains/nodes/sync (guard.domain.edit, not exposed in the console, CLI only) — see the end of this section.

GET /api/guard/domains/{id}/nodes — List assigned nodes

Purpose: list protection nodes assigned to the domain, with lock status and node metadata.

Authentication: guard.domain.node

Input parameters: path id = domain_id.

Output fields (data.list[] is DomainAssignedNodeVO):

Field Type Description
node_id string Node ID
name string Node name
machine_room string Machine room
ipv6 bool Whether the node supports IPv6
ip_addr string Node IP
line int32 Line code (telecom/unicom/mobile etc.)
lock_status int32 1=normal 2=locked (a locked node is skipped by config rendering and distribution; the binding is kept)

Errors: 404 domain not found or not accessible.

Example request:

curl -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/nodes"

GET /api/guard/domains/{id}/nodes/available — List assignable nodes

Purpose: list nodes still assignable to the domain (account node pool minus already-assigned).

Authentication: guard.domain.node

Input parameters: path id = domain_id.

Output fields (data.list[] is AssignableNodeVO): node_id / name / machine_room / ip_addr / line / ipv6 (same meanings as the table above, without lock_status).

Errors: 404 domain not found or not accessible.

Example request:

curl -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/nodes/available"

PUT /api/guard/domains/{id}/nodes — Assign nodes

Purpose: assign nodes to the domain (incremental; already-assigned nodes are skipped). The nodes are also synced into CNAME DNS records: with enable_parse=true the records are enabled at the same time (effective in node dispatch mode; in origin mode only last_status is synced and the records auto-enable on the next acceleration); without it the records are created but stay disabled. A successful call triggers config re-distribution.

Authentication: guard.domain.node

Input parameters (path id = domain_id; request body DomainNodeAssignReq):

Field Type Required Description
node_ids []string yes Node ID list, 1-100 items; nodes must be in the account node pool
enable_parse bool no Enable the DNS records of these nodes at assign time (default false)

Output fields (data is DomainNodeAssignResultVO):

Field Type Description
added int Bindings actually added (already-assigned nodes are skipped)
dns_synced bool Whether DNS records were synced (false when the DNS scheduling module is unavailable)

Errors: 400 selected node cannot be assigned (not in the account node pool or already assigned); 404 domain not found or not accessible.

Example request:

curl -X PUT -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"node_ids":["n-a","n-b"],"enable_parse":false}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/nodes"

DELETE /api/guard/domains/{id}/nodes/{node_id} — Remove a node

Purpose: remove a node from the domain. A successful call triggers config re-distribution.

Authentication: guard.domain.node

Input parameters: path id = domain_id, path node_id = node ID.

Protective rejections (400):

Output fields:

Field Type Description
domain_id string Domain ID
node_id string ID of the removed node

Errors: 400 see protective rejections above; 404 domain not found or the node is not assigned to the domain.

Example request:

curl -X DELETE -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/nodes/n-a"

PATCH /api/guard/domains/{id}/nodes/{node_id}/lock — Lock/unlock a node

Purpose: lock or unlock a node of the domain. When locked (2), config rendering and distribution skip the node while the binding is kept; unlocking (1) restores it. Status-only change; does not trigger re-distribution.

Authentication: guard.domain.node

Input parameters (path id = domain_id, path node_id = node ID; request body DomainNodeLockReq):

Field Type Required Description
lock_status int32 yes Lock status: 1=normal 2=locked

Output fields:

Field Type Description
domain_id string Domain ID
node_id string Node ID
lock_status int32 Lock status after the update

Errors: 400 invalid lock status (only 1/2 allowed); 404 domain not found or the node is not assigned to the domain.

Example request:

curl -X PATCH -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"lock_status":2}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/nodes/n-a/lock"

POST /api/guard/domains/nodes/sync — Node assignment reconciliation (ops)

Purpose: ops reconciliation tool — fully aligns node bindings of ALL domains with the upstream (aeg) per-user node allocation (adding missing bindings, removing surplus ones). Run it once after nodes are reclaimed to batch-clean ghost bindings. It overwrites manual per-domain fine-grained assignment; for day-to-day incremental assignment use PUT /api/guard/domains/{id}/nodes instead. The console no longer exposes this entry (CLI only: zcloud guard domains nodes sync). Changed domains trigger config re-distribution.

Authentication: guard.domain.edit. Platform-level users may target one customer via ?user_id=; business-level users only reconcile their own domains.

Input parameters: query user_id (optional) = only reconcile domains of the given user.

Output fields (data is DomainNodeSyncVO):

Field Type Description
domains int Number of changed domains
added int Node bindings added
removed int Node bindings removed (ghost-binding cleanup)
changed_domain_ids []string IDs of changed domains (omitted when nothing changed)

Example request:

curl -X POST -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/nodes/sync"

Domain Anti-Brute-Force Rules /api/guard/domains/{id}/brute-force/rules

Anti-brute-force: per-domain request-rate rules — when the same source IP / bot session exceeds the request threshold against a given URI within the counting window, the configured action is executed (block / redirect / captcha, etc.). Rules live in a shared table and are distributed to nodes; all 4 endpoints require guard.domain.bruteforce; successful create/update/delete calls trigger config re-distribution. Weak-password interception is NOT part of this group — it goes through the existing domain settings endpoints via the guard_weak_password_setting key (GET/PUT /api/guard/domains/{id}/settings).

GET /api/guard/domains/{id}/brute-force/rules — List rules

Purpose: list the domain's anti-brute-force rules (descending by rule ID).

Authentication: guard.domain.bruteforce

Input parameters: path id = domain_id.

Output fields (data.list[] is BruteForceRuleVO):

Field Type Description
id int64 Rule ID (server-generated)
name string Rule name
describe string Rule description
uri string Protected URI
rate int64 Request-count threshold
rate_time int64 Counting window length
req_time_unit string Counting window unit: ReqUnit_1_PSec=seconds | ReqUnit_2_PMin=minutes
status bool Rule switch
level string Limit level: ip (per source IP) | bot_session (per bot session)
action object Action to execute, fields below

action object fields:

Field Type Description
action_type string Action: block / pass / jump / log / js_check / meta_check / captcha
block_time int64 Block duration
block_time_unit string Block duration unit: ReqUnit_1_PSec | ReqUnit_2_PMin
code string Custom response status code (510-599)
content string Custom response body
limit int64 Rate-limit value
window int64 Rate-limit window
window_unit string Rate-limit window unit: ReqUnit_1_PSec | ReqUnit_2_PMin
jump_addr string Redirect address (effective when action_type=jump)

Errors: 404 domain not found or no access.

Example request:

curl -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/brute-force/rules"

POST /api/guard/domains/{id}/brute-force/rules — Create a rule

Purpose: create an anti-brute-force rule. The rule ID is server-generated; status is forced to true by the server (rules are enabled on creation). A successful call triggers config re-distribution.

Authentication: guard.domain.bruteforce

Input parameters (path id = domain_id; request body BruteForceRuleCreateReq, same fields as BruteForceRuleVO above without id). Notes:

Output fields (data is the created BruteForceRuleVO, including the server-generated id).

Errors: 400 invalid body / enum value; 404 domain not found or no access.

Example request:

curl -X POST -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"login-guard","describe":"login endpoint rate limit","uri":"/login","rate":10,"rate_time":60,"req_time_unit":"ReqUnit_1_PSec","level":"ip","action":{"action_type":"block","block_time":10,"block_time_unit":"ReqUnit_2_PMin"}}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/brute-force/rules"

PUT /api/guard/domains/{id}/brute-force/rules/{rule_id} — Update a rule

Purpose: update an anti-brute-force rule — the whitelisted fields (name/describe/uri/rate/rate_time/req_time_unit/status/level/action) are overwritten as a whole row; omitted fields are written as zero values. rule_id must belong to the domain (prevents cross-domain edits of the shared table). status is stored as sent (can be used to disable a rule). A successful call triggers config re-distribution.

Authentication: guard.domain.bruteforce

Input parameters: path id = domain_id, path rule_id = rule ID; request body BruteForceRuleUpdateReq (same fields and enum constraints as create).

Output fields (data is the updated BruteForceRuleVO).

Errors: 400 invalid body / enum value or non-numeric rule_id; 404 domain not found, rule not found, or rule does not belong to the domain.

Example request:

curl -X PUT -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"login-guard","describe":"login endpoint rate limit","uri":"/login","rate":20,"rate_time":1,"req_time_unit":"ReqUnit_2_PMin","status":true,"level":"bot_session","action":{"action_type":"captcha"}}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/brute-force/rules/20220414"

DELETE /api/guard/domains/{id}/brute-force/rules/{rule_id} — Delete a rule

Purpose: delete an anti-brute-force rule (detaches it from the domain and physically deletes the row); rule_id must belong to the domain. A successful call triggers config re-distribution.

Authentication: guard.domain.bruteforce

Input parameters: path id = domain_id, path rule_id = rule ID.

Output fields:

Field Type Description
domain_id string Domain ID
rule_id int64 ID of the deleted rule

Errors: 400 non-numeric rule_id; 404 domain not found, rule not found, or rule does not belong to the domain.

Example request:

curl -X DELETE -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/brute-force/rules/20220414"

Domain Cache Actions /api/guard/domains/{id}/cache

Cache actions: warm up / purge the domain's CDN cache. Both endpoints are asynchronous: the server builds a CDN cache instruction from the domain's node bindings and public-facing service config, then dispatches it to the nodes through the legacy-platform gen-service (zRPC cmd=554 → MQ) — send-and-return: nothing is persisted and there is no endpoint to query the execution result; a success response only means the instruction was submitted to the dispatch channel. Both endpoints require guard.domain.cache. Cache rules / advanced config / warm-up resource list are NOT part of this group — they go through the existing domain settings endpoints via the cache_config_v2_setting key (GET/PUT /api/guard/domains/{id}/settings).

POST /api/guard/domains/{id}/cache/warm — Warm up cache resources

Purpose: warm up the given resources into node caches (aligned with the legacy platform's "cache warm-up", cache instruction cmd_type=2). Asynchronous: send-and-return.

Authentication: guard.domain.cache

Input parameters (path id = domain_id; request body):

Field Type Description
cache_res string Resource paths to warm up, multiple entries separated by comma/space/newline; may be empty (passed through verbatim to the node cache instruction)

Output fields:

Field Type Description
domain_id string Domain ID
action string Always warm

Errors: 400 invalid request body, no nodes assigned to the domain, or public-facing service config (service_config_setting) missing/invalid; 404 domain not found or no access; 500 dispatch channel unavailable.

Example request:

curl -X POST -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"cache_res":"/index.html,/static/app.js"}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/cache/warm"

POST /api/guard/domains/{id}/cache/purge — Purge cache resources

Purpose: purge the given resources from node caches (aligned with the legacy platform's "cache purge", cache instruction cmd_type=1). Asynchronous: send-and-return.

Authentication: guard.domain.cache

Input parameters (path id = domain_id; request body):

Field Type Description
cache_res string Resource paths to purge, multiple entries separated by comma/space/newline; may be empty (passed through verbatim to the node cache instruction)

Output fields:

Field Type Description
domain_id string Domain ID
action string Always purge

Errors: 400 invalid request body, no nodes assigned to the domain, or public-facing service config (service_config_setting) missing/invalid; 404 domain not found or no access; 500 dispatch channel unavailable.

Example request:

curl -X POST -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"cache_res":"/index.html,/static/app.js"}' \
  "https://waf.example.com/api/guard/domains/d_8a3b1c/cache/purge"

5.2 Certificates /api/guard/certs

Certificate flow is a two-step process: "upload PEM → bind to domain". Content-Type is application/json, not multipart/form-data.

Three certificate types (certificate_type, same values as the legacy platform's shared table):

Value Type Required material Notes
1 Standard TLS (default) cert + key Regular RSA/ECDSA cert; may optionally carry a signing pair sign_cert + sign_key (standard algorithms, not SM2)
2 GM NTLS sign_cert + sign_key (signing pair) and cert + key (encryption pair) SM2 dual certificates. The shared table has no separate enc columns, so the encryption pair reuses the cert/key fields
3 Keyless cert + no_key_tls_addr The private key stays on the customer's own keyless server and is never stored here; key is discarded if sent

ssl_password_file (encrypted-cert credential / private-key passphrase) is optional for types 1 and 2, and ignored for type 3 since there is no local private key.

GET /api/guard/certs — Cert list

Purpose: table on the "Protection - Certificates" page.

Authentication: guard.cert.list

Input parameters: page / size / keyword (search name/common_name).

Output fields (data.list[] = CertVO):

Field Type Description
id uint64 Unique cert ID
name string Custom name
user_name string Owner display name (P1.3 replaces user_id, sourced from cloud sys.users)
certificate_type int32 Cert type: 1 standard TLS / 2 GM NTLS / 3 keyless
common_name string Cert CN
issuer string Issuer
expired_at int64 Expiry, Unix ms
auto_cert bool Auto-renewal
ctime / utime int64 Creation / update time

Field bound_domains does not exist; query bound domains via GET /api/guard/certs/{id}/domains.

Visualization recommendation:


POST /api/guard/certs — Upload certificate

Purpose: upload one PEM cert. The most common integration pitfall is using multipart/form-data; please use JSON.

Authentication: guard.cert.create

Security note: direct integrations still pass cert / key as JSON PEM strings. Do not put private keys in AI chats, tickets, logs, or telemetry. When operating through the Aegeon Cloud conversational assistant, prefer its secure certificate attachment flow: the browser uploads the cert/key to Aegeon and the chat only contains an attachment reference; the private key is not stored in the AI message.

Input parameters (request body CertUploadReq, application/json):

Field Type Required Description
name string yes Cert name
certificate_type int32 no 1 standard TLS (default) / 2 GM NTLS / 3 keyless
cert string yes PEM text (with -----BEGIN CERTIFICATE----- markers); the encryption cert when type is 2
key string depends Private key PEM text; required for types 1 and 2 (the encryption key when type is 2), discarded for type 3
sign_cert string depends Signing cert PEM. Required for type 2 (SM2); optional for type 1 (standard RSA/ECDSA)
sign_key string depends Signing private key PEM. Always sent together with sign_cert
no_key_tls_addr string depends Keyless server address IP:port or host:port. Required for type 3, ignored otherwise
ssl_password_file string no Encrypted-cert credential (private-key passphrase); ignored for type 3

Output fields: returns the new CertVO.

Visualization recommendation: not a chart. The frontend upload component should support both "paste PEM" and "read local file".

Example request:

curl -X POST https://waf.example.com/api/guard/certs \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"prod-2026","cert":"-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----","key":"-----BEGIN PRIVATE KEY-----\nMII...\n-----END PRIVATE KEY-----"}'

GM / keyless examples:

# GM NTLS: all four materials (signing pair + encryption pair) are mandatory
curl -X POST https://waf.example.com/api/guard/certs \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"gm-2026","certificate_type":2,"sign_cert":"-----BEGIN CERTIFICATE-----\n...","sign_key":"-----BEGIN PRIVATE KEY-----\n...","cert":"-----BEGIN CERTIFICATE-----\n...","key":"-----BEGIN PRIVATE KEY-----\n..."}'

# Keyless: cert plus the keyless server address only
curl -X POST https://waf.example.com/api/guard/certs \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"keyless-2026","certificate_type":3,"cert":"-----BEGIN CERTIFICATE-----\n...","no_key_tls_addr":"10.0.0.9:8443"}'

Common pitfalls:


GET /api/guard/certs/{id} — Cert detail

Purpose: detail page; includes full PEM text.

Authentication: guard.cert.view

Input parameters: path id.

Output fields: CertDetailVO = CertVO + cert + sign_cert (PEM text, useful for download) + no_key_tls_addr + has_ssl_password.

Private key material (key / sign_key) is never returned. no_key_tls_addr is not a secret and comes back so the edit form can pre-fill it.
The passphrase (ssl_password_file) is not echoed either — only the boolean has_ssl_password. It unlocks the private key, so returning it would let any read-only account with guard.cert.view walk away with it. The legacy platform is write-only here too.
Consequently an empty passphrase field on edit means keep the current one; clearing requires explicitly submitting an empty string.

Visualization recommendation: form display. Add a "Download cert" button (frontend assembles PEM into a download).


Keyless package endpoints

These endpoints require guard.cert.download. They return public release
metadata or a release artifact only; they never accept, assemble, or record a
business private key. First deployment does not require a certificate_type=3
certificate
: Keyless must be installed first to obtain the HOST:8443
address required by that form.

GET /api/guard/keyless/package returns the version, Linux
architectures, SHA-256 values for the ZIP verification bundle and enclosed
tar.gz, integrity-verification state, and a usage guide. Controlled CI builds
each release from the repository-vendored, source-only KeyServer snapshot,
cross-builds Linux amd64/arm64, and copies it with its SHA-256 checksum files
into the immutable Cloud server image. The backend rechecks package and ZIP
integrity before exposing it;
the release directory defaults to /app/releases/keyless.

POST /api/guard/keyless/package/download

Request body:

{"arch":"amd64"}

The response is a ZIP verification bundle with X-Artifact-SHA256. It contains
the tar.gz, SHA-256, and VERIFY.txt; verify the tar.gz according to VERIFY.txt
before extracting it. The download action is written to the operation audit
log. Any package with a missing or invalid checksum or bundle structure is not
downloadable. The
package includes verify-package.sh, install.sh, provision.sh, restart.sh,
status.sh and verify-transport.sh; the full instructions are in
docs/QUICKSTART.md. First install can run:

sudo ./scripts/install.sh --copy-private-key-file /absolute/path/to/business.key --enable

The recommended one-command installation reads and validates the source key
only on the target Linux host, then copies it to
/etc/keyless/keys/business.key (keyless:keyless, mode 0600). The source
is not modified or uploaded. Advanced users may keep a direct path with
--private-key-file or user.yml, provided the keyless user can read it and
search every parent directory.

Once the service is running and its reachable HOST:8443 is known, create a
Keyless certificate in the platform, paste the matching public fullchain, and
enter that address. KeyServer reads only the unencrypted RSA PEM/DER private
key on the target Linux host and does not load the platform certificate chain.
TLS connectivity does not mean remote signing was verified.

Integrations with an existing Keyless certificate may continue to use the
compatibility endpoints /api/guard/certs/{id}/keyless/package and
/api/guard/certs/{id}/keyless/package/download; new UI and first deployment
should use the certificate-independent endpoints above.


PUT /api/guard/certs/{id} — Update cert

Purpose: replace PEM directly (no need to delete-then-create).

Authentication: guard.cert.edit

Input parameters (request body CertUpdateReq, same fields as upload but all optional): name / certificate_type / cert / key / sign_cert / sign_key / no_key_tls_addr / ssl_password_file.

Output fields: returns the updated CertVO.


DELETE /api/guard/certs/{id} — Delete cert

Authentication: guard.cert.delete

Input parameters: path id.

Output fields: data is null.

Side effect: deletion auto-unbinds the cert from all bound domains.


GET /api/guard/certs/{id}/domains — List domains bound to a cert

Purpose: on the cert detail page, show "which domains is this cert protecting".

Authentication: guard.cert.view

Input parameters: path id.

Output fields (data[] = CertDomainVO[]):

Field Type Description
domain_id string Domain ID
domain string Domain
cert_id uint64 Cert ID (= path id)
ctime int64 Bind time

Visualization recommendation: table.


POST /api/guard/certs/{id}/bind — Bind cert to domain

Purpose: bind a specific cert to a domain.

Authentication: guard.cert.edit

Input parameters (request body CertBindReq):

Field Type Required Description
domain_id string yes Target domain ID

Output fields: data is null.

Visualization recommendation: not a chart.


DELETE /api/guard/certs/{id}/bind/{domainId} — Unbind cert from domain

Purpose: break the binding between a cert and a domain.

Authentication: guard.cert.edit

Input parameters: path id = cert ID; path domainId = domain ID.

Output fields: data is null.

Path: it is DELETE /bind/{domainId}, not POST /unbind.


Certificate Management UX Revamp · Backend Supplement Audit (2026-06-29)

The frontend is redesigning the certificate page (expiry overview + bound domains + SM2/国密 upload). Conclusion: the core capabilities already exist (list / upload incl. SM2 sign_cert·sign_key / detail with PEM / bind·unbind / GET /certs/{id}/domains); the redesign is mostly frontend type-alignment + wiring. Suggested backend supplements / clarifications:

Item Nature Notes
Cert type badge Field needed (else only a name heuristic) In practice certificate_type is always 1 on the backend (model.CertificateTypeTLS, hardcoded on upload) — it carries no DV/OV/EV level and does not distinguish RSA/ECC/GM; and the list CertVO returns neither PEM nor sign_cert, so the algorithm cannot be derived client-side from the list. The frontend currently works around this: it infers an algorithm badge from the cert name/CN suffix (_RSA/_ECDSA/_SM2), falling back to TLS when absent (not authoritative, may mislabel). For an authoritative "type" the backend should do one of: (a) make certificate_type actually graded + add a key_algorithm field; (b) parse the stored PEM and return key_algorithm + is_gm (optionally san[]) in the list VO.
auto_cert writable Suggested (auto-renew toggle) CertUpdateReq only has name/cert/key/sign_*, no auto_cert. For an editable "auto-renew" switch in the list/detail, update should accept auto_cert (or a dedicated endpoint); otherwise the frontend can only display it read-only.
Apply free cert (ACME) Optional (competitors have it) No issuance/apply endpoint today. If the product wants "one-click Let's Encrypt / DigiCert free cert", the backend needs an issuance flow endpoint; otherwise the frontend hides that entry.

No backend needed: SAN list / fingerprint / serial / key size detail fields can be parsed client-side by the frontend from the cert (full PEM) returned by GET /certs/{id}; no extra backend fields required.
Frontend to align (not a backend item, memo): the frontend CertVO type still uses old field names (cert_type/user_id); the backend actually returns certificate_type/user_name; CertDetailVO.domains[] does not exist on the backend (bound domains go through GET /certs/{id}/domains). The frontend will fix these — no backend work.


5.3 Policies /api/guard/policies

A policy is a rule container: CC / ACL / bwlist all attach to a policy. One domain binds one policy.

GET /api/guard/policies — Policy list

Authentication: guard.policy.list

Input parameters: page / size / keyword.

Output fields (data.list[] = PolicyVO):

Field Type Description
policy_id string Policy ID
name string Policy name
comment string Note (not remark)
user_id string Owner UUID (kept for legacy platform compatibility)
user_name string Owner display name (P1.3 added, sourced from cloud sys.users)
default_main_rule_version string Default main rule version
is_default bool Whether this is the system default
schema_id int64 Schema version
cc_rule_count int32 CC rule count under this policy
bwl_rule_count int32 bwlist rule count
acl_rule_count int32 ACL rule count
switches object Protection switches of the policy, passed through as-is (read-only). waf is a GuardMode tri-state (GuardMode_1_Close / GuardMode_2_Log / GuardMode_3_Enable); the others are CFGOPTION two-state (CFGOPTION_2_ENABLE / CFGOPTION_1_DISABLE, CFGOPTION_0_UNKNOWN = not configured). Use PUT /api/guard/policies/{id}/features/{key} to change them
ctime / utime int64 Time

switches is returned by the create / detail / list endpoints alike (they share the same VO); the site onboarding wizard uses it to show which protections the selected policy currently has on. Defaults for a newly created policy: waf=GuardMode_3_Enable, cc / acl / ddos / anti_crawler / black_white_list / user_priority = CFGOPTION_2_ENABLE, everything else CFGOPTION_1_DISABLE (geo / acl_rule stay CFGOPTION_0_UNKNOWN).

Visualization recommendation: table + three chips (cc/bwl/acl counts).


POST /api/guard/policies — Create policy

Authentication: guard.policy.create

Input parameters (PolicyCreateReq): name (required) / comment.

Name uniqueness: scoped to the owner (user_id), not global — different users may reuse the same policy name. When the owner already has a policy with that name the request does not fail; the server automatically appends an _01/_02… sequence suffix (same behaviour as the legacy zmod CheckPolicyNameAlready). If the requested name already carries an _0N suffix it is stripped first and the search restarts from _01.

Output fields: returns the new PolicyVO. The persisted name is whatever data.name says; it may differ from the requested name, so echo that value back to the user.


GET /api/guard/policies/{id} — Policy detail

Authentication: guard.policy.view

Input parameters: path id.

Output fields: PolicyVO.


PUT /api/guard/policies/{id} — Update policy

Authentication: guard.policy.edit

Input parameters (PolicyUpdateReq): name / comment.

Name uniqueness: renaming checks for duplicates within the policy owner's own policies (excluding itself), matching the legacy zmod "update guard policy" handler. A collision returns business code 2004 (err.guard.policy.name_exists); unlike creation it is not auto-suffixed.

Output fields: returns the updated PolicyVO.


DELETE /api/guard/policies/{id} — Delete policy

Authentication: guard.policy.delete

Input parameters: path id.

Output fields: data is null. Ensure no domain references this policy before deleting.


POST /api/guard/policies/{id}/copy — Copy policy

Authentication: guard.policy.copy

Input parameters: path id (source policy). All request-body fields optional:

Field Type Description
name string New policy name; empty auto-appends _01/_02… based on the source name. Duplicate checking is scoped to the new policy's owner, not global
user_id string Owner of the new policy; empty follows the source owner (falls back to the caller for a public policy)
domain_ids string[] After copying, attach these approved domains to the new policy and trigger delivery

Deep-copies all config blobs of the source policy (fields absent from the UI are preserved verbatim) plus three live rule child tables (WAF precise-rule groups+items, CC rules, ACL rules) with fresh IDs, and rewrites the ID arrays inside the blobs accordingly. The source policy is untouched. Output fields: data is the new policy VO.


GET /api/guard/policies/{id}/geo-config — Get geo blocking config

Auth: guard.policy.view

Friendly view of guard_policies.geo_config: mode (black|white), oversea (block overseas), world_list/prov_list/city_list, custom_list (exempt IP set IDs), stime/etime (active hours, 0-0 = all day), enabled (switches.geo, read-only).

PUT /api/guard/policies/{id}/geo-config — Update geo blocking config

Auth: guard.policy.edit

Input: mode (required, black|white) / oversea / world_list / prov_list / city_list / custom_list / stime / etime (0-23).

Omitting custom_list (exempt IP set IDs) keeps the stored value; it is written only when you pass the array explicitly ([] clears it). The Cloud access-control UI does not expose this field, and zcloud guard policies geo-config update does not send it, so existing IP-set bindings are never overwritten.

Persists without dispatching: changes become pending until POST /policies/{id}/publish. Legacy fields not covered by the request are preserved as-is.

GET /api/guard/policies/{id}/sensitive-config — Get sensitive-data protection config

Auth: guard.policy.view

Returns a passthrough view of guard_policies.sensitive_config: hide_sensitive (masking bitmask enum_sensitive + selected keys enum_sensitives + custom keywords src_str), info_leakage, hide_head (service header to strip), status_codes (abnormal status-code protection), and enabled (the switches.sensitive_protection toggle, read-only).

PUT /api/guard/policies/{id}/sensitive-config — Update sensitive-data protection config

Auth: guard.policy.edit

Input: config (required, whole JSON: hide_sensitive / info_leakage / hide_head / status_codes) / enabled (optional, syncs the sensitive_protection toggle).

config is passed through wholesale: gen-server owns the full schema; cloud only persists and triggers dispatch, preserving fields not present in the request.

GET /api/guard/policies/{id}/crawler-config — Get anti-crawler config

Auth: guard.policy.view

Returns a passthrough view of guard_policies.crawler_config: search_engine/scanner/script_tool/other (four recognition-block toggles), robots (custom robots.txt), limit_often_err_req + limit_often_err_req_cfg (frequent-error-request limiting: default_code/custom_code/limit_rate/limit_rate_time(_unit)/deny_time(_unit)), and enabled (switches.anti_crawler, read-only).

PUT /api/guard/policies/{id}/crawler-config — Update anti-crawler config

Auth: guard.policy.edit

Input: config (required, whole JSON) / enabled (optional, syncs the anti_crawler toggle). Passed through wholesale, absent legacy fields preserved; persists and triggers dispatch.

GET /api/guard/policies/{id}/global-blacklist-config — Get collaborative defense config

Auth: guard.policy.view

Returns a passthrough view of guard_policies.global_blacklist_config: block_time (seconds a malicious IP stays blocked once auto-added to the dynamic blacklist; candidates 100/300/400/500/1000) and enabled (switches.global_blacklist, read-only).

PUT /api/guard/policies/{id}/global-blacklist-config — Update collaborative defense config

Auth: guard.policy.edit

Input: config (required, {block_time}) / enabled (optional, syncs the global_blacklist toggle). Persists and triggers dispatch.

GET /api/guard/policies/{id}/pending-changes — Pending changes

Auth: guard.policy.view

Returns dirty (policy modified after last publish; never-published counts as dirty), last_modified_at, last_apply, and changes[] (cloud-side operations since last publish: action/resource_label/detail field-level diff/oper_name/ctime). Legacy-console edits only surface via dirty.

PUT /api/guard/policies/{id}/features/{featureKey} — Toggle feature

Auth: guard.policy.edit

Input: enabled (bool); with featureKey=waf you may pass mode (disable|log|block; when set, enabled is ignored).

Valid featureKeys: waf / cc / geo / global-blacklist / one-key-close / one-key-lock / tamper / sensitive / brute-force / crawler / web-lock.

5.4 CC Rules /api/guard/policies/{id}/cc/rules

CC = HTTP rate limiting (Connection / Concurrency Control). Lives under a policy and is isolated by policy_id.

GET /api/guard/policies/{id}/cc/rules — CC rule list

Authentication: guard.cc.list

Input parameters: path id = policy_id; query page / size.

Output fields (data.list[] = CcRuleVO):

Field Type Description
rule_id int64 Rule ID
name string Rule name
describe string Description
matches[] array Match conditions (path / method / headers etc.)
stats object Aggregation dimensions
limit object Rate limit threshold
action object Hit action (block / captcha / throttle etc.)
stime / etime int64 Effective start/end
status int32 1 = enabled, 2 = disabled
ctime / utime int64 Creation / update time

Visualization recommendation: table + status badge. matches is complex; collapse it under a "Detail" button modal.


POST /api/guard/policies/{id}/cc/rules — Create CC rule

Authentication: guard.cc.create

Input parameters (CcRuleCreateReq):

Field Type Required Description
name string yes Rule name
describe string no Description
matches[] array yes At least 1 match condition
stats object no Aggregation dimensions (IP / URI / UA etc.)
limit object yes Rate limit
action object yes Hit action
stime / etime int64 no Effective time window

Complex shapes: list one rule first as a template.

Output fields: returns the new CcRuleVO.


GET /api/guard/policies/{id}/cc/rules/{rid} — CC rule detail

Authentication: guard.cc.view

Input parameters: path id = policy_id, rid = rule_id. The backend verifies that rule_id belongs to the current policy_id; cross-policy reads return NotFound.

Output fields: CcRuleVO.


PUT /api/guard/policies/{id}/cc/rules/{rid} — Update CC rule

Authentication: guard.cc.edit

Input parameters: path id + rid, body same as Create.

Output fields: returns the updated CcRuleVO.


DELETE /api/guard/policies/{id}/cc/rules/{rid} — Delete CC rule

Authentication: guard.cc.delete

Input parameters: path id + rid.

Output fields: data is null.


PUT /api/guard/policies/{id}/cc/rules/{rid}/status — Toggle CC rule status

Purpose: enable/disable a rule from the list page using a switch component.

Authentication: guard.cc.edit

Input parameters (CcRuleStatusReq):

Field Type Required Values
status int32 yes 1 = enabled, 2 = disabled

Important: status is an int32 number, not the string "enabled" / "disabled".

Output fields: data is null.

Example request:

curl -X PUT https://waf.example.com/api/guard/policies/p_default/cc/rules/12345/status \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"status":1}'

5.5 ACL Rules /api/guard/policies/{id}/acl/rules

ACL = Access Control List (allow/block by IP / Header / URI). Similar shape to CC rules but no priority field.

GET /api/guard/policies/{id}/acl/rules — ACL rule list

Authentication: guard.acl.list

Input parameters: path id, query page / size.

Output fields (data.list[] = AclRuleVO):

Field Type Description
rule_id int64 Rule ID
name string Rule name
describe string Description
matches[] array Match conditions
action object Hit action (block / page / pass)
stime / etime int64 Effective start/end
status int32 1 = enabled, 2 = disabled
ctime / utime int64 Time

Visualization recommendation: table + status badge + action.type chip color (block red / pass green / page blue).


POST /api/guard/policies/{id}/acl/rules — Create ACL rule

Authentication: guard.acl.create

Input parameters (AclRuleCreateReq): name / describe / matches[] (≥1) / action / stime / etime.

When action.type is block or page, the content field is base64-encoded server-side; the caller passes plaintext.

Output fields: returns the new AclRuleVO.


GET /api/guard/policies/{id}/acl/rules/{rid} — ACL rule detail

Authentication: guard.acl.view

Input parameters: path id + rid.

Output fields: AclRuleVO.


PUT /api/guard/policies/{id}/acl/rules/{rid} — Update ACL rule

Authentication: guard.acl.edit

Input parameters: path id + rid, body same as Create.

Output fields: returns the updated AclRuleVO.


DELETE /api/guard/policies/{id}/acl/rules/{rid} — Delete ACL rule

Authentication: guard.acl.delete

Input parameters: path id + rid.

Output fields: data is null.


PUT /api/guard/policies/{id}/acl/rules/{rid}/status — Toggle ACL rule status

Authentication: guard.acl.edit

Input parameters (AclRuleStatusReq): status int32 (1=enabled 2=disabled, number).

Output fields: data is null.


5.6 BW List /api/guard/bwlist

The bwlist has two layers: a set (logical container, blacklist/whitelist) and IPs (individual entries within a set). Each set carries its own scope: all = applies to every domain of the current user (including domains added later), selected = applies only to the chosen domains. The backend writes the binding into each domain's config and triggers delivery automatically — no per-domain or per-policy switch is needed anymore.

Legacy compatibility: older sets bound via policy_id (reported as scope_type = policy) keep working; editing such a set and picking a new scope migrates it and unbinds policy_id. Grey-black (3) / grey-white (4) sets are deprecated and hidden by default (include_grey=true to list them), kept only for legacy data.

GET /api/guard/bwlist/summary — Account-level list-group counts

Purpose: counts for the verdict-flow nodes / type filter. Kept separate from the list because these numbers do not depend on the search keyword — the UI fetches them once per page visit.

Auth: guard.bwlist.list. Output: {black, white, cdn_white, total}.

Not mounted under /sets (/sets/summary would clash with the /sets/{id} route parameter).


GET /api/guard/bwlist/sets — Set list

Authentication: guard.bwlist.list

Input parameters: page / size / keyword / ip_set_type (1 = blacklist, 2 = whitelist) / policy_id / include_grey (default false, returns black/white sets only).

Output fields (data.list[] = IPSetVO):

Field Type Description
id uint64 Set ID
user_name string Owner display name (P1.3 replaces user_id, sourced from cloud sys.users)
name string Set name
policy_id string Legacy policy binding (empty for new sets)
ip_set_type int32 1 = blacklist, 2 = whitelist (3/4 grey sets deprecated; numeric enum, not string)
status int32 1 = disabled, 2 = enabled
count int64 Total entries
enable_count int64 Enabled entries
unable_count int64 Disabled entries
describe string Description (not remark)
is_default bool Whether default set
is_private bool Whether private
private_domain_id string Domain associated with private set
scope_type string Scope: all = all domains, selected = chosen domains, policy = legacy policy binding, empty = unbound
domain_ids array<string> Effective domain IDs when selected
domain_names array<string> Friendly names matching domain_ids
last_applied_at int64 Last sync time (Unix ms; 0 = not yet applied)
ctime / utime int64 Time

Visualization recommendation:

Search: keyword is a single box covering name LIKE ∪ describe LIKE ∪ member IP text LIKEmember CIDR containing that IP (searching 1.2.3.4 matches a set holding 1.2.3.0/24, matching zmod's FindIPInIPSet). Containment only kicks in when keyword parses as an IP.

GET /api/guard/bwlist/sets/{id} — Get one set

Permission: guard.bwlist.list (same as the list endpoint)

Input: path id.

Output: a single IPSetVO (fields as in the table above, including scope_type / domain_ids / domain_names).

Used by the console's list detail page to load a set by ID on deep link or refresh. Business-level users can only read sets they own; reading someone else's set returns 404 — "not found" and "not permitted" are deliberately indistinguishable, so IDs cannot be used to probe for other tenants' data. Platform-level users are not restricted.


POST /api/guard/bwlist/sets — Create set

Authentication: guard.bwlist.create

Input parameters (BWListSetCreateReq):

Field Type Required Description
name string yes Set name
ip_set_type int32 yes 1 = blacklist, 2 = whitelist (3/4 deprecated, do not create)
scope_type string no Scope: all = all domains, selected = chosen domains; mutually exclusive with policy_id
domain_ids array<string> conditional Required with at least 1 entry when scope_type=selected
policy_id string no Legacy policy binding (kept for compatibility, not recommended)
status int32 no 1 = disabled, 2 = enabled, default 2
describe string no Description

Output fields: returns the new IPSetVO.

With scope_type set, the backend writes the set into each in-scope domain's bwlist config (the domain-side switch is enabled automatically) and triggers delivery; an all-scope set also covers domains created later. Passing both scope_type and policy_id returns 400.


PUT /api/guard/bwlist/sets/{id} — Update set

Authentication: guard.bwlist.edit

Input parameters (BWListSetUpdateReq): name / status / describe / scope_type / domain_ids (all optional).

Output fields: returns the updated IPSetVO.

Updating status syncs domain config and triggers delivery: enabled sets enter black/white lists, disabled sets enter the disabled list (dis_list). Passing scope_type recomputes the scope: narrowing it withdraws the config from removed domains; passing a scope on a legacy policy-bound set unbinds policy_id and migrates it to the scope model.

status is discouraged: set-level enable/disable is an extension of this platform. The legacy platform has no such concept (its set list shows only info / description / IP / created / actions), and the field has never been used in practice. Neither the console nor the zcloud CLI offers it — once a set is disabled, the console shows no reason and provides no way back. For enable/disable, use the per-IP PUT /api/guard/bwlist/ips/{id}/status.
This endpoint still accepts status only to avoid breaking existing integrations; omitting it leaves the stored value untouched, so a status imported from the legacy platform is preserved and delivered as-is.


DELETE /api/guard/bwlist/sets/{id} — Delete set

Authentication: guard.bwlist.delete

Input parameters: path id.

Output fields: data is null. All IPs in the set are deleted as a side effect.

Deleting a set also unbinds it from the bwlist config of every effective domain (and legacy policy) and triggers redelivery for affected domains.


GET /api/guard/bwlist/sets/{id}/ips — List IPs in a set

Authentication: guard.bwlist.ip_list

Input parameters: path id = set ID; query page / size / keyword (fuzzy match on IP/CIDR).

Output fields (data.list[] = IPVO):

Field Type Description
id uint64 Entry ID
ipset_id uint64 Owning set
ip_addr string IP or CIDR (not ip)
status bool true=enabled false=disabled, default true

| ctime / utime | int64 | Time |

There is no expiry field. List entries have no TTL (matching zmod's IPV2 model): expired_at / expire_at / left_time / timeout / ttl are neither accepted nor returned — passing them has no effect. Delete the entry to lift a temporary block.

Visualization recommendation: table.


POST /api/guard/bwlist/sets/{id}/ips — Add a single IP

Authentication: guard.bwlist.ip_add

Input parameters (BWListIPAddReq):

Field Type Required Description
ip_addr string yes IP or CIDR
status bool no default true

Output fields: returns the new IPVO.


POST /api/guard/bwlist/sets/{id}/ips/batch — Bulk add IPs

Purpose: import hundreds of IPs in one call (e.g. threat-intel feeds), reducing round trips. Duplicate IPs are skipped (idempotent).

Authentication: guard.bwlist.ip_add

Input parameters (BWListIPBatchAddReq):

Field Type Required Description
ips[] array yes At least 1 item; each {ip_addr, status?}

Output fields: data is null or the list of new IDs (implementation-defined).


POST /api/guard/bwlist/sets/{id}/ips/batch/delete — Bulk delete IPs

Purpose: delete many IPs in a set at once (e.g. clearing expired bans) in a single atomic transaction, avoiding partial failures and audit-log spam from per-entry deletes.

Authentication: guard.bwlist.ip_delete

Input parameters (BWListIPBatchDeleteReq):

Field Type Required Description
ip_ids[] array<int64> yes At least 1 item; IP entry IDs to delete. Scoped to set {id}; IDs outside the set are ignored

Output fields: data.deleted is the number actually deleted. Returns 404 when nothing in the set matches.


PUT /api/guard/bwlist/ips/{id}/status — Enable/disable a single IP

Authentication: guard.bwlist.ip_add (reuses the write permission)

Input parameters: path id = IP entry ID; body (BWListIPStatusReq):

Field Type Required Description
status bool yes true = enable, false = disable; a disabled IP immediately stops matching

Output fields: returns the updated IPVO.


DELETE /api/guard/bwlist/ips/{id} — Delete a single IP

Important: the delete path is the top-level DELETE /api/guard/bwlist/ips/{id}, not the nested DELETE /sets/{sid}/ips/{id}.

Authentication: guard.bwlist.ip_delete

Input parameters: path id = IP entry ID.

Output fields: data is null.


5.7 IP Forward /api/guard/forwards

This is TCP/UDP port forwarding (L4), not path forwarding / reverse proxy (L7).

GET /api/guard/forwards — Forward list

Authentication: guard.forward.list

Input parameters:

Field Type Description
page / size int Standard pagination
user_id string Filter by owner
domain_id string Filter by domain
status int32 1 = disabled, 2 = enabled
keyword string Search domain / describe

Output fields (data.list[] = ForwardVO):

Field Type Description
id uint64 Forward ID
user_id string Owner UUID (kept for legacy platform compatibility)
user_name string Owner display name (P1.3 added, sourced from cloud sys.users)
domain string Forwarded domain/IP
domain_id string Associated domain ID
schema int32 3 = TCP, 4 = UDP, default 3
port int32 Port 1-65535
node_ipaddrs string Comma-separated source IPs
describe string Description
status int32 1 = disabled, 2 = enabled
src_setting json Source setting raw JSON
adv_settings json Advanced setting raw JSON
node_setting json Node setting raw JSON
dev_setting string Device setting
ctime / utime int64 Time

Visualization recommendation:


POST /api/guard/forwards — Create forward

Authentication: guard.forward.create

Input parameters (ForwardCreateReq):

Field Type Required Example Description
domain string yes *.example.com Forward domain
domain_id string yes d_8a3b1c Associated domain
port int32 yes 443 1-65535
schema int32 no 3 3=TCP (default) / 4=UDP
node_ipaddrs string no 10.0.0.1,10.0.0.2 Source IP list
describe string no Description
status int32 no 2 1=disabled 2=enabled
src_setting / adv_settings / node_setting json no Raw JSON config
dev_setting string no Device config

Fields source_path / target do not exist.

Output fields: returns the new ForwardVO.


GET /api/guard/forwards/{id} — Forward detail

Authentication: guard.forward.view

Input parameters: path id.

Output fields: ForwardVO.


PUT /api/guard/forwards/{id} — Update forward

Authentication: guard.forward.edit

Input parameters: path id, body same as Create (all fields optional).

Output fields: returns the updated ForwardVO.


PUT /api/guard/forwards/{id}/status — Toggle a forward

Permission: guard.forward.status

Input: path id; the body carries a single field.

Field Type Required Values Notes
status int32 yes 1 / 2 1=disabled 2=enabled (enforced by oneof=1 2)

How this differs from PUT /api/guard/forwards/{id}: this endpoint writes the status column only and skips
full revalidation of nodes and origins
. Rules whose guard nodes were reclaimed, taken offline, or never migrated
into the cloud node inventory (common for zmod-imported data) are rejected by the full update path with
"the selected guard node is unassigned or unavailable", which means such a rule cannot even be stopped
precisely when stopping it matters most. The legacy zmod endpoint
(PATCH /api/guard/forward/ipfromward/ipfromwards/{id}/status) skips the same checks.

Still enforced: tenant isolation, domain-bound legacy rules cannot be toggled here (convert to standalone first),
and port-conflict checking on the same node when enabling. Config delivery is triggered only when the status
actually changes; an unchanged status returns idempotently without delivery.

Output: the updated ForwardVO (including apply_triggered / apply_message).


DELETE /api/guard/forwards/{id} — Delete forward

Authentication: guard.forward.delete

Input parameters: path id.

Output fields: data is null.


Origin Forwarding UX Overhaul · Backend Supplement Audit (2026-06-30)

The frontend is redesigning the origin-forwarding page (benchmarked against competitors' "non-website / port forwarding": Alibaba Cloud Anti-DDoS port access, Tencent Cloud BGP Anti-DDoS non-website protection). A competitor's single L4 forwarding rule can configure: forwarding protocol (TCP/UDP), forwarding port, origin server port (separate from forwarding port), origin IPs/domains (Alibaba ≤20 comma-separated with auto load-balancing; Tencent ≤20 per rule), weight + load-balancing algorithm, session persistence (toggle + timeout), health check (TCP/UDP probe: interval / response timeout / healthy + unhealthy thresholds / port, auto-eject unhealthy origins), new-connection + read/write timeouts, connection / new-connection rate limits; the list displays per-origin health + real-time connections / bandwidth. Our current state:

# Item Nature Notes
schema semantics wrong in frontend Frontend bug (not backend) Backend schema = 3=TCP / 4=UDP (L4), but the frontend forward/index.vue mislabels it as 1=HTTP / 2=HTTPS / 3=both in its display + dropdown. Frontend must switch back to TCP/UDP. Recorded to prevent recurrence.
Origin IPs not editable Frontend bug (not backend) Origin addresses live in node_ipaddrs (comma-separated IP list), but the current form has no such field → rules are created with no origin. The redesign must add an "Origin IP" editor (match competitors: ≤20 comma-separated, auto load-balanced). Backend already supports it; no change needed.
src_setting / adv_settings / node_setting JSON schema undefined Needs backend clarification (blocks structured advanced form) Competitors' origin port / session persistence / health check / origin weight / load-balancing algorithm / connection timeout / rate limits can only be stuffed into these three raw JSON fields here, but the dto / model / CLI define no structure for them, and how the node consumes them is undocumented. Please provide the field schema for each of these three JSON blobs (which field carries origin port / session persistence〔toggle+timeout〕/ health check〔interval/timeout/thresholds/port〕/ weight / load-balancing algorithm / timeout / rate limit); otherwise the frontend can only offer a raw-JSON textarea (error-prone and ugly).
Origin health status not returned Suggested (display) Competitors show per-origin healthy / unhealthy + auto-eject. ForwardVO carries no origin health-check result. To display it, backend must add a health-status field / query endpoint (liveness of each node_ipaddr).
Per-rule real-time monitoring missing Optional (display) Competitors show per-rule real-time connections / bandwidth. We have none. If desired, add an analytics chart-key via the chart contract (rule 6) — do NOT stuff stats into the forward endpoint.

Conclusion: ① ② are frontend self-fixes (backend already supports them); ③ is the key blocker — until the backend provides the raw-JSON schema, the "advanced config" (origin port / session persistence / health check / weight) cannot become a structured form, so the frontend will first ship the core loop of protocol + forwarding port + origin IP + description + enable, deferring advanced items to the backend schema. ④ ⑤ are display enhancements, optional.


5.8 DNS Scheduling /api/guard/schedules

This module only covers DNS parsing scheduling (mode switch between SRC/NODE and batch record enable/disable). The legacy cron-driven schedule endpoints have been retired.

⚠️ Asynchronous semantics: every write persists to the compatibility tables guard_db.dns_records + guard_db.dns_affairs, then the cloud DNS worker applies it through the configured DNS provider API. Callers must poll the affairs endpoint to obtain the final status (initial AffairsStatus_StartAffairsStatus_Succeed / AffairsStatus_Faild).

⚠️ Source of truth: dns_records.group_type is the truth for the current mode, while guard_configs.parsing_state is synced asynchronously; both live in the legacy guard_db and may lag by up to 30s. The VO returns both; render a "syncing" badge whenever they disagree.

5.8.1 Domain Scheduling

GET /api/guard/schedules/domains — List domains

Authentication: guard.schedule.list

Input parameters:

Field Type Description
page / size int Pagination (size cap 100)
keyword string Fuzzy match against domain name
user_id string Filter by owner (super-admin / agency only)
mode int32 0=all / 1=SRC / 2=NODE

Output fields (data = ScheduleDomainListResp):

Field Type Description
list[].domain_id string Domain ID (guard_configs.domain_id)
list[].domain_name string Domain name
list[].user_id / user_name string Owner
list[].parsing_state int32 guard_configs.parsing_state (1=SRC / 2=NODE)
list[].dns_group_type int32 Majority vote of dns_records.group_type (source of truth, 1=SRC / 2=NODE)
list[].src_count int Records with group_type=1
list[].node_count int Records with group_type=2
list[].src_records[] array Source record summaries: subdomain / record_type / record_line / value / status
list[].node_records[] array Node record summaries: subdomain / record_type / record_line / value / status
list[].last_affair_status string Latest affair status (AffairsStatus_Start / AffairsStatus_Succeed / AffairsStatus_Faild)
list[].last_affair_ctime int64 Latest affair creation time (ms)
list[].last_affair_message string Latest affair message (HTML fragment)
total int64 Total

POST /api/guard/schedules/domains/{id}/switch-mode — Switch SRC/NODE

Authentication: guard.schedule.switch

Input parameters:

Field Type Required Description
path id string yes domain_id
body target_mode int32 yes 1=SRC / 2=NODE
body comment string no Affair note (written to dns_affairs.message)

Implementation notes: a single guard_db transaction SELECT FOR UPDATEs the domain's dns_records → updates group_type and switch_state=2 (switching) → inserts a dns_affairs row with status=AffairsStatus_Start. After commit, publishes NSQ Cmd=0.

Output fields: returns the newly created ScheduleAffairVO; the frontend should pin affairs_id for polling.


POST /api/guard/schedules/domains/switch-mode — Switch SRC/NODE for many domains

Authentication: guard.schedule.switch (same permission as the single-domain switch; no new perm key)

Input parameters:

Field Type Required Description
body domain_ids string[] yes 1–100 domain IDs; duplicates are de-duplicated
body target_mode int32 yes 1=SRC / 2=NODE
body comment string no Affair note (written to dns_affairs.message)

Implementation notes: matches the legacy platform's PATCH /api/guard/schedule/domain/dns/switch
id_list semantics — the whole batch runs in one transaction and any per-domain validation failure
rolls everything back
; there is no partial success. Inside the transaction each domain goes through
exactly the same checks as the single-domain switch (FOR UPDATE lock, no in-flight affair, target mode
has restorable records, no enabled locked records). Exactly one dns_affairs row is inserted whose
content is a comma-separated domain_id list — the legacy format, which cloud's LatestByDomainIDs
already splits on commas, so both platforms can read each other's rows. After commit the cloud-owned DNS
worker updates guard_configs.parsing_state for every domain and calls the DNS provider.

Output fields: returns the newly created ScheduleAffairVO (one row for the whole batch).


POST /api/guard/schedules/domains/{id}/init — Initialize parsing

Authentication: guard.schedule.init

Input parameters: path id (domain_id); optional body comment.

Implementation notes: reads source/dispatch settings from guard_domain_settings and node bindings from domain_node_ships, locks existing dns_records, deletes and recreates source/node records, then inserts a dns_affairs row and publishes NSQ Cmd=0 so zdns syncs once.

Output fields: returns the newly created ScheduleAffairVO.


POST /api/guard/schedules/domains/{id}/reset — Reset parsing

Authentication: guard.schedule.reset

Input parameters: path id (domain_id); optional body comment.

Implementation notes: every dns_records.switch_state is restored to 1 and status is rolled back to last_status; an affair is created and NSQ Cmd=0 is published.

Output fields: returns the newly created ScheduleAffairVO.


GET /api/guard/schedules/domains/{id}/records — List DNS records of a domain

Authentication: guard.schedule.records

Input parameters:

Field Type Description
path id string domain_id
group_type int32 1=SRC / 2=NODE
status int32 1=disabled / 2=enabled
page / size int Pagination

Output fields (data = ScheduleRecordsResp):

Field Type Description
list[] DnsRecordVO DNS record view
list[].record_id string Primary key
list[].associated_id string Upstream DNS provider record ID used to verify that the record is rebuilt or linked on the ZDNS/provider side
list[].domain / subdomain / value string Domain / subdomain / parsing value
list[].record_type int32 Internal DNS record type code
list[].record_line int32 Parsing line
list[].ttl int64 TTL (seconds)
list[].status / last_status int32 1=disabled / 2=enabled
list[].group_type int32 1=SRC / 2=NODE
list[].switch_state int32 1=ready / 2=switching
list[].ctime / utime int64 Timestamps (ms)

Read-only query against zdns_db.dns_records; no affair is created.


5.8.2 Record Scheduling

POST /api/guard/schedules/domains/{id}/records — Create a DNS record

Auth: guard.schedule.batch

Input (ScheduleRecordCreateReq):

Field Type Required Description
path id string yes domain_id
record_type int32 yes 1=A / 2=AAAA (CNAME records are generated by origin-domain mode, not manually creatable)
value string yes A=IPv4 / CNAME=hostname, ≤255 chars
ttl int64 no 0=default 600; otherwise 60-86400 seconds
group_type int32 yes 1=SRC / 2=NODE
record_line int32 no ISP line: 1-7 basic / 14 search engines / 34-126 provincial; 0 or omitted = default line

Semantics: the record is enabled immediately when its group matches the active mode, otherwise created disabled; duplicates on group+type+line+value are rejected; default-line guard — a group serving enabled A records must keep at least one default-line (Line 1) record, or visitors not matching any ISP line cannot resolve (mirrors zmod NoIPv4DefaultLine).

Output: data is the scheduling affair ScheduleAffairVO.


PUT /api/guard/schedules/records/{id} — Update a DNS record

Auth: guard.schedule.batch

Input (ScheduleRecordUpdateReq, all three required):

Field Type Description
path id string record_id
value string validated against the record's existing type (A=IPv4 / CNAME=hostname)
ttl int64 60-86400 seconds
record_line int32 same domain of values as in create

Semantics: type and group are immutable (recreate to change); locked records (mark=2) refuse edits; the default-line guard runs after the change.

Output: data is the scheduling affair ScheduleAffairVO.


DELETE /api/guard/schedules/records/{id} — Delete a DNS record

Auth: guard.schedule.batch

Semantics: locked records, the last enabled record of the active mode, and deletions violating the default-line guard are rejected.

Output: data is the scheduling affair ScheduleAffairVO.


POST /api/guard/schedules/records/batch-status — Batch enable/disable records

Authentication: guard.schedule.batch

Input parameters (ScheduleBatchStatusReq):

Field Type Required Description
record_ids string[] yes Target record_id set
status int32 yes 1=disabled / 2=enabled
comment string no Affair note

Implementation notes: UPDATE dns_records SET last_status=status, status=? WHERE record_id IN (?); an affair is created and NSQ Cmd=0 is published.

Output fields: returns the newly created ScheduleAffairVO.


5.8.3 Affair Records

GET /api/guard/schedules/affairs — Affair list

Authentication: guard.schedule.affairs

Input parameters:

Field Type Description
page / size int Pagination
user_id string Filter by owner
status string AffairsStatus_Start / AffairsStatus_Succeed / AffairsStatus_Faild
ctime_from / ctime_to int64 Time range (ms)
domain_id string Filter by affected domain (implemented as content LIKE)

Output fields (data = ScheduleAffairListResp): see ScheduleAffairVO below.


GET /api/guard/schedules/affairs/{id} — Affair detail

Authentication: guard.schedule.affairs

Input parameters: path id (affairs_id).

Output fields (data = ScheduleAffairVO):

Field Type Description
affairs_id string Affair ID, format {ts}_{rand10}
user_id / user_name string Operator
status string AffairsStatus_Start / AffairsStatus_Succeed / AffairsStatus_Faild (string enum, matches the legacy MarshalJSON behaviour)
message string Affair message, may contain HTML snippets (e.g. <br>)
content string Affected domain_ids, comma separated
json_content object Extension JSON; carries outbox retry counters, etc.
affairs_oper string AffairsOperType_Page / AffairsOperType_Cron / AffairsOperType_Cli
ctime / utime int64 Created / updated (ms)

Polling tip: after triggering a write, poll this endpoint every 2-5s until status becomes Succeed or Faild; if it stays at Start for an extended period, the cloud DNS worker is still executing or retrying — render a "syncing" indicator.


DNS Scheduling UX Overhaul · Backend Supplement Audit (2026-07-01)

The frontend will redesign the DNS-scheduling page. Benchmarked against competitors: Alibaba Cloud DNS "Intelligent Resolution / GTM Global Traffic Manager", Tencent DNSPod, Cloudflare Load Balancing, and various WAF CNAME-onboarding + disaster failover flows. Our model = per-domain switching of DNS resolution between origin (SRC=1) ↔ protection nodes (NODE=2) (cloud writes compatibility tables and the cloud DNS worker applies changes through the configured DNS provider API; each write produces an affair transaction that the worker writes back to terminal state). Capabilities competitors have that we lack: health check + auto-failover (node down → auto-switch to origin), weighted load balancing, intelligent lines (ISP/geo), single-record CRUD, TTL editing. Backend supplement audit:

# Item Nature Notes
Sync-state detection too weak Suggested (needed for redesign) The frontend "syncing" state currently relies on dns_group_type (derived from SRC/NODE record counts; most domains have both groups → falls into the "align to parsing_state" branch, so it almost never fires) + the last affair being Start. Please return a clean sync_state/is_syncing field directly on ScheduleDomainVO (computed from the real consistency of dns_records group_type vs guard_configs.parsing_state + whether an unfinished affair exists) — don't make the frontend guess from record counts.
Bulk all-record toggle tools Closed These tools are too dangerous for the new system. Keep the internal implementation only, but do not publish route, permission, API docs, or CLI docs; the public surface remains the precise batch-status record operation.
Origin/node health status Enhancement (prerequisite for health display / failover) Competitors show per origin/node healthy/unhealthy + auto-eject. Our DNSRecordVO has no health field, so operators switch blind. To show health or do auto-failover, the backend (or zdns) must return a liveness field per record/node.
Auto-failover policy Enhancement (optional, large effort) Competitors: on node failure the CNAME auto-switches node IPs, and in extreme cases auto-switches back to origin. We only have manual switch-mode. If the product wants auto-DR, the backend needs a policy engine (health probing + auto-switch + event notification). Assess later, not first phase.
Weight / intelligent lines Enhancement (product scope) DNSRecordVO has no weight; record_line currently only uses 1=Default. For multi-origin weighted load balancing / per-line resolution (ISP/geo), the backend needs a weight field + a line enum + zdns support.
Single-record CRUD Enhancement Closed (2026-08-04) POST/PUT/DELETE /schedules/...records are live: create / update (value/TTL/line) / delete, writable TTL, with the default-line guard.
affair message is legacy Chinese HTML Memo (non-blocking) dns_affairs.message is a compatibility-table contract and keeps Chinese HTML (documented exemption). If the redesign wants to show structured transaction status/progress, the backend should additionally return a structured status_detail (rather than making the frontend scrape HTML). Also put target_mode (to origin=1 / node=2) on the affair, otherwise the "switch history" timeline can only show a generic "resolution switch" and cannot label which mode was switched to (as currently implemented).

Frontend self-fixes (not backend): DnsRecordVO.mark / .protect_status are declared client-side but the backend DTO never returns them → the frontend will drop them; parsing_state not being on the domain Detail (it comes only from /schedules/domains) is a deliberate split, unchanged.

Conclusion: ① ② should be settled before the redesign (sync-state field + orphaned-endpoint fate), low cost. ③④⑤⑥ are the product decision of "whether to upgrade DNS scheduling from manual origin/node switching into intelligent resolution with health/weight/lines/CRUD", dependent on the final mock's scope — please have the backend assess feasibility on the zdns_db + NSQ side first. The core loop (manual SRC/NODE switch + batch record enable/disable + init/reset + affair polling) is already fully supported, so the redesign can start with pure UX (a domain-centric flow + clearer current-mode/sync-state + a switch-history timeline).


5.8bis Ops: Config Snapshots + Per-Origin Status · Backend Follow-ups (2026-07-01)

Source: the domain-centric holistic review + two ops scenarios (dozens of domains each disabling a specific origin / snapshot before bulk ops and one-click restore afterward). The frontend has shipped a "Config Snapshot UI" first (on the port-forwarding page, localStorage-backed + replay via existing PUT /forwards/:id), to switch to a server-side implementation once the APIs below exist.

# Need Current gap Suggested backend
Config snapshot CRUD + restore No API. Frontend interim: snapshots in browser localStorage; restore replays PUT /forwards/:id per rule, only for rules that still exist; no add/delete reconciliation Server-side snapshots: POST /guard/snapshots (name+scope+payload), GET /guard/snapshots?scope=, POST /guard/snapshots/:id/restore (atomic replay with add/delete reconciliation), DELETE /guard/snapshots/:id. scope starts with forward, extensible to domain/schedule
Per-origin enable/disable (example 1) forwards.node_ipaddrs is a comma-separated string; an individual origin has no status flag. To "disable origin 1 of domain A but keep origin 2", you must edit and delete the IP (destructive, lost once removed) Frontend has shipped a backward-compatible scheme + UI: ForwardCreateReq/ForwardVO gain disabled_ipaddrs (comma-separated subset of node_ipaddrs that is disabled); create/update already send it; the forward drawer has a per-origin toggle and the list dims/strikes disabled origins. Backend just needs to honor the field (IPs in disabled_ipaddrs don't receive traffic / join load-balancing); ignoring it for now is storage-safe. Once honored it takes effect and re-enables in one click. ①'s snapshot can then carry just status flags
Origin-dimension cross-domain batch Current batch applies the same action to whole rules; can't express heterogeneous ops like "A origin 1 + B origin 3" Optional: GET /guard/origins?... (list across domains at the origin level) + batch enable/disable APIs, paired with ②'s status flags

Priority: ② per-origin soft-disable is the foundation — relatively cheap and directly solves "how to restore after disabling"; ① server-side snapshots next; ③ product-dependent. ②③ hinge on whether L4 forwarding moves from "whole-rule toggle" to "per-origin granularity" — a product decision; have the backend assess feasibility first.

④ Onboarding closed-loop · Verify access (benchmarking big vendors): the onboarding wizard's completion step now has a "Verify access" button; the frontend interim derives liveness from GET /guard/domains/:id's proxy_switch && !stoping (passive, depends on the backend's async detection). Suggest a backend active probe POST /guard/domains/:id/verify-access: check in real time ① whether DNS already CNAMEs to us ② whether the origin is reachable, returning { dns_ok, origin_ok, resolved_cname, message }, so "Verify access" gives an instant result (matching Aliyun/Tencent/Huawei access checks).


5.8ter Config Delivery Records (Apply) · Backend Follow-ups (2026-07-02)

The frontend redesigned "Config Delivery Records" into a delivery-task monitor (list progress bars + detail node-status grouped-by-cluster + failure clustering + retry/cancel + live polling while delivering). The current model (ApplyVO status/success-fail counts, ApplyNodeStat clustered by node_group_name, ApplyDetailItemVO.apply_err per-node error, retryApplyApi(node_ids)/quitApplyApi) already supports the core. Hardening items (edge cases from the architecture review):

⚠️ Architecture reality (read after checking backend code — determines where each item lives): cloud is a thin frontapply + apply_detail are written in a single-DB ACID transaction to cloud_guard, then pushed via outbox → zRPC cmd=550 → gen-service (the legacy config-generation platform); the one that actually delivers to nodes and writes back apply_status (PENDING1→RUNNING4→SUCCESS2/FAILED3)/effect_status/apply_err is gen-service (cloud does not write these); the outbox already re-pushes after 30s without a writeback. So each item lands differently: ② severity is cloud-side doable (cloud knows what changed at trigger time — just tag it; lowest cost, prioritize); ① generation/rollback, ③ normalized error codes, ④ effect semantics mostly live in gen-service (need the legacy platform, not a cloud-Go tweak); ⑤ timeout/retry is partly covered by cloud's outbox (node-level eventual consistency still in gen-service); affair uses zdns/NSQ while apply uses gen-service/outbox — two backends, costly to merge.

# Need Current gap Suggested backend
Config generation + rollback apply has no version; two rapid deliveries to the same domain can arrive out of order (older lands last → node keeps stale config); no rollback Attach a monotonic generation per delivery; nodes accept only newer versions; POST /guard/applies/rollback (domain + target gen). Same capability family as config snapshots §5.8bis①
Severity level No field distinguishing "security config" (rules/CC/DDoS) from ordinary config Add severity on apply; security partial-failure = red alarm (frontend already escalates by severity, pending the field) — uncovered nodes = exposed traffic
Normalized failure reasons apply_err exists; frontend does failure clustering (group by err); if err is free text, clustering fragments Fill normalized reason codes (e.g. CERT_INVALID/TIMEOUT) + message so clustering is accurate
Effect verification truthfulness effect_status semantics unclear: node ack or actively verified the config is running? If only ack, verify effectiveness on delivered nodes and backfill; otherwise "effected" is false certainty (frontend already softens to "pending confirmation")
Offline-node eventual consistency + timeout Is an offline node at delivery time "failed" or "to be re-delivered"? How to bound a stuck "delivering"? Mark offline as "to re-deliver", auto-deliver on reconnect (not a failure); a delivery timeout state machine (e.g. flip to failed after 30s without ack)
Unified change center (big decision) affair (DNS switch, scheduling) and apply (config delivery) are structurally identical: async, node-facing, stateful, retryable; currently split across two pages Long-term, merge into one "change/task center" so ops sees all pending/failed changes in one place. Product-level decision; keep them separate for now

Priority: ① (versioning+rollback: out-of-order protection + same line as snapshots) and ② (severity alarm) are highest; ③④⑤ are robustness; ⑥ is long-term architecture.


5.8quater Protection Nodes & Back-to-Origin (Tenant View) · Pending Backend APIs (2026-07-02)

Background: cloud is positioned as the tenant product (users jump in from an upper console). The "Cluster Nodes" management UI (platform ops / node list / groups / ACL / assignment / deploy-upgrade) has been fully removed from the cloud frontend (it belongs to the upper console); the backend /api/node is fully retained (agent self-registration and domain_node_ships are the foundation of scheduling/delivery; the upper console calls these APIs directly). Two new tenant-view APIs are needed (modeled on Aliyun/Tencent WAF back-to-origin IP disclosure + Anti-DDoS line views; the frontend already implements this contract and shows a notice — never fake data — until they are ready).
Frontend consumption (adjusted after the 2026-07-02 review; no standalone page): origin-ip-ranges → the onboarding wizard's completion step ("allow back-to-origin IP ranges" block) + the domain workbench access section's "back-to-origin allowlist" modal (shared component OriginIpRanges.vue); my-nodes → the health badge + line names on the "protection node" in the DNS-scheduling detail flow diagram.

API Description Response
GET /api/guard/my-nodes Aggregation of protection nodes assigned to the current user (by line/group, desensitized: display names and counts only, no internal management IPs / datacenter details). Source = node assignment relations + node status { groups: [{ name (line/group display name), status (1=healthy 2=maintenance 3=abnormal), node_count, domain_count, domains?: string[] }] }
GET /api/guard/origin-ip-ranges Back-to-origin IP ranges for the current user's domains (node egress IPs aggregated into CIDRs; users must allow them at the origin firewall) { list: [{ cidr, line? }] }

Notes: ① gate with the tenant's existing guard.domain.list (read-only); ② the status health here is the official data source for the scheduling page's "node health" placeholder (prerequisite for auto failover); ③ if back-to-origin egress IPs differ from node management IPs (NAT / dedicated egress), expose the actual egress — never leak management IPs to tenants.


5.9 WAF Rules /api/guard/waf/rules

A WAF rule group is mounted under a policy (policy_id). Each group contains a set of sub-rules (rules[]). The gateway matches a request against the zone / pattern fields, and on hit applies action (block / log / captcha).

⚠️ Status convention: status is unified as 1 = disabled, 2 = enabled (the S-6 fix aligned this with forwards / bwlist / schedules). The POST /status endpoint validates oneof=1 2.

⚠️ Path ID is tag: in PUT/DELETE /api/guard/waf/rules/{id}, {id} is the rule group's tag (uint64 primary key) — NOT rule_id (the business code). The frontend reads tag from the list response.

GET /api/guard/waf/rules — WAF rule group list

Authentication: guard.waf.list

Input parameters:

Field Type Description
page / size int Standard pagination
policy_id string Filter by policy (omit to return all rule groups visible to the current user)

Output fields (data.list[] = WafGroupVO):

Field Type Description
tag uint64 Rule group primary key (used as path {id})
rule_id int64 Business rule code
name string Rule group name
describe string Description
waf_type int32 WAF type internal code
sub_rule_condition int32 Sub-rule combination logic (0 = AND / 1 = OR, per model implementation)
scope string Scope (domain / path / empty = all)
action int32 1 = block, 2 = log, 3 = captcha
status int32 1 = disabled, 2 = enabled
policy_id string Owning policy ID
rules[] WafRuleVO Sub-rule list (each has zone / pattern / pattern_type / is_not)
ctime / utime int64 Timestamps (ms)

WafRuleVO (sub-rule) fields:

Field Type Description
tag uint64 Sub-rule primary key
rule_id int64 Business rule code
group_id int64 Owning rule group (joins WafGroupVO.tag / rule_id)
zone string Match zone (e.g. URL / ARGS / HEADER / BODY)
sub_field string Sub-field inside the zone (e.g. header name)
pattern_type int32 Match type internal code (exact / regex / contains, etc.)
pattern string Match expression
describe string Description
is_not bool true = negated match
ctime / utime int64 Timestamps (ms)

Visualization recommendation:


POST /api/guard/waf/rules — Create WAF rule group

Authentication: guard.waf.create

Input parameters (WafGroupCreateReq):

Field Type Required Example Description
name string yes "SQL injection guard" Rule group name
policy_id string yes "1" Owning policy
rule_id int64 no Business code; generated server-side if omitted
describe string no Description
waf_type int32 no WAF type internal code
sub_rule_condition int32 no Sub-rule combination logic
scope string no "*.example.com" Scope
action int32 no 1 1 = block / 2 = log / 3 = captcha
status int32 no 2 1 = disabled, 2 = enabled
rules[] object[] no Sub-rules (each = WafRuleReq, see below)

WafRuleReq (sub-rule body):

Field Type Description
rule_id int64 Business code; generated server-side if omitted
zone string Match zone
sub_field string Sub-field inside the zone
pattern_type int32 Match type
pattern string Match expression
describe string Description
is_not bool true = negated

Example:

curl -X POST https://waf.example.com/api/guard/waf/rules \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "SQL injection guard",
    "policy_id": "1",
    "describe": "OWASP Top 10 SQLi blacklist",
    "action": 1,
    "status": 2,
    "rules": [
      { "zone": "ARGS", "pattern_type": 2, "pattern": "union\\s+select", "describe": "SQLi: union select" }
    ]
  }'

Output fields: the new WafGroupVO (including the assigned tag and the persisted rules[]).

Fields that do not exist: a boolean enabled (use integer status) / description (use describe) / domains[] (scope is the scope string).


PUT /api/guard/waf/rules/{id} — Update WAF rule group

Authentication: guard.waf.edit

Input parameters (path id = rule group tag; body WafGroupUpdateReq, all fields optional, applied incrementally; rules[] is a full replace):

Field Type Description
name string Rule group name
describe string Description
waf_type *int32 Pointer type; omit to keep unchanged
sub_rule_condition *int32 Pointer type
scope string Scope
action *int32 Pointer type
rules[] object[] Full replace of sub-rules

This endpoint does not update status — use the dedicated PUT /api/guard/waf/rules/{id}/status for enable / disable.

Output fields: the updated WafGroupVO.


DELETE /api/guard/waf/rules/{id} — Delete WAF rule group

Authentication: guard.waf.delete

Input parameters: path id (rule group tag).

Implementation note: cascades to sub-rules (waf_rules.group_id = tag).

Output fields: data is null.


PUT /api/guard/waf/rules/{id}/status — Toggle WAF rule group enable / disable

Authentication: guard.waf.status

Input parameters (path id = rule group tag; body WafStatusReq):

Field Type Required Value Description
status int32 yes 1 / 2 1 = disabled, 2 = enabled; validated by oneof=1 2

Example:

curl -X PUT https://waf.example.com/api/guard/waf/rules/42/status \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": 1 }'

Output fields: data is null.

GET /api/guard/waf/rules/export — Export precision rules as a JSON file

Auth: guard.waf.export

Query params:

Field Type Required Notes
policy_id string yes Policy ID

Output: returns a JSON file stream directly (Content-Disposition: attachment; filename="waf-precision-rules.json", not the {code,data} envelope). File shape:

{
  "version": 1,
  "kind": "waf-precision-rules",
  "rules": [
    {
      "name": "block-injection", "describe": "", "waf_type": 8, "sub_rule_condition": 1,
      "scope": "/api/", "action": 1, "status": 2,
      "rules": [ { "zone": "URL", "sub_field": "", "pattern_type": 1, "pattern": "select", "describe": "", "is_not": false } ]
    }
  ]
}

Each group in rules[] omits id/tag/policy_id/timestamps, so files import into any policy.

POST /api/guard/waf/rules/import — Bulk-import precision rules from JSON

Auth: guard.waf.import

Body (WafRuleImportReq):

Field Type Required Notes
policy_id string yes Target policy ID
rules array yes Rule groups (≥1); pass the rules field from an exported file

Behavior: each group is validated/created independently (append; one failure doesn't block the rest), then a single push is triggered.

Example:

curl -X POST https://waf.example.com/api/guard/waf/rules/import \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "policy_id": "P123", "rules": [ { "name": "block-x", "waf_type": 8, "action": 1, "sub_rule_condition": 1, "rules": [ { "zone": "URL", "pattern_type": 1, "pattern": "select" } ] } ] }'

Output fields: data = { imported, failed, errors[] }.


5.9 WEB Base Protection /api/guard/policies/{id}/waf/*

WEB base protection migrated from zmod, in four parts: base config (custom block page/engine/XML/body size/schema), high-frequency penalty, precision whitelist, and BODY inspection whitelist. Precision rules themselves still go through /api/guard/waf/rules. Everything except the penalty (stored in the dedicated column guard_policies.waf_rate_limit) is inlined in the guard_policies.waf_config JSON. All write endpoints dispatch immediately (ApplyTagWaf) to the policy's bound domains. Enum fields use zmod pb enum name strings (e.g. WAFPageType_1_DEFAULT), passed through by the frontend. Permissions: read guard.waf.view/guard.waf.list, write guard.waf.create/edit/delete.

Endpoint Description
GET /api/guard/policies/{id}/waf/base-config Get base protection config
PUT /api/guard/policies/{id}/waf/base-config Update base config (validates block code 509-599, PageType linkage, req_body_size out-of-range falls back to 4)
GET /api/guard/policies/{id}/waf/rule-versions Built-in policy "rule library version" options (deduped, hides interface-test for non-admins, v1/v2 engine)
GET /api/guard/policies/{id}/waf/policy-schemas?rule_version=<ver> Built-in policy "policy mode" options (filters waf_schemas by rule version, returns id/name)
GET /api/guard/policies/{id}/waf/semantic Get semantic detection config (14 analyzers: switch + level; returns config="{}" when never configured)
PUT /api/guard/policies/{id}/waf/semantic Update semantic detection (projected onto every approved domain of the policy and dispatched immediately; unapproved domains are back-filled on approval)
GET /api/guard/policies/{id}/waf/rate-limit Get high-frequency penalty
PUT /api/guard/policies/{id}/waf/rate-limit Update high-frequency penalty
GET /api/guard/policies/{id}/waf/white-rules Precision whitelist list
POST /api/guard/policies/{id}/waf/white-rules Add precision whitelist (id auto-increment, enabled by default)
PUT /api/guard/policies/{id}/waf/white-rules/{wid} Update precision whitelist (incl. status)
DELETE /api/guard/policies/{id}/waf/white-rules/{wid} Delete precision whitelist
GET /api/guard/policies/{id}/waf/white-rules/export Export precision whitelist as JSON (waf-white-rules, attachment)
POST /api/guard/policies/{id}/waf/white-rules/import Import precision whitelist from JSON (append, body {rules[]}, returns imported/failed)
GET /api/guard/policies/{id}/waf/inner-rules?version= Built-in rule candidates (whitelist rule picker, reads main_rule_*_v3)
GET /api/guard/policies/{id}/waf/body-rules BODY inspection whitelist list
POST /api/guard/policies/{id}/waf/body-rules Add BODY whitelist (rule_id auto, first 20220413, status forced true)
PUT /api/guard/policies/{id}/waf/body-rules/{bid} Update BODY whitelist (incl. status)
DELETE /api/guard/policies/{id}/waf/body-rules/{bid} Delete BODY whitelist

Precision whitelist entry: { id, name, describe, rule_id(comma string), rule_name, op(str/regex), url, status(CFGOPTION_2_ENABLE/..), zones[{zone,sub_field}] }. "Which checks to skip" is expressed by rule_id (comma-separated built-in rule IDs to bypass); whitelist has no action/waf_type.

BODY whitelist entry: { rule_id, op(prefix/suffix/regex/equal), data(match path), describe, status(bool) }. Note status is a bool (unlike whitelist's CFGOPTION string).

Base config body: { waf_policy{name,policy_id,default_main_rule_version}, waf_custom_err_code{page_type,code,content,redirect_addr}, waf_xml{status,max_depth,...}, req_body_size, engine{version} }.

⚠️ Precision rule (/api/guard/waf/rules) sub-rule pattern/describe are stored base64 in user_rule_v3 (consistent with zmod/gen-server); this migration added the codec with plaintext-compat fallback.


§6 Analytics

📊 Analytics · 18 paths (covering 80+ chart-key single-chart endpoints) · used to build WAF monitoring dashboards, operational reports, closed-loop alert handling
All /api/analytics/* paths are public; fields can only be added, paths cannot be modified or removed.
Each chart-key has a clearly recommended chart in the per-page sections below.

6.0 Common Calling Contract

Most Analytics endpoints are homogeneous chart queries. This chapter uses "common contract + index tables + special endpoint expansions".

Authentication & Permissions

Header: Authorization: Bearer <token> or Authorization: ApiKey zck_..., optionally with Accept-Language: zh-CN / en-US.

Permissions fall into three classes:

Class How decided Example
Page-level read-only analytics.<page>.view GET /api/analytics/overview/kpi requires analytics.overview.view
Special action Called out per row / sub-section POST /api/analytics/overview/export requires analytics.overview.export
Authenticated only No specific business permission GET /api/analytics/glossary

When using an API Key, the key's scopes must cover the required permission. For example, calling GET /api/analytics/access/status requires analytics.access.view in scope.

Single-chart GET template

curl -sS 'https://waf.example.com/api/analytics/access/status?window=last_24h&site_id=site-001' \
  -H "Authorization: ApiKey $ZCLOUD_API_KEY" \
  -H 'Accept-Language: en-US'

Returns the unified envelope. Chart data always uses the Chart Unified Contract (docs/specs/chart-contract.md). This is the only public contract for the new system; historical PostgreSQL tables, aggregate tables, and ES indexes are internal data sources only and do not affect request parameters or response shape.

Chart data always contains exactly these 5 fields; series / totals / kpis / points / list are not public top-level fields:

Field Type Description
chart_key string Identical to the requested <chart>
render_hint enum One of 8 vocab words: kpi / categorical_distribution / categorical_distribution_over_time / time_series_single / time_series_multi / topn / geo / table; the frontend selects its renderer accordingly
schema object Column metadata {dimensions:[{name,type,unit?,values?}], measures:[{name,type,unit?,format?}]}
rows array Tidy long table — one observation per row (no wide-pivoted columns); empty data returns []
meta object Debug fields {source, cache, latency_ms, partial?, available?, ...}

window.granularity is not a request parameter: clients pass window=last_24h; the backend automatically picks the 5m/1h/1d aggregation table based on the window size and echoes the chosen granularity in the response.

Batch template

For POST /api/analytics/batch and POST /api/analytics/<page>/batch.

curl -sS -X POST https://waf.example.com/api/analytics/overview/batch \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
        "time_window": "last_24h",
        "site_id": "",
        "domain_id": "",
        "compare": false,
        "charts": [
          { "key": "kpi" },
          { "key": "bandwidth" }
        ]
      }'

The batch response's data.data is keyed by chart-key; data.meta gives total elapsed, cache hit ratio, failed chart count, and effective window. If a single chart fails, inspect the partial / error fields on its node.


6.1 Glossary

GET /api/analytics/glossary — Statistics glossary

Purpose: returns the analytics glossary; the frontend renders tooltips ("what is QPS / block rate") from this map.

Authentication: authenticated session

Input parameters: none.

Output fields:

Field Type Description
data.terms map<string,string> key = term short name, value = localized explanation

Visualization recommendation:

Example response:

{
  "code": 0,
  "data": {
    "terms": {
      "qps": "Queries per second",
      "block_rate": "Block rate"
    }
  }
}

6.2 Cross-page Batch

POST /api/analytics/batch — Cross-page batch query

Purpose: load multiple chart-keys in one round-trip when initializing a dashboard, avoiding N single-chart GETs. The backend runs subqueries in parallel and merges responses.

Authentication: maps to analytics.<page>.view based on the page field.

Input parameters (request body):

Field Type Required Example Description
page string yes overview Must be one of: overview / access / protect / ai / bot / alert / health / ops / closure / cache
time_window string no last_24h Same as window
stime / etime int64 no 1746748800000 Custom timestamps
site_id string no Site filter
domain_id string no Domain filter
target_user_id string no Tenant-level switch viewed user
compare bool no false Enable previous-period comparison
charts[] array yes [{key:"kpi"}] At least 1 chart-key

logs (Phase 1 access logs) and reports (Phase 4 report center) are independent groups and do not support batch.

Output fields:

Field Type Description
data.data map key = chart-key; value is always the 5-field Chart Unified Contract: {chart_key, render_hint, schema, rows, meta}
data.meta.elapsed_ms int Total elapsed
data.meta.cache_hit_ratio float Cache hit ratio (0-1)
data.meta.total_charts int Total charts requested
data.meta.failed_charts int Failed charts
data.meta.window object Effective time window

Visualization recommendation:

Example request/response:

// Request
{
  "page": "overview",
  "time_window": "last_24h",
  "compare": false,
  "charts": [
    { "key": "kpi" },
    { "key": "bandwidth" }
  ]
}
// Response
{
  "code": 0,
  "data": {
    "data": {
      "kpi": {
        "chart_key": "overview/kpi",
        "render_hint": "kpi",
        "schema": {
          "dimensions": [],
          "measures": [
            { "name": "domain_count", "type": "integer", "unit": "" },
            { "name": "requests",     "type": "integer", "unit": "requests" },
            { "name": "blocked",      "type": "integer", "unit": "events" },
            { "name": "block_rate",   "type": "percent", "unit": "%" },
            { "name": "qps",          "type": "float",   "unit": "qps" },
            { "name": "ai_detect",    "type": "integer", "unit": "events" }
          ]
        },
        "rows": [
          { "domain_count": 8, "requests": 12345, "blocked": 678, "block_rate": 5.49, "qps": 0.143, "ai_detect": 0 }
        ],
        "meta": { "source": "postgres", "cache": "miss", "latency_ms": 10 }
      },
      "event-type": {
        "chart_key": "overview/event-type",
        "render_hint": "categorical_distribution",
        "schema": {
          "dimensions": [{ "name": "event_type", "type": "string" }],
          "measures": [{ "name": "count", "type": "integer", "unit": "events" }]
        },
        "rows": [
          { "event_type": "sql_injection", "count": 1234 },
          { "event_type": "xss", "count": 567 }
        ],
        "meta": { "source": "elasticsearch", "cache": "miss", "latency_ms": 15, "partial": false }
      }
    },
    "meta": {
      "elapsed_ms": 22,
      "cache_hit_ratio": 0,
      "total_charts": 2,
      "failed_charts": 0,
      "window": { "stime": 1746662400000, "etime": 1746748800000, "granularity": "1h" }
    }
  }
}

Note: in the example above, kpi's measure names requests / blocked are the public contract truth names, aligned with the pkg/chart/contract source-of-truth structs. Underlying database field names are not exposed; the frontend reads values strictly by schema.measures[].name.


POST /api/analytics/{page}/batch — Page-level batch

Purpose: equivalent to POST /api/analytics/batch, but page is fixed by URL (more direct for fixed-page frontends).

Authentication: maps to analytics.<page>.view based on {page}.

Supported pages:

URL Permission
POST /api/analytics/overview/batch analytics.overview.view
POST /api/analytics/access/batch analytics.access.view
POST /api/analytics/protect/batch analytics.protect.view
POST /api/analytics/ai/batch analytics.ai.view
POST /api/analytics/bot/batch analytics.bot.view
POST /api/analytics/alert/batch analytics.alert.view
POST /api/analytics/health/batch analytics.health.view
POST /api/analytics/ops/batch analytics.ops.view
POST /api/analytics/closure/batch analytics.closure.view
POST /api/analytics/cache/batch analytics.cache.view

Input/Output: identical to POST /api/analytics/batch. Callers don't need to send page in the body; if they do, the path's page name wins.

Visualization recommendation: same as above.


6.3 Single-chart GET entry

GET /api/analytics/{page}/{chart} — Single-chart common entry

Purpose: fetch a single chart-key's data. {page} follows the batch enum; {chart} follows the chart-key index per page section.

Authentication: maps to analytics.<page>.view (a few special charts use independent permissions; see per-section).

Input parameters: see §A Common Query Parameters.

Output fields: unified envelope + data:

Visualization recommendation: the frontend dispatches each chart by render_hint; complex chart columns are defined by schema.


6.4 Overview page

Use case: WAF protection monitoring dashboard home — KPIs + trends + rankings + map.

All GET endpoints below use the §6.0 Single-chart GET template, §A Common Query Parameters, and the Chart Unified Contract response shape.

API chart-key render_hint Recommended chart Description
GET /api/analytics/overview/kpi kpi kpi KpiGroupCard (6 measures) Site count / requests / blocked / block_rate / qps / ai_detect
GET /api/analytics/overview/bandwidth bandwidth time_series_multi Line chart (dual Y-axis) Total bandwidth + origin bandwidth time-series
GET /api/analytics/overview/request-attack request-attack time_series_multi Line chart (two series) Requests vs attacks
GET /api/analytics/overview/event-type event-type categorical_distribution Pie / donut Event type distribution (dim=event_type, measure=count)
GET /api/analytics/overview/waf-type waf-type categorical_distribution Pie / donut WAF hit type distribution
GET /api/analytics/overview/geo geo geo Map heatmap (CN/world) Attack geo distribution
GET /api/analytics/overview/top-domains top-domains topn Horizontal bar / table Top 5 attacked domains
GET /api/analytics/overview/domain-traffic domain-traffic table Domain list traffic column Per-domain requests/attacks aggregate (up to 1000 domains)
GET /api/analytics/overview/top-ip top-ip topn Horizontal bar / table Top attacking IPs; meta.row_extras may carry geo info
GET /api/analytics/overview/top-url top-url topn Horizontal bar / table Top attacked URLs
GET /api/analytics/overview/bot bot categorical_distribution Pie / donut Human / good bot / bad bot / blocked composition

The "Detection Engine Health" card is already live (no new backend chart-key needed): the frontend EngineHealthCard.vue reuses the detection page's already-implemented detection/semantic/kpi + detection/latency/kpi (real WAF ES), self-fetching and rendering; the whole card links to /reports/detection-engine.

chart-key data shape

kpi (render_hint = kpi)

schema.measures contains 6 entries:

measure name type unit Description
domain_count integer (empty) Site count
requests integer requests Total requests in window
blocked integer events Total blocked events in window
block_rate percent % block_rate = blocked / requests
qps float qps QPS = requests / window seconds
ai_detect integer events AI detection count (currently fixed 0; later connected to ES real data)

rows (single row): [ { domain_count, requests, blocked, block_rate, qps, ai_detect } ].

Public field names: requests / blocked are API contract field names. If the underlying database still uses historical fields such as request_today / attack_today, the backend converts them in the service layer and never exposes them to callers.

Recommended chart: KpiGroupCard (6 KPI cards). Render block_rate as a percentage with a progress bar; qps with a mini sparkline.


bandwidth / request-attack — Time-series two-series

Output: [{ctime: int64ms, bandwidth: float, origin_bandwidth: float}, ...] or [{ctime, requests, attacks}, ...].

Recommended chart: line chart with ctime on X-axis and two-series Y-axis.


event-type (render_hint = categorical_distribution)

schema part Content
dimensions [{ name: "event_type", type: "string" }]
measures [{ name: "count", type: "integer", unit: "events" }]

rows (long table): [ { event_type: "sql_injection", count: 1234 }, { event_type: "xss", count: 567 }, ... ].

Recommended chart: PieCard (≤8 categories — auto pie) / BarCard (>8 categories — auto horizontal bar); the frontend dispatches by the categorical_distribution vocab word.


waf-type — Dimension distribution

Output: [{key: string, count: int}, ...].

Recommended chart: pie (≤ 8 categories) or donut.


geo — Geo heatmap

Output: [{region: string, count: int}, ...]. region is country/province name.

Recommended chart: map heatmap (CN map + world map).


top-domains — Domain ranking

Output: [{host: string, attack_count: int}, ...], ordered by attack_count DESC, ≤ 5.

Recommended chart: horizontal bar.


POST /api/analytics/overview/export — Overview export

Purpose: export KPIs and chart snapshots as CSV or JSON for offline analysis or reporting. Returns raw file stream, not wrapped in envelope.

Authentication: analytics.overview.export

Input parameters (request body):

Field Type Required Example Description
format string yes csv / json Export format
window string no last_24h Time window
charts[] array yes [{key:"kpi"}] Chart-keys to export

Output: returns the file stream directly with Content-Type: text/csv or application/json and Content-Disposition: attachment.

Visualization recommendation: not a chart; triggers a download.


6.5 Access page

Use case: traffic and quality dashboard — requests, traffic, cache hit, status codes, latency distribution, ISP, top IP/URL, geo.

API chart-key render_hint Recommended chart Description
GET /api/analytics/access/request-hm request-hm time_series_single (no compare) / time_series_multi (compare) LineCard Requests trend; with compare=true uses sub-shape B (period enum dim distinguishes current/previous)
GET /api/analytics/access/flow-hm flow-hm time_series_multi LineCard multi-series 5 measures: total_bytes / request_bytes / response_bytes / upstream_send / upstream_receive
GET /api/analytics/access/cache-hm cache-hm time_series_multi Line chart (dual Y-axis) Cache hit count + cache bytes trend
GET /api/analytics/access/bandwidth bandwidth time_series_multi LineCard multi-series 4 measures: bandwidth / origin_bandwidth / up_bandwidth / down_bandwidth
GET /api/analytics/access/status status categorical_distribution_over_time StackedBarCard 4 HTTP status classes (dim=status_class enum["2xx","3xx","4xx","5xx"] + time) stacked over time
GET /api/analytics/access/flow-duration flow-duration time_series_multi Line chart (3 percentiles) Request latency P50/P95/P99 (D4: real-time only when window ≤ 24h)
GET /api/analytics/access/isp isp categorical_distribution Pie ISP distribution (mobile/unicom/telecom/other)
GET /api/analytics/access/top-ip top-ip topn Table / horizontal bar (with geo) Top access IPs (default 10, configurable via top)
GET /api/analytics/access/top-url top-url topn Table / horizontal bar Top URLs; sortable via order=bytes_desc/cache_desc
GET /api/analytics/access/geo geo geo Map heatmap Access geo distribution

Supported chart-keys (Access redesign · docs/proposals/2026-06-29-access-analytics-redesign.md §3)

The following chart-keys use the same batch endpoint POST /api/analytics/access/batch and single-chart GET /api/analytics/access/{chart}, returning the strict 5 fields (chart_key / render_hint / schema / rows / meta). All listed chart-keys are implemented and use the unified contract. protocol returns meta.available=false until collection fields are added. dims/measures are suggested names; the contract self-check is authoritative.

API chart-key render_hint dimensions measures Description
GET /api/analytics/access/kpi kpi kpi period(enum: current/previous; 2 rows when compare) requests, qps_peak, bandwidth_peak(bps), bandwidth_95th(bps), uv, cache_req_hit_rate(%), cache_byte_hit_rate(%), origin_rate(%), rate_4xx(%), rate_5xx(%), pass_rate(%), block_rate(%), block_count, effective_rate(%) Implemented; PG flow/status + ES (uv=distinct uuid/session; pass/block from z_final_action)
GET /api/analytics/access/action-hm action-hm categorical_distribution_over_time action(enum: pass/block/challenge), time(ms) count Implemented; ES z_final_action bucketed by time (disposition trend migrated to protect/action-trend)
GET /api/analytics/access/status-origin status-origin categorical_distribution_over_time status_class(enum: 2xx/3xx/4xx/5xx), time(ms) count Implemented; ES upstream_status
GET /api/analytics/access/cache-rate cache-rate time_series_multi time(ms) req_hit_rate(%), byte_hit_rate(%), origin_rate(%) Implemented; PG cache_count/request_count, cache_bytes/total_bytes
GET /api/analytics/access/latency-hist latency-hist categorical_distribution_over_time bucket(enum: 0-50ms/50-200/200-500/500ms-1s/1-3s/>3s), time(ms) count Implemented; ES request_time bucketed
GET /api/analytics/access/latency-pct latency-pct time_series_multi time(ms) p50(ms), p95(ms), p99(ms) Implemented; ES request_time percentiles (P50/P95/P99)
GET /api/analytics/access/slow-url slow-url topn url(string) avg_time(ms) (extra: request_count) Implemented; PG flow_urls.ava_req_time
GET /api/analytics/access/error-url error-url topn url(string) count_4xx, count_5xx Implemented; ES uri × status
GET /api/analytics/access/top-ua top-ua topn user_agent(string) count Implemented; ES user_agent
GET /api/analytics/access/device device categorical_distribution device(enum: desktop/mobile/tablet/bot/other) count Implemented; ES user_agent parsed (backend UA parser)
GET /api/analytics/access/method method categorical_distribution method(enum: GET/POST/PUT/DELETE/HEAD/OPTIONS/other) count Implemented; ES method
GET /api/analytics/access/content-type content-type categorical_distribution content_type(string) count (extra: bytes) Implemented; ES response_content_type
GET /api/analytics/access/top-referer top-referer topn referer(string) count Implemented; ES referer (pending backend confirmation whether this field is collected)
GET /api/analytics/access/human-bot human-bot categorical_distribution category(enum: human/good_bot/bad_bot/blocked) count Implemented; ES z_final_type + z_bot_action (donut share comparison of human/good-bot/bad-bot/blocked, not a time series)
GET /api/analytics/access/protocol protocol categorical_distribution scheme(http/https) / http_version(1.1/2/3) / tls_version / ip_family(v4/v6) (4 facets, single-select via query param) count Implemented with empty-response fallback; access ES does not yet collect scheme/http_version/tls_version/ip_family, so the API returns meta.available=false

chart-key data shape

request-hm

Without compare (render_hint = time_series_single):

schema part Content
dimensions [{ name: "time", type: "timestamp", unit: "ms" }]
measures [{ name: "requests", type: "integer", unit: "requests" }]

rows: [ { time: 1746748800000, requests: 1234 }, ... ].

With compare (render_hint = time_series_multi sub-shape B: 1 categorical + 1 ts + 1 measure):

schema part Content
dimensions [{ name: "period", type: "enum", values: ["current","previous"] }, { name: "time", type: "timestamp", unit: "ms" }]
measures [{ name: "requests", type: "integer", unit: "requests" }]

rows: [ { period: "current", time: ..., requests: ... }, { period: "previous", time: ..., requests: ... }, ... ] long table; pivots into 2 series by period.

Recommended chart: LineCard; in compare mode the frontend pivots by period and renders solid + dashed lines automatically.


flow-hm (render_hint = time_series_multi)

schema part Content
dimensions [{ name: "time", type: "timestamp", unit: "ms" }]
measures 5 entries: total_bytes / request_bytes / response_bytes / upstream_send / upstream_receive (type=integer, unit=bytes, format=iec)

rows: [ { time: ..., total_bytes: ..., request_bytes: ..., response_bytes: ..., upstream_send: ..., upstream_receive: ... }, ... ].

Recommended chart: LineCard multi-series (5 lines) / stacked area.


cache-hm — Cache trend

Output: [{ctime, cache_count, cache_bytes, cache_response}, ...].

Truth fields (D8): underlying data uses total_cache_count / total_cache_bytes / total_cache_response_bytes; the response maps these to cache_count / cache_bytes / cache_response.

Recommended chart: dual Y-axis line (left axis count, right axis bytes).


bandwidth (render_hint = time_series_multi)

schema part Content
dimensions [{ name: "time", type: "timestamp", unit: "ms" }]
measures 4 entries: bandwidth / origin_bandwidth / up_bandwidth / down_bandwidth (type=float, unit=bps)

rows: [ { time: ..., bandwidth: ..., origin_bandwidth: ..., up_bandwidth: ..., down_bandwidth: ... }, ... ].

Recommended chart: LineCard multi-series (4 lines).


status (render_hint = categorical_distribution_over_time)

schema part Content
dimensions [{ name: "status_class", type: "enum", values: ["2xx","3xx","4xx","5xx"] }, { name: "time", type: "timestamp", unit: "ms" }]
measures [{ name: "count", type: "integer", unit: "requests" }]

rows tidy long table (no wide-pivoted c2xx/c3xx/c4xx/c5xx columns):

[
  { "status_class": "2xx", "time": 1746748800000, "count": 1200 },
  { "status_class": "3xx", "time": 1746748800000, "count": 30 },
  { "status_class": "4xx", "time": 1746748800000, "count": 8 },
  { "status_class": "5xx", "time": 1746748800000, "count": 0 },
  { "status_class": "2xx", "time": 1746752400000, "count": 1340 },
  ...
]

Each timestamp must cover all 4 status_class values (fill missing with count: 0 — the stacked bar chart depends on a complete categorical grid).

Recommended chart: StackedBarCard (stacked time-series by status_class). The frontend dispatches by the categorical_distribution_over_time vocab word.


flow-duration — Latency percentiles

Output: {p50, p95, p99} or time-series array.

Truth boundary (D4): percentile fields are computed by ES in real time only when the window ≤ 24h; longer windows return available:false.

Recommended chart: line chart (3 percentile lines) or 3 KPI cards.


isp — ISP distribution

Output: {mobile, unicom, telecom, other}.

Recommended chart: pie (4 slices).


top-ip — Top IPs

Output: [{remote_addr, count, country, region, isp}, ...], ≤ top.

Recommended chart: table (with country flag + region + ISP); or horizontal bar.


top-url — Top URLs

Output: [{url, request_count, request_bytes, cache_bytes}, ...].

Recommended chart: table by default; or horizontal bar (switchable by bytes/cache).


geo — Geo heatmap

Output: [{region, count}, ...].

Recommended chart: map heatmap.


6.6 Protect page

Use case: hit analysis for the three protection engines — WAF / CC / DDoS.

API chart-key render_hint Recommended chart Description
GET /api/analytics/protect/overview overview kpi Multi-KPI cards WAF/CC/DDoS totals summary (time-series provided by independent statistics charts)
GET /api/analytics/protect/waf/statistics waf/statistics time_series_multi Line WAF hit trend (waf hits + total attacks)
GET /api/analytics/protect/waf/types waf/types categorical_distribution Pie / horizontal bar WAF hit type distribution (SQL injection / XSS / scanner...)
GET /api/analytics/protect/waf/top-ip waf/top-ip topn RankingCard / horizontal bar dim=ip(string), measure=count(events); country/province go to meta.row_extras
GET /api/analytics/protect/waf/geo waf/geo geo GeoHeatmapCard dim=country(geo), measure=count(events); provinces temporarily stored in meta.provinces
GET /api/analytics/protect/cc/statistics cc/statistics time_series_single Line CC hit trend
GET /api/analytics/protect/cc/top-ip cc/top-ip topn Table / horizontal bar Top CC attack IPs
GET /api/analytics/protect/cc/geo cc/geo geo Map heatmap CC attack geo
GET /api/analytics/protect/cc/top-url cc/top-url topn Table Top CC attack URLs
GET /api/analytics/protect/ddos/statistics ddos/statistics time_series_multi Line (dual Y-axis) DDoS event count + peak bandwidth time-series
GET /api/analytics/protect/ddos/types ddos/types categorical_distribution Pie / horizontal bar DDoS attack type distribution (syn flood / udp flood / ...)
GET /api/analytics/protect/ddos/top-ip ddos/top-ip topn Table Top DDoS source IPs (with peak bandwidth)

Supported chart-keys (IA restructure & Protect redesign · docs/proposals/2026-07-01-analytics-ia-and-protect-redesign.md §3)

The following chart-keys use the same batch endpoint POST /api/analytics/protect/batch and single-chart GET /api/analytics/protect/{chart}, returning the strict 5 fields (chart_key / render_hint / schema / rows / meta). All listed chart-keys are implemented and use the unified contract. dims/measures are suggested names; the contract self-check is authoritative.

API chart-key render_hint dimensions measures Description
GET /api/analytics/protect/kpi kpi kpi period(enum: current/previous; 2 rows when compare) requests, blocked, block_rate(%), attack_ips, targeted_hosts, ddos_peak_bps(bps) Implemented; PG <prefix>_attack_domains(request_count/attack_count/ddos_bytes) + PG COUNT(DISTINCT remote_addr) (no ES); block_rate=blocked/requests
GET /api/analytics/protect/kpi-trend kpi-trend time_series_multi time(time) requests, blocked Implemented; feeds the sparkline on the KPI cards
GET /api/analytics/protect/action-trend action-trend categorical_distribution_over_time action(enum: pass/block/captcha), time(ms) count Implemented; ES(waf/access) z_final_action(0/1/2) date_histogram bucketed by time
GET /api/analytics/protect/module-share module-share categorical_distribution module(enum: waf/cc/ddos/bot) count Implemented; ES z_final_mod(mod_waf/mod_cc/mod_ddos/mod_bot) terms; or PG attack_domains per-module columns summed
GET /api/analytics/protect/top-rule top-rule topn rule_id(string) count (extra: rule_name/waf_type in meta.row_extras) Implemented; ES waf index z_waf_id terms (or WAF engine ES matches.rule_id)
GET /api/analytics/protect/top-host top-host topn host(string) attack_count Implemented; PG <prefix>_attack_domains GROUP BY host, SUM(attack_count)
GET /api/analytics/protect/top-url top-url topn uri(string) count Implemented; aggregates the {tfs,hs,ds}_flow_urls statistics table on mod='waf' (same table as cc/top-url, different mod). The former implementation aggregated ES request.uri, which is always empty because that index template is dynamic:false; switched to the statistics table on 2026-08-11
GET /api/analytics/protect/events events table columns: time, remote_addr, country, host, uri, method, z_final_type, z_waf_type, action, attack_count Implemented; ES waf index hits (raw-log query already exists, collapsed into table columns)

chart-key data shapes (key differences)

overview: render_hint = kpi, rows = [{waf, cc, ddos_bytes}] (single-row summary, dimensions=[], measures=waf/cc/ddos_bytes). Recommend 3 KPI cards; time-series is provided by the independent protect/waf/statistics and protect/ddos/statistics charts.

waf/statistics: render_hint = time_series_multi, rows = [{time, waf, attack_count}, ...] tidy long table (time=ms timestamp, two measures for two lines). Recommend line chart (two series).

waf/types / ddos/types: render_hint = categorical_distribution, rows = [{waf_type, count}, ...] / [{ddos_type, count}, ...] tidy long table, pie or horizontal bar.

waf/top-ip (render_hint = topn)

schema part Content
dimensions [{ name: "ip", type: "string" }]
measures [{ name: "count", type: "integer", unit: "events" }]

rows: [ { ip: "1.2.3.4", count: 1234 }, { ip: "5.6.7.8", count: 567 }, ... ], sorted DESC by count. Extra columns like country / province go into meta.row_extras (the frontend reads on demand; not declared as schema columns).

Recommended chart: RankingCard / horizontal bar.


ddos/top-ip: top IPs with geo, recommend tables with country flag.

waf/geo (render_hint = geo)

schema part Content
dimensions [{ name: "country", type: "geo" }]
measures [{ name: "count", type: "integer", unit: "events" }]

rows: [ { country: "China", count: 1234 }, { country: "US", count: 567 }, ... ].

Province data is temporarily stored in meta.provinces (shape [{province, count}]). The frontend GeoHeatmapCard uses rows for the main map and meta.provinces for province-level drill-down; if province-level analytics needs its own chart later, add a dedicated protect/waf/geo-provinces chart-key.

Recommended chart: GeoHeatmapCard (CN / world map heatmap).


cc/geo: render_hint = geo, rows = [{country, count}, ...], map heatmap.

ddos/statistics: render_hint = time_series_multi, rows = [{time, events, bandwidth}, ...] tidy long table (events=integer events, bandwidth=float bytes/iec). Recommend dual Y-axis line.


6.7 AI page

AI page paths are public. /logs uses an independent permission analytics.ai.logs; others use analytics.ai.view.
Some charts return a BatchChartResult placeholder (rows: [], meta.available = false, meta.reason explains why). Paths and contracts are stable; the backend may enable real data later without changing paths.

API chart-key Recommended chart Description
GET /api/analytics/ai/attack-trend attack-trend Line AI attack trend
GET /api/analytics/ai/top-ip top-ip Table / horizontal bar Top AI hit IPs
GET /api/analytics/ai/top-url top-url Table Top AI hit URLs
GET /api/analytics/ai/detection detection KPI cards / radar AI detection capability panel
GET /api/analytics/ai/test-results test-results Table / bar AI test results
GET /api/analytics/ai/logs logs Table (paged) AI hit logs (independent permission analytics.ai.logs)

Placeholder responses: not-yet-upgraded charts return a standard BatchChartResult (all 5 fields present, rows: [], meta.available = false, meta.reason explains why); the frontend should render an "empty" placeholder card, not fake data.


6.8 Bot page

Use case: identify and analyze bot/crawler traffic.

API chart-key Recommended chart Description
GET /api/analytics/bot/statistics statistics KPI (6) Bot requests / sessions / IPs / known/unknown bot overview (trend line is served by an independent chart; not rendered until split)
GET /api/analytics/bot/effectiveness effectiveness Capability cards / table Groups bot_reason into product capabilities such as JS session challenge, human/device check, automation-tool detection, dynamic token, and dynamic packaging so operators can see which defenses are effective
GET /api/analytics/bot/reason reason Pie / horizontal bar Bot protection reason distribution, sourced from ES bot_reason
GET /api/analytics/bot/advance-warn advance-warn Table Bot warning list
GET /api/analytics/bot/browser browser Pie Browser distribution (chrome/safari/firefox/edge/wechat/other)
GET /api/analytics/bot/operating operating Pie OS distribution (android/ios/windows/mac/other)
GET /api/analytics/bot/geo geo Map heatmap Bot geo
GET /api/analytics/bot/top-agent top-agent Table / horizontal bar Top User-Agents
GET /api/analytics/bot/top-ip top-ip Table (with geo) Top bot IPs
GET /api/analytics/bot/scatter scatter Scatter Bot warning scatter (X=ctime / Y=top_visit_count, size=top_ip_count)
GET /api/analytics/bot/sessions sessions Table (paged) Bot session list (independent permission analytics.bot.session)
GET /api/analytics/bot/sessions/{sid} - Timeline Single bot session timeline detail (independent permission analytics.bot.session)

chart-key data shapes

statistics: render_hint = kpi, rows = [{requests, sessions, ips, known_bot, unknown_bot, req_per_session}] (single-row summary, 6 measures). Recommend 6 KPI cards; requests / sessions trend is served by an independent chart (currently not split; add bot/sessions-trend when needed).

effectiveness: render_hint = table, rows = [{capability, label, description, status, reasons, count}, ...]. capability is the product capability group (for example session_challenge / client_integrity / automation_tool / dynamic_token / dynamic_packaging), status is effective or no_hits, and reasons keeps the original bot_reason:count evidence for raw-log drilldown.

reason: render_hint = categorical_distribution, rows = [{bot_reason, count}, ...]; use it as the evidence distribution for Bot, human verification, and dynamic token hits.

browser: render_hint = categorical_distribution, rows = [{browser, count}, ...] (6 enum rows: chrome/safari/firefox/edge/wechat/other), pie.

operating: render_hint = categorical_distribution, rows = [{os, count}, ...] (5 enum rows: android/ios/windows/mac/other), pie.

scatter: render_hint = table (no native scatter hint; table fallback), rows = [{time, session_id, top_visit_count, top_ip_count, top_ua_count}, ...]; the frontend interprets X / Y / size to render the scatter chart.

sessions/{sid}: render_hint = table, rows = [{time, uri, remote_addr, method, status}, ...] (5 trimmed fields), the access timeline of that session; returns empty when no session_id is passed (via the order query parameter).


6.9 Alert page

Use case: alert overview + list + detail + acknowledgment.

API Method chart-key Recommended chart Description
GET /api/analytics/alert/total GET total KPI card Total alerts
GET /api/analytics/alert/hm GET hm Line / heatmap Alert trend (count aggregated by ctime)
GET /api/analytics/alert/types GET types Pie Alert type distribution (by policy_type)
GET /api/analytics/alert/domains GET domains Horizontal bar / table Alert domain ranking
GET /api/analytics/alert/list GET list Table (paged) Alert list
GET /api/analytics/alert/{id} GET - Detail card Single alert detail (with closure metadata)
PATCH /api/analytics/alert/{id}/ack PATCH - Not a chart Acknowledge specified alert

GET /api/analytics/alert/{id} output fields

Field Type Description
id int Alert ID
uuid string Alert UUID
policy_type string Alert type
title / body string Alert title / body
domain / domain_id string Associated domain
status int Alert status (0/1/2/3, see D10)
ctime int64 Creation time
last_update_timestamp int64 Last update time
process_uid string Closer (D10 truth field)
process_time int64 Closure time (D10 truth field)
user_id string Owner

Non-super-admin is filtered by user_id; cross-tenant reads return 404. Permission analytics.alert.view.

PATCH /api/analytics/alert/{id}/ack request

Authentication: analytics.alert.ack

Input: path id; body may be empty.

Output: data is null.


6.10 Health (Phase 3)

Use case: business availability + origin quality + slow URI + geo quality.

API chart-key Recommended chart Description
GET /api/analytics/health/summary summary KPI cards (6) c2xx/c3xx/c4xx/c5xx/n4xx/n5xx totals
GET /api/analytics/health/status-breakdown status-breakdown Stacked time-series bar Three-layer c/n/a status code breakdown (c=client, n=gateway, a=app)
GET /api/analytics/health/origin-errors origin-errors Table / horizontal bar Origin error ranking (ES upstream_addr terms, only upstream_status >= 500)
GET /api/analytics/health/origin-latency origin-latency Line (3 series) Origin latency (mobile/unicom/telecom average)
GET /api/analytics/health/slow-uri slow-uri Table Slow URI Top (initially ranked by hit count; slow ranking will use real latency data after it is connected)
GET /api/analytics/health/availability availability KPI / multi-line HTTP/Ping/DNS/TCP/Page/IPv6 availability + downtime
GET /api/analytics/health/geo-isp-quality geo-isp-quality Table / map Geo / ISP quality

Truth boundary: percentile / p50 / p95 / p99 fields exist in neither chart nor statistic packages; only windows ≤ 24h get real-time ES percentile (D4). The availability table m_ava_domain.*AvailableDomain keys on domain_or_ip (not domain_id).


6.11 Ops (Phase 5)

Use case: platform-level operational view — high-traffic / high-error users and domains, origin errors, node capacity.

Permission boundary: analytics.ops.view is read-only across the platform; does not allow passing target_user_id to switch view; only analytics.ops.admin can. Regular tenant accounts cannot access ops even with view.

API chart-key Recommended chart Description
GET /api/analytics/ops/summary summary KPI cards (6) Platform capacity overview (requests/bytes/peak bandwidth/origin bandwidth/active domains/active users)
GET /api/analytics/ops/traffic-users traffic-users Horizontal bar / table Top traffic users (by total_bytes DESC)
GET /api/analytics/ops/traffic-domains traffic-domains Horizontal bar / table Top traffic domains
GET /api/analytics/ops/error-users error-users Table Top error users (by c5xx DESC, with c4xx/n5xx)
GET /api/analytics/ops/error-domains error-domains Table Top error domains
GET /api/analytics/ops/origin-errors origin-errors Table Origin error ranking
GET /api/analytics/ops/nodes nodes Table / topology Node / data-center view (RPC GetWafIpWithMachineRoom + ES server_addr/bind_addr)
GET /api/analytics/ops/query-pressure query-pressure Line (placeholder) ES query pressure (depends on instrumentation; returns available:false until metrics are connected)

Truth boundary: node system metrics (CPU/memory/disk) charts do not exist; use external Zabbix. Out of scope for this iteration. Fields node_id / server_node are forbidden.


6.12 Closure (Phase 6)

Use case: handle alerts and risk queues on one page; bulk acknowledgment supported.

Truth fields (D10): process_uid / process_time / status / level. Forbidden: handle_user / handle_time / risk_score / alert_status. AlertRecord.status (0/1/2/3) and RiskRecord.status (1/2) have different semantics — frontend i18n keys must not be shared.

API Method chart-key Recommended chart Description
GET /api/analytics/closure/summary GET summary KPI cards (5) Pending alerts/risks + handled today + average handle time (ms)
GET /api/analytics/closure/alerts GET alerts Table (paged) Pending alert queue (status=0)
GET /api/analytics/closure/risks GET risks Table (paged) Pending risk queue (connects to RiskRecord later; returns an empty list when no data is available)
GET /api/analytics/closure/trend GET trend Line / stacked bar Closure history trend (group by ctime + status)
POST /api/analytics/closure/alerts/confirm POST - Not a chart Bulk acknowledge alerts (proxies /api/alert/records/confirm)
POST /api/analytics/closure/risks/confirm POST - Not a chart Bulk acknowledge risks (proxies /api/chart/risk/events/:event_id/confirm)

summary output

{
  "alerts_pending": 12,
  "alerts_handled_today": 8,
  "risks_pending": 0,
  "risks_handled_today": 0,
  "avg_handle_time_ms": 0
}

confirm request body

{
  "ids": ["a1", "a2", "a3"],
  "remark": "added to blacklist"
}

Authentication: analytics.closure.confirm

Output: data is null.


6.13 Cache (Phase 6)

Use case: analyze the value of CDN/edge cache in saving origin bandwidth.

Truth fields (D8): total_cache_count / total_cache_bytes / total_cache_response_bytes. Forbidden: single-field names cache_count / cache_bytes / cache_hit (do not exist).

API chart-key Recommended chart Description
GET /api/analytics/cache/summary summary KPI cards (4) Hit rate / saved origin bytes / hit count / average cache object size
GET /api/analytics/cache/trend trend Line (dual Y-axis) Hit rate trend (request count vs cache hit count)
GET /api/analytics/cache/top-uri top-uri Table / horizontal bar URI Top (hit count / hit rate / saved bytes)
GET /api/analytics/cache/content-types content-types Pie Content type distribution (response_content_type aggregation)

summary output

Field Type Description
hit_rate float Cache hit rate = total_cache_count / request_count
saved_response_bytes int Saved origin bandwidth (bytes the origin would have served if not cached)
total_cache_count int Hit count
total_cache_bytes int Cached bytes
avg_object_bytes float Average cache object size = total_cache_bytes / total_cache_count
request_count int Total requests

Business metrics:


6.14 Access Logs (Phase 1)

Raw logs are detail queries, not single-chart endpoints; they share the same authentication, but requests/responses are list / detail / export.

API Method Permission Recommended chart Description
GET /api/analytics/logs GET analytics.logs.view Table (paged, 10,000-row ceiling) Paged query of raw access/attack logs
POST /api/analytics/logs/search POST analytics.logs.view Table (paged + advanced filters) Same result shape, but the request is a JSON body so field_filters recursive groups are available
POST /api/analytics/logs/histogram POST analytics.logs.view Time histogram Same filters as search; log volume bucketed over time and split by final action
GET /api/analytics/logs/{uuid} GET analytics.logs.view Detail card (7 sections) Single log detail (basic / request / response / upstream / protection / waf_detail / ai_detail)
POST /api/analytics/logs/export POST analytics.logs.export Not a chart Field-whitelist export (csv/json, size ≤ 10000; larger goes async below)
POST /api/analytics/logs/export/estimate POST analytics.logs.export Not a chart Estimate rows before exporting (ES _count; returns {total, over_limit, limit})
POST /api/analytics/logs/exports POST analytics.logs.export Not a chart Create an async export job (PIT + search_after; 1,000,000-row cap)
GET /api/analytics/logs/exports GET analytics.logs.export Not a chart Export jobs with progress (UI polls every 2s only while one is active)
GET /api/analytics/logs/exports/{id}/download GET analytics.logs.export Not a chart Download the artifact (plain CSV; gzip on disk, transfer encoding negotiated via Accept-Encoding, not enveloped)
DELETE /api/analytics/logs/exports/{id} DELETE analytics.logs.export Not a chart Cancel a running export job

Log response fields (narrowed 2026-08-06)

The list endpoints (GET /logs, POST /logs/search) no longer echo the raw ES document.
Each document used to come back with all 60 fields, twenty-odd of which had no consumer, plus
bind_addr / bot_fp / z_bypass which are not even in the ES mapping (dynamic:false drops
them) and therefore never match a query. The response is now 35 fields:

uuid, session_id, host, uri, method, request
args, scheme, protocol, request_length, remote_addr, country
province, city, isp, status, response_length, response_content_type
request_time, upstream_addr, upstream_status, upstream_cache_status, upstream_response_time, server_addr
server_port, z_final_action, z_final_mod, z_final_type, z_final_id, z_white
botd, bot_reason, request_headers, request_body, user_agent

Notes:

List pagination ceiling

Bounded by the ES max_result_window, the list can page through at most 10,000 rows:

Export fields

Sync export (/logs/export) and async export (/logs/exports) now share one field set
(previously 12 and 45 columns respectively). With no fields, all 36 columns are
exported = the 35 above plus ctime.

When fields is supplied it is intersected with the whitelist; unknown names are dropped
silently, without an error
, and if everything is dropped the default set is used. These legacy
names are now inert (they either never existed in ES or had no producer):

user_id, domain_id, province_zh, city_zh, server_protocol, upstream_send,
upstream_received, upstream_bytes_sent, upstream_bytes_received, crawler_category,
crawler_reason, search_engine, scanner_category, ai_predict, ai_score, ai_segment,
ai_usage

Async log export

Breaks past the ES from + size 10,000-row ceiling.

Constraint Value On breach Rationale
Row cap 1,000,000 400 Bigger CSVs will not open in spreadsheet tools; better to block at submit than after a two-hour wait
Per-user concurrency 1 active 409 pending counts as active, otherwise a double-click queues two jobs
Global concurrency 2 running queued as pending The risk is many users hitting production ES at once, not one user double-clicking
Retention 3 days, max 10 per user cron evicts oldest Time alone lets heavy users pile up files; count alone lets stale files sit forever

Also:

Frontend note: do not point an <a href download> at the download endpoint. Browser navigation carries no Authorization header (the token lives in localStorage, not a cookie), so it receives the 401 JSON and saves it as download.json. Fetch with auth and save the resulting blob.

GET /api/analytics/logs filter fields

15 filters: uuid / session_id / remote_addr / host / uri / method / status / z_final_action / z_final_type / z_final_mod / z_final_id / z_waf_id / z_cc_id / bot_reason / z_white.

Truth boundary: z_final_action is an integer enum 0=pass / 1=block / 2=captcha; bot_reason is the Bot protection evidence field, for example block botd / token check failed; z_white is an independent bool (whitelist hit), not part of the action enum. Forbidden: match_content / match_area / hit_rule / rule_desc (exist in neither chart nor statistic packages).

field_filters advanced filtering (recursive groups)

field_filters only takes effect in a JSON request body: POST /api/analytics/logs/search, POST /api/analytics/logs/histogram, and POST /api/analytics/logs/export (inside the query sub-object). GET /api/analytics/logs binds the query string only and does not support field_filters.

Every node has exactly one of two shapes:

Shape Fields Notes
Leaf field + op + value logic / children left empty. This is the legacy shape and its behaviour is completely unchanged; existing callers keep working as-is
Group logic + children field / op / value are ignored; children may hold leaves or further groups, nested arbitrarily

logic values and the ES clause they produce:

logic ES DSL
and {"bool":{"filter":[...]}}
or {"bool":{"should":[...],"minimum_should_match":1}}
not {"bool":{"must_not":[...]}}

Semantics

Guardrails

Limit Max Error on breach
Nesting depth (top-level node counts as level 1) 5 err.chart.field_filters_too_deep
Total leaf conditions (accumulated across groups) 50 err.chart.field_filters_too_many
logic vocabulary and / or / not err.chart.field_filters_logic_invalid

All three return HTTP 200 with business code 400; message is localized per Accept-Language.

Full op table

op ES DSL Notes
empty / CMPTYPE_01_EQ term / terms Exact match
CMPTYPE_08_IN term / terms Multi-value exact match (note: not substring containment)
CMPTYPE_02_NE / CMPTYPE_07_NOT bool.must_not Negation
CMPTYPE_03_GT / 04_GTE / 05_LT / 06_LTE range Numeric fields only: status / server_port / request_time / request_length / response_length / z_final_action
CMPTYPE_09_EXISTS exists Field existence check (equivalent to KQL field:*); no value required
CMPTYPE_10_WILDCARD wildcard True substring containment; a value without * / ? is wrapped as *x*, values with wildcards pass through as-is; multiple values are OR-ed; numeric / boolean fields are rejected

Field names are used bare (no .keyword suffix): the zcloud-access-* mapping is dynamic:false with direct keyword mappings, so there is no .keyword sub-field.

OR example (status=403 or status=404)

{
  "window": "last_24h",
  "field_filters": [
    {
      "logic": "or",
      "children": [
        { "field": "status", "op": "CMPTYPE_01_EQ", "value": ["403"] },
        { "field": "status", "op": "CMPTYPE_01_EQ", "value": ["404"] }
      ]
    }
  ]
}

Generated ES clause:

{"bool":{"minimum_should_match":1,"should":[{"term":{"status":403}},{"term":{"status":404}}]}}

Nested example ((host=a.com AND status=403) OR remote_addr=1.2.3.4)

{
  "window": "last_24h",
  "field_filters": [
    {
      "logic": "or",
      "children": [
        {
          "logic": "and",
          "children": [
            { "field": "host", "op": "CMPTYPE_01_EQ", "value": ["a.com"] },
            { "field": "status", "op": "CMPTYPE_01_EQ", "value": ["403"] }
          ]
        },
        { "field": "remote_addr", "op": "CMPTYPE_01_EQ", "value": ["1.2.3.4"] }
      ]
    }
  ]
}

Generated ES clause:

{"bool":{"minimum_should_match":1,"should":[
  {"bool":{"filter":[{"term":{"host":"a.com"}},{"term":{"status":403}}]}},
  {"term":{"remote_addr":"1.2.3.4"}}]}}

exists / wildcard example (uri contains admin AND a WAF rule was hit)

{
  "window": "last_24h",
  "field_filters": [
    { "field": "uri", "op": "CMPTYPE_10_WILDCARD", "value": ["admin"] },
    { "field": "z_waf_id", "op": "CMPTYPE_09_EXISTS" }
  ]
}

Generated ES clauses (top-level array is an implicit AND):

{"wildcard":{"uri":{"value":"*admin*"}}}
{"exists":{"field":"z_waf_id"}}

Fallback response

The stub returns:

{ "available": false, "reason": "Raw log ES query pending..." }

The list / detail / export contracts are stable; ES zcloud-access-* will be connected without changing the public contract.


6.15 Reports (Phase 4)

The report center is for templates / generation / download; it does not use the single-chart response shape. List and detail still use the unified envelope; download returns a file stream.

API Method Permission Recommended chart Description
GET /api/analytics/reports/templates GET analytics.reports.view Table / card grid Template list (with platform_only flag)
GET /api/analytics/reports GET analytics.reports.view Table (paged) Report history list
GET /api/analytics/reports/{id} GET analytics.reports.view Detail card Single report detail (status, params, artifact URL)
POST /api/analytics/reports/generate POST analytics.reports.generate Not a chart Trigger generation (platform-summary requires analytics.reports.platform)
GET /api/analytics/reports/{id}/download GET analytics.reports.download Not a chart Download artifact (pdf/csv/json/html)

Template enum (D7): protection-value / asset-risk / attack-source / business-health / platform-summary (platform ops / super-admin only) / raw-log-export.

Async threshold: estimated rows ≤ 100k goes synchronous; larger forces async with a task_id; the frontend polls /reports/:id for status + download URL. Sync generation times out after 30s and then degrades to async.

generate request body

{
  "template": "protection-value",
  "format": "pdf",
  "window": "last_30d",
  "stime": 1735660800000,
  "etime": 1738339200000,
  "filters": {}
}
Field Type Required Description
template string yes Template name (D7 closed enum)
format string yes pdf / csv / json / html
window string no Time window alias
stime / etime int64 no Custom timestamps
filters object no Template-specific filters

6.16 CLI Mapping

All Analytics APIs are wrapped by zcloud analytics commands:

# Original 6 pages
zcloud analytics overview kpi --format json
zcloud analytics access status --window last_24h --format json
zcloud analytics protect waf/types --format json
zcloud analytics ai logs --page 1 --size 20 --format json
zcloud analytics bot session <session-id> --format json
zcloud analytics alert ack <alert-id>

# 2026-04-30 chart-rebuild 6 phase extension (13 new commands)
zcloud analytics health summary --window last_24h --format json
zcloud analytics ops traffic-users --top 20 --format json
zcloud analytics closure summary --format json
zcloud analytics cache summary --format json
zcloud analytics logs list --window last_24h --status 403 --format json
zcloud analytics logs detail req-abc123 --format json
zcloud analytics logs export --format csv --fields ctime,uuid,host,uri,status > logs.csv
zcloud analytics closure alerts confirm --ids a1,a2,a3
zcloud analytics closure risks confirm --ids ev_001,ev_002
zcloud analytics reports templates --format json
zcloud analytics reports list --format json
zcloud analytics reports describe r-001 --format json
zcloud analytics reports generate --template protection-value --window last_30d --format pdf
zcloud analytics reports download r-001 --output report.pdf

When new Analytics APIs are added, the CLI mapping must be added or confirmed in sync; for new chart-keys, at least the CLI chart-key list and docs must be updated.


§7 Plan Catalog

External exposure scope: Only the two read-only endpoints below are exposed for external integration. Plan create/edit/delete, user assignment, subscription queries, and all order lifecycle operations (renew / change / refund / audit — /api/plan/orders/*) are platform-console admin operations that write the live shared billing tables — they are NOT part of the external API/CLI surface (platform ops only, via console + RBAC).

GET /api/plan/plans — Plan list (paginated)

Query the plan catalog with optional product-type and keyword filters, paginated.

Required permission: plan.plan.list

Query parameters

Parameter Type Required Default Description
page int No 1 Page number (1-based)
size int No 20 Page size
prod_type int No 0 (all) Product type filter: 2=WAF · 4=Monitor · 32=GFIP
keyword string No Fuzzy match on plan name

Response data shape

{
  "list": [ { "plan_id": "...", "name": "Basic", "prod_type": 2, "price": 99.00, "valid": 365, "level": 1, "open_status": true, ... } ],
  "total": 10,
  "page": 1,
  "size": 20
}

Examples

# Bearer Session
curl -H "Authorization: Bearer $TOKEN" \
  "$API/api/plan/plans?prod_type=2&keyword=basic&page=1&size=20"

# API Key
curl -H "Authorization: ApiKey zck_prefix.secret" \
  "$API/api/plan/plans?prod_type=2"

GET /api/plan/plans/{id} — Plan detail

Retrieve a single plan's full details by ID (including the content quota JSON).

Required permission: plan.plan.view

Path parameters

Parameter Type Required Description
id string Yes Plan ID (UUID)

Response data fields (PlanVO)

Field Type Description
plan_id string Plan unique ID (UUID)
name string Plan name
content object Quota JSON (fields differ per prod_type)
price float Price (2 decimal places)
scene string Applicable scenario description
comment string Remark
open_status bool Whether publicly listed for purchase
valid int64 Validity period (days)
effect int32 Effect mode
level int64 Plan level
creator_id string Creator user ID
ctime int64 Creation time (Unix ms)
utime int64 Update time (Unix ms)
version string Plan source version (cloud / zmod)
prod_type int32 Product type (2=WAF 4=Monitor 32=GFIP)

Example

curl -H "Authorization: Bearer $TOKEN" \
  "$API/api/plan/plans/550e8400-e29b-41d4-a716-446655440000"

Equivalent CLI commands

zcloud plan list --prod-type 2 --keyword basic
zcloud plan describe <plan_id>

§8 Node Install / Upgrade

Purpose: one-line install / upgrade of skynet-node on a protection-node host. Two sides: the management plane (platform session + RBAC) registers packages, mints one-time commands, queries jobs, and revokes tokens; the installer side (install-token only) fetches the script, downloads package/env, and reports results.
Backend: src/backend/internal/node/{handler,service,repo}/install.go, routes in src/backend/internal/node/route.go, reaper in src/backend/internal/app/install_reaper.go.

8.0 Auth & status codes

Dimension Management endpoints Installer-side endpoints
Paths /install/artifacts /commands /upgrades /jobs /tokens/:id/revoke /install/script /package /env /report
Auth Platform session (Bearer Session / API Key) + RBAC Only Authorization: Bearer <install_token>
RBAC action (perms.NodeNode) artifact / install / upgrade / job / revoke none (token self-authenticates)
Auth failure 401 / 403 (platform envelope) 401 with WWW-Authenticate: Bearer realm="node-install" (challenge)

Key points:

8.1 POST /api/node/install/artifacts — register a local package + precheck

Registers a package already present on the backend host, scans it, computes sha256, and runs prechecks.

curl -sS -X POST https://<cloud>/api/node/install/artifacts \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"skynet-node-1.0.0.tar.gz","version":"1.0.0","package_path":"/data/artifacts/skynet-node-1.0.0.tar.gz"}'

Constraints:

Response (excerpt — package_path returns the file name only, never the backend absolute path):

{"code":0,"data":{
  "artifact_id":"8f1c…","name":"skynet-node-1.0.0.tar.gz","version":"1.0.0",
  "sha256":"…64hex…","status":"ready",
  "precheck":{"passed":true,"checks":[
    {"key":"required_env","severity":"error","passed":true,"message":"required env keys found"},
    {"key":"binary_version_drift","severity":"warning","passed":true,"message":"…"}
  ]}
}}

GET /api/node/install/artifacts lists registered packages; package_path there is also the file name only, and frontends/examples must not show the real backend absolute path.

8.2 POST /api/node/install/commands · POST /api/node/install/upgrades · POST /api/node/install/uninstalls — mint a one-time command

curl -sS -X POST https://<cloud>/api/node/install/commands \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
        "artifact_id":"8f1c…",
        "node_id":"optional-target-node-UUID",
        "server_url":"https://<cloud>",
        "ttl_seconds":3600,
        "max_uses":20,
        "env":{
          "ZCLOUD_NGX_ACCESS_TOPIC":"cloud/ngx",
          "ZCLOUD_CC_TOPIC":"cc/sync",
          "ZCLOUD_SYNC_TOPIC":"zcloud-sync",
          "ZCLOUD_DELTA_TOPIC":"zcloud-delta",
          "ZCLOUD_BLOCK_TOPIC":"zcloud-block"
        }
      }'

The upgrade command uses /upgrades, equivalent to action=upgrade, with the same body.

The uninstall command uses /uninstalls, equivalent to action=uninstall, with the same body (same as install/upgrade: artifact_id required, server_url required, env/ttl_seconds/max_uses optional). Like upgrade, it targets an existing node and node_id is required (omitting it → 400 "卸载必须指定目标节点"). The response is likewise a one-time command (curl … | sudo bash one-liner; the plaintext token appears only once). Permission: node.node.uninstall.

⚠️ Destructive, irreversible: the bootstrap decides which package script to run from the server-authoritative X-Install-Action response header returned by GET /api/node/install/package (its value is the action bound to the token's job — here uninstall), running the package's uninstall.sh instead of install.sh and auto-answering its interactive [y/N] confirm via a here-string. uninstall.sh stops and removes all of the node's on-host protection services (nginx / agent / waf-spoa, etc.) and their data directories; the operation cannot be undone.

Scope: uninstall only removes the node's on-host services; it does not delete the node record from the cloud node list. To also drop the node record, the operator separately calls DELETE /api/node/nodes/:id.

Response:

{"code":0,"data":{
  "job_id":"…","token_id":"…","token_prefix":"nit_xxxxxxx",
  "expires_at":1735900000000,
  "command":"curl -fsSL --connect-timeout 10 --max-time 60 -H 'Authorization: Bearer nit_…' 'https://<cloud>/api/node/install/script' | sudo bash -s -- --token 'nit_…' --server 'https://<cloud>'"
}}

Constraints / behavior:

8.3 Installer side: script / package / env / report

The script fetched & run by command (GET /install/script) does: set -euo pipefail + temp-dir cleanup on exit → download package & env with bounded --connect-timeout/--max-time/--retry → verify X-Artifact-SHA256 matches local sha256sum (mismatch → report failed and exit) → overwrite the package's env.conf with the cloud env → if the registration env contains AGENT_PORT, inject it into the extracted install*.sh (changing the agent listen/register port from the default 33020; only the extracted copy is patched, never the verified package) → run the package's install.sh → report running / success / failed via /report.

Token quota semantics (important):

Endpoint Auth Consumes max_uses (use_count)? Counter / side effect
GET /install/script validate token validity No (ValidateBearerNoUse) no consumption; the script can be re-fetched
GET /install/package validate + consume Yes, use_count+1 headers X-Artifact-SHA256 / X-Install-Job-ID; job → running
GET /install/env validate + consume Yes, use_count+1 returns env text; 403 if env payload unavailable
POST /install/report ValidateBearerForReport No independent report_count+1; does not consume max_uses

Manual installer-side testing:

TOKEN=nit_xxx; BASE=https://<cloud>
curl -fsSL -H "Authorization: Bearer $TOKEN" "$BASE/api/node/install/script"
curl -fsSL -D - -o pkg.tar.gz -H "Authorization: Bearer $TOKEN" "$BASE/api/node/install/package"
curl -fsSL -H "Authorization: Bearer $TOKEN" "$BASE/api/node/install/env"
curl -fsS -X POST "$BASE/api/node/install/report" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"status":"success","message":"done","hostname":"node-1","node_version":"1.0.0"}'

8.4 GET /api/node/install/jobs · /jobs/:id — query jobs

curl -sS -H "Authorization: Bearer $TOKEN" https://<cloud>/api/node/install/jobs        # latest 50
curl -sS -H "Authorization: Bearer $TOKEN" https://<cloud>/api/node/install/jobs/<job_id>  # includes reports[]

status: pendingrunningsuccess / failed. started_at / finished_at are millisecond timestamps, 0 meaning not yet occurred. The detail endpoint attaches recent reports (up to 20) and recent_report.

8.5 POST /api/node/install/tokens/:id/revoke — revoke a token

curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
  https://<cloud>/api/node/install/tokens/<token_id>/revoke

8.6 Stale-job reaper

A single-process background reaper (install_reaper.go) runs once at startup, then every 10m. It atomically marks jobs with ctime older than now - 24h (StaleJobMaxAge) still in pending/running as failed (finished_at=now, message="install job timed out without report"), and revokes any still-active tokens pointing at those jobs in the same transaction — preventing a killed installer from later reusing its bearer token to resurrect a closed job. Terminal jobs are never rewritten; across replicas the transactional WHERE clause keeps it idempotent (only the first process matches; others get RowsAffected=0 and exit silently).

8.7 Frontend display conventions

8.8 POST /api/node/reg — node agent self-registration

Node agent self-registration endpoint. Public endpoint: no RBAC, no Bearer auth — same category as POST /api/node/install/report (agent infrastructure, no zcloud CLI command). The only access gate is the shared token dummy_token in the request body, which must equal the agent's hardcoded constant value; a mismatch is rejected.

Backend: src/backend/internal/node/{handler,service}/reg.go, route src/backend/internal/node/route.go (rg.POST("/reg", h.RegNode)).

Request body fields (note manger_addr is the agent's existing misspelling, missing an a — it must not be renamed to manager_addr):

Field Type Required Description
node_id string no Empty on a fresh install; non-empty means the agent already holds a node ID (an existing node is reused, not re-created)
manger_addr string yes Management address host:port, auto-detected by the agent via ip route get and reported. Falls back to default port 33020 when the port is missing
node_type string no Proto enum name string, e.g. "NODE_1_WAF". Only WAF defense nodes are supported (empty / "NODE_1_WAF" / "waf" / "1" all map to WAF; any other value returns a non-zero code)
extend_config string no url-escaped extended config; not consumed by self-registration yet
dummy_token string yes Shared token; must equal the agent's hardcoded constant value, otherwise rejected
plugin string no Plugin list, e.g. "waf,detect,agent,ebpf"
only_acl bool no ACL-only mode flag; self-registered nodes do not take this path
ip string no only_acl companion parameter; empty for standard registration
acl_tags string no only_acl companion parameter
ip_groups string no only_acl companion parameter
curl -sS -X POST https://<cloud>/api/node/reg \
  -H 'Content-Type: application/json' \
  -d '{
        "node_id":"",
        "manger_addr":"192.168.14.171:33020",
        "node_type":"NODE_1_WAF",
        "dummy_token":"<agent hardcoded shared token>",
        "plugin":"waf,detect,agent,ebpf"
      }'

Response: standard envelope {code, message, data}, where data is RegNodeResponse:

{"code":0,"message":"success","data":{
  "node_id":"3f2c…",
  "listen_addr":":33020",
  "tls":false,
  "cert":"",
  "key":"",
  "settings":{},
  "plugin":{}
}}
Response field Type Description
node_id string Node ID. Returns the existing ID when an existing node is matched idempotently; returns a newly created ID on first registration
listen_addr string Listen address, e.g. ":33020", taken from the management address port (falls back to default 33020)
tls bool Fixed false on the new (NSQ) platform (no longer distributing per-node certs via etcd)
cert string Empty string on the new platform
key string Empty string on the new platform
settings object Pushed config. The new platform distributes config over NSQ pub/sub, so this is always an empty map {}
plugin object Plugin config. Always an empty map {} on the new platform

Semantics:


§9 Network Diagnostics (Netdiag)

Run a full-chain checkup on a domain or use the Ping tool. All checks are read-only probes fired from the management server; nothing is written. Every endpoint requires the netdiag.tool.run permission. Customer-tier accounts (role_id ≥ 10) can only diagnose their own domains; any platform domain they cannot see is treated as "not onboarded".

Endpoint Description
GET /api/netdiag/dns?domain=<domain> DNS resolution check
GET /api/netdiag/icp?domain=<domain> ICP filing lookup
GET /api/netdiag/ssl?domain=<domain> SSL certificate check
GET /api/netdiag/access?domain=<domain> Cloud-WAF access config check
GET /api/netdiag/nodes?domain=<domain> Node connectivity check (onboarded domains only)
GET /api/netdiag/origin?domain=<domain> Origin health check (onboarded domains only)
GET /api/netdiag/ping?target=<target> Ping check (domain or IP)

GET /api/netdiag/dns — DNS resolution check

Compares public resolution, direct authoritative-NS resolution, and whether the current CNAME points to the platform access alias.

{
  "domain": "www.example.com", "main_domain": "example.com",
  "ns": ["ns1.dnspod.net", "ns2.dnspod.net"],
  "public_ips": ["203.0.113.10"], "authoritative_ips": ["203.0.113.10"],
  "cname": "example-com.u2x8.wafcname.com",
  "expected_cname": "example-com.u2x8.wafcname.com",
  "cname_matched": true, "on_platform": true
}

GET /api/netdiag/icp — ICP filing lookup

Calls an external API to look up the main domain's filing. Returns checked=false on failure (does not block the checkup).

{ "checked": true, "filed": true, "main_domain": "example.com",
  "site_name": "Example Tech Co.", "site_no": "京ICP备2024012345号-1", "subject_no": "京ICP备2024012345号" }

GET /api/netdiag/ssl — SSL certificate check

Onboarded domains are probed via a WAF node (checks the platform-issued cert); otherwise the domain's 443 is dialed directly. via marks the probe path.

{ "found": true, "issuer": "Let's Encrypt · R3", "subject_cn": "www.example.com",
  "not_before": 1747094400000, "not_after": 1754870400000, "days_left": 30,
  "sans": ["www.example.com", "example.com"], "hostname_match": true, "via": "node" }

GET /api/netdiag/access — Cloud-WAF access config check

{ "on_platform": true, "domain_id": "...", "cname": "...",
  "audit_status": 4, "parsing_state": 2,
  "ports": [ { "port": 80, "scheme": "http" }, { "port": 443, "scheme": "https" } ],
  "node_count": 12, "origin_count": 1 }

GET /api/netdiag/nodes — Node connectivity check

Fires an HTTP probe as the domain to each node and each protected port. Onboarded domains only; returns 404 otherwise.

{ "total": 12, "reachable": 12, "avg_latency_ms": 21,
  "nodes": [ { "node_id": "...", "name": "华东-BGP-01", "line": 4, "ip": "10.0.1.1", "ok": true,
    "checks": [ { "port": 80, "scheme": "http", "ok": true, "status_code": 200, "latency_ms": 12 } ] } ] }

GET /api/netdiag/origin — Origin health check

The management server dials the origin: TCP first, then HTTP (with the domain as Host). status_code: -1 = connect failed, 0 = pure TCP connected. Onboarded domains only.

{ "origins": [ { "addr": "203.0.113.10", "checks": [
    { "port": 80, "scheme": "http", "ok": true, "status_code": 0, "latency_ms": 46 },
    { "port": 443, "scheme": "https", "ok": false, "status_code": -1, "latency_ms": 3000, "error": "timeout" } ] } ] }

GET /api/netdiag/ping — Ping check

Pings the target from the management server (fixed 4 packets). The target is strictly validated and passed as an argument — never through a shell.

{ "target": "www.example.com", "ok": true, "sent": 4, "received": 4, "loss_pct": 0,
  "rtt_min_ms": 11.2, "rtt_avg_ms": 13.4, "rtt_max_ms": 15.8, "output": "..." }

§A Analytics Common Query Parameters

These apply to all single-chart GET endpoints (GET /api/analytics/<page>/<chart>). Defaults apply if not specified. Passing an unauthorized target_user_id or cross-OEM resource returns 403.

Parameter Type Required Example Description
window string no last_1h / last_24h / last_7d Time window alias; default last_24h
stime int64 no 1746748800000 Custom start time (Unix ms; paired with etime; takes precedence over window)
etime int64 no 1746835200000 Custom end time (Unix ms)
site_id string no site-001 Site filter
domain_id string no d_8a3b1c Domain filter
target_user_id string no u-tenant-001 Tenant-level switch viewed user; backend enforces cross-tenant authorization
compare bool no false Enable previous-period comparison (only some charts support it)
top int no 10 TopN, default 10, max 100
order string no bytes_desc Sorting; chart-defined (e.g. top-url supports request_count_desc/bytes_desc/cache_desc)
page int no 1 Pagination for list-style charts
size int no 20 Per-page count, max 100

Single-chart response skeleton:

{
  "code": 0,
  "message": "ok",
  "data": {
    "chart_key": "access/status",
    "render_hint": "categorical_distribution_over_time",
    "schema": {
      "dimensions": [
        { "name": "status_class", "type": "enum", "values": ["2xx", "3xx", "4xx", "5xx"] },
        { "name": "time", "type": "timestamp", "unit": "ms" }
      ],
      "measures": [
        { "name": "count", "type": "integer", "unit": "requests" }
      ]
    },
    "rows": [],
    "meta": {
      "cache": "miss",
      "source": "postgres",
      "latency_ms": 12,
      "window": {
        "stime": 1777526400000,
        "etime": 1777530000000,
        "granularity": "5m",
        "bucket_table": "tfs_flow_domains"
      }
    }
  }
}

window.granularity is a response field describing the actual aggregation granularity (5m / 1h / 1d), not a request parameter. The client passes window=last_24h, and the backend automatically picks the table.


§B Visualization Recommendations

B.1 Chart Unified Contract — render_hint lookup

All chart-keys are dispatched automatically by the frontend to one of 6 chart components based on render_hint. This is the contract truth (docs/specs/chart-contract.md §2) — no custom vocab words allowed.

render_hint schema shape Recommended frontend component Typical use
kpi 0~1 dim + 1+ measure KpiGroupCard Multi-metric KPI card group (e.g. overview/kpi with 6 measures)
categorical_distribution 1 categorical dim + 1 measure PieCard (≤8 cats) / BarCard (>8 cats) One-dimension share (e.g. overview/event-type)
categorical_distribution_over_time 1 categorical + 1 timestamp + 1 measure StackedBarCard / LineCard multi-series Multi-class stacked over time (e.g. access/status)
time_series_single 1 timestamp + 1 measure LineCard Single-measure time series (e.g. access/request-hm without compare)
time_series_multi 1 timestamp + ≥2 measures, or 1 categorical + 1 timestamp + 1 measure LineCard multi-series Multi-measure time series (e.g. access/flow-hm with 5 measures); compare uses sub-shape B
topn 1 string dim + 1 measure BarCard horizontal / RankingCard Pre-sorted TOP-N (e.g. protect/waf/top-ip)
geo 1 geo dim + 1 measure GeoHeatmapCard Geographic distribution (e.g. protect/waf/geo)
table any TableCard Fallback for non-visualizable data

Adding a new hint vocab word requires bilateral review (cloud + Aegeon); never extend the vocab unilaterally.

B.2 Data shape lookup

The table maps "output data shape → recommended chart" — handy as a quick lookup when wiring up the frontend. Actual responses still follow the Chart Unified Contract and are defined by schema + rows.

Data shape Typical fields Recommended chart Avoid
Single value (scalar) {count: 12345} KPI card Line / pie
Multi-KPI (4-6 scalars) {domain_count, requests, blocked, block_rate, qps} KPI card grid / radar Single pie
Time-series single [{ctime, value}, ...] Line / area Pie
Time-series multi [{ctime, requests, attacks}, ...] Multi-line / stacked area Pie
Time-series compare {current:[...], previous:[...]} Two-line solid+dashed Single line
Dimension distribution (≤ 8) [{key, count}, ...] Pie / donut Table
Dimension distribution (> 8) [{key, count}, ...] Horizontal bar / column Pie (fragmented)
Geo distribution [{region, count}, ...] Map heatmap Table
Top ranking [{key, count, ...}, ...] Horizontal bar / table Line / pie
2D matrix [[v11, v12], [v21, v22]] Heatmap Line
Scatter [{x, y, size, ...}, ...] Scatter / bubble Pie
Paged list {list, total, page, size} Table (paged) Any chart
Timeline detail {items: [{ctime, ...}]} Vertical timeline Pie
Placeholder {available: false, ...} Render n-empty placeholder, not a chart Fake data

Color suggestions:



Full API Index

The complete REST set is exported as OpenAPI v3:

GET /api/openapi.json

Importable into Postman / Insomnia / Swagger UI. All routes include full schema, parameters, response samples, and required permission keys.


RedNet Cloud · REST API Documentation · the source of truth is /api/openapi.json

POST /api/guard/bwlist/ips/check-conflicts — Pre-add IP conflict check (read-only)

Purpose: cloud-only enhancement (absent in zmod). Allowlists take precedence over blocklists in the engine — blocklisting an IP that is already allowlisted silently does nothing. This endpoint checks the opposite-color sets of the same owner before adding (black → white 2/3; white → black 1).

Auth: guard.bwlist.list. Input {"set_id"?, "ip_set_type"?, "ips":[...]} — pass set_id from the detail/edit flows, or ip_set_type when creating a set that does not exist yet (ips deduped, ≤1000). Output checked + conflicts[] (ip_addr/set_id/set_name/ip_set_type).

Exact ip_addr match only (platform CMPTYPE_01_EQ convention, no CIDR containment); informational — the add is not blocked.


GET /api/guard/bwlist/sets/{id}/domains — Domains bound to a list (domain-binding mode)

Purpose: Mirrors zmod's "bind policies & domains" dialog: besides policy binding (applies to every domain under the policy), a list can be bound directly to specific domains. The two paths are independent; either one takes effect.

Auth: guard.bwlist.list. Output: data.bound / data.available with {domain_id, domain}.

PUT /api/guard/bwlist/sets/{id}/domains — Overwrite the domains bound to a list

Auth: guard.bwlist.edit. Input: {"domain_ids": [...]} (full target set; empty = unbind all; must belong to the list's owner).

Storage semantics (identical to zmod addIPSetToData/delIPSetToData): after diffing, the set ID is appended to / removed from blacks (blacklists) or Whites (all other types — capital W) in each domain's guard_domain_settings.domain_bwl_config_setting; all other blob fields (dis_list/names/hash/minio_flag) are preserved verbatim. Affected domains are dispatched.