Files
fleet/tools/hangar
Andrey Kizimenko 8c6bedf661 Hangar: local dev environment — multi-server + SCEP, MDM assets & TUF tabs (#49454)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** N/A — internal developer tooling (`tools/hangar`).

## Summary

Fleet Hangar is the local dev-environment control panel (`tools/hangar`,
Go + Wails). This PR expands it into a broader **local dev-services**
toolkit for contributors/QA:

- **Multi-server support** — run up to 3 independent local Fleet servers
in parallel, each on its own git worktree, offset ports, and docker
compose project (server switcher + server-scoped
Server/Logs/Database/Git tabs).
- **SCEP tab** — run local SCEP CA servers using the in-repo
`server/mdm/scep/cmd/scepserver` (built once to a cached binary).
Per-depot profiles, `ca -init`, concurrent start/stop with live logs,
and one-click copy for the SCEP URL / challenge / thumbprint (parsed
from `ca.pem`).
- **MDM assets tab** — run `tools/mdm/assets export` from saved configs;
results list each written file with copy-contents/path + size +
timestamp, plus the `FLEET_MDM_APPLE_*` env block.
- **TUF tab** — drive `tools/tuf/test/main.sh` from platform checkboxes.
Hangar runs the file-server itself (`SKIP_SERVER=1`) so `fleetctl
package` can reach the TUF URL during packaging; streams live build
output; shows ngrok tunnel + TUF-server prerequisites; and offers
kill-server + delete-assets.
- **Supporting work** — DB backups in app-data + cross-server restore;
ngrok live public-URL links + stale-tunnel heal; per-server
open-in-browser; Settings → Troubleshoot cards to reap stray
`scepserver`/TUF-server processes and delete `test_tuf`.

Opening as a **draft for transparency**. All changes are confined to
`tools/hangar/`; nothing touches the Fleet server, agent, or any shipped
code.

**Architecture:** each tab is an `internal/<feature>` package (pure,
unit-tested logic) behind a thin `services/<feature>_service.go` Wails
adapter, reached from the UI as `api.*`. Long-running processes go
through the shared process engine; everything builds from / runs against
the primary repo (Server 1).

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes — N/A: `tools/hangar`
is a developer tool and is not part of a Fleet release.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented, JS inline code is prevented, and untrusted data
interpolated into shell scripts/commands is validated against shell
metacharacters.
- External commands (`scepserver`, `go run ./tools/mdm/assets`, `bash
main.sh`, the backup/restore `docker` invocation) are spawned with
discrete argv slices via the process engine — no shell string
interpolation — so user-supplied values (challenge, enroll secret,
depot/dir paths) can't inject. Backup names are validated to
`[A-Za-z0-9._-]`; server-id path segments are sanitized to
`[A-Za-z0-9_-]` (no traversal); TUF asset deletion is scoped to
`<repo>/test_tuf`.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- Binary builds / one-shot commands run under bounded
`context.WithTimeout`; the TUF-server readiness and ngrok local-API
fetches use short HTTP timeouts; no unbounded loops or retries were
added.
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI — N/A: no Fleet server API
changes.

## Testing

- [x] Added/updated automated tests
- Go unit tests across the new packages: `settings` (SCEP profiles, TUF
config, `migrate` incl. the empty-`servers` case), `scep` (depot/CA
parsing, arg builders), `mdmassets` (export args, `wrote … in …`
parsing, config persistence), `tuf` (env building, file-server args,
asset delete), and `troubleshoot` (live-PID filtering) — plus the
existing backups logic.
  - `tsc --noEmit` clean and `task build` green.
- [ ] Where appropriate, automated tests simulate multiple hosts and
test for host isolation — N/A.
- [x] QA'd all new/changed functionality manually (ongoing local testing
of all three tabs).

## Database migrations

N/A — no database migrations.

## New Fleet configuration settings

N/A — no Fleet server configuration settings (Hangar stores its own
settings in app-data).

## fleetd/orbit/Fleet Desktop

N/A — no fleetd/orbit/Fleet Desktop changes.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Multi-server support (up to three) with server switcher, server-scoped
health/logs, and server-scoped Docker Compose controls.
* New **Servers** settings section plus per-server configuration
(including ports/compose project) and server-aware start/stop/quit
flows.
* New **SCEP**, **MDM Assets**, and **TUF** tabs for managing
profiles/assets and discovering ngrok URLs.
  * Git worktree listing/creation/removal.
  * Centralized, server-scoped database backup management.
* **Bug Fixes**
* Improved process discovery to skip dead or racing entries and avoid
duplicate docker-compose-up display.
  * Self-healing pruning of stale ngrok tunnel selections.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-05 17:48:34 -05:00
..
2026-07-09 11:20:45 -03:00

Fleet Hangar

A desktop control panel for Fleet contributors, built with Go and Wails 3. Bundles the daily tasks of working on a Fleet clone (checking out branches, building, running fleet serve, tailing logs, managing the dev MySQL, driving fleetctl, applying GitOps repos, spinning up osquery-perf) into one app. macOS-first.

Why Go? To match the rest of the repo so Fleet engineers can contribute to it. The backend is plain Go (os/exec, syscall, goroutines); only the desktop shell is Wails. Hangar began as a Rust/Tauri app; it was ported to Go and that port is now the canonical implementation.

Architecture

  • internal/ — all the logic, pure and unit-tested (each package takes explicit paths/timestamps so tests are hermetic):
    • processes — spawn/log/lifecycle engine: child-process management, streamed log readers (level detection, secret scrubbing, on-disk rotation, in-memory ring), running.json crash-recovery, SIGTERM→SIGKILL on process groups, docker-compose orchestration, TLS probe
    • settings, gitrepo, db, gitops, fleetctl, deps, troubleshoot, perf, perfconfig — one per former src-tauri/src/*.rs module
    • paths (macOS dirs + path safety), shellpath (login-shell PATH warming), traymenu (tray menu model)
  • services/ — thin Wails-bound service structs; each exported method is callable from the frontend. They resolve real paths and delegate to internal/.
  • main.go / tray.go / emitter.go — the native shell: app bootstrap, system tray, and window lifecycle (hide-to-tray, dock reopen, Cmd+Q→confirm).
  • frontend/ — the React + TypeScript UI (shared with the Rust app). The only Wails-specific glue is src/lib/tauri.ts (the api.* IPC layer over the generated bindings) and src/lib/events.ts (the listen() adapter over Wails events).

Development

Requirements: Go (see go.mod), Node 24+, and the Wails 3 prerequisites. Install the CLIs once:

go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.98
go install github.com/go-task/task/v3/cmd/task@latest

Then, from this directory:

task dev       # live-reload dev mode (Vite + Go)
task build     # type-check + production build -> bin/fleet-hangar
task package   # build + bundle + ad-hoc sign -> "bin/Fleet Hangar.app"
task dist      # zip the existing .app into a shareable "bin/Fleet Hangar.zip"
task pkg       # wrap the existing .app into a Fleet-installable "bin/Fleet Hangar.pkg"
go test ./...  # backend unit tests

After changing any Go service signature, regenerate the TypeScript bindings (also run by task build):

wails3 generate bindings -clean=true -ts

Notes

  • Names. The bundle is Fleet Hangar.app (the PRODUCT_NAME Taskfile var) so Finder, Launchpad, and Spotlight show "Fleet Hangar". The executable inside stays fleet-hangar (the APP_NAME var) — it's also reused for the -server binary and Docker image tags, where a space would break things. The bundle identifier is com.fleetdm.fleet-hangar — the same ID the original Rust app used, so settings written by it carry over untouched. Settings live under ~/Library/Application Support/<id>/ and logs under ~/Library/Logs/<id>/; DB backups live in <repo>/db-backups/.

  • Distribution. task package produces only an ad-hoc-signed .app; task dist zips whatever bundle is in bin/ into bin/Fleet Hangar.zip (via ditto, so the signature survives) for handoff. Two paths:

    • Quick / trusted teammate: task packagetask dist, then the recipient clears quarantine after unzipping: xattr -dr com.apple.quarantine "/path/to/Fleet Hangar.app" (an ad-hoc-signed app from another machine is otherwise blocked by Gatekeeper).
    • Clean install anywhere: configure SIGN_IDENTITY + KEYCHAIN_PROFILE in build/darwin/Taskfile.yml, run task darwin:sign:notarize (Developer ID sign + Apple notarization), then task dist. No quarantine step needed.

    dist never rebuilds, so running it after sign:notarize preserves the notarized signature.

  • Install onto hosts via Fleet. task pkg wraps the existing bin/Fleet Hangar.app into bin/Fleet Hangar.pkg — a component installer that drops the app into /Applications. Upload it under Fleet > Software > Add software > Custom package; Fleet reads the identifier and version from the app's Info.plist to track install status. Like dist, it never rebuilds, so notarize first (task darwin:sign:notarize) for real hosts — an ad-hoc build installs cleanly only because a pkg install skips the quarantine flag.

Known issues

  • Rare crash on display sleep/wake or monitor changes (upstream Wails v3 alpha bug, not ours). Wails' screen_darwin.go stores the autoreleased [NSString UTF8String] buffers for each screen's id/name in a C struct and reads them from Go later, after the autorelease pool has drained — a use-after-free. On an ApplicationDidChangeScreenParameters event the dangling pointer trips fatal error: invalid pointer found on stack. It's a fatal error, not a panic, so it can't be recovered, and the handler is registered inside Wails so we can't intercept it. It's infrequent (one occurrence observed over an ~11h session). Until a fixed Wails alpha ships, relaunch the app if it happens. Reported upstream: wailsapp/wails#5556.