Add new admin panel, failover, dns fallback, providers, limiters. Update XHTTP

This commit is contained in:
Sergei Maklagin
2026-05-11 00:59:35 +03:00
parent 652e0baf57
commit 3bd162ed6f
241 changed files with 36409 additions and 4086 deletions

View File

@@ -0,0 +1,872 @@
openapi: 3.0.3
info:
title: Manager API
version: 1.0.0
servers:
- url: /manager/v1
security:
- BearerAuth: []
tags:
- name: Squads
- name: Nodes
- name: Users
- name: BandwidthLimiters
- name: TrafficLimiters
- name: ConnectionLimiters
- name: RateLimiters
- name: Info
paths:
/version:
get:
tags: [Info]
summary: Server version
responses:
"200":
content:
application/json:
schema:
type: object
required: [version]
properties:
version: {type: string, example: "1.13.11-abc1234"}
/squads:
get:
tags: [Squads]
summary: List squads
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {$ref: "#/components/parameters/FilterIDIn"}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Squad"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [Squads]
summary: Create squad
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/SquadCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/squads/count:
get:
tags: [Squads]
summary: Count squads
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {$ref: "#/components/parameters/FilterIDIn"}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/squads/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [Squads]
summary: Get squad
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [Squads]
summary: Update squad
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/SquadUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [Squads]
summary: Delete squad
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
/nodes:
get:
tags: [Nodes]
summary: List nodes
parameters:
- {in: query, name: uuid, schema: {type: string, format: uuid}}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Node"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [Nodes]
summary: Create node
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/NodeCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/nodes/count:
get:
tags: [Nodes]
summary: Count nodes
parameters:
- {in: query, name: uuid, schema: {type: string, format: uuid}}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/nodes/{uuid}:
parameters: [{$ref: "#/components/parameters/NodeUUID"}]
get:
tags: [Nodes]
summary: Get node
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [Nodes]
summary: Update node
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/NodeUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [Nodes]
summary: Delete node
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
/nodes/{uuid}/status:
parameters: [{$ref: "#/components/parameters/NodeUUID"}]
get:
tags: [Nodes]
summary: Get node status
responses:
"200":
content:
application/json:
schema:
type: object
required: [status]
properties:
status:
type: string
enum: [online, offline]
/users:
get:
tags: [Users]
summary: List users
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: inbound, schema: {type: string}}
- {in: query, name: type, schema: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/User"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [Users]
summary: Create user
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/users/count:
get:
tags: [Users]
summary: Count users
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: inbound, schema: {type: string}}
- {in: query, name: type, schema: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/users/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [Users]
summary: Get user
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [Users]
summary: Update user
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [Users]
summary: Delete user
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
/bandwidth-limiters:
get:
tags: [BandwidthLimiters]
summary: List bandwidth limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [global, connection]}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: type, schema: {type: string}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: down_start, schema: {type: string}}
- {in: query, name: down_end, schema: {type: string}}
- {in: query, name: up_start, schema: {type: string}}
- {in: query, name: up_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/BandwidthLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [BandwidthLimiters]
summary: Create bandwidth limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/bandwidth-limiters/count:
get:
tags: [BandwidthLimiters]
summary: Count bandwidth limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [global, connection]}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: type, schema: {type: string}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: down_start, schema: {type: string}}
- {in: query, name: down_end, schema: {type: string}}
- {in: query, name: up_start, schema: {type: string}}
- {in: query, name: up_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/bandwidth-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [BandwidthLimiters]
summary: Get bandwidth limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [BandwidthLimiters]
summary: Update bandwidth limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [BandwidthLimiters]
summary: Delete bandwidth limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
/traffic-limiters:
get:
tags: [TrafficLimiters]
summary: List traffic limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: used_start, schema: {type: string}}
- {in: query, name: used_end, schema: {type: string}}
- {in: query, name: quota_start, schema: {type: string}}
- {in: query, name: quota_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/TrafficLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [TrafficLimiters]
summary: Create traffic limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/traffic-limiters/count:
get:
tags: [TrafficLimiters]
summary: Count traffic limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: used_start, schema: {type: string}}
- {in: query, name: used_end, schema: {type: string}}
- {in: query, name: quota_start, schema: {type: string}}
- {in: query, name: quota_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/traffic-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [TrafficLimiters]
summary: Get traffic limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [TrafficLimiters]
summary: Update traffic limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [TrafficLimiters]
summary: Delete traffic limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
/connection-limiters:
get:
tags: [ConnectionLimiters]
summary: List connection limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [connection]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}}
- {in: query, name: lock_type, schema: {type: string, enum: [manager, default]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/ConnectionLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [ConnectionLimiters]
summary: Create connection limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/connection-limiters/count:
get:
tags: [ConnectionLimiters]
summary: Count connection limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [connection]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}}
- {in: query, name: lock_type, schema: {type: string, enum: [manager, default]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/connection-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [ConnectionLimiters]
summary: Get connection limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [ConnectionLimiters]
summary: Update connection limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [ConnectionLimiters]
summary: Delete connection limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
/rate-limiters:
get:
tags: [RateLimiters]
summary: List rate limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}}
- {in: query, name: interval, schema: {type: string}}
- {in: query, name: count_start, schema: {type: integer, format: int64}}
- {in: query, name: count_end, schema: {type: integer, format: int64}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/RateLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [RateLimiters]
summary: Create rate limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/rate-limiters/count:
get:
tags: [RateLimiters]
summary: Count rate limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}}
- {in: query, name: interval, schema: {type: string}}
- {in: query, name: count_start, schema: {type: integer, format: int64}}
- {in: query, name: count_end, schema: {type: integer, format: int64}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/rate-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [RateLimiters]
summary: Get rate limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [RateLimiters]
summary: Update rate limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [RateLimiters]
summary: Delete rate limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
parameters:
FilterCreatedAtStart: {in: query, name: created_at_start, schema: {type: string, format: date-time}}
FilterCreatedAtEnd: {in: query, name: created_at_end, schema: {type: string, format: date-time}}
FilterUpdatedAtStart: {in: query, name: updated_at_start, schema: {type: string, format: date-time}}
FilterUpdatedAtEnd: {in: query, name: updated_at_end, schema: {type: string, format: date-time}}
FilterSortAsc: {in: query, name: sort_asc, schema: {type: string}}
FilterSortDesc: {in: query, name: sort_desc, schema: {type: string}}
FilterOffset: {in: query, name: offset, schema: {type: integer, format: int64, minimum: 0}}
FilterLimit: {in: query, name: limit, schema: {type: integer, format: int64, minimum: 1}}
FilterSquadIdIn:
in: query
name: squad_id_in
schema: {type: array, items: {type: integer, format: int32}}
style: form
explode: true
FilterIDIn:
in: query
name: id_in
schema: {type: array, items: {type: integer, format: int32}}
style: form
explode: true
IntID:
in: path
name: id
required: true
schema: {type: integer, format: int32, example: 1}
NodeUUID:
in: path
name: uuid
required: true
schema: {type: string, format: uuid, example: "a3b8c9d0-4e2f-4a1b-8c3d-9e7f6a5b4c3d"}
responses:
BadRequest:
content:
text/plain:
schema: {type: string}
NotFound:
InternalError:
content:
text/plain:
schema: {type: string}
Count:
content:
application/json:
schema:
type: object
required: [count]
properties:
count: {type: integer, format: int64, example: 42}
schemas:
SquadIDs:
type: array
minItems: 1
items: {type: integer, format: int32}
example: [1]
Squad:
type: object
required: [id, name, created_at, updated_at]
properties:
id: {type: integer, format: int32}
name: {type: string, example: "default"}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
SquadCreate:
type: object
required: [name]
properties:
name: {type: string, example: "default"}
SquadUpdate:
type: object
required: [name]
properties:
name: {type: string, example: "default-renamed"}
Node:
type: object
required: [uuid, name, squad_ids, created_at, updated_at]
properties:
uuid: {type: string, format: uuid}
name: {type: string, example: "node-1"}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
NodeCreate:
type: object
required: [uuid, name, squad_ids]
properties:
uuid: {type: string, format: uuid}
name: {type: string, example: "node-1"}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
NodeUpdate:
type: object
required: [name]
properties:
name: {type: string, example: "node-1-renamed"}
User:
type: object
required: [id, squad_ids, username, inbound, type, uuid, password, secret, flow, alter_id, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string, example: "alice"}
inbound: {type: string, example: "vless-in"}
type: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}
uuid: {type: string}
password: {type: string}
secret: {type: string}
flow: {type: string}
alter_id: {type: integer, format: int32}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
UserCreate:
type: object
required: [squad_ids, username, inbound, type]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string, example: "alice"}
inbound: {type: string, example: "vless-in"}
type:
type: string
enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]
uuid: {type: string, format: uuid}
password: {type: string}
secret: {type: string}
flow: {type: string}
alter_id: {type: integer, format: int32}
UserUpdate:
type: object
properties:
uuid: {type: string, format: uuid}
password: {type: string}
secret: {type: string}
flow: {type: string}
alter_id: {type: integer, format: int32}
BandwidthLimiter:
type: object
required: [id, squad_ids, outbound, strategy, mode, speed, raw_speed, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}}
speed: {type: string, example: "10mbit"}
raw_speed: {type: integer, format: int64}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
BandwidthLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, mode, speed]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}}
speed: {type: string, example: "10mbit"}
BandwidthLimiterUpdate:
type: object
required: [outbound, strategy, mode, speed]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [global, connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}}
speed: {type: string}
TrafficLimiter:
type: object
required: [id, squad_ids, outbound, strategy, mode, raw_used, quota, raw_quota, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, bypass]}
mode: {type: string, enum: [upload, download, bidirectional]}
raw_used: {type: integer, format: int64}
quota: {type: string, example: "10gb"}
raw_quota: {type: integer, format: int64}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
TrafficLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, mode, quota]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, bypass]}
mode: {type: string, enum: [upload, download, bidirectional]}
quota: {type: string, example: "10gb"}
TrafficLimiterUpdate:
type: object
required: [outbound, strategy, mode, quota]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [global, bypass]}
mode: {type: string, enum: [upload, download, bidirectional]}
quota: {type: string}
ConnectionLimiter:
type: object
required: [id, squad_ids, outbound, strategy, lock_type, count, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
ConnectionLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, lock_type, count]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64}
ConnectionLimiterUpdate:
type: object
required: [outbound, strategy, lock_type, count]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64}
RateLimiter:
type: object
required: [id, squad_ids, outbound, strategy, connection_type, count, interval, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}
connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64}
interval: {type: string, example: "1s"}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
RateLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, connection_type, count, interval]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}
connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64}
interval: {type: string, example: "1s"}
RateLimiterUpdate:
type: object
required: [outbound, strategy, connection_type, count, interval]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}
connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64}
interval: {type: string}

View File

@@ -0,0 +1,530 @@
package server
import (
"context"
"crypto/subtle"
_ "embed"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
sHTTP "github.com/sagernet/sing/protocol/http"
"github.com/sagernet/sing/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"golang.org/x/net/http2"
)
type APIServer struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
listener *listener.Listener
tlsConfig tls.ServerConfig
httpServer *http.Server
manager constant.Manager
options option.ManagerAPIServerOptions
}
func NewAPIServer(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerAPIServerOptions) (*APIServer, error) {
if options.APIKey == "" {
return nil, E.New("missing api key")
}
return &APIServer{
Adapter: boxService.NewAdapter(C.TypeManagerAPI, tag),
ctx: ctx,
logger: logger,
listener: listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
}),
options: options,
}, nil
}
func (s *APIServer) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
boxManager := service.FromContext[adapter.ServiceManager](s.ctx)
managerService, ok := boxManager.Get(s.options.Manager)
if !ok {
return E.New("manager ", s.options.Manager, " not found")
}
s.manager, ok = managerService.(constant.Manager)
if !ok {
return E.New("invalid ", s.options.Manager, " manager")
}
chiRouter := chi.NewRouter()
s.Route(chiRouter)
if s.options.TLS != nil {
tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS))
if err != nil {
return err
}
s.tlsConfig = tlsConfig
}
if s.tlsConfig != nil {
err := s.tlsConfig.Start()
if err != nil {
return E.Cause(err, "create TLS config")
}
}
tcpListener, err := s.listener.ListenTCP()
if err != nil {
return err
}
if s.tlsConfig != nil {
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
}
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
s.httpServer = &http.Server{
Handler: chiRouter,
}
go func() {
err = s.httpServer.Serve(tcpListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("serve error: ", err)
}
}()
return nil
}
func (s *APIServer) Close() error {
return common.Close(
common.PtrOrNil(s.httpServer),
common.PtrOrNil(s.listener),
s.tlsConfig,
)
}
func (s *APIServer) Route(r chi.Router) {
r.Route("/manager/v1", func(r chi.Router) {
r.Use(newCORSMiddleware(s.options.CORS))
r.Use(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request))
handler.ServeHTTP(writer, request)
})
})
r.Group(func(r chi.Router) {
r.Use(s.requireAPIKey)
r.Get("/version", func(w http.ResponseWriter, req *http.Request) {
render.JSON(w, req, render.M{
"version": C.Version,
})
})
registerIntCRUD(r, "/squads",
s.manager.GetSquads, s.manager.GetSquadsCount,
s.manager.GetSquad, s.manager.CreateSquad,
s.manager.UpdateSquad, s.manager.DeleteSquad)
registerIntCRUD(r, "/users",
s.manager.GetUsers, s.manager.GetUsersCount,
s.manager.GetUser, s.manager.CreateUser,
s.manager.UpdateUser, s.manager.DeleteUser)
registerIntCRUD(r, "/bandwidth-limiters",
s.manager.GetBandwidthLimiters, s.manager.GetBandwidthLimitersCount,
s.manager.GetBandwidthLimiter, s.manager.CreateBandwidthLimiter,
s.manager.UpdateBandwidthLimiter, s.manager.DeleteBandwidthLimiter)
registerIntCRUD(r, "/traffic-limiters",
s.manager.GetTrafficLimiters, s.manager.GetTrafficLimitersCount,
s.manager.GetTrafficLimiter, s.manager.CreateTrafficLimiter,
s.manager.UpdateTrafficLimiter, s.manager.DeleteTrafficLimiter)
r.Put("/traffic-limiters/{id}/used", s.updateTrafficLimiterUsed)
registerIntCRUD(r, "/connection-limiters",
s.manager.GetConnectionLimiters, s.manager.GetConnectionLimitersCount,
s.manager.GetConnectionLimiter, s.manager.CreateConnectionLimiter,
s.manager.UpdateConnectionLimiter, s.manager.DeleteConnectionLimiter)
registerIntCRUD(r, "/rate-limiters",
s.manager.GetRateLimiters, s.manager.GetRateLimitersCount,
s.manager.GetRateLimiter, s.manager.CreateRateLimiter,
s.manager.UpdateRateLimiter, s.manager.DeleteRateLimiter)
r.Route("/nodes", func(r chi.Router) {
r.Get("/", listHandler(s.manager.GetNodes))
r.Post("/", createHandler(s.manager.CreateNode))
r.Get("/count", countHandler(s.manager.GetNodesCount))
r.Get("/{uuid}", getByStringIDHandler("uuid", s.manager.GetNode))
r.Put("/{uuid}", updateByStringIDHandler("uuid", s.manager.UpdateNode))
r.Delete("/{uuid}", deleteByStringIDHandler("uuid", s.manager.DeleteNode))
r.Get("/{uuid}/status", func(w http.ResponseWriter, req *http.Request) {
status, err := s.manager.GetNodeStatus(chi.URLParam(req, "uuid"))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, render.M{"status": status})
})
})
})
r.Get("/swagger", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, req.URL.Path+"/", http.StatusMovedPermanently)
})
r.Get("/swagger/", s.swaggerUI)
r.Get("/swagger/openapi.yaml", s.swaggerSpec)
})
}
// updateTrafficLimiterUsed overwrites the running raw_used counter
// of a traffic limiter. Used by the admin panel "reset traffic" button
// (which posts {"used": 0}); also fine for any operator who needs to
// nudge the counter to a specific number.
func (s *APIServer) updateTrafficLimiterUsed(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, "id"))
if err != nil {
writeBadRequest(w, req, err)
return
}
var body struct {
Used uint64 `json:"used"`
}
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := s.manager.UpdateTrafficLimiterUsed(id, body.Used)
if err != nil {
writeUpdateError(w, req, err)
return
}
render.JSON(w, req, item)
}
func (s *APIServer) requireAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
header := request.Header.Get("Authorization")
if header == "" {
writer.Header().Set("WWW-Authenticate", `Bearer realm="manager-api"`)
render.Status(request, http.StatusUnauthorized)
render.PlainText(writer, request, "missing api key")
return
}
token := strings.TrimPrefix(header, "Bearer ")
if token == header {
writer.Header().Set("WWW-Authenticate", `Bearer realm="manager-api"`)
render.Status(request, http.StatusUnauthorized)
render.PlainText(writer, request, "invalid api key format")
return
}
if subtle.ConstantTimeCompare([]byte(token), []byte(s.options.APIKey)) == 0 {
render.Status(request, http.StatusUnauthorized)
render.PlainText(writer, request, "invalid api key")
return
}
next.ServeHTTP(writer, request)
})
}
func newCORSMiddleware(cfg *option.ManagerAPICORSOptions) func(http.Handler) http.Handler {
const (
allowedMethods = "GET, POST, PUT, DELETE, OPTIONS"
fallbackHeaders = "Authorization, Content-Type"
)
var (
originSet map[string]struct{}
allowAnyOrigin = true
exposedHeaders string
maxAge = "600"
)
if cfg != nil {
hasWildcard := false
filtered := make([]string, 0, len(cfg.AllowedOrigins))
for _, o := range cfg.AllowedOrigins {
if o == "*" {
hasWildcard = true
continue
}
if o == "" {
continue
}
filtered = append(filtered, o)
}
if len(filtered) > 0 && !hasWildcard {
originSet = make(map[string]struct{}, len(filtered))
for _, o := range filtered {
originSet[o] = struct{}{}
}
allowAnyOrigin = false
}
if len(cfg.ExposedHeaders) > 0 {
exposedHeaders = strings.Join(cfg.ExposedHeaders, ", ")
}
if cfg.MaxAge > 0 {
maxAge = strconv.Itoa(cfg.MaxAge)
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
h := w.Header()
h.Set("Vary", "Origin")
emitOrigin := ""
if !allowAnyOrigin {
if _, ok := originSet[origin]; ok {
emitOrigin = origin
}
} else if origin != "" {
emitOrigin = origin
} else {
emitOrigin = "*"
}
if emitOrigin != "" {
h.Set("Access-Control-Allow-Origin", emitOrigin)
}
h.Set("Access-Control-Allow-Methods", allowedMethods)
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
h.Set("Access-Control-Allow-Headers", reqHeaders)
} else {
h.Set("Access-Control-Allow-Headers", fallbackHeaders)
}
if exposedHeaders != "" {
h.Set("Access-Control-Expose-Headers", exposedHeaders)
}
h.Set("Access-Control-Max-Age", maxAge)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
func (s *APIServer) swaggerUI(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = writer.Write([]byte(swaggerUIHTML))
}
func (s *APIServer) swaggerSpec(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/yaml")
_, _ = writer.Write(openAPISpec)
}
func registerIntCRUD[T any, CR any, UP any](
r chi.Router, path string,
list func(map[string][]string) ([]T, error),
count func(map[string][]string) (int, error),
get func(int) (T, error),
create func(CR) (T, error),
update func(int, UP) (T, error),
del func(int) (T, error),
) {
r.Route(path, func(r chi.Router) {
r.Get("/", listHandler(list))
r.Post("/", createHandler(create))
r.Get("/count", countHandler(count))
r.Get("/{id}", getByIntIDHandler("id", get))
r.Put("/{id}", updateByIntIDHandler("id", update))
r.Delete("/{id}", deleteByIntIDHandler("id", del))
})
}
func listHandler[T any](fn func(map[string][]string) ([]T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
filters := parseListFilters(req.URL.Query())
applyDefaultLimit(filters)
items, err := fn(filters)
if err != nil {
writeError(w, req, err)
return
}
if items == nil {
items = []T{}
}
render.JSON(w, req, items)
}
}
func applyDefaultLimit(filters map[string][]string) {
if _, ok := filters["limit"]; !ok {
filters["limit"] = []string{"100"}
}
}
func countHandler(fn func(map[string][]string) (int, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
count, err := fn(parseListFilters(req.URL.Query()))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, render.M{"count": count})
}
}
func parseListFilters(q url.Values) map[string][]string {
out := make(map[string][]string, len(q))
for k, vs := range q {
if !strings.HasSuffix(k, "_in") {
out[k] = vs
continue
}
expanded := make([]string, 0, len(vs))
for _, v := range vs {
s := strings.TrimSpace(v)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
expanded = append(expanded, p)
}
}
}
if len(expanded) == 0 {
continue
}
out[k] = expanded
}
return out
}
func createHandler[T, CR any](fn func(CR) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var body CR
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(body)
if err != nil {
writeBadRequest(w, req, err)
return
}
render.Status(req, http.StatusCreated)
render.JSON(w, req, item)
}
}
func getByIntIDHandler[T any](idKey string, fn func(int) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, idKey))
if err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(id)
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func getByStringIDHandler[T any](idKey string, fn func(string) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
item, err := fn(chi.URLParam(req, idKey))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func updateByIntIDHandler[T, UP any](idKey string, fn func(int, UP) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, idKey))
if err != nil {
writeBadRequest(w, req, err)
return
}
var body UP
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(id, body)
if err != nil {
writeUpdateError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func updateByStringIDHandler[T, UP any](idKey string, fn func(string, UP) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var body UP
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(chi.URLParam(req, idKey), body)
if err != nil {
writeUpdateError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func deleteByIntIDHandler[T any](idKey string, fn func(int) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, idKey))
if err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(id)
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func deleteByStringIDHandler[T any](idKey string, fn func(string) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
item, err := fn(chi.URLParam(req, idKey))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func writeBadRequest(w http.ResponseWriter, req *http.Request, err error) {
render.Status(req, http.StatusBadRequest)
render.PlainText(w, req, err.Error())
}
func writeError(w http.ResponseWriter, req *http.Request, err error) {
if err == constant.ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
render.Status(req, http.StatusInternalServerError)
render.PlainText(w, req, err.Error())
}
func writeUpdateError(w http.ResponseWriter, req *http.Request, err error) {
if err == constant.ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
render.Status(req, http.StatusBadRequest)
render.PlainText(w, req, err.Error())
}

View File

@@ -0,0 +1,145 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/sagernet/sing-box/option"
)
func TestCORSMiddleware_Preflight(t *testing.T) {
called := false
h := newCORSMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
req := httptest.NewRequest(http.MethodOptions, "/manager/v1/squads", nil)
req.Header.Set("Origin", "http://localhost:8081")
req.Header.Set("Access-Control-Request-Method", "GET")
req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rec.Code)
}
if called {
t.Fatal("next handler should not run for OPTIONS preflight")
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8081" {
t.Fatalf("Access-Control-Allow-Origin = %q, want echoed origin", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "Authorization, Content-Type" {
t.Fatalf("Access-Control-Allow-Headers = %q, want echoed request headers", got)
}
if got := rec.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatal("Access-Control-Allow-Methods should be set")
}
}
func TestCORSMiddleware_PassesThroughGET(t *testing.T) {
called := false
h := newCORSMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
req.Header.Set("Origin", "http://localhost:8081")
req.Header.Set("Authorization", "Bearer test")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !called {
t.Fatal("next handler should run for GET")
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8081" {
t.Fatalf("Access-Control-Allow-Origin = %q, want echoed origin", got)
}
if got := rec.Header().Get("Vary"); got != "Origin" {
t.Fatalf("Vary = %q, want Origin", got)
}
}
func TestCORSMiddleware_NoOriginFallsBackToWildcard(t *testing.T) {
h := newCORSMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
req := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want * for missing origin", got)
}
}
func TestCORSMiddleware_AllowedOriginsAllowList(t *testing.T) {
cfg := &option.ManagerAPICORSOptions{
AllowedOrigins: []string{"https://panel.example.com"},
}
h := newCORSMiddleware(cfg)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
allowed := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
allowed.Header.Set("Origin", "https://panel.example.com")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, allowed)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://panel.example.com" {
t.Fatalf("allowed origin = %q, want exact echo", got)
}
denied := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
denied.Header.Set("Origin", "https://attacker.example")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, denied)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("denied origin = %q, want empty", got)
}
}
func TestCORSMiddleware_StaticCredentialsHeader(t *testing.T) {
h := newCORSMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
req := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
req.Header.Set("Origin", "https://panel.example.com")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("Access-Control-Allow-Credentials = %q, want empty (credentials are statically disabled)", got)
}
}
func TestCORSMiddleware_FullConfig(t *testing.T) {
cfg := &option.ManagerAPICORSOptions{
AllowedOrigins: []string{"*"},
ExposedHeaders: []string{"X-Total-Count"},
MaxAge: 120,
}
h := newCORSMiddleware(cfg)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
req := httptest.NewRequest(http.MethodOptions, "/manager/v1/squads", nil)
req.Header.Set("Origin", "https://panel.example.com")
req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, DELETE, OPTIONS" {
t.Fatalf("Allow-Methods = %q, want static methods list", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "Authorization, Content-Type" {
t.Fatalf("Allow-Headers = %q, want echoed request headers", got)
}
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "X-Total-Count" {
t.Fatalf("Expose-Headers = %q, want %q", got, "X-Total-Count")
}
if got := rec.Header().Get("Access-Control-Max-Age"); got != "120" {
t.Fatalf("Max-Age = %q, want %q", got, "120")
}
}

View File

@@ -0,0 +1,28 @@
package server
import _ "embed"
//go:embed openapi.yaml
var openAPISpec []byte
const swaggerUIHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Manager API - Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: "./openapi.yaml",
dom_id: "#swagger-ui",
deepLinking: true,
});
};
</script>
</body>
</html>`