Files
Nexus-Guard-Suite/reference/ios-agent/docs/API_COMPAT.md
T
datadunia 84689b208e docs: add ESP32 and iOS agent architecture reference
- 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>
2026-07-06 14:20:10 +07:00

366 lines
9.1 KiB
Markdown

# iOS Agent — API Compatibility Reference
**Status**: API CONTRACT
**Mirror**: Android Agent (`apps/android-agent/`)
## Overview
The iOS 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 (iOS identifierForVendor) |
### 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 (iOS `identifierForVendor`)
- `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)
```swift
let combined = Data(base64Encoded: encryptedConfig)!
let nonce = combined.prefix(12)
let ciphertext = combined.dropFirst(12)
let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext)
let plaintext = try AES.GCM.open(sealedBox, using: key)
let json = try JSONSerialization.jsonObject(with: plaintext)
```
### Implementation Notes
- Use `CryptoKit` for AES-256-GCM
- Use `CryptoKit` for SHA-256 key derivation
- 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 UserDefaults
2. Compare with current hash
3. If different, trigger config reload
```swift
let lastHash = UserDefaults.standard.string(forKey: "config_hash") ?? ""
if lastHash != response.forwardsHash {
// Config changed, rebuild tunnel
tunnelManager.rebuildTunnel(with: newConfig)
UserDefaults.standard.set(response.forwardsHash, forKey: "config_hash")
}
```
## 5. Hardware ID
### iOS identifierForVendor
```swift
import UIKit
let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
```
### Alternative: Custom Serial
```swift
func getHardwareId() -> String {
// Use Keychain to persist custom HWID
if let savedHwid = KeychainManager.get(key: "hwid") {
return savedHwid
}
let newHwid = UUID().uuidString
KeychainManager.set(key: "hwid", value: newHwid)
return newHwid
}
```
## 6. 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
## 7. Examples
### Provisioning Flow
```swift
// 1. Get hardware ID
let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
// 2. Send provisioning request
let body = ["token": token, "hwid": hwid]
let request = URLRequest(url: URL(string: "\(serverUrl)/api/v1/provision")!)
// 3. Parse encrypted config
let encrypted = try JSONDecoder().decode([String: String].self, from: data)["encrypted_config"]!
// 4. Decrypt config
let config = try CryptoManager.decryptConfig(encrypted: encrypted, hwid: hwid, salt: salt)
// 5. Store in Keychain
KeychainStorage.save(config)
// 6. Apply to WireGuard
try await tunnelManager.startTunnel(config: config)
```
### Heartbeat Flow
```swift
// 1. Get tunnel status
let tunnelUp = tunnelManager.isTunnelUp()
let lastHandshake = tunnelManager.getLastHandshake()
// 2. Send heartbeat
let body: [String: Any] = [
"device_id": config.deviceId,
"tunnel_up": tunnelUp,
"last_handshake": lastHandshake?.iso8601String ?? "1970-01-01T00:00:00Z"
]
// 3. Check for config changes
let response = try JSONDecoder().decode(HeartbeatResponse.self, from: data)
if config.configHash != response.forwardsHash {
// Config changed, reload
let newConfig = try parseConfig(from: response)
tunnelManager.rebuildTunnel(with: newConfig)
KeychainStorage.save(newConfig)
}
```
## 8. Error Handling
### HTTP Retries
```swift
func sendRequest(url: URL, body: Data, maxRetries: Int = 3) async throws -> Data {
for attempt in 1...maxRetries {
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
return data
}
} catch {
if attempt == maxRetries {
throw error
}
try await Task.sleep(nanoseconds: UInt64(attempt * 1_000_000_000))
}
}
throw AgentError.heartbeatFailed("Max retries exceeded")
}
```
### Timeout Settings
- Connect timeout: 10 seconds
- Read timeout: 30 seconds
- Write timeout: 10 seconds
## 9. 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
- WireGuard keys stored in iOS Keychain (hardware-backed)