# 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: 1. **Provisioning**: `POST /api/v1/provision` — Initial key exchange 2. **Heartbeat**: `POST /api/v1/heartbeat` — Periodic status + config sync ## 1. Provisioning ### Request ``` POST /api/v1/provision Content-Type: application/json ``` ```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 ``` ```json { "encrypted_config": "base64-encoded-bytes" } ``` | Field | Type | Description | |-------|------|-------------| | `encrypted_config` | string | AES-256-GCM encrypted ConfigPayload | ### Response (Error) ```json { "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: ```json { "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 ``` ```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 ``` ```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) ```json { "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's `SERVER_SALT` environment 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: 1. Store last received `forwards_hash` in NVS 2. Compare with current hash 3. If different, trigger config reload ```c // 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 ```c // 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 ```c // 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 ```c #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