The page hits my phone. The fix lives on my laptop. That gap, the walk from "something is broken" to a machine where I can actually do something about it, is the most useless part of being on call, and it always bothered me. So in May I started building Crow, a native iOS client for Kubernetes. I wanted the phone that woke me up to also be the phone that fixes it.
It's SwiftUI, targeting iOS 17+. The bar I set was the list of things I actually do half-asleep: check pod status, read logs, exec into a container, scale a deployment, restart a rollout, cordon a node. If I couldn't do those from the lock screen, the app had no reason to exist.
Authentication
This is where I spent the first week, and I underestimated it. Kubernetes clusters authenticate a dozen ways, and iOS has none of kubectl's credential plugin machinery to fall back on. I ended up supporting three methods by hand.
Bearer token: paste it in. The token goes into the iOS Keychain with kSecAttrAccessible set to kSecAttrAccessibleWhenUnlockedThisDeviceOnly, so it's encrypted at rest and never rides along in an iCloud backup. That last part matters more than it sounds. A cluster token in someone's cloud backup is a token you've lost control of.
GCP OAuth for GKE: a full OAuth 2.0 flow in an ASWebAuthenticationSession. You authenticate with Google, Crow gets an authorization code, trades it for access and refresh tokens at GCP's token endpoint, then hands the access token to the GKE API server as a bearer token. Refresh happens quietly on any 401, so you never see the seam.
Kubeconfig import: paste or QR code. The QR path exists for one reason: I tried typing a base64-encoded CA certificate on a phone keyboard once, gave up somewhere in the middle, and decided no human should ever do that. Crow parses the YAML, pulls out the cluster endpoint, CA cert, and client credentials, and stores each piece separately in the Keychain.
Our clusters require mTLS, so that's the path I care about most. Crow takes a PKCS#12 bundle. You import a .p12 file through the iOS share sheet, and the Security framework pulls the client certificate and private key out of it:
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)
}
}
Self-signed CAs get pinned explicitly in the same delegate, evaluating server trust against the imported CA instead of the system trust store. No "just trust everything" escape hatch, because that escape hatch is how you end up talking to the wrong API server.
Then there's a Face ID gate in front of cluster access. LAContext().evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics) runs before any API call, with a configurable timeout (default 5 minutes) before it asks again. My phone is the thing most likely to get picked up off a table while unlocked, and I did not want that to be the same gesture as deleting a production pod. Someone holding my unlocked phone still can't touch the cluster without my face.
Generic resource browser
I started to build a screen per resource type. Pods, then Deployments, then Services. Three screens in, I could see the rest of my life stretching out ahead of me, one bespoke view at a time, and every new CRD in the ecosystem breaking the app. So I stopped. Instead Crow asks the API server what exists, via the discovery endpoints (/api/v1, /apis/{group}/{version}), and renders whatever comes back.
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 list view hits the right list endpoint, takes the JSON back, and draws a table. Each row shows name, namespace, age, and a status indicator pulled from .status.conditions where a resource has them or .status.phase for pods. The detail view is the full JSON in a collapsible tree, because sooner or later you need the field nobody built a nice label for.
The payoff is that CRDs work for free. Argo CD Applications, Istio VirtualServices, Cert-Manager Certificates: none of them are special-cased, they're just things the discovery endpoint returned. Even the Argo CD integration (sync, refresh, rollback) is nothing but JSON patches sent straight to the Application CRD. I never talk to Argo's own API server. I found that satisfying in a way that's hard to justify to anyone who wasn't about to write forty screens.
Log streaming
Reading logs is the thing I do most on call, so it had to feel instant. Crow holds 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, one log line per chunk, and I feed it into the buffer as it arrives.
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 buffer caps at 5000 lines so a chatty pod can't quietly eat all the memory on the device. The harder problem is cellular. The stream drops silently when the server closes the connection, and on a train you notice this constantly. Crow catches the drop in urlSession(_:task:didCompleteWithError:) and reconnects with sinceTime set to the timestamp of the last line it saw, so you don't get a wall of duplicated logs on every reconnect. Getting that timestamp right took a few tries. My first version replayed the last chunk every single time the tunnel blinked.
Exec
Exec was the feature I assumed I'd cut and then couldn't stop building. It's a real WebSocket 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 that socket with a channel byte prefix: 0x00 for stdin, 0x01 for stdout, 0x02 for stderr, 0x03 for resize.
The terminal emulator is deliberately dumb. It handles just enough ANSI (cursor movement, color, clear screen, scrolling regions) to run bash, vim if you're patient, and most interactive CLIs. It is not a full xterm. Point htop at it and it renders with artifacts, and I decided I was fine with that. The job is checking a file, tailing a log, running one debug command at 2 AM, not living in the shell. I stopped exactly where "good enough for that" ended.
RBAC-gated mutations
A phone is a fat-fingering machine, and the whole app is one accidental tap away from deleting the wrong thing. So every destructive action gets an RBAC pre-check first. Before Crow even shows you a pod's delete button, it asks the cluster whether you're allowed, with a SelfSubjectAccessReview:
{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": {
"resourceAttributes": {
"namespace": "production",
"verb": "delete",
"resource": "pods"
}
}
}
If the answer is no, the button is gone, not greyed out, gone. A disabled button is still an invitation to tap and get annoyed. If the answer is yes, you type the resource name to confirm, a pattern I stole outright from GitHub's repo deletion dialog because it works. Every mutation lands in a local SQLite table with timestamp, resource, action, and result, so there's an honest record of what I did from my phone at 2 AM, including the parts I'd rather not remember.
There's also a read-only toggle in settings that kills all mutation UI regardless of RBAC. Some nights I only want to look, and I'd rather remove the gun from the room entirely.
Which brings me back to the page that started this. It still hits my phone. The difference now is that the walk to my laptop is optional, and most nights I don't take it.