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
- §0 Overall Conventions — protocol / envelope / status codes / pagination / three audiences
- §1 Authentication (read first) — Bearer Session + API Key dual channel / scope narrowing
Public Endpoints (no auth)
- §2 CLI Release — version · install.sh · download · checksums
- §3 User Authentication — login · logout
Business Endpoints (dual-channel auth)
- §4 System Management
- 4.1 User Management —
/api/sys/users - 4.2 API Key Management —
/api/sys/apikeys - 4.3 Permission Tree —
/api/sys/permissions/tree - 4.4 Operation Audit —
/api/sys/audit-logs,/api/sys/login-records
- 4.1 User Management —
- §5 Guard Resource Management
- 5.1 Domains —
/api/guard/domains - 5.2 Certificates —
/api/guard/certs - 5.3 Policies —
/api/guard/policies - 5.4 CC Rules —
/api/guard/policies/{id}/cc/rules - 5.5 ACL Rules —
/api/guard/policies/{id}/acl/rules - 5.6 BW List —
/api/guard/bwlist - 5.7 IP Forward —
/api/guard/forwards - 5.8 DNS Scheduling —
/api/guard/schedules - 5.9 WAF Rules —
/api/guard/waf/rules
- 5.1 Domains —
Plan Catalog
- §7 Plan Catalog — Read-only external: list · describe; admin ops excluded
Node Operations
- §8 Node Install / Upgrade — artifacts · commands · upgrades · jobs · tokens + installer-side script/package/env/report
Analytics (92 chart-keys · single-shape contract)
- §6.0 Common Calling Contract — 5-field contract / render_hint 8-word whitelist
- §6.1 Glossary · §6.2 Cross-page Batch · §6.3 Single-chart GET entry
- §6.4 Overview page · 8 chart-keys
- §6.5 Access page · 10 live + 15 planned chart-keys
- §6.6 Protect page · 12 live + 7 planned chart-keys (WAF / CC / DDoS)
- §6.7 AI page · 6 chart-keys
- §6.8 Bot page · 12 chart-keys
- §6.9 Alert page · 5 chart-keys
- §6.10 Health · 7 chart-keys
- §6.11 Ops · 8 chart-keys
- §6.12 Closure · 4 chart-keys
- §6.13 Cache · 4 chart-keys
- §6.14 Access Logs — non-chart endpoint
- §6.15 Reports — non-chart endpoint
- §6.16 CLI Mapping — 30 cobra command mapping
Reference
- §A Common Query Parameters — window / stime / etime / site_id / domain_id / target_user_id / compare / top / order
- §B Visualization Recommendations — B.1 render_hint lookup · B.2 data shape lookup
- §C Related Documentation
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:
GET /api/cli/versionGET /api/cli/install.shGET /api/cli/download/{filename}GET /api/cli/checksums.txtPOST /api/auth/login
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:
- The plaintext API Key is returned exactly once in the create response; only
key_hash(bcrypt digest) is stored - If the user is locked or deleted, all API Keys under that user become invalid automatically
- If
scopesis non-empty, the required permission must match one of them exactly; an empty array means full inheritance of the issuing user's RBAC - Cross-OEM use is blocked by middleware: a 403 is returned when
api_keys.oem_idmismatches the request hostname - Optional IP allowlist
allowed_ip_cidrs: if non-empty, only requests from matching CIDRs are accepted
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:
- Sending plaintext password → backend base64-decode fails, returns generic "wrong username or password" without locating the real cause
- Reusing token across multiple endpoints → logout on one may affect others; recommend per-device login
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 | |
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:
- Treating
role_idsas a single value → users may have multiple roles; always handle as an array - Using
levelas a permission threshold →levelis a display field (business hierarchy); userole_idas the permission threshold (see pitfall_role_level_vs_role_id)
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:
- Expecting frontend base64 encoding here → this endpoint accepts plaintext directly (login is base64 only for legacy reasons)
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-actionsfor 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:
1052scope exceeds the user's RBAC boundary1054expires_in_days > 3651055namemissing
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:
- Storing the
api_keyfield in your DB → store onlykey_id+prefix; hand the plaintext to the operator once - Treating empty
scopesas no permission → empty actually means full inheritance of current RBAC; pass an explicit list to narrow
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:
- Regular users (
role_id ≥ 10): only their own - Platform users (
role_id < 10: super-admin / ops / auditor): all keys in this OEM
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 (status → revoked); 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:
- Regular users: only their own
- Platform users: any key in this OEM (for emergency stop)
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:
- Recommended chart: table + time-series line chart (call count aggregated by ctime)
- Derived metrics: success-rate stacked bar (split by status_code 2xx/4xx/5xx)
Visible scope:
- Regular users: only keys they issued
- Platform users: any key in this OEM (super-admin: all)
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:
- Recommended chart: KPI card grid (4 cards: total_calls / success rate / QPS / errors) + horizontal bar chart (top_endpoints)
- Derived metrics: pie chart (success / client_err / server_err)
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:
- Regular users: only records where they are the operator (
user_id = currentUserID) - Platform users: all in this OEM (super-admin: all)
Visualization recommendation:
- Recommended chart: table sorted by ctime desc
- Derived metrics: bar chart aggregated by action (create / revoke / renew / revoke-all)
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:
- Recommended chart: three-level tree (n-tree / el-tree), module → resource → action
- Reuse scenarios: API Key scope picker, role-permission checkbox tree
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:
- Recommended chart: table (primary) + top KPI cards (total / approved / pending) +
switchesrendered as a chip grid for switch states - Derived metrics: pie chart (4-way split of
audit_status)
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:
- Paginating with
page_size→ the parameter issize;page_sizeis silently ignored - Expecting
protection_enabled/current_cert_id/origin_addr→ these fields don't exist; protection switches live in theswitchesmap; cert binding is queried via/api/guard/certs/:id/domainsreverse lookup
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_idis 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_sitesentries with the same address, port and protocol are deduplicated silently, without an error.Either way works for certificates: pass
cert_idhere (domain, origins and certificate binding all happen in one transaction, rolled back as a whole on any failure), or callPOST /api/guard/certs/:id/bindafterwards.
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:
- Passing top-level boolean fields → settings values are stringified JSON, not bool
- Inventing non-existent keys → backend whitelists; unknown keys return 400
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 /domains → POST /forwards → POST /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(thenGET /certs/{id}for common_name/expiry/cert_type) anddetail.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 entryPOST /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):
- the node's DNS records are still enabled (disable parsing first);
- parsing was disabled less than 30 minutes ago (DNS cache blackhole protection);
- the node IP is referenced by port forwarding rules (adjust them first);
- removal would leave no default-line record in node dispatch mode (configure a default line on another node first).
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 theguard_weak_password_settingkey (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:
- Unit fields (
req_time_unit/action.block_time_unit/action.window_unit) only accept the enum-name stringsReqUnit_1_PSec/ReqUnit_2_PMin(numbers fail to parse);levelonly acceptsip/bot_session. Other values return400. action.action_typeis not hard-validated by the server and is stored as-is (aligned with the legacy platform); the valid values are those in the table above.- Any
statusvalue in the body is ignored — the created rule is alwaystrue.
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 thecache_config_v2_settingkey (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_domainsdoes not exist; query bound domains viaGET /api/guard/certs/{id}/domains.
Visualization recommendation:
- Recommended chart: table + near-expiry highlight (
expired_at - now < 30dcolored row) - Derived metrics: KPI cards (total / expiring in 30d / expired); pie chart (auto_cert true/false)
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:
- Using
multipart/form-datato upload files → backend doesn't support it; returns 400 - Type 2 with only
sign_cert/sign_keyand no encryption pair → 400; all four GM materials are mandatory - Type 3 without
no_key_tls_addr→ 400; nodes need it to reach the keyless server - GM material that is not SM2 (RSA/ECDSA PEM pasted in) → 400. The legacy platform swallows the parse error and stores the record anyway; cloud deliberately rejects it so the problem is caught before deployment
- Passing file paths in
cert/key→ must be the PEM text content itself - Pasting private keys into AI chat or logs → use a secure attachment / secret-handling path and let the server decrypt only when calling this API
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_addris 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 booleanhas_ssl_password. It unlocks the private key, so returning it would let any read-only account withguard.cert.viewwalk 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.
- An empty string means "leave unchanged" for every field except
ssl_password_file, which uses JSONnull/omission for "unchanged" and an empty string to clear the passphrase. - Material you do not submit is carried over from the stored record (e.g. changing only
no_key_tls_addron a keyless cert reuses the stored certificate — no need to re-paste the PEM) — but only whilecertificate_typestays the same. - Omitting
certificate_typekeeps the current type. Switching type rebuilds the whole record and explicitly clears mutually exclusive fields (standard → keyless clearskey; GM → standard clearssign_cert/sign_key). Across a type switch the private key, signing pair and keyless address are never carried over and must be resubmitted (thecertitself still carries over). - Replacing the material still has to cover bound domains: if the new CN/SAN does not cover a bound domain the API returns 400 and you must unbind first.
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}, notPOST /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 byGET /certs/{id}; no extra backend fields required.
Frontend to align (not a backend item, memo): the frontendCertVOtype still uses old field names (cert_type/user_id); the backend actually returnscertificate_type/user_name;CertDetailVO.domains[]does not exist on the backend (bound domains go throughGET /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 |
switchesis 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 elseCFGOPTION_1_DISABLE(geo/acl_rulestayCFGOPTION_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, andzcloud guard policies geo-config updatedoes 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:
statusis 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
priorityfield.
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.typeisblockorpage, thecontentfield 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 asscope_type=policy) keep working; editing such a set and picking a new scope migrates it and unbindspolicy_id. Grey-black (3) / grey-white (4) sets are deprecated and hidden by default (include_grey=trueto 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/summarywould 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:
- Recommended chart: table (primary) + KPI cards (blacklist count / whitelist count / enabled %)
- Derived metrics: pie chart (
ip_set_type1/2 split)
Search: keyword is a single box covering name LIKE ∪ describe LIKE ∪ member IP text LIKE ∪ member 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_typeset, the backend writes the set into each in-scope domain's bwlist config (the domain-side switch is enabled automatically) and triggers delivery; anall-scope set also covers domains created later. Passing bothscope_typeandpolicy_idreturns400.
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
statussyncs domain config and triggers delivery: enabled sets enter black/white lists, disabled sets enter the disabled list (dis_list). Passingscope_typerecomputes the scope: narrowing it withdraws the config from removed domains; passing a scope on a legacy policy-bound set unbindspolicy_idand migrates it to the scope model.
statusis 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 thezcloudCLI offers it — once a set is disabled, the console shows no reason and provides no way back. For enable/disable, use the per-IPPUT /api/guard/bwlist/ips/{id}/status.
This endpoint still acceptsstatusonly to avoid breaking existing integrations; omitting it leaves the stored value untouched, so astatusimported 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
IPV2model):expired_at/expire_at/left_time/timeout/ttlare 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 nestedDELETE /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:
- Recommended chart: table + status / schema dual chips
- Derived metrics: pie chart (TCP/UDP split)
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/targetdo 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 (initialAffairsStatus_Start→AffairsStatus_Succeed/AffairsStatus_Faild).⚠️ Source of truth:
dns_records.group_typeis the truth for the current mode, whileguard_configs.parsing_stateis synced asynchronously; both live in the legacyguard_dband 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
statusbecomesSucceedorFaild; if it stays atStartfor 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 | 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_statusare declared client-side but the backend DTO never returns them → the frontend will drop them;parsing_statenot 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'sproxy_switch && !stoping(passive, depends on the backend's async detection). Suggest a backend active probePOST /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 (
ApplyVOstatus/success-fail counts,ApplyNodeStatclustered bynode_group_name,ApplyDetailItemVO.apply_errper-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 front —
apply+apply_detailare written in a single-DB ACID transaction tocloud_guard, then pushed via outbox → zRPCcmd=550→ gen-service (the legacy config-generation platform); the one that actually delivers to nodes and writes backapply_status(PENDING1→RUNNING4→SUCCESS2/FAILED3)/effect_status/apply_erris 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/nodeis fully retained (agent self-registration anddomain_node_shipsare 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 componentOriginIpRanges.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); ② thestatushealth 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 thezone/patternfields, and on hit appliesaction(block / log / captcha).⚠️ Status convention:
statusis unified as1= disabled,2= enabled (the S-6 fix aligned this withforwards/bwlist/schedules). ThePOST /statusendpoint validatesoneof=1 2.⚠️ Path ID is
tag: inPUT/DELETE /api/guard/waf/rules/{id},{id}is the rule group'stag(uint64 primary key) — NOTrule_id(the business code). The frontend readstagfrom 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:
- Recommended chart: table +
action/statusdual chips +rules.lengthcount badge - Derived metric: pie chart of groups grouped by
action(block / log / captcha split)
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 integerstatus) /description(usedescribe) /domains[](scope is thescopestring).
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 dedicatedPUT /api/guard/waf/rules/{id}/statusfor 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 columnguard_policies.waf_rate_limit) is inlined in theguard_policies.waf_configJSON. 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: readguard.waf.view/guard.waf.list, writeguard.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-rulepattern/describeare stored base64 inuser_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.granularityis not a request parameter: clients passwindow=last_24h; the backend automatically picks the 5m/1h/1d aggregation table based on the window size and echoes the chosengranularityin 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:
- Recommended chart: not directly charted; the frontend looks up the glossary by chart-key when attaching tooltips
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) andreports(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:
- Recommended chart: this endpoint itself is a data-loading layer, not directly charted; frontend dispatches each chart-key result to its own chart component
- Derived metrics:
data.meta.cache_hit_ratiocan be displayed as a KPI card in dev mode to monitor dashboard cache efficiency
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 namesrequests/blockedare the public contract truth names, aligned with thepkg/chart/contractsource-of-truth structs. Underlying database field names are not exposed; the frontend reads values strictly byschema.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:
dataalways uses the Chart Unified Contract:{chart_key, render_hint, schema, rows, meta}schemadeclares the dimension and measure columns ofrows; callers must not infer response fields from underlying database columns
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.vuereuses the detection page's already-implementeddetection/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/blockedare API contract field names. If the underlying database still uses historical fields such asrequest_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/batchand single-chartGET /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.protocolreturnsmeta.available=falseuntil 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 tocache_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/batchand single-chartGET /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 frontendGeoHeatmapCardusesrowsfor the main map andmeta.provincesfor province-level drill-down; if province-level analytics needs its own chart later, add a dedicatedprotect/waf/geo-provinceschart-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.
/logsuses an independent permissionanalytics.ai.logs; others useanalytics.ai.view.
Some charts return a BatchChartResult placeholder (rows: [],meta.available = false,meta.reasonexplains 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.reasonexplains 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. Permissionanalytics.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.*AvailableDomainkeys ondomain_or_ip(notdomain_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.viewis read-only across the platform; does not allow passingtarget_user_idto switch view; onlyanalytics.ops.admincan. 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_nodeare 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 namescache_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:
- Cache hit rate =
total_cache_count / request_count - Saved origin bandwidth =
total_cache_response_bytes - Average cache object size =
total_cache_bytes / total_cache_count
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:
request_headers.cookieis never returned by any endpoint (excluded via_source.excludes,
so it never leaves ES)- The backend appends derived columns:
province_zh/city_zh(localized names),user_agent
(hoisted fromrequest_headers.user-agent; ES has no top-level field), and
crawler_category/crawler_reason/search_engine/scanner_category - The engine writes
-1for "no value this time", including on string fields (a real log has
city/country/upstream_addr/z_final_modall set to-1). Treat it as empty. GET /logs/{uuid}is not narrowed — only cookie is excluded. The watermark defense-log
lookup and the situation event detail share this endpoint and needbasic.user_idfor
ownership checks.
List pagination ceiling
Bounded by the ES max_result_window, the list can page through at most 10,000 rows:
(page-1)*size + size ≤ 10000must hold, i.e.page ≤ floor(10000/size)(500 pages atsize=20)- The backend does not clamp; going past it surfaces the ES
Result window is too largeerror totalin the response is the true hit count and may be far above 10,000 — do not derive
the page count from it- For everything: shorten the time window, add filters, or use the async export (1M-row cap)
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:
- Responses never contain
file_path— server paths stay server-side - Cross-tenant download is reported as "not found"; distinguishing "absent" from "not yours" would leak job existence
- After cron sweeps the file, download returns
err.chart.export_file_expired - No SSE for progress: exports run for minutes, 2s polling is granular enough and survives reloads
- Artifacts are stored gzipped (879k rows measured at 21.8MB vs 116MB raw); what users get is always a directly openable
.csv - Transfer encoding is negotiated via
Accept-Encoding: clients that accept gzip receive the compressed stream verbatim withContent-Encoding: gzip(180k rows measured at 27MB vs 239MB — 8.9x less to transfer), and browsers plus Go's http.Client decompress it themselves; bare clients that do not accept gzip get a server-decompressed stream - Do not confuse the two size fields on a task:
file_sizeis the gzipped on-disk footprint,raw_sizeis the decompressed size (i.e. the file the user ends up with — this is what the UI shows).raw_sizewas added 2026-08-14; tasks created before that report 0, so consumers should fall back tofile_size expires_at=created_atplus the retention window (currently 3 days). Downloads are refused once it passes, regardless of whether the file is still on disk: the purge job only has a CLI entry point and no resident scheduler, so expired artifacts usually linger — checking file existence alone would make the "kept for 3 days" promise meaningless- The CSV carries a UTF-8 BOM; without it Excel garbles non-ASCII text
Frontend note: do not point an
<a href download>at the download endpoint. Browser navigation carries noAuthorizationheader (the token lives in localStorage, not a cookie), so it receives the 401 JSON and saves it asdownload.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_actionis an integer enum0=pass / 1=block / 2=captcha;bot_reasonis the Bot protection evidence field, for exampleblock botd/token check failed;z_whiteis 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
- The top-level
field_filtersarray itself is an implicit AND group (unchanged legacy semantics). logicempty butchildrenpresent → treated asand, so conditions are never silently dropped.- A group with empty
children→ ignored (no error, no clause). - The field whitelist applies at every nesting level; entering
childrennever bypasses it.
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
.keywordsuffix): thezcloud-access-*mapping isdynamic:falsewith direct keyword mappings, so there is no.keywordsub-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 insrc/backend/internal/node/route.go, reaper insrc/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:
- The install token is carried only in the
Authorizationheader and never in the URL query. - All four installer-side endpoints return
401+ challenge header on auth failure;/envreturns403when the token is valid but its env payload is no longer available;/reportreturns403whenjob_iddoes not match the token's bound job. - Cloud-owned tables are fixed as
node_install_*(node_install_artifacts/node_install_tokens/node_install_jobs/node_install_reports), living only inguard_localand never writing legacy tables.
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:
package_pathmust be an absolute path on the backend host, ending in.tar.gz/.tgz, and located inside an allowed artifact root.- Default allowed roots are
/data/artifacts,/opt/cloud/artifacts(override via envNODE_INSTALL_ARTIFACT_ROOTS, comma-separated)./tmpis deliberately excluded — it is world-writable, so allowing it would let any local process drop a tarball and ride the register flow; tests needing a temp dir must setNODE_INSTALL_ARTIFACT_ROOTSexplicitly. - A failed blocking check (
severity=error) →status=failed;warning/infodo not block →status=ready. Blocking checks include:VERSIONSmanifest,env.confpresent without production markers, required env keys, required topic hints, complete spoasig-*rule references.binary_version_driftandpulsar_config_riskarewarning(non-blocking).
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-Actionresponse header returned byGET /api/node/install/package(its value is the action bound to the token's job — hereuninstall), running the package'suninstall.shinstead ofinstall.shand auto-answering its interactive[y/N]confirm via a here-string.uninstall.shstops 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:
server_urlmust match the cloud's configured public base URL (scheme + host[:port]), otherwise 400 — this prevents a privileged caller from embedding an attacker-controlled host in the install command (the installer would then trust that host's package/env/report with the bearer token). Local-test exception: when configured to allow local, a loopbackserver_urlis accepted.server_urlmust be an absolute URL; https is enforced outside localhost; query / fragment are stripped.envis the registration-time node variables (env.conf text); empty falls back to the package's bundledenv.conf. Values are shell single-quote escaped before delivery.- env keys use a deny-list (not an allow-list): the package is variable-agnostic and all variables are supplied at registration by the upper layer, so arbitrary business keys are accepted (e.g.
REG_URL/AGENT_PORT/PULSAR_ADDR). A request is rejected (→ 400, no token / job created) only when a key matches sensitive patterns (PASSWORD/PASSWD/SECRET/PRIVATE/CRED/APIKEY/API_KEY/AUTH_TOKEN/ACCESS_TOKEN/ACCESS_KEY/SECRET_KEY/SIGNING_KEY/BOOTSTRAP_TOKEN, case-insensitive) or looks like an install token / Bearer credential. The env text is persisted tonode_install_jobs.env_payloadso/install/envcan be replayed after a backend restart / across replicas. AGENT_PORT(optional registration variable): when set, this install's agent listens on + registers with that port (default33020), to coexist with an existing agent on the same host. The port is injected by the cloud into the extracted installer scripts after the sha256 check, so the package file itself is left unmodified.ttl_seconds: default3600, cap86400(24h).max_uses: minimum 5 (values below 5 are raised to 5), default20, cap100. Keep retry headroom (downloading package + env consumes at least one each, and failed retries consume more) — do not set it at the minimum.artifact.status != ready→ 400, no token / job created.- The plaintext token appears exactly once, in
command; the DB stores only the tokensha256hash + a 12-charprefix.node_install_jobs.commandstores only a redacted template (<redacted>), never the plaintext token or Bearer header. job.speckeeps only whitelisted fields (server_url/env_sha256/env_keys/artifact/token_prefix);env_payloadis stripped from every job response.
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 |
reportuses an independentreport_count; multiple installer reports (running→success/failed) never drainmax_uses.failed/runningreports do not close the token; onlysuccessmarks the tokenused(terminal, unusable afterward).- A
failedreport is accepted even on arevokedtoken, so an operator-revoked run can still settle its job tofailed. - If
reportincludesjob_id, it must match the token's bound job, otherwise 403. Once a job is terminal (success/failed), a late report does not overwrite the terminal state.
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: pending → running → success / 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
- Only an
activetoken can be revoked; alreadyused/revoked→ 400. - After revocation, installer-side script/package/env return 401 immediately; a
failedreport can still be submitted to settle the job. - Tokens have no separate
expiredstatus; expiry is derived fromexpires_at.status=activeonly means not-revoked / not-used — it is not equivalent to "still usable".
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
- The plaintext token in the minted command is shown only once; the UI must warn "shown once — copy and store it now".
- Lists/details show only the
token_prefix(e.g.nit_xxxxxxx) and status, never echoing the plaintext token. - Clear the clipboard after the command is copied (timed clear), so the token does not linger in the clipboard.
- Artifact lists must not show the real backend
package_pathabsolute path (the backend already returns the file name only).
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, routesrc/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:
- Idempotent (upsert by management address): re-registering the same
manger_addrreturns the samenode_idand never creates a duplicate node; with old and new agents coexisting it preserves the existing node and does not overwrite its fields. - Fresh registration: creates a node row (
machine_roomdefaults todefault; ops can re-home it to a real machine room from the node list) and derives oneip_addrrow from the management IP — so the node auto-appears in the node list with both the management address and the business IP filled in. Theip_addrinsert is best-effort: a non-fatal error such as an already-used IP does not fail registration. - Failure handling: always returns HTTP
200; business errors are conveyed via a non-zerocodein the response body. A mismatcheddummy_token, a missing or invalidmanger_addr, or a non-WAFnode_typeall return a non-zerocodewith an error message.
§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.runpermission. 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.granularityis a response field describing the actual aggregation granularity (5m / 1h / 1d), not a request parameter. The client passeswindow=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:
- Protection (attack/block): red palette (#ff4d4f / #ff7875)
- Traffic (request/bandwidth): blue palette (#2563eb / #60a5fa)
- Cache / AI (gain): green palette (#10b981 / #34d399)
- Neutral metrics: gray / purple palette (#6b7280 / #8b5cf6)
§C Related Documentation
- Authentication — Bearer session, API Key, and 401 retry
- Examples — three-language calling samples (curl / Python / Go)
- Permission Matrix — permission key per endpoint and OEM isolation rules
- CLI Tool — equivalent CLI commands
- Errors — full business error code table
- Quickstart — make your first call in 5 minutes
- /api/openapi.json — full OpenAPI v3 schema (machine-readable)
- /llms.txt / /llms-full.txt — AI agent quick-read entries
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.