Skip to content

Add 'ios tunnel service install/uninstall/status' to run the tunnel agent as a managed OS service - #811

Open
danielpaulus wants to merge 2 commits into
mainfrom
feature/issue-750-tunnel-service-install
Open

Add 'ios tunnel service install/uninstall/status' to run the tunnel agent as a managed OS service#811
danielpaulus wants to merge 2 commits into
mainfrom
feature/issue-750-tunnel-service-install

Conversation

@danielpaulus

Copy link
Copy Markdown
Owner

Problem

Running the tunnel agent (ios tunnel start, and the upcoming --cloud fleet-agent mode) persistently on a host means hand-writing an OS service today: a systemd unit, the right flags and env (ORCHESTRATOR_URL, --userspace, pair-record path, working dir), a restart policy, and enable-ing it at boot. That is manual, easy to get wrong (env not inherited, wrong working dir, missing restart), and has to be repeated on every device host. Issue #750 asks for a cloudflared-style service group so onboarding a host is one command.

Design

ios tunnel service install   [--userspace] [--user] [--pair-record-path=<p>] [--udid=<udid>] [--tunnel-info-host=<h>] [--tunnel-info-port=<p>]
ios tunnel service uninstall [--user]
ios tunnel service status    [--user]
  • install captures the current invocation into a service definition that runs ios tunnel start …:
    • flags: --pair-record-path (resolved to an absolute path — relative paths are anchored to the install-time cwd, the default literal is passed through), --userspace, --udid (flag or GO_IOS_UDID), --tunnel-info-host/port;
    • env allowlist baked into the definition (Environment= on systemd / EnvironmentVariables on launchd): ORCHESTRATOR_URL, GO_IOS_AGENT_HOST — a service does not inherit the operator's shell env, so a known-good config must be captured explicitly;
    • Restart=always, enabled at boot, started immediately.
  • Idempotent: re-running install stops/removes any existing definition and writes + re-enables the fresh one.
  • uninstall stops, disables, and deletes the service; a service that isn't installed is reported, not an error. status prints installed/running state.
  • systemd-first: on Linux, any other detected init system (OpenRC/upstart/SysV) fails with a clear message instead of writing an untested script. macOS launchd and the Windows service manager work through the same abstraction.
  • Privileges: a system service needs sudo/admin; permission errors are decorated with actionable guidance (rerun with sudo, or pass --user). --user installs a systemd --user unit / launchd LaunchAgent for the no-root --userspace case; docs note the loginctl enable-linger requirement for headless hosts. Installing a user service without --userspace logs a warning (the kernel tunnel needs root).
  • Dispatched as a global command (needs no device/usbmuxd), before the device commands so the install/uninstall literals don't collide with app (un)install.
  • The github.com/kardianos/service dependency lives only in package main; the ios library packages stay dependency-clean.

Implementation

  • cmd_tunnel_service.go — config capture (tunnelServiceConfig), install/uninstall flows against a small serviceController interface (satisfied by service.Service, faked in tests), init-system gate, privilege-error decoration, command runner and output (JSON by default, --nojson human-readable).
  • cmd_global.go — registers tunnel service in globalCommands.
  • main.go — docopt usage line + detailed help text (sudo requirement, --user alternative, lingering note).
  • internal/clihelp/help.yaml, testdata/help/global.golden, README.md — help catalog entry and docs.
  • go.mod/go.sumgithub.com/kardianos/service v1.3.0 (root module only).

Options considered

  • Document a systemd unit (status quo) — works but stays manual and per-host; exactly the friction the issue describes.
  • Hand-rolled systemd-only installer — lowest dependency cost and we know the exact unit, but Linux-only, and we'd own unit rendering, enable/disable, and privilege handling ourselves.
  • github.com/kardianos/service (preferred) — one API over systemd/launchd/Windows SCM, battle-tested (gitlab-runner, telegraf), matches the cloudflared/ngrok precedent cross-platform from day one, and the dependency is confined to package main. We still gate Linux to systemd so we never ship an untested OpenRC/SysV script.

Test plan

  • Unit tests (device-free, no service is registered by tests):
    • TestTunnelServiceConfigCapture — golden files for flag+env capture: defaults, cloud/userspace/user, per-device agent, GO_IOS_UDID fallback, default pair-record literal (testdata/tunnelservice/*.golden).
    • Fake-injected failure paths: no systemd on Linux (TestInstallTunnelServiceNoSystemd), permission errors with sudo/--user hints (TestInstallTunnelService{InsufficientPrivileges,UserServicePrivilegeError}), idempotent reinstall ordering stop→uninstall→install→start (TestInstallTunnelServiceIdempotent), uninstall incl. not-installed (TestUninstallTunnelService*), dispatch matcher (TestIsTunnelServiceCommand).
  • go build ./..., go test ./..., and gofmt -l clean; docopt parsing of the new usage line (incl. --user vs --userspace) verified.
  • Manually verified read-only paths on macOS: ios tunnel service status (reports not installed via launchd) and ios help tunnel service.
  • Actual service registration (install → reboot survival → uninstall) is verified manually/e2e on the Linux device runner as a follow-up — no service is installed by CI or unit tests.

Fixes #750

🤖 Generated with Claude Code

https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk

…ed OS service

Register the tunnel agent (ios tunnel start) as a managed OS service the way
'cloudflared service install' does, so onboarding a device host is one command
instead of a hand-written unit-file checklist.

- New 'ios tunnel service (install | uninstall | status)' command group in
  package main (cmd_tunnel_service.go) using github.com/kardianos/service;
  the dependency stays out of the ios library packages.
- install captures the current invocation's flags (--pair-record-path resolved
  to an absolute path, --userspace, --udid, --tunnel-info-host/port) and the
  ORCHESTRATOR_URL / GO_IOS_AGENT_HOST environment into the service definition,
  sets Restart=always, enables at boot, and starts the service. Re-running
  install replaces the existing definition (idempotent).
- uninstall stops, disables, and removes the service; uninstalling a missing
  service is not an error. status reports installed/running state.
- systemd-first on Linux: any other init system fails with a clear message
  instead of writing an untested script. --user installs a systemd --user
  unit / launchd LaunchAgent for the no-root (--userspace) case; permission
  errors are decorated with sudo/--user guidance.
- Dispatched as a global command (no device/usbmuxd needed) before the device
  commands so the install/uninstall literals don't collide with app
  (un)install.
- Unit tests: golden-file coverage of the flag+env capture, and fake-injected
  coverage of the install/uninstall flows (no systemd, insufficient
  privileges, idempotent reinstall). No service is registered by tests.

Fixes #750

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

Copy link
Copy Markdown
Owner Author

/test-devices

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 Running real-device tests on PR #811run.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

❌ Real-device tests failed — see run.

… usbmuxd env

Windows: the installed service runs `ios tunnel start` directly, but a Windows
service must connect back to the service-control dispatcher (svc.Run) on start.
The tunnel agent never does, so the SCM would kill it with error 1053. Reject
Windows in checkServiceSystem with an actionable message instead of registering
a service that can never start; docs/help no longer advertise Windows.

Env capture: add GO_IOS_AGENT_PORT (partner of the already-captured
GO_IOS_AGENT_HOST; both select the HTTP API bind host:port) and
USBMUXD_SOCKET_ADDRESS to the allowlist so a non-default agent port or usbmuxd
socket is reproduced by the service instead of silently falling back to defaults.

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

Copy link
Copy Markdown
Owner Author

Adversarial review (round 2)

Reviewed config-capture fidelity, install/uninstall state machine, kardianos platform quirks, and dispatch ordering. Verified behavior at runtime on macOS (launchd) and by inspecting the kardianos v1.3.0 templates. No real services were left installed.

Confirmed defects — fixed in 45c8eef

  1. Windows service would never start (correctness bug). The installed service runs ios tunnel start directly, but a Windows service must connect back to the service-control dispatcher (svc.Run) on startup — the tunnel agent never does, so the SCM kills it with error 1053 ("did not respond in a timely fashion"). main.go has no service.Interactive()/svc.Run wiring. The PR advertised Windows support and checkServiceSystem only gated non-systemd Linux, so tunnel service install on Windows would silently register a service that can never run. Fix: reject Windows in checkServiceSystem with an actionable message (pointing at nssm / scheduled task), matching the existing non-systemd-Linux gating; help text + README no longer promise Windows. (Proper SCM support is a larger, untestable-here change better left to a follow-up.)

  2. Incomplete env capture. GO_IOS_AGENT_HOST was captured but not its partner GO_IOS_AGENT_PORT — both feed ios.HttpApiHost/HttpApiPort, so a custom agent port silently reverted to the default under the service (exactly the 'wrong config' failure the issue warns about). Also added USBMUXD_SOCKET_ADDRESS (ios/usbmuxconnection.go) so a non-standard usbmuxd socket is reproduced rather than falling back to default and seeing no devices. Golden + test updated.

Verified correct (dismissed)

  • Dispatch ordering / install collision: tunnel service is in globalCommands (dispatched before deviceCommands) and isTunnelServiceCommand requires both tunnel && service, so it can't collide with app install/uninstall. Confirmed by parsing + running the built binary.
  • Idempotent reinstall (stop→uninstall→install→start): correct for same-scope ops; matches unit test. Ignoring Stop errors before uninstall is deliberate and standard.
  • launchd restart policy: RunAtLoad=true + kardianos KeepAlive default true gives start-at-load + restart-on-crash. Correct.
  • systemd Restart=always + user unit: kardianos hardcodes WantedBy=multi-user.target, which is valid in the user manager too; lingering caveat in the help is accurate.
  • --enabletun appears in help text but is not read by tunnel start (dead doc flag) — correctly not captured.
  • kardianos dependency stays in package main only.
  • Note: mixing scopes (installing a system service while a user service is loaded) can confuse launchd's non-scoped launchctl list, but that's an inherent kardianos/launchctl limitation, not a PR-fixable state-machine bug.

Acceptance criteria

  • install/enable/start survives reboot on Linux/systemd
  • reproduces flags + ORCHESTRATOR_URL (now also agent port + usbmuxd socket)
  • uninstall fully removes (stop/disable/delete); not-installed is a no-op
  • clear errors on insufficient privileges / unsupported manager (now incl. Windows)
  • docs note sudo + --user + lingering

go build ./..., go test ./..., gofmt -l all clean.

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.

Add ios tunnel service install/uninstall to run the tunnel agent as a managed OS service (cloudflared-style)

1 participant