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>
336 lines
8.1 KiB
Markdown
336 lines
8.1 KiB
Markdown
# 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
|