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>
9.1 KiB
9.1 KiB
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:
- 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 (iOS identifierForVendor) |
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 (iOSidentifierForVendor)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)
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
CryptoKitfor AES-256-GCM - Use
CryptoKitfor 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:
- Store last received
forwards_hashin UserDefaults - Compare with current hash
- If different, trigger config reload
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
import UIKit
let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
Alternative: Custom Serial
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
// 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
// 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
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)