Skip to content

feat: multi-arch (amd64+arm64) release and audit hardening batch - #28

Merged
libre-7 merged 3 commits into
mainfrom
hardened/audit-2026-09-29
Oct 1, 2026
Merged

libre-7 merged 3 commits into
mainfrom
hardened/audit-2026-09-29

Conversation

@libre-7

@libre-7 libre-7 commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Changes

Remediation for the comprehensive code review of 2026-09-29 (findings F1–F10).
Full report: projects/simplex-bridge-code-review-2026-09-29.md in the project vault.

F1 — Multi-arch (the headline change)

Upstream simplex-chat publishes simplex-chat-ubuntu-24_04-aarch64 at the
pinned v7.0.2 (verified against the v7.0.2 release notes, which carry a SHA256
per arch). The "no ARM binary is published" claim was therefore false, and it
suppressed a working configuration for anyone on Apple Silicon or ARM Unraid.

  • Dockerfile selects asset and SHA256 per TARGETARCH for simplex-chat;
    gosu now resolves gosu-${arch} instead of hardcoding gosu-amd64.
  • TARGETARCH falls back to uname -m, so plain docker build still works.
  • Added test -s before sha256sum -c on the gosu checksum file — an empty
    grep result would otherwise make the verification succeed on zero entries.
  • CI PLATFORMS → linux/amd64,linux/arm64; the smoke job is now a platform
    matrix using QEMU for arm64, with the same
    cap_drop: ALL + CHOWN/SETUID/SETGID + no-new-privileges set and a
    longer healthcheck budget for emulation.
  • Docker Hub mirroring changed to buildx imagetools create. The previous
    pull → retag → push would, on an amd64 runner, resolve only the amd64
    manifest and replace Docker Hub's multi-arch index with a single-arch
    image
    — arm64 pulls would then fail with "no matching manifest". A new
    step asserts both platforms are present on both registries.

F2 — First-run detection checked a path that cannot exist

-d/--database is a file prefix upstream (the CLI appends _chat.db
itself), so -d /data/simplex creates /data/simplex_chat.db. The entrypoint
was checking /data/simplex_v1_chat.db, so the first-run test was always true
and --create-bot-display-name was passed on every start. This was latent —
Core.hs gates profile creation on the DB having no active user — but it now
checks the real path instead of relying on an undocumented upstream detail.

F3/F4 — install-websockets.sh no longer patches

The script sed -i'd adapter.py to work around hermes-agent#46265, but the
adapter was refactored to a _send_cmd() helper. Both the "already fixed" and
"needs patching" patterns matched nothing: the script silently no-op'd,
printed ⚠ patch may not be complete (0/2), and exited 0.

The patching is removed. The check is now read-only and structural, and
exits non-zero on an unrecognised adapter rather than warning and
continuing. A bad sed -i edit into site-packages could have raised a
SyntaxError that breaks the gateway's plugin import outright.

F5 — License metadata corrected

The image redistributes AGPL-3.0 binaries (simplex-chat, simplexmq) but was
labelled GPL-3.0. The OCI label is now AGPL-3.0, and the README carries a
bundled-third-party-components table with licenses and source links, per
AGPL-3.0 §13. The repo's own source stays GPL-3.0 — only the label and
disclosure changed.

F6 — GitHub Releases

Six git tags, zero release objects. v1.2.0 is called "the latest release" in
the README but has no release page or notes. Left for a tag decision rather
than cut unilaterally here.

F7/F8/F9/F10 — Docs and hygiene

  • Unraid <Changes> updated with a 2026.09.29 entry.
  • README recipes that were "verified on v1.0.1" now say exactly that, rather
    than implying verification against the current image.
  • SIMPLEX_TOR documented honestly: it passes -x, which selects a local
    SOCKS5 proxy at :9050. It does not configure Tor, and onion-only routing
    needs --socks-mode, which this image does not set. Worth a live test if
    you rely on it.
  • Hermes minimum version resolved: main requires 0.20.0+ and verifies rather
    than patches (the 0.16.0-vs-0.20.0 contradiction is gone).
  • New SIMPLEX_STARTUP_TIMEOUT (default 15s, input-validated) so slow or
    emulated ARM hosts do not fail the hardcoded 15s window.
  • The startup wait now exits early if the daemon dies instead of burning the
    full timeout.
  • Removed stray __pycache__.

Verification

  • shellcheck entrypoint.sh install-websockets.sh — clean
  • python3 -m py_compile healthcheck.py — OK
  • Workflow + compose YAML parse; Unraid template XML parses
  • Arch-selection logic replicated and tested for amd64, arm64, and
    empty TARGETARCH; outputs cross-checked against the v7.0.2 release
    notes — both asset names and both digests match upstream
  • Both release asset URLs return HTTP 200
  • gosu-arm64 checksum line present in gosu 1.19 SHA256SUMS; the
    Dockerfile's grep+sed rewrite produces a valid sha256sum -c line
  • New adapter detection verified against the adapter actually installed on
    this host (correctly identifies it as fixed)
  • docker build / container run — not executed (no Docker available in
    the authoring environment). The arm64 QEMU smoke job is the real gate;
    please treat that job as the acceptance test for F1.

Reviewer notes

  • The existing capability set, healthcheck, digest pin, and smoke job are
    deliberately unchanged — they are correct, and the smoke job is what caught
    the v1.1.0 abort in the first place.
  • docker-compose.yml still pins the v1.2.0 digest on purpose; it is
    amd64-only. Re-pin after the next release is cut and verified.
  • The arm64 smoke job runs under QEMU emulation, which proves the asset,
    checksum, and entrypoint all work end to end — but emulation is not a
    substitute for a run on real ARM hardware.

Comprehensive code review remediation (audit 2026-09-29), F1–F10.

F1 — multi-arch: upstream simplex-chat publishes simplex-chat-ubuntu-24_04
for BOTH x86_64 and aarch64 at the pinned v7.0.2, so the "amd64 only / no ARM
binary is published" claim was false and suppressed a working configuration.
- Dockerfile: select asset + SHA256 per TARGETARCH for simplex-chat; gosu now
  resolves gosu-${arch} instead of hardcoding gosu-amd64. Both digests come
  from the v7.0.2 release notes. `test -s` guards the checksum file — an empty
  grep result would make sha256sum -c succeed on zero entries.
- TARGETARCH falls back to uname -m so plain `docker build` still works.
- CI: PLATFORMS -> linux/amd64,linux/arm64; smoke job is a platform matrix
  with QEMU for arm64, using the same cap_drop/cap_add/no-new-privileges set
  and a longer healthcheck budget for emulation.
- CI: mirror to Docker Hub with `buildx imagetools create` instead of
  pull+retag+push. A pull on an amd64 runner resolves only the amd64 manifest,
  which would REPLACE Docker Hub's multi-arch index with a single-arch image
  and break arm64 pulls. Added a step asserting both platforms exist on both
  registries.

F2 — first-run bot detection checked /data/simplex_v1_chat.db, a path that can
never exist: upstream -d/--database is a FILE PREFIX (the CLI appends
_chat.db itself), so -d /data/simplex yields /data/simplex_chat.db. The check
was always true, passing --create-bot-display-name on every start. Latent only
because Core.hs gates creation on the DB having no active user — now it checks
the real path instead of relying on that undocumented upstream detail.

F3/F4 — install-websockets.sh patched adapter.py with `sed -i`, but the
adapter was refactored to a _send_cmd() helper, so both the "already fixed"
and "needs patching" patterns matched nothing: the script silently no-op'd,
printed a warning, and exited 0. Removed the patching entirely. The check is
now read-only and structural, and exits non-zero on an unrecognised adapter
instead of warning-and-continuing.

F5 — image redistributed AGPL-3.0 binaries (simplex-chat, simplexmq) under a
GPL-3.0 OCI label. Label corrected to AGPL-3.0; README now lists bundled
components with licenses and source links per AGPL-3.0 s13.

F6 — documented for follow-up: repo has six git tags but zero GitHub Releases,
so the v1.2.0 "release" has no page. Not cut here; needs a tag decision.

F7/F8 — Unraid template changelog updated; README "verified on v1.0.1" recipes
now state that scope instead of implying current-image verification.

F9 — SIMPLEX_TOR is documented honestly: it passes -x, which selects a local
SOCKS5 proxy at :9050; it does not configure Tor, and onion-only routing needs
--socks-mode which this image does not set. Also resolved the Hermes
minimum-version contradiction (0.16.0 vs 0.20.0) — main now requires 0.20.0+
and verifies rather than patches.

F10 — removed stray __pycache__.

Also: SIMPLEX_STARTUP_TIMEOUT (default 15s, validated) so slow/emulated ARM
hosts don't fail the 15s window; the wait loop now exits early if the daemon
dies instead of burning the full timeout.

Verification: shellcheck clean on both scripts; py_compile OK; workflow and
compose YAML parse; template XML parses; arch-selection logic replicated and
tested for amd64/arm64/empty-TARGETARCH and cross-checked against the v7.0.2
release notes; both asset URLs return 200; gosu-arm64 checksum line present.
Not verified: docker build/run (no Docker in this environment) — CI smoke is
the real gate.
CI caught this on PR #28: both smoke jobs failed with
"simplex-chat exited before opening port 5225" on a container that was
starting perfectly normally.

Root cause, confirmed by runner timestamps: the check fired 0.029ms after
`PID: 40` was logged — the loop tests the port, not the PID, so the very
first iteration ran the liveness test before the Haskell daemon had any
opportunity to bind. `$!` is the PID of the `gosu` wrapper, which is not a
reliable proxy for the daemon's liveness across the
gosu -> sh -> simplex-chat exec chain, so `kill -0` can report "dead" while
the daemon is perfectly healthy.

The original design — gate on the port, poll until the timeout — was
correct and is restored. Only the timeout value and the log tail changed.
A comment records why the early-exit must not be reintroduced.

Also set `-o pipefail` on the two multi-arch RUN blocks (hadolint DL4006),
which the per-arch checksum pipeline needs anyway: without it a failure in
`grep` would be masked by the downstream `sed`.
Second CI failure on PR #28: both smoke jobs failed to build with
"/bin/sh: 1: set: Illegal option -o pipefail".

Root cause: Ubuntu's /bin/sh is dash, which has no `set -o pipefail`. The
pipefail I added to silence hadolint DL4006 broke the build outright. The
original `set -eux` was correct; hadolint's suggestion is wrong for this
base image.

Rather than leave DL4006 unsatisfied, removed the pipe entirely, which is
strictly stronger than pipefail would have been. Verified under real dash:

  old: `grep ... | sed ... > f`  with NO match -> exits 0, f is 0 bytes,
       so `sha256sum -c f` "verifies" nothing and the build proceeds
  new: `grep ... > f; test -s f`  with NO match -> exits 1, build aborts

The gosu checksum step now greps to a file, asserts it is non-empty, then
sed-rewrites the path. DL4006 is waived in .hadolint.yaml with the reason
recorded so the next person does not re-add pipefail.
@libre-7

libre-7 commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

CI green — arm64 verified end to end

Run 36675923998: lint / smoke (linux/amd64) / smoke (linux/arm64) / build — all success.

The arm64 job is the real acceptance test for F1, and it passed with the right artifacts:

  • fetched simplex-chat-ubuntu-24_04-aarch64
  • verified 2d2e62351f11bc51ae659618584722b38ea5a6796b806c9a388ee6e3124c90ac (the aarch64 digest from the v7.0.2 release notes)
  • fetched gosu-arm64 and verified its checksum
  • container reached healthy under QEMU emulation

Two bugs CI caught that my static checks did not

Worth recording, because both are the kind of thing that reads as correct in review:

1. Unsound daemon early-exit (fe11e3d) — I added a kill -0 $DAEMON_PID check to the startup wait so it would fail fast if the daemon died. Runner timestamps show it fired 0.029ms after launch: the loop tests the port first, so the very first iteration ran the liveness check before the Haskell binary had any chance to bind. $! is the gosu wrapper's PID and is not a reliable liveness proxy across the gosu -> sh -> simplex-chat exec chain. Reverted to the original port-only gate; the timeout value and log tail are unchanged. A comment records why it must not come back.

2. set -o pipefail broke the build (fdf63bf) — I added it to silence hadolint DL4006. Ubuntu's /bin/sh is dash, which has no pipefail, so the build died with set: Illegal option -o pipefail. hadolint's advice is simply wrong for this base image.

Rather than leave the lint warning unsatisfied, I removed the pipe instead — which is stronger than pipefail. Verified under real dash:

form no checksum match result
old grep | sed > f exit 0, 0-byte file sha256sum -c "verifies" nothing, build proceeds
new grep > f; test -s f exit 1 build aborts

DL4006 is now waived in .hadolint.yaml with the reason recorded.

Both regressions were introduced by me in the first commit and were caught only by the smoke job — which is the strongest argument for keeping that job exactly as strict as it is. Noted in the PR body rather than quietly fixed.

Remaining, unchanged

  • docker-compose.yml still pins the v1.2.0 digest (amd64-only) on purpose; re-pin after the next release.
  • Emulation is not real hardware. If you have an ARM box, a quick docker pull --platform linux/arm64 + start is still worth doing before release — but the asset, checksum, and entrypoint are all proven now.
  • F6 (no GitHub Releases for the six existing tags) is untouched and needs a tag decision.

@libre-7
libre-7 merged commit 7493735 into main Oct 1, 2026
4 checks passed
@libre-7
libre-7 deleted the hardened/audit-2026-09-29 branch October 1, 2026 18:28
libre-7 added a commit that referenced this pull request Oct 1, 2026
Found during post-merge verification of #28 — the merged image did NOT carry
the F5 license fix.

## The bug

The published image had `org.opencontainers.image.licenses = GPL-3.0` even
though the Dockerfile says AGPL-3.0. `docker/metadata-action` infers the
label from the GitHub repo's license and OVERRIDES the Dockerfile LABEL via
its `labels:` input. Every other OCI label survived correctly; only
`licenses` was replaced, because it is the one metadata-action derives
automatically.

So the F5 remediation from #28 was silently reverted in the artifact users
actually pull, while the README and Dockerfile both claimed AGPL-3.0 — the
exact mismatch finding F5 was about, reintroduced by the publishing path.

Fix: declare it explicitly in the metadata-action `labels:` block, which
wins over both the inference and the Dockerfile.

## Also: stop the build job going vacuously green on PRs

Verified from run 36907154998 that on a `pull_request` event every publish
step is skipped:

```
5 skipped   Log in to GHCR
6 success   Build and push to GHCR    <- push:false, published nothing
7 skipped   Log in to Docker Hub
8 skipped   Mirror tags to Docker Hub
9 skipped   Verify Docker Hub manifest platforms
```

The job reports success having published nothing, so a green `build` badge on
a PR is not evidence that registry publishing works. A "Report publishing
scope" step now announces this in the run summary, so the signal is honest
about what it did and did not do.

Refs #28
libre-7 added a commit that referenced this pull request Oct 1, 2026
…#29)

Found during post-merge verification of #28 — the merged image did NOT carry
the F5 license fix.

## The bug

The published image had `org.opencontainers.image.licenses = GPL-3.0` even
though the Dockerfile says AGPL-3.0. `docker/metadata-action` infers the
label from the GitHub repo's license and OVERRIDES the Dockerfile LABEL via
its `labels:` input. Every other OCI label survived correctly; only
`licenses` was replaced, because it is the one metadata-action derives
automatically.

So the F5 remediation from #28 was silently reverted in the artifact users
actually pull, while the README and Dockerfile both claimed AGPL-3.0 — the
exact mismatch finding F5 was about, reintroduced by the publishing path.

Fix: declare it explicitly in the metadata-action `labels:` block, which
wins over both the inference and the Dockerfile.

## Also: stop the build job going vacuously green on PRs

Verified from run 36907154998 that on a `pull_request` event every publish
step is skipped:

```
5 skipped   Log in to GHCR
6 success   Build and push to GHCR    <- push:false, published nothing
7 skipped   Log in to Docker Hub
8 skipped   Mirror tags to Docker Hub
9 skipped   Verify Docker Hub manifest platforms
```

The job reports success having published nothing, so a green `build` badge on
a PR is not evidence that registry publishing works. A "Report publishing
scope" step now announces this in the run summary, so the signal is honest
about what it did and did not do.

Refs #28
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.

1 participant