Skip to content

gRPC API

Агент — gRPC-клиент, backend — сервер (Архитектура → Направление соединения). Весь контракт — два метода.

service ControlPlane {
// TLS (без клиентского сертификата), одноразовый enrollment-токен.
rpc Enroll(EnrollRequest) returns (EnrollResponse);
// mTLS. Долгоживущий стрим; вся работа мультиплексируется в нём.
rpc Sync(stream AgentMessage) returns (stream ServerMessage);
}

Идентичность ноды в Sync берётся из клиентского сертификата (CN = node id) — в сообщениях она не передаётся и не может быть подделана.

message EnrollRequest {
string token = 1; // одноразовый, из панели
string csr_pem = 2; // ключ сгенерирован на ноде, ноду не покидает
string hostname = 3;
string agent_version = 4;
}
message EnrollResponse {
string node_id = 1;
string client_cert_pem = 2; // CSR, подписанный внутренним CA
string ca_cert_pem = 3; // для пиннинга серверного сертификата backend
}

Детали протокола доверия — Безопасность агента.

message AgentMessage {
oneof msg {
Hello hello = 1; // первое сообщение после открытия стрима
ApplyResult apply_result = 2; // итог применения DesiredState
StatusReport status = 3; // периодически (30 с) и по изменению
UsageReport usage = 4; // накопленные дельты трафика
LogBatch logs = 5; // ответ на Command.get_logs / поток лога
CertRenewal cert_renewal = 6; // плановая ротация mTLS-сертификата агента
}
}
message CertRenewal {
string csr_pem = 1; // новый ключ сгенерирован на ноде
}
message Hello {
string hostname = 1;
string os = 2;
string arch = 3;
string agent_version = 4;
string xray_version = 5;
int64 applied_revision = 6; // что применено локально; backend сравнит и дошлёт
}
message ApplyResult {
int64 revision = 1;
bool success = 2;
string error = 3; // пусто при success
}
message StatusReport {
bool xray_running = 1;
double cpu_percent = 2;
int64 memory_used_bytes = 3;
int64 memory_total_bytes = 4;
int64 disk_used_bytes = 5;
int64 disk_total_bytes = 6;
int32 connections = 7;
int64 xray_uptime_seconds = 8;
}
message UsageReport {
int64 report_id = 1; // монотонный на ноду; идемпотентность
repeated UsageEntry entries = 2;
}
message UsageEntry {
string subscription_id = 1;
int64 uplink_bytes = 2;
int64 downlink_bytes = 3;
}
message LogBatch {
string command_id = 1; // какой команде отвечаем
repeated LogEntry entries = 2;
bool eof = 3; // для get_logs; в live-потоке не используется
}
message LogEntry {
int64 timestamp_ms = 1;
LogSource source = 2; // AGENT | XRAY
string line = 3;
}
message ServerMessage {
oneof msg {
DesiredState desired_state = 1;
UsageAck usage_ack = 2;
Command command = 3;
CertIssued cert_issued = 4; // ответ на CertRenewal
}
}
message CertIssued {
string client_cert_pem = 1; // агент атомарно заменяет identity; ключ остаётся локальным
}
message DesiredState {
int64 revision = 1;
NodeConfig config = 2; // всегда полный снапшот, не diff
}
message UsageAck {
int64 report_id = 1; // агент очищает подтверждённый буфер
}
message Command {
string command_id = 1;
oneof cmd {
RestartXray restart_xray = 2; // принудительный рестарт процесса
GetLogs get_logs = 3;
StartLogStream start_log_stream = 4; // live-tail в LogBatch
StopLogStream stop_log_stream = 5;
}
}
message GetLogs {
LogSource source = 1;
int32 limit = 2; // последние N строк
}

Backend отправляет DesiredState при открытии стрима (если Hello.applied_revision < desired_revision) и при каждом изменении. Доставляется только последний снапшот: если нода отстала на несколько ревизий, промежуточные не отправляются.

Полное желаемое состояние ноды. Агент приводит ноду к нему и ничего не додумывает.

message NodeConfig {
XrayConfig xray = 1; // конфигурация движка Xray
repeated Certificate certificates = 10; // движко-независимая часть
}
message XrayConfig {
repeated Inbound inbounds = 1; // клиентские и chain-in
repeated Outbound outbounds = 2; // freedom, blackhole, хопы цепочек
repeated RoutingRule routing_rules = 3; // порядок значим: первое совпадение побеждает
}
message Certificate {
string id = 1;
string cert_pem = 2;
string key_pem = 3;
}

Xray-специфика изолирована в собственном поле намеренно: появление второго движка (sing-box, WireGuard) — аддитивное поле NodeConfig с новым номером, а не новая версия контракта. Абстракция «обобщённого сервиса» при этом не вводится — общее между движками станет ясно только при реальной второй реализации; сейчас покупается лишь совместимость транспорта. Сертификаты — вне XrayConfig: доставка PEM на ноду от движка не зависит.

message Inbound {
string tag = 1;
Protocol protocol = 2;
uint32 port = 3;
string listen = 4;
StreamSettings stream = 5;
Sniffing sniffing = 6;
repeated InboundUser users = 7;
}
message InboundUser {
string email = 1; // "<subscription_id>@astral" или "chain:<chain_id>:<pos>"
oneof account {
VlessAccount vless = 10;
VmessAccount vmess = 11;
TrojanAccount trojan = 12;
ShadowsocksAccount shadowsocks = 13;
}
}
message Outbound {
string tag = 1;
Protocol protocol = 2; // + FREEDOM | BLACKHOLE
string address = 3; // следующий хоп; пусто для freedom/blackhole
uint32 port = 4;
oneof account {
VlessAccount vless = 10;
TrojanAccount trojan = 11;
ShadowsocksAccount shadowsocks = 12;
}
StreamSettings stream = 20;
}
message RoutingRule {
string inbound_tag = 1;
string outbound_tag = 2;
repeated string protocols = 3; // сниффенные ("bittorrent"); пусто — весь инбаунд
}
enum Protocol { PROTOCOL_UNSPECIFIED = 0; VLESS = 1; VMESS = 2; TROJAN = 3; SHADOWSOCKS = 4; FREEDOM = 5; BLACKHOLE = 6; }
enum Network { NETWORK_UNSPECIFIED = 0; TCP = 1; WS = 2; GRPC = 3; HTTPUPGRADE = 4; XHTTP = 5; }
enum SecurityT { SECURITY_UNSPECIFIED = 0; NONE = 1; TLS = 2; REALITY = 3; }
enum LogSource { LOG_SOURCE_UNSPECIFIED = 0; AGENT = 1; XRAY = 2; }
message VlessAccount { string id = 1; string flow = 2; }
message VmessAccount { string id = 1; }
message TrojanAccount { string password = 1; }
message ShadowsocksAccount { string method = 1; string password = 2; }
message StreamSettings {
Network network = 1;
SecurityT security = 2;
oneof transport {
TcpSettings tcp = 10;
WsSettings ws = 11;
GrpcSettings grpc = 12;
HttpUpgradeSettings httpupgrade = 13;
XhttpSettings xhttp = 14;
}
oneof tls {
TlsSettings tls_settings = 20;
RealitySettings reality = 21;
}
}
message TcpSettings { bool accept_proxy_protocol = 1; }
message WsSettings { string path = 1; map<string, string> headers = 2; }
message GrpcSettings { string service_name = 1; bool multi_mode = 2; }
message HttpUpgradeSettings { string path = 1; string host = 2; }
message XhttpSettings { string path = 1; string host = 2; XhttpMode mode = 3; }
enum XhttpMode { XHTTP_MODE_UNSPECIFIED = 0; AUTO = 1; PACKET_UP = 2; STREAM_UP = 3; STREAM_ONE = 4; }
message TlsSettings {
repeated string server_names = 1;
string alpn = 2;
string min_version = 3;
string certificate_id = 4; // ссылка на NodeConfig.certificates; пути к файлам — забота агента
}
message RealitySettings {
string dest = 1;
repeated string server_names = 2;
string private_key = 3;
string public_key = 4;
repeated string short_ids = 5;
string fingerprint = 6;
}
message Sniffing {
bool enabled = 1;
repeated string dest_override = 2; // http, tls, quic, bittorrent
bool route_only = 3;
}

TlsSettings.certificate_id вместо путей к файлам: контракт не знает про файловую систему ноды — раскладку PEM по путям и подстановку путей в конфиг Xray выполняет агент.

Ситуация Статус gRPC
Невалидный/истёкший/использованный enrollment-токен UNAUTHENTICATED
Sync без клиентского сертификата или нода disabled/удалена UNAUTHENTICATED
Ошибка применения desired state Не ошибка RPC: агент шлёт ApplyResult{success: false}, стрим живёт
Разрыв стрима Агент переподключается с экспоненциальным backoff (1 с … 60 с, jitter)

Поля только добавляются (proto3, новые номера); удаление — резервированием номера. Несовместимые изменения — новый пакет astral.controlplane.v2, backend обслуживает обе версии на период миграции агентов. Версия агента известна из Hello — backend может подсветить устаревшие ноды в панели.