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>
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user