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)
|
||||
@@ -0,0 +1,474 @@
|
||||
# iOS Agent — Module Architecture
|
||||
|
||||
**Status**: ARCHITECTURE ONLY
|
||||
**Mirror**: Android Agent (`apps/android-agent/`)
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the module architecture for the iOS WireGuard agent. The agent uses NetworkExtension (NEPacketTunnelProvider) for tunnel management and mirrors the Android agent's architecture.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ iOS (SwiftUI) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ App Layer │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ContentView│ │SettingsView│ │LogView │ │PortView │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
|
||||
│ │ AgentManager │ │
|
||||
│ │ (orchestrates all components) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Service Layer │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │Provisioning│ │Heartbeat│ │TunnelMgr │ │ConfigMgr│ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
|
||||
│ │ CryptoManager │ │
|
||||
│ │ AES-256-GCM (CryptoKit) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ NetworkExtension Layer │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ PacketTunnelProvider │ │
|
||||
│ │ (WireGuard tunnel via wireguard-apple) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Storage Layer │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Keychain │ │ UserDefaults│ │ CoreData│ │ FileMgr │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Task Architecture
|
||||
|
||||
```
|
||||
iOS Threads/Tasks
|
||||
├── Main Thread # UI updates
|
||||
├── AgentManager # Orchestration (async/await)
|
||||
├── HeartbeatTask # HTTP heartbeat (30s interval)
|
||||
├── TunnelManager # WireGuard tunnel management
|
||||
└── BackgroundTask # BGAppRefreshTask (background refresh)
|
||||
```
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ BOOT │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ APP_INIT │ ◄──────────────────────────────┐
|
||||
└──────┬──────┘ │
|
||||
│ loaded │
|
||||
▼ │
|
||||
┌─────────────┐ fail ┌─────────────┐│
|
||||
│ PROVISION │───────────────►│ APP_RETRY ││
|
||||
└──────┬──────┘ └──────┬──────┘│
|
||||
│ success │ │
|
||||
▼ └───────┘
|
||||
┌─────────────┐
|
||||
│ TUNNEL_UP │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ HEARTBEAT │ ◄─── 30s interval
|
||||
└──────┬──────┘
|
||||
│ config_changed
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ TUNNEL_REBUILD │
|
||||
└──────┬──────┘
|
||||
│
|
||||
└──► HEARTBEAT
|
||||
```
|
||||
|
||||
## Module Design
|
||||
|
||||
### 1. AgentManager
|
||||
|
||||
**Responsibilities**:
|
||||
- Orchestrate all agent components
|
||||
- Manage agent lifecycle (start/stop)
|
||||
- Handle state transitions
|
||||
|
||||
**Key Properties**:
|
||||
```swift
|
||||
class AgentManager: ObservableObject {
|
||||
@Published var state: AgentState = .disconnected
|
||||
@Published var tunnelInfo: TunnelInfo?
|
||||
@Published var logs: [LogEntry] = []
|
||||
|
||||
private let provisioning: ProvisioningService
|
||||
private let heartbeat: HeartbeatService
|
||||
private let tunnel: TunnelManager
|
||||
private let config: KeychainStorage
|
||||
}
|
||||
```
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func startAgent() async
|
||||
func stopAgent()
|
||||
func rebuildTunnel(with config: WireGuardConfig) async
|
||||
```
|
||||
|
||||
### 2. PacketTunnelProvider
|
||||
|
||||
**Responsibilities**:
|
||||
- Handle NetworkExtension tunnel lifecycle
|
||||
- Start/stop WireGuard tunnel
|
||||
- Report tunnel status
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void)
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void)
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?)
|
||||
```
|
||||
|
||||
**WireGuard Integration**:
|
||||
```swift
|
||||
// Uses wireguard-apple library
|
||||
let adapter = WireGuardAdapter(with: self) { logLevel, message in
|
||||
Logger.log(level: logLevel, message: message)
|
||||
}
|
||||
|
||||
adapter.start(tunnelConfiguration: tunnelConfig) { error in
|
||||
if let error = error {
|
||||
completionHandler(error)
|
||||
} else {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. TunnelManager
|
||||
|
||||
**Responsibilities**:
|
||||
- Build WireGuard config from stored keys
|
||||
- Start/stop tunnel via PacketTunnelProvider
|
||||
- Monitor tunnel state
|
||||
- Track last handshake time
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func startTunnel(config: WireGuardConfig) async throws
|
||||
func stopTunnel()
|
||||
func isTunnelUp() -> Bool
|
||||
func getLastHandshake() -> Date?
|
||||
```
|
||||
|
||||
**Config Build**:
|
||||
```swift
|
||||
func buildTunnelConfig(from config: WireGuardConfig) -> TunnelConfiguration {
|
||||
let interface = InterfaceConfiguration(
|
||||
privateKey: config.privateKey,
|
||||
addresses: [config.internalIp],
|
||||
dns: [config.dns]
|
||||
)
|
||||
|
||||
let peer = PeerConfiguration(
|
||||
publicKey: config.serverPub,
|
||||
allowedIPs: config.allowedIps,
|
||||
endpoint: config.endpoint,
|
||||
preSharedKey: config.presharedKey,
|
||||
persistentKeepAlive: 25
|
||||
)
|
||||
|
||||
return TunnelConfiguration(interface: interface, peers: [peer])
|
||||
}
|
||||
```
|
||||
|
||||
### 4. HeartbeatService
|
||||
|
||||
**Responsibilities**:
|
||||
- HTTP POST to `/api/v1/heartbeat` every 30s
|
||||
- Send device status + tunnel state
|
||||
- Receive config sync (detect changes)
|
||||
- Trigger tunnel rebuild if config changed
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func startHeartbeatLoop()
|
||||
func stopHeartbeat()
|
||||
func sendHeartbeat(config: WireGuardConfig, tunnelUp: Bool, lastHandshake: Date?) async -> WireGuardConfig?
|
||||
```
|
||||
|
||||
**HTTP Request**:
|
||||
```json
|
||||
POST /api/v1/heartbeat
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": "uuid",
|
||||
"status": "connected",
|
||||
"tunnel_up": true,
|
||||
"last_handshake": "2026-06-26T09:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Response**:
|
||||
```json
|
||||
{
|
||||
"device_id": "uuid",
|
||||
"status": "ok",
|
||||
"internal_ip": "10.172.21.2",
|
||||
"private_key": "hex",
|
||||
"preshared_key": "hex",
|
||||
"server_pub": "hex",
|
||||
"endpoint": "italy-twenty.gl.at.ply.gg:59750",
|
||||
"dns": "1.1.1.1",
|
||||
"allowed_ips": "0.0.0.0/0",
|
||||
"server_wg_ip": "10.172.21.1",
|
||||
"forwards_hash": "hash-string"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. ProvisioningService
|
||||
|
||||
**Responsibilities**:
|
||||
- HTTP POST to `/api/v1/provision`
|
||||
- Parse encrypted config response
|
||||
- Decrypt config via CryptoManager
|
||||
- Store config in Keychain
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func provision(serverUrl: String, token: String, hwid: String) async -> WireGuardConfig?
|
||||
func decryptConfig(encrypted: String, hwid: String, salt: String) -> WireGuardConfig?
|
||||
```
|
||||
|
||||
**HTTP Request**:
|
||||
```json
|
||||
POST /api/v1/provision
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"token": "registration-token",
|
||||
"hwid": "ios-hardware-id"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Response**:
|
||||
```json
|
||||
{
|
||||
"encrypted_config": "base64-encoded-bytes"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. CryptoManager
|
||||
|
||||
**Responsibilities**:
|
||||
- Key derivation: SHA-256(hwid + salt)
|
||||
- AES-256-GCM decryption
|
||||
- Secure memory handling
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func deriveKey(token: String, hwid: String) -> SymmetricKey
|
||||
func decrypt(encoded: String, key: SymmetricKey) -> Data?
|
||||
```
|
||||
|
||||
**Algorithm**:
|
||||
```swift
|
||||
// Key Derivation
|
||||
let input = "\(token)\(hwid)".data(using: .utf8)!
|
||||
let hash = SHA256.hash(data: input)
|
||||
let key = SymmetricKey(data: hash)
|
||||
|
||||
// Decryption
|
||||
let combined = Data(base64Encoded: encoded)!
|
||||
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)
|
||||
```
|
||||
|
||||
### 7. KeychainStorage
|
||||
|
||||
**Responsibilities**:
|
||||
- Store WireGuard keys in Keychain
|
||||
- Store agent config in UserDefaults
|
||||
- Secure key access
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func saveConfig(_ config: AgentConfig)
|
||||
func loadConfig() -> AgentConfig?
|
||||
func saveWireGuardConfig(_ config: WireGuardConfig)
|
||||
func loadWireGuardConfig() -> WireGuardConfig?
|
||||
func clearAll()
|
||||
```
|
||||
|
||||
**Storage Strategy**:
|
||||
- **Keychain**: WireGuard keys (private_key, preshared_key)
|
||||
- **UserDefaults**: Non-sensitive config (server_url, device_id, endpoint)
|
||||
- **File**: Log entries (optional)
|
||||
|
||||
### 8. LogStore
|
||||
|
||||
**Responsibilities**:
|
||||
- Buffer log entries in memory
|
||||
- Persist to CoreData (optional)
|
||||
- Provide log stream for UI
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func add(level: LogLevel, tag: String, message: String)
|
||||
func clear()
|
||||
func getLogs() -> [LogEntry]
|
||||
```
|
||||
|
||||
**Log Levels**:
|
||||
```swift
|
||||
enum LogLevel: String {
|
||||
case debug = "DEBUG"
|
||||
case info = "INFO"
|
||||
case warn = "WARN"
|
||||
case error = "ERROR"
|
||||
}
|
||||
```
|
||||
|
||||
## Data Structures
|
||||
|
||||
### WireGuardConfig
|
||||
|
||||
```swift
|
||||
struct WireGuardConfig: Codable {
|
||||
let deviceId: String
|
||||
let privateKey: String
|
||||
let presharedKey: String
|
||||
let internalIp: String
|
||||
let serverPub: String
|
||||
let endpoint: String
|
||||
let dns: String
|
||||
let allowedIps: String
|
||||
let serverWgIp: String
|
||||
let configHash: String
|
||||
let forwardsHash: String
|
||||
}
|
||||
```
|
||||
|
||||
### AgentConfig
|
||||
|
||||
```swift
|
||||
struct AgentConfig: Codable {
|
||||
let serverUrl: String
|
||||
let registrationToken: String
|
||||
let isProvisioned: Bool
|
||||
}
|
||||
```
|
||||
|
||||
### AgentState
|
||||
|
||||
```swift
|
||||
enum AgentState: String {
|
||||
case disconnected = "disconnected"
|
||||
case connecting = "connecting"
|
||||
case connected = "connected"
|
||||
case failed = "failed"
|
||||
}
|
||||
```
|
||||
|
||||
### LogEntry
|
||||
|
||||
```swift
|
||||
struct LogEntry: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let level: LogLevel
|
||||
let tag: String
|
||||
let message: String
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Types
|
||||
|
||||
```swift
|
||||
enum AgentError: Error {
|
||||
case wifiNotConnected
|
||||
case provisioningFailed(String)
|
||||
case heartbeatFailed(String)
|
||||
case cryptoDecryptionFailed
|
||||
case configInvalid
|
||||
case tunnelInitFailed
|
||||
case keychainError(OSStatus)
|
||||
}
|
||||
```
|
||||
|
||||
### Recovery Strategy
|
||||
|
||||
| Error | Recovery |
|
||||
|-------|----------|
|
||||
| WiFi not connected | Retry 3x, then pause heartbeat |
|
||||
| HTTP provision fail | Retry 3x, then notify user |
|
||||
| HTTP heartbeat fail | Log only, continue |
|
||||
| Crypto decrypt fail | Clear config, re-provision |
|
||||
| Tunnel init fail | Retry 3x, then notify user |
|
||||
| Keychain error | Use UserDefaults fallback |
|
||||
|
||||
## Background Tasks
|
||||
|
||||
### BGAppRefreshTask
|
||||
|
||||
iOS doesn't have boot receiver like Android. Use Background Tasks:
|
||||
|
||||
```swift
|
||||
// Register task
|
||||
BGAppRefreshTaskRequest.register(forTaskWithIdentifier: "com.nexusguard.agent.refresh", using: nil) {
|
||||
task in
|
||||
self.handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
||||
}
|
||||
|
||||
// Schedule task
|
||||
let request = BGAppRefreshTaskRequest(identifier: "com.nexusguard.agent.refresh")
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // 30 minutes
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
```
|
||||
|
||||
### Background Modes
|
||||
|
||||
Add to `Info.plist`:
|
||||
```xml
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>vpn-api</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **wireguard-apple**: WireGuard tunnel library
|
||||
- **CryptoKit**: AES-256-GCM encryption
|
||||
- **NetworkExtension**: VPN tunnel management
|
||||
- **BackgroundTasks**: Background refresh
|
||||
- **SwiftUI**: UI framework
|
||||
- **Combine**: Reactive state management
|
||||
|
||||
## Android → iOS Mapping
|
||||
|
||||
| Android | iOS | Notes |
|
||||
|---------|-----|-------|
|
||||
| `LifecycleService` | `PacketTunnelProvider` | Background service |
|
||||
| `startForeground()` | `NEPacketTunnelProvider` | Always-on tunnel |
|
||||
| `SharedPreferences` | `Keychain + UserDefaults` | Config storage |
|
||||
| `BroadcastReceiver` | `BGAppRefreshTask` | Background triggers |
|
||||
| `lifecycleScope` | `Task {}` | Coroutine/async |
|
||||
| `OkHttp` | `URLSession` | HTTP client |
|
||||
| `Base64` | `Data(base64Encoded:)` | Encoding |
|
||||
| `Log.d/i/w/e` | `os_log` or `print` | Logging |
|
||||
@@ -0,0 +1,425 @@
|
||||
# iOS Agent — Technical Design Document
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Date**: 2026-07-06
|
||||
**Status**: Draft
|
||||
**Mirror**: Android Agent (`apps/android-agent/`)
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
NexusGuard iOS Agent is an iOS-based WireGuard client for mobile devices. It mirrors the Android agent's architecture, UI design, and heartbeat flow, using iOS-native APIs (NetworkExtension, CryptoKit, Keychain).
|
||||
|
||||
## 2. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
- iOS-based WireGuard tunnel (NetworkExtension)
|
||||
- Auto-provisioning via HTTP (same API as Android/ESP32)
|
||||
- Heartbeat monitoring with config sync (30s interval)
|
||||
- Keychain-based config storage (hardware-backed encryption)
|
||||
- SwiftUI UI matching Android agent design
|
||||
- Background operation via NetworkExtension
|
||||
|
||||
### Non-Goals
|
||||
- gRPC support (not needed for mobile)
|
||||
- Port forwarding (userspace agent only)
|
||||
- Jailbroken device support
|
||||
- macOS/iPadOS support (iPhone only for v1)
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ iOS (SwiftUI) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ App Layer │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ContentView│ │SettingsView│ │LogView │ │PortView │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
|
||||
│ │ AgentManager │ │
|
||||
│ │ (orchestrates all components) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Service Layer │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │Provisioning│ │Heartbeat│ │TunnelMgr │ │ConfigMgr│ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
|
||||
│ │ CryptoManager │ │
|
||||
│ │ AES-256-GCM (CryptoKit) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ NetworkExtension Layer │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ PacketTunnelProvider │ │
|
||||
│ │ (WireGuard tunnel via wireguard-apple) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Storage Layer │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Keychain │ │ UserDefaults│ │ CoreData│ │ FileMgr │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Task Architecture
|
||||
|
||||
```
|
||||
iOS Threads/Tasks
|
||||
├── Main Thread # UI updates
|
||||
├── AgentManager # Orchestration (async/await)
|
||||
├── HeartbeatTask # HTTP heartbeat (30s interval)
|
||||
├── TunnelManager # WireGuard tunnel management
|
||||
└── BackgroundTask # BGAppRefreshTask (background refresh)
|
||||
```
|
||||
|
||||
### 3.3 State Machine
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ BOOT │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ APP_INIT │ ◄──────────────────────────────┐
|
||||
└──────┬──────┘ │
|
||||
│ loaded │
|
||||
▼ │
|
||||
┌─────────────┐ fail ┌─────────────┐│
|
||||
│ PROVISION │───────────────►│ APP_RETRY ││
|
||||
└──────┬──────┘ └──────┬──────┘│
|
||||
│ success │ │
|
||||
▼ └───────┘
|
||||
┌─────────────┐
|
||||
│ TUNNEL_UP │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ HEARTBEAT │ ◄─── 30s interval
|
||||
└──────┬──────┘
|
||||
│ config_changed
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ TUNNEL_REBUILD │
|
||||
└──────┬──────┘
|
||||
│
|
||||
└──► HEARTBEAT
|
||||
```
|
||||
|
||||
## 4. Module Design
|
||||
|
||||
### 4.1 AgentManager
|
||||
|
||||
**Responsibilities**:
|
||||
- Orchestrate all agent components
|
||||
- Manage agent lifecycle (start/stop)
|
||||
- Handle state transitions
|
||||
|
||||
**Key Properties**:
|
||||
```swift
|
||||
class AgentManager: ObservableObject {
|
||||
@Published var state: AgentState = .disconnected
|
||||
@Published var tunnelInfo: TunnelInfo?
|
||||
@Published var logs: [LogEntry] = []
|
||||
|
||||
private let provisioning: ProvisioningService
|
||||
private let heartbeat: HeartbeatService
|
||||
private let tunnel: TunnelManager
|
||||
private let config: KeychainStorage
|
||||
}
|
||||
```
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func startAgent() async
|
||||
func stopAgent()
|
||||
func rebuildTunnel(with config: WireGuardConfig) async
|
||||
```
|
||||
|
||||
### 4.2 PacketTunnelProvider
|
||||
|
||||
**Responsibilities**:
|
||||
- Handle NetworkExtension tunnel lifecycle
|
||||
- Start/stop WireGuard tunnel
|
||||
- Report tunnel status
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void)
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void)
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?)
|
||||
```
|
||||
|
||||
### 4.3 TunnelManager
|
||||
|
||||
**Responsibilities**:
|
||||
- Build WireGuard config from stored keys
|
||||
- Start/stop tunnel via PacketTunnelProvider
|
||||
- Monitor tunnel state
|
||||
- Track last handshake time
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func startTunnel(config: WireGuardConfig) async throws
|
||||
func stopTunnel()
|
||||
func isTunnelUp() -> Bool
|
||||
func getLastHandshake() -> Date?
|
||||
```
|
||||
|
||||
### 4.4 HeartbeatService
|
||||
|
||||
**Responsibilities**:
|
||||
- HTTP POST to `/api/v1/heartbeat` every 30s
|
||||
- Send device status + tunnel state
|
||||
- Receive config sync (detect changes)
|
||||
- Trigger tunnel rebuild if config changed
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func startHeartbeatLoop()
|
||||
func stopHeartbeat()
|
||||
func sendHeartbeat(config: WireGuardConfig, tunnelUp: Bool, lastHandshake: Date?) async -> WireGuardConfig?
|
||||
```
|
||||
|
||||
### 4.5 ProvisioningService
|
||||
|
||||
**Responsibilities**:
|
||||
- HTTP POST to `/api/v1/provision`
|
||||
- Parse encrypted config response
|
||||
- Decrypt config via CryptoManager
|
||||
- Store config in Keychain
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func provision(serverUrl: String, token: String, hwid: String) async -> WireGuardConfig?
|
||||
func decryptConfig(encrypted: String, hwid: String, salt: String) -> WireGuardConfig?
|
||||
```
|
||||
|
||||
### 4.6 CryptoManager
|
||||
|
||||
**Responsibilities**:
|
||||
- Key derivation: SHA-256(hwid + salt)
|
||||
- AES-256-GCM decryption
|
||||
- Secure memory handling
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func deriveKey(token: String, hwid: String) -> SymmetricKey
|
||||
func decrypt(encoded: String, key: SymmetricKey) -> Data?
|
||||
```
|
||||
|
||||
### 4.7 KeychainStorage
|
||||
|
||||
**Responsibilities**:
|
||||
- Store WireGuard keys in Keychain
|
||||
- Store agent config in UserDefaults
|
||||
- Secure key access
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func saveConfig(_ config: AgentConfig)
|
||||
func loadConfig() -> AgentConfig?
|
||||
func saveWireGuardConfig(_ config: WireGuardConfig)
|
||||
func loadWireGuardConfig() -> WireGuardConfig?
|
||||
func clearAll()
|
||||
```
|
||||
|
||||
### 4.8 LogStore
|
||||
|
||||
**Responsibilities**:
|
||||
- Buffer log entries in memory
|
||||
- Persist to CoreData (optional)
|
||||
- Provide log stream for UI
|
||||
|
||||
**Key Methods**:
|
||||
```swift
|
||||
func add(level: LogLevel, tag: String, message: String)
|
||||
func clear()
|
||||
func getLogs() -> [LogEntry]
|
||||
```
|
||||
|
||||
## 5. Data Structures
|
||||
|
||||
### 5.1 WireGuardConfig
|
||||
|
||||
```swift
|
||||
struct WireGuardConfig: Codable {
|
||||
let deviceId: String
|
||||
let privateKey: String
|
||||
let presharedKey: String
|
||||
let internalIp: String
|
||||
let serverPub: String
|
||||
let endpoint: String
|
||||
let dns: String
|
||||
let allowedIps: String
|
||||
let serverWgIp: String
|
||||
let configHash: String
|
||||
let forwardsHash: String
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 AgentConfig
|
||||
|
||||
```swift
|
||||
struct AgentConfig: Codable {
|
||||
let serverUrl: String
|
||||
let registrationToken: String
|
||||
let isProvisioned: Bool
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 AgentState
|
||||
|
||||
```swift
|
||||
enum AgentState: String {
|
||||
case disconnected = "disconnected"
|
||||
case connecting = "connecting"
|
||||
case connected = "connected"
|
||||
case failed = "failed"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 LogEntry
|
||||
|
||||
```swift
|
||||
struct LogEntry: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let level: LogLevel
|
||||
let tag: String
|
||||
let message: String
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
### 6.1 Error Types
|
||||
|
||||
```swift
|
||||
enum AgentError: Error {
|
||||
case wifiNotConnected
|
||||
case provisioningFailed(String)
|
||||
case heartbeatFailed(String)
|
||||
case cryptoDecryptionFailed
|
||||
case configInvalid
|
||||
case tunnelInitFailed
|
||||
case keychainError(OSStatus)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Recovery Strategy
|
||||
|
||||
| Error | Recovery |
|
||||
|-------|----------|
|
||||
| WiFi not connected | Retry 3x, then pause heartbeat |
|
||||
| HTTP provision fail | Retry 3x, then notify user |
|
||||
| HTTP heartbeat fail | Log only, continue |
|
||||
| Crypto decrypt fail | Clear config, re-provision |
|
||||
| Tunnel init fail | Retry 3x, then notify user |
|
||||
| Keychain error | Use UserDefaults fallback |
|
||||
|
||||
## 7. Background Tasks
|
||||
|
||||
### 7.1 BGAppRefreshTask
|
||||
|
||||
iOS doesn't have boot receiver like Android. Use Background Tasks:
|
||||
|
||||
```swift
|
||||
// Register task
|
||||
BGAppRefreshTaskRequest.register(forTaskWithIdentifier: "com.nexusguard.agent.refresh", using: nil) {
|
||||
task in
|
||||
self.handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
||||
}
|
||||
|
||||
// Schedule task
|
||||
let request = BGAppRefreshTaskRequest(identifier: "com.nexusguard.agent.refresh")
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // 30 minutes
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
```
|
||||
|
||||
### 7.2 Background Modes
|
||||
|
||||
Add to `Info.plist`:
|
||||
```xml
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>vpn-api</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
### 8.1 Key Storage
|
||||
|
||||
- WireGuard private keys stored in iOS Keychain (hardware-backed)
|
||||
- Non-sensitive config in UserDefaults
|
||||
- Never log keys or tokens
|
||||
|
||||
### 8.2 TLS
|
||||
|
||||
- Server communication over HTTPS (TLS 1.2+)
|
||||
- Use `URLSession` with default TLS settings
|
||||
- Certificate pinning (optional, future)
|
||||
|
||||
### 8.3 Hardware ID
|
||||
|
||||
- Use `identifierForVendor` for device identification
|
||||
- Persists across app reinstalls until device factory reset
|
||||
- Used for key derivation (HWID + salt)
|
||||
|
||||
## 9. Testing Strategy
|
||||
|
||||
### 9.1 Unit Tests
|
||||
|
||||
- Crypto: Decrypt known ciphertext with known key
|
||||
- Config: Keychain read/write cycle
|
||||
- Heartbeat: Parse server response
|
||||
|
||||
### 9.2 Integration Tests
|
||||
|
||||
- Provision → Tunnel → Heartbeat cycle
|
||||
- Config change detection
|
||||
- Tunnel rebuild on config change
|
||||
|
||||
### 9.3 UI Tests
|
||||
|
||||
- VPN toggle interaction
|
||||
- Tab switching
|
||||
- Settings save/load
|
||||
|
||||
### 9.4 Device Tests
|
||||
|
||||
- iPhone 12+ running iOS 16+
|
||||
- NetworkExtension background operation
|
||||
- Keychain access in background
|
||||
|
||||
## 10. Future Enhancements
|
||||
|
||||
### 10.1 Phase 2
|
||||
|
||||
- iPadOS support
|
||||
- Widget for quick status
|
||||
- Shortcuts integration
|
||||
- Siri commands
|
||||
|
||||
### 10.2 Phase 3
|
||||
|
||||
- macOS Catalyst support
|
||||
- Apple Watch companion
|
||||
- CarPlay integration
|
||||
|
||||
## 11. References
|
||||
|
||||
- [Apple NetworkExtension Documentation](https://developer.apple.com/documentation/networkextension)
|
||||
- [WireGuard for iOS](https://github.com/WireGuard/wireguard-apple)
|
||||
- [CryptoKit Documentation](https://developer.apple.com/cryptokit/)
|
||||
- [Background Tasks](https://developer.apple.com/documentation/backgroundtasks)
|
||||
- [NexusGuard Server API](../../server-core/docs/)
|
||||
@@ -0,0 +1,506 @@
|
||||
# iOS Agent — UI Design Specs
|
||||
|
||||
**Status**: DESIGN ONLY
|
||||
**Mirror**: Android Agent (`apps/android-agent/`)
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the UI design for the iOS WireGuard agent. It mirrors the Android agent's design, colors, layout, and user experience.
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Status Colors
|
||||
|
||||
| Name | Hex | RGB | Usage |
|
||||
|------|-----|-----|-------|
|
||||
| `status_disconnected` | `#FFB0B0B0` | (176, 176, 176) | Disconnected state |
|
||||
| `status_connecting` | `#FFFFD93D` | (255, 217, 61) | Connecting state |
|
||||
| `status_connected` | `#FF4CAF50` | (76, 175, 80) | Connected state |
|
||||
| `status_failed` | `#FFFF6B6B` | (255, 107, 107) | Failed state |
|
||||
|
||||
### UI Colors
|
||||
|
||||
| Name | Hex | RGB | Usage |
|
||||
|------|-----|-----|-------|
|
||||
| `card_bg` | `#FF1A1A2E` | (26, 26, 46) | Card background |
|
||||
| `card_bg_dark` | `#FF0F0F1A` | (15, 15, 26) | Badge background |
|
||||
| `text_primary` | `#FFFFFFFF` | (255, 255, 255) | Primary text |
|
||||
| `text_secondary` | `#FFB0B0B0` | (176, 176, 176) | Secondary text |
|
||||
| `accent_green` | `#FF4CAF50` | (76, 175, 80) | Accent/highlight |
|
||||
| `accent_blue` | `#FF2196F3` | (33, 150, 243) | Links/actions |
|
||||
| `border_color` | `#FF2A2A3E` | (42, 42, 62) | Card borders |
|
||||
|
||||
### Log Colors
|
||||
|
||||
| Level | Hex | RGB |
|
||||
|-------|-----|-----|
|
||||
| `DEBUG` | `#FF6B6B6B` | (107, 107, 107) |
|
||||
| `INFO` | `#FFB0B0B0` | (176, 176, 176) |
|
||||
| `WARN` | `#FFFFD93D` | (255, 217, 61) |
|
||||
| `ERROR` | `#FFFF6B6B` | (255, 107, 107) |
|
||||
|
||||
## Color Definitions (Swift)
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// Status colors
|
||||
static let statusDisconnected = Color(red: 0.69, green: 0.69, blue: 0.69) // #B0B0B0
|
||||
static let statusConnecting = Color(red: 1.0, green: 0.85, blue: 0.24) // #FFD93D
|
||||
static let statusConnected = Color(red: 0.30, green: 0.69, blue: 0.31) // #4CAF50
|
||||
static let statusFailed = Color(red: 1.0, green: 0.42, blue: 0.42) // #FF6B6B
|
||||
|
||||
// UI colors
|
||||
static let cardBackground = Color(red: 0.10, green: 0.10, blue: 0.18) // #1A1A2E
|
||||
static let cardBackgroundDark = Color(red: 0.06, green: 0.06, blue: 0.10) // #0F0F1A
|
||||
static let textPrimary = Color.white // #FFFFFF
|
||||
static let textSecondary = Color(red: 0.69, green: 0.69, blue: 0.69) // #B0B0B0
|
||||
static let accentGreen = Color(red: 0.30, green: 0.69, blue: 0.31) // #4CAF50
|
||||
static let accentBlue = Color(red: 0.13, green: 0.59, blue: 0.95) // #2196F3
|
||||
static let borderColor = Color(red: 0.16, green: 0.16, blue: 0.24) // #2A2A3E
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Structure
|
||||
|
||||
### Main Screen (ContentView)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ NexusGuard [⚙️ Settings]│
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ ● STATUS: Connected │ │
|
||||
│ │ │ │
|
||||
│ │ IP Address 10.172.21.2 │ │
|
||||
│ │ Admin Web https://... │ │
|
||||
│ │ Device ID abc-123-... │ │
|
||||
│ │ Handshake 14:32:05 │ │
|
||||
│ │ Allowed IPs 0.0.0.0/0 │ │
|
||||
│ │ Transport HTTPS │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [Status] [Ports] [Log] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Port Forwards: 2 │ │
|
||||
│ │ TCP :8080 → 10.172.21.2:80│ │
|
||||
│ │ UDP :53 → 10.172.21.2:53│ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [Copy Log] [Clear Log] │ │
|
||||
│ │ │ │
|
||||
│ │ 14:32:05 [INFO] Agent... │ │
|
||||
│ │ 14:32:05 [INFO] Tunnel... │ │
|
||||
│ │ 14:32:05 [WARN] Retry... │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ ══════════════════ │ │
|
||||
│ │ VPN Toggle │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Settings Screen (SettingsView)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ ← Settings │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Server URL │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ https://api-nexus.datadunia.com│ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Registration Token │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ •••••••••••••••• │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Auto-Start on Boot │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [Toggle] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Save Settings │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Export Config │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Import Config │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Paste Config │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Status Card
|
||||
|
||||
```swift
|
||||
struct StatusCard: View {
|
||||
let state: AgentState
|
||||
let config: WireGuardConfig?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Status indicator
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 12, height: 12)
|
||||
Text(statusText)
|
||||
.font(.headline)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
|
||||
// Info fields
|
||||
if state == .connected {
|
||||
InfoRow(label: "IP Address", value: config?.internalIp ?? "-")
|
||||
InfoRow(label: "Admin Web", value: "https://api-nexus.datadunia.com")
|
||||
InfoRow(label: "Device ID", value: config?.deviceId ?? "-")
|
||||
InfoRow(label: "Handshake", value: lastHandshake ?? "-")
|
||||
InfoRow(label: "Allowed IPs", value: config?.allowedIps ?? "-")
|
||||
InfoRow(label: "Transport", value: transport)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.cardBackground)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.borderColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch state {
|
||||
case .disconnected: return .statusDisconnected
|
||||
case .connecting: return .statusConnecting
|
||||
case .connected: return .statusConnected
|
||||
case .failed: return .statusFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Info Row
|
||||
|
||||
```swift
|
||||
struct InfoRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Port Forward Row
|
||||
|
||||
```swift
|
||||
struct PortForwardRow: View {
|
||||
let forward: PortForward
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(forward.protocol.uppercased())
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.cardBackgroundDark)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.accentGreen)
|
||||
.cornerRadius(4)
|
||||
|
||||
Text(":\(forward.publicPort)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textPrimary)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(forward.targetIp):\(forward.targetPort)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textSecondary)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Log Entry
|
||||
|
||||
```swift
|
||||
struct LogEntryView: View {
|
||||
let entry: LogEntry
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(entry.timestamp, style: .time)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(entry.level.color)
|
||||
|
||||
Text("[\(entry.level.rawValue)]")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(entry.level.color)
|
||||
|
||||
Text("\(entry.tag): \(entry.message)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(entry.level.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LogLevel {
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .debug: return Color(red: 0.42, green: 0.42, blue: 0.42) // #6B6B6B
|
||||
case .info: return Color(red: 0.69, green: 0.69, blue: 0.69) // #B0B0B0
|
||||
case .warn: return Color(red: 1.0, green: 0.85, blue: 0.24) // #FFD93D
|
||||
case .error: return Color(red: 1.0, green: 0.42, blue: 0.42) // #FF6B6B
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### VPN Toggle
|
||||
|
||||
```swift
|
||||
struct VPNToggle: View {
|
||||
@Binding var isOn: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
RoundedRectangle(cornerRadius: 25)
|
||||
.fill(isOn ? Color.accentGreen : Color.statusDisconnected)
|
||||
.frame(height: 50)
|
||||
.overlay(
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: 40, height: 40)
|
||||
.offset(x: isOn ? 40 : -40)
|
||||
Spacer()
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tab Layout
|
||||
|
||||
```swift
|
||||
struct TabBar: View {
|
||||
@Binding var selectedTab: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
TabButton(title: "Status", isSelected: selectedTab == 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
TabButton(title: "Ports", isSelected: selectedTab == 1) {
|
||||
selectedTab = 1
|
||||
}
|
||||
TabButton(title: "Log", isSelected: selectedTab == 2) {
|
||||
selectedTab = 2
|
||||
}
|
||||
}
|
||||
.background(Color.cardBackground)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct TabButton: View {
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(isSelected ? .textPrimary : .textSecondary)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isSelected ? Color.accentGreen : Color.clear)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Sizes
|
||||
|
||||
| Element | Size | Weight | Design |
|
||||
|---------|------|--------|--------|
|
||||
| Status text | 18pt | Semibold | Default |
|
||||
| Info label | 14pt | Regular | Default |
|
||||
| Info value | 14pt | Regular | Monospaced |
|
||||
| Tab text | 14pt | Medium | Default |
|
||||
| Log text | 12pt | Regular | Monospaced |
|
||||
| Badge text | 10pt | Bold | Default |
|
||||
|
||||
### Font Definitions
|
||||
|
||||
```swift
|
||||
extension Font {
|
||||
static let statusText = Font.headline
|
||||
static let infoLabel = Font.subheadline
|
||||
static let infoValue = Font.subheadline.monospaced()
|
||||
static let tabText = Font.subheadline.weight(.medium)
|
||||
static let logText = Font.caption.monospaced()
|
||||
static let badgeText = Font.caption2.bold()
|
||||
}
|
||||
```
|
||||
|
||||
## Spacing
|
||||
|
||||
| Element | Value |
|
||||
|---------|-------|
|
||||
| Card padding | 16pt |
|
||||
| Card corner radius | 12pt |
|
||||
| Card border width | 1pt |
|
||||
| Row spacing | 12pt |
|
||||
| Badge padding | 8pt horizontal, 4pt vertical |
|
||||
| Badge corner radius | 4pt |
|
||||
| Button height | 50pt |
|
||||
| Button corner radius | 25pt |
|
||||
|
||||
## Animation
|
||||
|
||||
### Status Change
|
||||
|
||||
```swift
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
state = .connected
|
||||
}
|
||||
```
|
||||
|
||||
### Tab Switch
|
||||
|
||||
```swift
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedTab = 1
|
||||
}
|
||||
```
|
||||
|
||||
### VPN Toggle
|
||||
|
||||
```swift
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
||||
isVPNOn.toggle()
|
||||
}
|
||||
```
|
||||
|
||||
## Haptic Feedback
|
||||
|
||||
```swift
|
||||
// Success
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
|
||||
// Error
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
|
||||
// Selection
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
iOS agent uses dark mode only (matches Android design). Force dark mode:
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct NexusGuardApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### iPhone SE
|
||||
|
||||
- Compact layout
|
||||
- Smaller font sizes
|
||||
- Reduced padding
|
||||
|
||||
### iPhone 14/15
|
||||
|
||||
- Standard layout
|
||||
- Default font sizes
|
||||
- Standard padding
|
||||
|
||||
### iPhone 14/15 Pro Max
|
||||
|
||||
- Expanded layout
|
||||
- Larger font sizes
|
||||
- Increased padding
|
||||
|
||||
## Accessibility
|
||||
|
||||
### VoiceOver
|
||||
|
||||
```swift
|
||||
Text("Connected")
|
||||
.accessibilityLabel("VPN Status: Connected")
|
||||
.accessibilityHint("Double tap to toggle VPN")
|
||||
|
||||
InfoRow(label: "IP Address", value: "10.172.21.2")
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("IP Address: 10.172.21.2")
|
||||
```
|
||||
|
||||
### Dynamic Type
|
||||
|
||||
```swift
|
||||
Text("Status")
|
||||
.font(.headline)
|
||||
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
|
||||
```
|
||||
|
||||
### Reduce Motion
|
||||
|
||||
```swift
|
||||
@Environment(\.accessibilityReduceMotion) var reduceMotion
|
||||
|
||||
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.3)) {
|
||||
state = .connected
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user