I get paged on my phone. The fix usually involves my laptop. The gap between "I know something is wrong" and "I can do something about it" has always bothered me. So in May I started building Crow, a native iOS client for Kubernetes.
It's SwiftUI, targeting iOS 17+. The core idea is that you should be able to do the common on-call tasks from your phone: check pod status, read logs, exec into a container, scale a deployment, restart a rollout, cordon a node.
Authentication
Kubernetes clusters authenticate in many ways, and iOS doesn't have kubectl's credential plugin system. Crow supports three methods:
Bearer token: paste it in. The token is stored in the iOS Keychain with kSecAttrAccessible set to kSecAttrAccessibleWhenUnlockedThisDeviceOnly, so it's encrypted at rest and never included in iCloud backups.
GCP OAuth for GKE: a full OAuth 2.0 flow in an ASWebAuthenticationSession. The user authenticates with Google, Crow receives an authorization code, exchanges it for access and refresh tokens via GCP's token endpoint, then uses the access token as a bearer token against the GKE cluster's API server. Token refresh happens transparently on 401 responses.
Kubeconfig import: accepts paste or QR code. The QR code path exists because typing a base64-encoded CA certificate on a phone keyboard is something nobody should do. Crow decodes the kubeconfig YAML, extracts the cluster endpoint, CA cert, and client credentials, and stores each piece separately in the Keychain.
For mTLS (which our clusters require), Crow uses PKCS#12 bundles. You import a .p12 file via the iOS share sheet. The Security framework extracts the client certificate and private key:
var importResult: CFArray?
let status = SecPKCS12Import(
p12Data as CFData,
[kSecImportExportPassphrase: password] as CFDictionary,
&importResult
)
guard status == errSecSuccess,
let items = importResult as? [[String: Any]],
let identity = items.first?[kSecImportItemIdentity as String]
else { throw AuthError.pkcs12ImportFailed }
The extracted SecIdentity is used in the URLSessionDelegate to respond to TLS client certificate challenges:
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
completionHandler(.useCredential, credential)
}
}
For self-signed CAs, the CA certificate is pinned explicitly in the same delegate by evaluating the server trust against the imported CA rather than the system trust store.
There's a Face ID gate on cluster access. LAContext().evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics) runs before any API call, with a configurable timeout (default 5 minutes) before re-prompting. Even if someone picks up your unlocked phone, they can't get into your production cluster without biometric confirmation.
Generic resource browser
Instead of building individual screens for Pods, Deployments, Services, etc., Crow discovers available resource types from the Kubernetes API server's discovery endpoints (/api/v1, /apis/{group}/{version}) and renders them dynamically.
struct ResourceType {
let group: String // "" for core, "apps" for deployments, etc.
let version: String // "v1"
let kind: String // "Pod", "Deployment", etc.
let namespaced: Bool
let verbs: [String] // ["get", "list", "watch", "create", "delete", ...]
}
The resource list view queries the appropriate list endpoint, receives JSON, and renders a table. Each resource's row shows name, namespace, age, and a status indicator derived from .status.conditions (for resources that have them) or .status.phase (for pods). The detail view renders the full JSON in a collapsible tree.
This means CRDs (Custom Resource Definitions) work automatically. Argo CD Applications, Istio VirtualServices, Cert-Manager Certificates are all just resource types that the discovery endpoint returns. The Argo CD integration (sync, refresh, rollback) works by sending the appropriate JSON patches to the Application CRD, not through Argo's API server.
Log streaming
Log streaming uses a persistent HTTP connection to the pod's log endpoint (/api/v1/namespaces/{ns}/pods/{pod}/log?follow=true&container={container}). The response is a chunked transfer-encoded stream where each chunk is a log line.
let task = session.dataTask(with: logRequest)
// URLSessionDataDelegate receives chunks incrementally
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
didReceive data: Data) {
let chunk = String(data: data, encoding: .utf8) ?? ""
DispatchQueue.main.async {
self.logBuffer.append(contentsOf: chunk.split(separator: "\n"))
if self.logBuffer.count > maxLines {
self.logBuffer.removeFirst(self.logBuffer.count - maxLines)
}
}
}
The log buffer is capped at 5000 lines to avoid unbounded memory growth. On flaky cellular connections, the stream drops silently (the server closes the connection). Crow detects this via the urlSession(_:task:didCompleteWithError:) delegate method and reconnects with sinceTime set to the timestamp of the last received line, avoiding duplicate log entries.
Exec
Exec (running a command in a container) uses a real WebSocket connection to /api/v1/namespaces/{ns}/pods/{pod}/exec?command=sh&stdin=true&stdout=true&stderr=true&tty=true. The Kubernetes exec protocol multiplexes stdin, stdout, and stderr over the WebSocket using a channel byte prefix: 0x00 for stdin, 0x01 for stdout, 0x02 for stderr, 0x03 for resize.
The terminal emulator is minimal: it handles a subset of ANSI escape codes (cursor movement, color, clear screen, scrolling regions) sufficient to run bash, vim (barely), and most interactive CLIs. It's not a full xterm emulator, and complex TUI applications (like htop) render with artifacts. Good enough for kubectl exec use cases: checking a file, tailing a log, running a debug command.
RBAC-gated mutations
Every destructive action does an RBAC pre-check. Before showing the delete button for a pod, Crow sends a SelfSubjectAccessReview:
{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": {
"resourceAttributes": {
"namespace": "production",
"verb": "delete",
"resource": "pods"
}
}
}
If the response says the action is denied, the button is hidden entirely (not just disabled). If allowed, the user must type the resource name to confirm (borrowed from GitHub's repo deletion pattern). Every mutation is logged locally in a SQLite database on the device, with timestamp, resource, action, and result, so you have an audit trail of what you did from your phone at 2 AM.
There's also a read-only mode toggle in settings that disables all mutation UI regardless of RBAC. For when you just want to look without any risk of fat-fingering a delete.