84689b208e
- reference/esp32-agent/: DESIGN, HARDWARE, API_COMPAT, ARCHITECTURE, README - reference/ios-agent/: DESIGN, API_COMPAT, UI_DESIGN, ARCHITECTURE, README - Both mirror Android agent architecture, UI design, and heartbeat flow - Architecture-only (no code) — avoids submodule conflicts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
8.1 KiB
8.1 KiB
API Compatibility Reference
Version: 1.0.0 Date: 2026-06-26
This document defines the HTTP API contract between NexusGuard Server Core and Embedded Agent (ESP32).
Overview
The embedded agent communicates with the server via two HTTP endpoints:
- Provisioning:
POST /api/v1/provision— Initial key exchange - Heartbeat:
POST /api/v1/heartbeat— Periodic status + config sync
1. Provisioning
Request
POST /api/v1/provision
Content-Type: application/json
{
"token": "string (required)",
"hwid": "string (required)"
}
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | Registration token (single-use) |
hwid |
string | Yes | Hardware ID (ESP32 MAC or serial) |
Response (Success)
HTTP/1.1 200 OK
Content-Type: application/json
{
"encrypted_config": "base64-encoded-bytes"
}
| Field | Type | Description |
|---|---|---|
encrypted_config |
string | AES-256-GCM encrypted ConfigPayload |
Response (Error)
{
"error": "string"
}
| Status | Error | Description |
|---|---|---|
| 400 | "invalid token format" |
Token is not valid UUID |
| 400 | "token already used" |
Token was already consumed |
| 400 | "invalid hardware id" |
HWID is empty or invalid |
| 403 | "token expired" |
Token has expired (24h TTL) |
| 500 | "provisioning failed" |
Server internal error |
Decrypted ConfigPayload
After decrypting encrypted_config, the agent receives:
{
"device_id": "string (UUID)",
"private_key": "string (hex, 64 chars)",
"preshared_key": "string (hex, 64 chars)",
"internal_ip": "string (e.g. 10.172.21.2)",
"server_pub": "string (hex, 64 chars)",
"endpoint": "string (e.g. italy-twenty.gl.at.ply.gg:59750)",
"dns": "string (e.g. 1.1.1.1)",
"allowed_ips": "string (e.g. 0.0.0.0/0)",
"server_wg_ip": "string (e.g. 10.172.21.1)"
}
| Field | Type | Description |
|---|---|---|
device_id |
string | Device UUID (use in heartbeat) |
private_key |
string | WireGuard private key (hex) |
preshared_key |
string | Pre-shared key (hex) |
internal_ip |
string | Device IP address |
server_pub |
string | Server WireGuard public key (hex) |
endpoint |
string | Server WireGuard endpoint |
dns |
string | DNS server IP |
allowed_ips |
string | Allowed IPs (e.g. "0.0.0.0/0" for full tunnel) |
server_wg_ip |
string | Server WireGuard IP |
2. Heartbeat
Request
POST /api/v1/heartbeat
Content-Type: application/json
{
"device_id": "string (required)",
"status": "string (optional)",
"state": "string (optional)",
"tunnel_up": "boolean (optional)",
"last_handshake": "string (optional, ISO 8601)"
}
| Field | Type | Required | Description |
|---|---|---|---|
device_id |
string | Yes | Device UUID |
status |
string | No | "connected", "recovering", "stopped" |
state |
string | No | Free-form state description |
tunnel_up |
boolean | No | WireGuard tunnel status |
last_handshake |
string | No | ISO 8601 timestamp of last handshake |
Response (Success)
HTTP/1.1 200 OK
Content-Type: application/json
{
"device_id": "string",
"status": "ok",
"internal_ip": "string",
"private_key": "string (hex)",
"preshared_key": "string (hex)",
"server_pub": "string (hex)",
"endpoint": "string",
"server_wg_ip": "string",
"dns": "string",
"allowed_ips": "string",
"forwards_hash": "string"
}
| Field | Type | Description |
|---|---|---|
device_id |
string | Device UUID |
status |
string | "ok" or error |
internal_ip |
string | Device IP (may change) |
private_key |
string | WireGuard private key (may rotate) |
preshared_key |
string | Pre-shared key (may rotate) |
server_pub |
string | Server public key |
endpoint |
string | Server endpoint |
server_wg_ip |
string | Server WireGuard IP |
dns |
string | DNS server |
allowed_ips |
string | Allowed IPs |
forwards_hash |
string | Hash of port forwards (for change detection) |
Response (Error)
{
"error": "string"
}
| Status | Error | Description |
|---|---|---|
| 400 | "invalid device id" |
Device ID is not valid UUID |
| 403 | "device is suspended" |
Device is suspended by admin |
| 500 | "heartbeat failed" |
Server internal error |
3. Crypto Protocol
Key Derivation
key = SHA256(hwid + salt)
hwid: Hardware ID (ESP32 MAC or serial)salt: Server'sSERVER_SALTenvironment variable- Output: 32-byte AES key
Encryption (Server Side)
plaintext = ConfigPayload JSON
nonce = random 12 bytes
ciphertext = AES-256-GCM-Encrypt(key, nonce, plaintext)
encrypted_config = nonce + ciphertext
Decryption (Agent Side)
encrypted_config = response.encrypted_config (base64 decoded)
nonce = encrypted_config[0:12]
ciphertext = encrypted_config[12:]
plaintext = AES-256-GCM-Decrypt(key, nonce, ciphertext)
ConfigPayload = JSON.parse(plaintext)
Implementation Notes
- Use
mbedtls_sha256()for key derivation - Use
mbedtls_gcm_*()for AES-256-GCM - Nonce size: 12 bytes (fixed for GCM)
- Tag size: 16 bytes (default for GCM)
4. Config Change Detection
Heartbeat Hash Comparison
The server returns forwards_hash in heartbeat response. Agent should:
- Store last received
forwards_hashin NVS - Compare with current hash
- If different, trigger config reload
// Pseudo-code
char last_hash[64];
nvs_get_str(nvs_handle, "config_hash", last_hash, sizeof(last_hash));
if (strcmp(last_hash, response.forwards_hash) != 0) {
// Config changed, rebuild tunnel
tunnel_rebuild(&config);
nvs_set_str(nvs_handle, "config_hash", response.forwards_hash);
}
5. Versioning
Current Version
- API Version: v1 (implicit, no version in URL)
- Agent Version: 1.0.0
Compatibility Policy
- Breaking Changes: Major version bump (v2)
- New Fields: Added without version bump (agent ignores unknown fields)
- Deprecation: 6-month notice before removal
Agent Behavior
- Agent ignores unknown fields in server response
- Agent sends only required fields in request
- Agent handles missing optional fields gracefully
6. Examples
Provisioning Flow
// 1. Get hardware ID
char hwid[32];
get_hardware_id(hwid, sizeof(hwid));
// 2. Send provisioning request
char *response = http_post(server_url, "/api/v1/provision",
"{\"token\":\"%s\",\"hwid\":\"%s\"}", token, hwid);
// 3. Parse encrypted config
cJSON *json = cJSON_Parse(response);
char *encrypted = cJSON_GetObjectItem(json, "encrypted_config")->valuestring;
// 4. Decrypt config
wg_config_t config;
decrypt_config(encrypted, strlen(encrypted), hwid, salt, &config);
// 5. Store in NVS
config_save(&config);
// 6. Apply to WireGuard
tunnel_init(&config);
Heartbeat Flow
// 1. Get tunnel status
bool tunnel_up = tunnel_is_up();
int64_t last_hs = tunnel_get_last_handshake();
// 2. Send heartbeat
char *response = http_post(server_url, "/api/v1/heartbeat",
"{\"device_id\":\"%s\",\"tunnel_up\":%s,\"last_handshake\":\"%s\"}",
config.device_id, tunnel_up ? "true" : "false",
format_timestamp(last_hs));
// 3. Check for config changes
cJSON *json = cJSON_Parse(response);
const char *new_hash = cJSON_GetObjectItem(json, "forwards_hash")->valuestring;
if (strcmp(config.config_hash, new_hash) != 0) {
// Config changed, reload
load_config_from_response(json, &config);
tunnel_rebuild(&config);
config_save(&config);
}
7. Error Handling
HTTP Retries
#define MAX_RETRIES 3
#define RETRY_DELAY_MS 1000
for (int i = 0; i < MAX_RETRIES; i++) {
esp_err_t err = http_post(...);
if (err == ESP_OK) break;
vTaskDelay(RETRY_DELAY_MS * (i + 1) / portTICK_PERIOD_MS);
}
Timeout Settings
- Connect timeout: 10 seconds
- Read timeout: 30 seconds
- Write timeout: 10 seconds
8. Security Notes
- All communication over HTTPS (TLS 1.2+)
- Registration token is single-use (consumed on first provisioning)
- Device can re-provision by obtaining new token
- Keys never logged or transmitted in plaintext