Skip to content
Hack Your WorldSoftware · Infrastructure · Home automation

Analysis

Why a Static Go Binary Is Not a Portable Linux Application

A portable binary above separate Linux packaging, service, privilege and Bluetooth hardware layers
AI image: Hack Your World

A static Go binary can move between Linux distributions while the application around it still fails. Sysfs layout, system services, privileges, packaging, Bluetooth hardware, and installation paths belong in the portability claim.

The binary was the easy layer

The Arch package recipe builds two Go commands with CGO disabled:

CGO_ENABLED=0 go build -o build/pearedd ./cmd/pearedd
CGO_ENABLED=0 go build -o build/peared ./cmd/peared

That removes a common source of runtime library mismatch. It does not package Bluetooth itself. The CLI delegates device operations to bluetoothctl. Adapter discovery reads /sys/class/bluetooth. The planned daemon expects BlueZ, D-Bus, and eventually PipeWire integration. Pairing may cross a privilege boundary.

The repository therefore has at least six portability layers:

What has to travel with a “portable” Bluetooth CLI
Layer Current Peared assumption What can differ
Executable Go binaries built with CGO disabled Architecture, release artifact, installation path
Kernel view Adapters appear under Linux sysfs Permissions, missing attributes, device topology
Bluetooth service BlueZ and D-Bus are available Package names, versions, service state, policy
Command interface bluetoothctl performs scan, pair, connect, and disconnect Invocation semantics and output across versions
Privileges Non-root commands may use sudo Polkit, groups, sudo policy, interactive prompts
Packaging An Arch VCS package exists Debian metadata, service units, completions, ownership

Calling the top row portable while leaving the other five implicit is how a tool works on the development machine and fails during installation.

I invented an adapter flag that did not exist

Peared needs to behave predictably when a computer has more than one Bluetooth controller. An early implementation tried to pass the selected controller as a global command-line option:

bluetoothctl --adapter hci1 scan on

The code was internally tidy. The external interface was imaginary. BlueZ documents adapter choice as the interactive select <controller> command, not a --adapter option.

The first fix removed that flag but made a second assumption. It assembled adapter selection and the requested operation into one argument list, effectively asking one non-interactive invocation to process select and pair or scan together. The tests initially confirmed the argument list I had invented rather than the behavior of the real program.

The next commit separated the interaction:

  1. Invoke bluetoothctl select hci1.
  2. If selection succeeds, invoke bluetoothctl pair … or the scan command.
  3. Combine both outputs so the caller can see selection and operation results.

The tests now assert two calls, their order, their exact arguments, selection failure, and combined output. That is a better unit boundary. It still does not prove that separate processes preserve the intended default controller with every supported BlueZ version. Only an integration test against the real service can establish that.

Sysfs is an API, not just a folder

The daemon discovers adapters by reading entries whose names begin with hci under /sys/class/bluetooth. It follows each device link, reads available attributes, and infers whether the transport is USB, PCI, ACPI, or another platform path.

That was a deliberate move away from scraping a human-formatted command for basic inventory. It also created a Linux-specific contract. The code has to handle a missing root, permission denial, symlinks, missing attribute files, and cancellation while the directory is being inspected.

The repository contains tests for all of those cases, including a fake sysfs tree and an explicit permission error type. Those tests make the parser portable across fixtures. They do not show that Debian and Arch expose identical attributes for the adapters I care about. A distribution matrix needs real or simulated system integration above the unit tests.

XDG solved one path without solving installation

Peared resolves configuration in a useful order: an explicit path, a PEARED_CONFIG environment variable, then the platform user configuration directory returned by Go. On Linux that follows the XDG configuration convention, normally placing the file beneath ~/.config when XDG_CONFIG_HOME is not set.

That keeps personal adapter choices out of the source tree and avoids hard-coding a developer’s home directory. The sample uses placeholders instead of real hardware addresses.

It does not answer who creates the directory, what permissions the file needs, whether the daemon runs as the user or as a system service, or how configuration migrates between releases. Those are package and service decisions. A cross-distribution path convention is only one piece of a cross-distribution lifecycle.

Automatic sudo is not a portability strategy

The current runner detects a non-root user and defaults to putting sudo in front of bluetoothctl. If sudo is missing, construction fails with an explanation. That makes an early command more likely to work on one workstation, but it imports the workstation’s privilege policy into the application.

A graphical session may expect Polkit authorization through BlueZ. A minimal Debian server may not have sudo. A locked-down workstation may allow Bluetooth management but forbid arbitrary escalation. A background daemon cannot answer an interactive password prompt.

I would make the privilege contract explicit before calling the tool portable:

  • prefer the narrow BlueZ or Polkit permission needed for the operation;
  • never assume membership in a broadly named group grants the same rights everywhere;
  • make optional escalation visible and opt-in;
  • fail before scanning or pairing if the required authorization path cannot work;
  • test both an allowed non-root path and a clean denial.

“Try sudo” is troubleshooting advice. It should not quietly become the architecture.

The Arch package is real; the Debian package is not

The repository includes a peared-git PKGBUILD for x86-64 and ARM64. It names BlueZ and D-Bus as runtime dependencies, Go and Git as build dependencies, builds both commands, runs go test ./... during the package check, and installs the binaries under /usr/bin. Its revision-based version and skipped checksum follow the normal shape of an Arch VCS package whose source changes over time.

That is useful packaging evidence for Arch. It is not a release. The source has no tag-pinned binary artifact, systemd unit, Debian control metadata, or Debian installation test. The roadmap still lists Debian packages, generic archives, reproducible instructions, and smoke tests as future work.

I would not turn “targeting Debian” in the README into “supports Debian” on a download page. The honest matrix today is:

What the repository proves today
Claim Status Evidence still needed
The Go source has unit-test coverage 33 test functions are present A fresh passing run in a pinned toolchain
An Arch package recipe exists Yes Clean makepkg build and installation test
The CLI can invoke BlueZ operations Wrapped and mocked in tests Real scan, pair, connect, and adapter-selection matrix
Debian is supported Not yet proven Package, dependencies, service policy, and clean-system test

What I would test before the first release

  1. Build and run the same commit on clean current Arch and Debian systems.
  2. Test internal-only, USB-only, and multiple-controller machines.
  3. Remove and reinsert the preferred USB adapter during discovery.
  4. Run as an ordinary desktop user without automatic sudo.
  5. Exercise both authorized and denied pair operations.
  6. Verify configuration paths with XDG defaults and an explicit override.
  7. Install and remove each package, checking files, ownership, completions, and service state.
  8. Capture BlueZ versions and make failures name the violated external contract.

A container can help test the parser and package build. It cannot represent a real USB controller, desktop authorization agent, D-Bus policy, or host Bluetooth daemon without deliberately modeling those boundaries.

The distribution belongs in the support claim

I still want Peared’s intent to remain stable across distributions: list controllers, select one, and manage devices predictably. The implementation should share as much Go code as possible. The installation and integration layers should be allowed to differ where the operating systems actually differ.

Portability is not hiding those branches. It is naming them, testing them, and keeping the user-facing behavior consistent above them. The binary is one artifact. The supported product is the binary plus every external contract required to make it useful.

The first release should make a smaller promise

I would release the first usable version for one named Arch setup before advertising broad Linux support. That package should install both commands, declare every runtime dependency, ship completion and service files intentionally, and document one tested non-root authorization path. The release notes should name the tested BlueZ version and adapter shapes.

Debian can follow as a separate supported target after the same scan, selection, pairing, installation, and removal checks pass there. A generic tarball can remain an expert option, but it should not imply that dropping two binaries into /usr/local/bin supplies the service and policy around them.

That smaller promise is commercially useful too. A person deciding whether to spend time on the tool can see exactly what is supported instead of debugging the gap between “Linux” and the machine in front of them.

The environments I actually checked

I reviewed the current public Peared repository, including the adapter provider, Bluetooth command runner, configuration loader, tests, Arch package recipe, architecture, roadmap, and the two commits that revised adapter selection. The external contracts are cross-checked against BlueZ’s official bluetoothctl documentation, the freedesktop.org XDG Base Directory specification, Go’s standard-library documentation, and Arch’s VCS packaging guidance.

I could not run go test ./... in the article workspace because a Go toolchain is not installed there. I did not run makepkg, install the binaries, access a Bluetooth adapter, pair a device, test Debian, or validate the privilege model. The 33-test count describes source functions, not a fresh passing test run. The adapter-selection discussion is a source and commit-history audit, not a claim that the current interaction has passed a live multi-controller matrix.

Disclosure: I maintain the linked repository.