Skip to content

restapi: full CLI parity + production readiness - #817

Open
danielpaulus wants to merge 17 commits into
mainfrom
feature/restapi-parity
Open

restapi: full CLI parity + production readiness#817
danielpaulus wants to merge 17 commits into
mainfrom
feature/restapi-parity

Conversation

@danielpaulus

@danielpaulus danielpaulus commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Brings the REST API to feature parity with the ios CLI and makes it production-grade. Auth model from #792 (GO_IOS_API_KEY / --disable-auth) is preserved. 56 new routes across 12 commits; go build/vet/test ./restapi/... green (incl. -race).

Production readiness

  • Explicit http.Server with graceful shutdown (SIGINT/SIGTERM), stream-safe timeouts (ReadHeaderTimeout/IdleTimeout/MaxHeaderBytes, deliberately no WriteTimeout so streaming endpoints survive), optional TLS (--tls-cert/--tls-key), configurable bind (--addr).
  • Unauthenticated /healthz + /readyz; swagger UI gated behind auth when a token is set.
  • MustMarshal no longer panics (error envelope); RespondError helper for a consistent {"error":...} shape.
  • New ios/golog-based structured logging (module=go-ios/restapi + udid + job attrs).

Endpoints (mirroring the exact library call each CLI command uses)

  • Device info: devicename, date, battery, diagnostics, mobilegestalt, processes, lockdown
  • Device mgmt: reboot, shutdown, erase (confirm-gated), devmode, lang, memlimitoff
  • Files/crash: files ls/pull/push (stream-based → traversal-safe), crashes ls/rm
  • Media: wallpaper, icon-layout, pasteboard
  • Profiles/image: profile add/remove, image list/unmount
  • Settings: assistivetouch, timeformat, wifi
  • Monitoring: sysmontap (streaming)
  • MDM (supervised): security-info, fetch-unlock-token, clear-passcode, clear-screen-time-password
  • HTTP proxy: set/remove
  • Tunnel (agent-level, /api/v1): GET /tunnels, DELETE /tunnels/:udid, POST /tunnels/:udid/refresh, POST /tunnel-agent/shutdown
  • Async jobs: POST /jobs/runtest|runwda|forward, GET /jobs, GET /jobs/:id, GET /jobs/:id/logs (per-job streamed logs), DELETE /jobs/:id — long-running ops as background jobs with isolated, streamable logs; terminal state is immutable so a stop isn't relabeled a failure.

Tests

  • Job manager + per-job log sink unit-tested (lifecycle, stop-is-terminal, per-device isolation, log stream/close) incl. -race.
  • Production-readiness units (health, RespondError, no-panic marshal, flag parsing).
  • Route-registration smoke test builds the full tree so gin route conflicts fail loudly.

Remaining / intentionally out

  • ui (tap/swipe/button/type/app/…): the CLI's ui is an HTTP client proxying to WDA (:8100) / DeviceKit (:12004), not a go-ios library — see the open design note below. In the meantime UI automation is reachable through the API via POST /jobs/runwda + POST /jobs/forward (host→8100) then talking to WDA directly.
  • runxctest (xctestrun-file upload) — planned follow-up on the job subsystem.
  • pcap — deferred until ios/pcap exposes a packet-callback streaming API (it currently writes a local file).
  • CLI-only: interactive debug (lldb) and dproxy; tunnel start (privileged daemon); sign/prepare create-cert (host-local, no device).

Follow-ups before merge

  • Handler tests (httptest + mocked device) for the device-touching endpoints.
  • Triage the two CI checks (CodeQL + the Linux e2e flake).

🤖 Generated with Claude Code

@stoktamisoglu

Copy link
Copy Markdown
Contributor

Hi @danielpaulus , @shamanec , I couldn't find a better place to ask the question so I write here as comment to the latest pull request :).

Do you have any plan for the next release date even roughly? Thanks.

danielpaulus and others added 10 commits August 10, 2026 09:58
…ts, no-panic marshal)

Foundational hardening for running the REST API in production. No endpoint
behavior changes; the auth model from #792 (GO_IOS_API_KEY / --disable-auth)
is preserved.

- Serve via an explicit http.Server with graceful shutdown on SIGINT/SIGTERM
  (drains in-flight requests, 10s timeout).
- Bound abuse without breaking streams: ReadHeaderTimeout, IdleTimeout,
  MaxHeaderBytes. Deliberately no WriteTimeout so /syslog,/listen,/ostrace,
  /notifications can stream indefinitely.
- Optional TLS via --tls-cert/--tls-key; configurable bind via --addr
  (default :8080). Flags parsed in the same tolerant FlagSet as --disable-auth.
- Unauthenticated /healthz and /readyz probes outside /api/v1.
- Gate the swagger UI behind auth (served under /api/v1) when a token is set.
- MustMarshal no longer panics on an unmarshalable value — it returns a JSON
  error envelope, so a stream/handler can't be crashed by it.
- Add RespondError helper for a consistent {"error":...} envelope.

Tests: MustMarshal no-panic + valid-unchanged, health endpoints, RespondError,
parseServerConfig (defaults, flags, tolerates unknown args). go build/vet/test
./restapi/... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…s, mobilegestalt, ps, lockdown)

First batch of CLI-parity endpoints, all read-only, each mirroring the exact
go-ios library call the corresponding `ios` CLI command uses:

- GET /device/:udid/devicename    -> ios.GetValues (ios devicename)
- GET /device/:udid/date          -> ios.GetValues (ios date)
- GET /device/:udid/battery       -> ios.GetBatteryDiagnostics (ios batterycheck)
- GET /device/:udid/diagnostics   -> diagnostics.AllValues (ios diagnostics list)
- GET /device/:udid/mobilegestalt -> diagnostics.MobileGestaltQuery (ios mobilegestalt), keys via ?key=
- GET /device/:udid/processes     -> instruments.ProcessList (ios ps), ?apps=true filters apps
- GET /device/:udid/lockdown      -> ios.GetValues (ios lockdown get)

Handlers surface library errors as {"error":...} via RespondError (no discarded
errors, no panics). Wired through registerDeviceInfoRoutes in routes.go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…e, lang, memlimitoff)

Mirrors the ios CLI: diagnostics.Reboot/Shutdown, mcinstall.Erase (gated by
?confirm=true), amfi.EnableDeveloperMode + imagemounter.IsDevModeEnabled +
amfi.RevealDevMode, ios.Get/SetLanguage, and instruments ProcessControl
DisableMemoryLimit. Errors surfaced via RespondError; no panics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…h, ios crash ls/rm)

Uses the iOS 17+ file service and streams pull/push through the HTTP body, so
there is no caller-supplied host path and no host-side traversal. Domains:
app|app-group|crash|temp. Crash reports: list + remove (crash-log downloads are
available via /files?domain=crash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
- GET/PUT /wallpaper: springboard GetHomeScreenWallpaperPNG (image/png); set via
  multipart (image+p12 supervisor identity+screen) -> mcinstall.SetWallpaperSupervised.
- GET/PUT /icon-layout: springboard Get/SetIconLayout.
- GET/PUT /pasteboard: pasteboard Get/SetText (iOS 17+).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…age list/unmount)

- POST /profiles: mcinstall AddProfile / AddProfileSupervised (multipart p12); DELETE /profiles/:name.
- GET /image/list: mounted image signatures (hex); DELETE /image: imagemounter.UnmountImage.
GET /profiles and GET/PUT /image already existed; only the missing verbs added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
- GET/PUT /assistivetouch: ios.Get/SetAssistiveTouch.
- GET/PUT /timeformat: ios.Get/SetUses24HourClock.
- PUT/DELETE /wifi: mcinstall.PrepareWifi/RemoveWifi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
GET /sysmontap streams instruments Sysmontap CPU-usage samples (matches the
existing syslog/listen streaming pattern). pcap is deferred until ios/pcap
exposes a packet-callback streaming API (it currently writes a local file).
…, clear-passcode, clear-screen-time-password)

All under /device/:udid/mdm, POST multipart with a p12 supervisor identity +
password (escalated mcinstall session via conn.Escalate). Credentials stay in
memory, never logged or persisted. clear-passcode also takes a base64 token.
…streamed logs

Long-running device operations now run as background jobs:
- POST /jobs/runtest, /jobs/runwda, /jobs/forward -> 202 + job id
- GET  /jobs (per-device), GET /jobs/:id (status), DELETE /jobs/:id (stop)
- GET  /jobs/:id/logs streams that job's isolated log (history + live tail)

Each job captures its output on a dedicated jobLog sink (io.Writer wired into the
testmanagerd TestListener), so concurrent jobs never interleave. Lifecycle events
are logged via ios/golog with module=go-ios/restapi + udid + job attrs. Terminal
state is immutable, so stopping a job isn't relabeled as a failure when its
context-cancelled goroutine returns. In-memory job manager + jobLog are unit-tested
(incl. -race): lifecycle, stop-is-terminal, per-device isolation, log stream/close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
@danielpaulus
danielpaulus force-pushed the feature/restapi-parity branch from ea8ae66 to 6e88833 Compare August 10, 2026 14:05
PUT /httpproxy (supervised, multipart host/port/p12/user/pass/password) ->
mcinstall.SetHttpProxy; DELETE /httpproxy -> mcinstall.RemoveProxy.
Agent-level, not device-scoped, so they live at /api/v1 (behind auth):
- GET    /tunnels               list running tunnels
- DELETE /tunnels/:udid         stop a device tunnel
- POST   /tunnels/:udid/refresh refresh a device tunnel
- POST   /tunnel-agent/shutdown stop the tunnel agent
They query the running agent via ios.HttpApiHost/HttpApiPort. 'tunnel start' is
not exposed (privileged long-running daemon). Added a route-registration smoke
test that builds the full tree so gin route conflicts fail loudly.
@danielpaulus danielpaulus changed the title restapi: full CLI parity + production readiness (in progress) restapi: full CLI parity + production readiness Aug 10, 2026
httptest-based tests exercising the request-validation branches (which run before
any device I/O): missing/invalid params across files, mobilegestalt, wifi, mdm,
crashes, devmode, and the job endpoints (erase confirm-gate, missing bundle/ports,
job-not-found 404). Closes the biggest coverage gap for the parity endpoints.
@danielpaulus
danielpaulus marked this pull request as ready for review August 10, 2026 14:30
@danielpaulus
danielpaulus force-pushed the feature/restapi-parity branch from e5b3cc5 to a0ee58d Compare August 10, 2026 15:48
danielpaulus and others added 3 commits August 10, 2026 11:54
Add RateLimitUDID: a token-bucket (golang.org/x/time/rate) gin middleware keyed
by device UDID, applied across the /device/:udid group. Requests over the limit
get 429; each UDID has its own bucket so devices don't throttle each other.
Configurable via --rate-limit (req/s, default 20) and --rate-burst (default 40);
0 disables. Uses the atomic sync.Map LoadOrStore pattern (no create race).

Tests (device-free, -race): burst-then-429, disabled-when-zero, per-device
isolation, and a concurrent-hammer test asserting the shared bucket isn't
exceeded under load.
Adversarial review follow-ups on the 56-endpoint REST parity work:

Security/correctness fixes:
- Bound in-memory uploads: readFormFile and the raw-body reads in
  SetPasteboard and AddProfile now go through readAllLimited (256 MiB cap)
  so an authenticated client can't OOM the daemon with an oversized
  multipart file or body. PushFile already streams (Content-Length gated)
  and is unaffected.
- Fix a lost-line gap in GET /jobs/:id/logs: snapshot() then subscribe()
  raced, dropping any line written in between. Added jobLog.
  snapshotAndSubscribe() which takes the backlog and the live subscription
  under one lock.
- Bound the process-wide job registry: DELETE /jobs/:id on an already
  terminal job now purges it (jobManager.remove, terminal-only) so finished
  jobs' buffered logs don't accumulate forever. Running jobs are still
  stopped, never silently dropped.

Tests (httptest + in-context device):
- Auth coverage: BearerAuth accept/reject, and a tree-walk asserting all
  80 registered /api/v1 routes return 401 unauthenticated.
- Files: Content-Disposition base-name sanitisation (traversal-y remote
  can't inject a host path), ls/pull/push validation, push 411 without
  Content-Length.
- Upload limits: readAllLimited boundaries + oversized pasteboard body.
- Proxy/MDM multipart validation.
- Jobs: full HTTP lifecycle (create/list/get/stop/delete), per-device
  isolation (cross-udid GET/DELETE 404 and no stop), remove-terminal-only,
  atomic snapshot+subscribe.

go build/vet/test ./restapi/... green incl. -race; gofmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
@danielpaulus

Copy link
Copy Markdown
Owner Author

Review round: adversarial security + correctness pass (pushed cbde822)

Reviewed all 56 endpoints for the four priority classes (files traversal, proxy SSRF, auth gaps, jobs races/resource exhaustion), got a second opinion from Codex, verified each claim, and fixed the confirmed defects with tests. go build/vet/test ./restapi/... green incl. -race; gofmt clean.

CI triage

  • CodeQL (failing): not this PR. The only blocking annotation is ncm/ncm.go:185 go/allocation-size-overflow — a pre-existing alert (created 2024-06-11, open on main). This PR does not touch ncm/; zero alerts in any restapi/ file. CodeQL is mis-attributing a stale-baseline alert to the diff. Recommend dismissing/annotating alert Deviceinfo doesn't work #9 (maintainer action) — no code change is warranted here.
  • macOS e2e (failing): confirmed infra flake, unrelated. TestTunnelLoad/.../syslog-b failed with ios [syslog]: no streamed output within 10s while the concurrent syslog-a streamed 11 MB fine — two concurrent syslog relays on one device, one starved. Lives in test/e2e/tunnel/perf_test.go; nothing to do with restapi/. (Linux e2e is green.)

Confirmed defects fixed

  • [Med] Unbounded in-memory uploadsreadFormFile and the raw-body reads in SetPasteboard/AddProfile used io.ReadAll with no cap; an authenticated client could OOM the daemon. Now routed through readAllLimited (256 MiB). PushFile already streams (Content-Length gated) and was fine.
  • [Med] Lost log line in GET /jobs/:id/logssnapshot() then subscribe() raced; a line written in between was dropped from the stream. Added jobLog.snapshotAndSubscribe() (backlog + subscription under one lock).
  • [Med] Unbounded job-registry growth — terminal jobs (and their buffered logs) lived forever. DELETE /jobs/:id on a terminal job now purges it (remove, terminal-only); running jobs are still stopped, never silently dropped.

Reviewed and dismissed (not defects)

  • Files traversal — claim holds. Pull streams to c.Writer, push streams from c.Request.Body; the only host-side use of the caller path is path.Base(remote) in Content-Disposition (test added). remote is device-side (iOS sandbox enforced), matching the CLI.
  • Proxy "SSRF"PUT /httpproxy sets the device's proxy config; no server-side fetch, so not SSRF.
  • Auth coverage — every /api/v1 route is behind BearerAuth (health//swagger intentionally outside/gated). Added a tree-walk test asserting all 80 routes 401 unauthenticated.
  • Jobs terminal-state immutability & double-closefinish is a no-op once non-running, jobLog.close is idempotent; a stop-then-late-finish stays stopped. Verified under -race.

Streaming framing inventory (for the separate SSE workstream — not changed here)

endpoint new in #817 framing
/syslog no undelimited concatenated JSON
/listen no undelimited concatenated JSON
/notifications no NDJSON (\n)
/ostrace no NDJSON (\n)
/sysmontap yes NDJSON (\n)
/jobs/:id/logs yes raw text lines (lines carry \n)

Both new streaming endpoints are already newline-delimited. The two undelimited legacy streams (/syslog, /listen) are pre-existing and untouched by this PR; left for the SSE-alignment workstream to avoid scope creep here. None are true text/event-stream SSE yet.

Tests added (restapi/api/handlers_more_test.go)

Auth (accept/reject + all-routes-401), files (base-name sanitisation, ls/pull/push validation, push-411), upload limits, proxy/MDM multipart validation, jobs HTTP lifecycle, per-device isolation (cross-udid 404 + no-stop), remove-terminal-only, atomic snapshot+subscribe.

Merge-readiness

Code is merge-ready. Both red checks are non-blockers external to this PR (stale CodeQL baseline + a device-side syslog-concurrency e2e flake). Suggested pre-merge: dismiss CodeQL alert #9 on ncm/ncm.go and re-run the macOS e2e to confirm the flake clears.

Make the REST daemon match the TypeSpec-authored OpenAPI contract the
official SDKs are generated from.

Real SSE framing (event:/data:/blank-line) on all six streaming
endpoints, with a periodic heartbeat on idle, replacing the previous
NDJSON/concatenated-JSON writes:
  - /syslog       -> event "syslog"       (SyslogMessage)
  - /notifications-> event "appstate"     (AppStateNotification)
  - /ostrace      -> event "ostrace"      (OsTraceEntry)
  - /listen       -> event "attachdetach" (AttachDetachEvent)
  - /sysmontap    -> event "sample"       (CpuUsageSample)
  - /jobs/{id}/logs-> event "log"         (JobLogLine)
  - all           -> event "heartbeat"    ({}) on idle

Payload models use the spec's camelCase field names; a shared streamSSE
helper drives frames + heartbeats and flushes after each write.

Other spec conformance:
  - setlocation: longtitude -> longitude (query param, checks, messages,
    swagger annotations)
  - screenshot: content-type image/png (was application/octet-stream)
  - streaming error paths now use the GenericResponse envelope

Device-free unit tests cover the SSE framing + heartbeats, payload
mappers, longitude, and the removed misspelled param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
@danielpaulus

Copy link
Copy Markdown
Owner Author

SDK-contract conformance pass

Made the REST daemon conform to the TypeSpec-authored OpenAPI contract the SDKs are generated from (spec/openapi/openapi.yaml x-sse-events + docs/DESIGN.md). API is not final, so no back-compat aliases were kept.

Real SSE framing on all six streaming endpoints

Replaced the previous NDJSON / concatenated-JSON writes with proper SSE frames (event: <name>\ndata: <compact-json>\n\n) plus a heartbeat frame on idle. A shared streamSSE helper (new restapi/api/sse.go) drives the frame loop, emits heartbeats via a ticker (reset on each real event), and flushes after every write. The text/event-stream + no-cache + chunked headers from the streaming middleware are unchanged.

Endpoint event: name payload model
/device/{udid}/syslog syslog SyslogMessage
/device/{udid}/notifications appstate AppStateNotification
/device/{udid}/ostrace ostrace OsTraceEntry
/device/{udid}/listen attachdetach AttachDetachEvent
/device/{udid}/sysmontap sample CpuUsageSample
/device/{udid}/jobs/{id}/logs log JobLogLine (backlog replayed as log frames, then live)
all heartbeat {}

Payload structs use the spec's camelCase field names. AttachDetachEvent.properties is emitted via a local DeviceProperties struct with camelCase JSON tags (the underlying ios.DeviceProperties has none and would marshal PascalCase).

Other spec fixes

  • longtitudelongitude in SetLocation: query read, empty-check, error + success messages, and swagger @Param/@Description. No alias — the old misspelled param no longer satisfies the required field.
  • Screenshot content-type now image/png (dropped the application/octet-stream override + redundant header line).
  • Streaming error paths now use RespondError (GenericResponse envelope) instead of ad-hoc gin.H.

Tests (device-free)

restapi/api/sse_test.go + additions to device_endpoints_test.go:

  • exact wire framing of writeSSEFrame (event/data/blank-line, compact JSON) and the heartbeat frame
  • streamSSE emits typed frames and terminates on end-of-stream (via a fake next, no c.Stream/CloseNotifier dependency)
  • heartbeat is emitted on idle (test-only interval override) then real events resume
  • payload mappers (toAppStateNotification, toOsTraceEntry, toAttachDetachEvent) incl. camelCase JSON
  • setLocation reads longitude (422 on missing; old longtitude rejected)

go build ./... && go vet ./restapi/... && go test ./restapi/... && go test -race ./restapi/... all green; gofmt -l clean.

Divergences deliberately left

  • AppStateNotification key mapping is best-effort. The instruments channel yields an untyped map[string]interface{} whose exact keys for bundle id / state aren't captured in-repo; the mapper tries the likely key names and falls back to empty strings. Verifying/locking the real key names needs a device capture — flagging rather than guessing further (server-side, testable once captured).
  • Screenshot 200 image/png path is only exercisable with a real device (the service connects on call), so the unit test asserts the longitude/error side; the content-type change itself is a one-line c.Data fix covered by the e2e suite.

danielpaulus added a commit that referenced this pull request Aug 11, 2026
Integrate the five per-language examples suites (typescript, python, java,
csharp, mcp) and tie them together:

- sdks/docs/EXAMPLES.md: top-level overview (docs + pre-release smoke test),
  per-language index, shared GO_IOS_BASE_URL/GO_IOS_API_KEY/GO_IOS_UDID/RUN_UI
  convention, how to run them all. Linked from sdks/README.md. Documents the
  canonical daemon port (8080, matching the daemon's --addr default) and the
  SDK-library-vs-daemon default discrepancy (libraries default to :60105 from
  the spec servers URL) for a follow-up reconcile.
- sdks.yml: add the device-free MCP list-tools smoke check (npm run examples)
  to the mcp job so a broken MCP server fails CI.
- verify-sdk-examples.yml: new dispatch-only, farm-gated workflow that runs
  every SDK's example runner against a live go-ios REST daemon + device. Inert
  until the full REST daemon lands on main (#817/#821); the daemon-start step
  is a documented placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants