diff --git a/.dockerignore b/.dockerignore index 0c2556d..7252d96 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,8 +24,9 @@ Thumbs.db # Local test data data/ -# Docs, CI config, and assets — not needed to build the image +# Docs, CI config, tests, and assets — not needed to build the image README.md +SECURITY.md LICENSE icons/ templates/ @@ -33,3 +34,4 @@ ca_profile.xml docker-compose.yml .hadolint.yaml install-websockets.sh +tests/ diff --git a/.github/workflows/base-refresh.yml b/.github/workflows/base-refresh.yml index 67fe999..6b49a75 100644 --- a/.github/workflows/base-refresh.yml +++ b/.github/workflows/base-refresh.yml @@ -23,14 +23,31 @@ jobs: - name: Resolve current ubuntu:24.04 digest id: digest run: | - CURRENT=$(skopeo inspect docker://ubuntu:24.04 --format '{{.Digest}}') + set -euo pipefail + # Use buildx, which is preinstalled on ubuntu-latest. The previous + # `skopeo inspect` had no install step and skopeo is NOT on the + # runner, so this step failed and left `digest` empty — which then + # either errored the comparison or produced a PR with a blank + # "new" digest. `set -e` makes any future failure loud instead of + # silently yielding an empty variable. + CURRENT=$(docker buildx imagetools inspect ubuntu:24.04 \ + --format '{{.Manifest.Digest}}') + if [ -z "$CURRENT" ]; then + echo "::error::could not resolve ubuntu:24.04 digest (empty result)" + exit 1 + fi echo "digest=$CURRENT" >> "$GITHUB_OUTPUT" echo "Upstream ubuntu:24.04 digest: $CURRENT" - name: Extract pinned digest from Dockerfile id: pinned run: | + set -euo pipefail PINNED=$(awk 'NR==1 {match($0, /@sha256:[0-9a-f]+/); print substr($0, RSTART+8, RLENGTH-8)}' Dockerfile) + if [ -z "$PINNED" ]; then + echo "::error::could not parse a @sha256: pin from line 1 of the Dockerfile" + exit 1 + fi echo "pinned=$PINNED" >> "$GITHUB_OUTPUT" echo "Pinned digest in Dockerfile: $PINNED" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7e1b7b5..affad26 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -34,7 +34,40 @@ jobs: - name: Lint shell scripts (shellcheck) run: | - shellcheck entrypoint.sh install-websockets.sh + shellcheck entrypoint.sh install-websockets.sh tests/*.sh + + - name: Verify Python sources compile + run: | + python3 -m py_compile healthcheck.py tests/test-setup-userid.py tests/ss_from_proc.py + + - name: Readiness-gate regression tests (F2) + # Creates real TCP listeners and checks port_listening() matches an + # EXACT port. Catches a regression to a substring match, which would + # let an unrelated host service (e.g. :15225) satisfy the startup gate + # under host networking. + run: bash tests/test-port-gate.sh + + - name: Installer interpreter tests (F1) + # install-websockets.sh must install into the gateway's virtualenv and + # verify with that same interpreter. `docker` and `pip` are stubbed, so + # this neither needs Docker nor touches the network. It skips (exit 77) + # on runners with no Hermes install, since the venv-detection path is + # only meaningful inside a Hermes container. + run: | + set +e + bash tests/test-installer-interpreter.sh + rc=$? + if [ "$rc" -eq 77 ]; then + echo "::notice title=Installer interpreter tests skipped::No Hermes interpreter on this runner." + elif [ "$rc" -ne 0 ]; then + exit "$rc" + fi + + - name: First-run setup userId tests (F3) + # Drives the real setup block from entrypoint.sh against a fake + # WebSocket daemon: the /_address_settings userId must come from the + # daemon, and success must be confirmed by corrId correlation. + run: python3 tests/test-setup-userid.py smoke: # Builds the image and actually runs it with the same capability set diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e72853..9f47f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,105 @@ All notable changes to this project are documented here. Versions are image releases; the `vX.Y.Z` git tag builds the same image. +## [Unreleased] + +Remediation of the 2026-10-03 code review. No image behaviour change for +correctly-configured deployments; two of these fixes change failure modes from +"silently wrong" to "loudly wrong". + +### Fixed + +- **`install-websockets.sh` installed `websockets` where the gateway could not + import it.** The script used bare `pip` / `python3` via `docker exec`, but the + gateway runs `/app/venv/bin/python3` in a venv created with + `include-system-site-packages = false`. Packages landed in the system + interpreter (or a user's `~/.local`), which is **not on the venv's + `sys.path`** — so the script installed the dependency, verified it with the + same wrong interpreter, reported success, and left the SimpleX platform + unable to load. It now resolves the gateway interpreter explicitly + (`HERMES_VENV`, default `/app/venv`) and uses it for both the install and the + verification; the install is pinned to the image's `websockets` version + (`WEBSOCKETS_VERSION`, default `17.0.1`) instead of tracking latest; and a + missing venv produces a loud warning rather than a silent fallback. +- **The adapter check silently skipped.** It located the adapter with + `import plugins.platforms.simplex.adapter` under the wrong interpreter, then + fell back to `find /app/venv -path '*/simplex/adapter.py'` — which returns + nothing on a current Hermes layout, where the plugin lives in a source + checkout (`/app/hermes-agent-src`). The script printed "skipping DM send + verification" and exited 0, so the verification added in v1.3.0 never ran. It + now imports with the gateway interpreter and falls back to a filesystem-wide + search, and it says on stderr that an unverified state is not a verified one. +- **Readiness gates matched any port *containing* the number.** Both the + startup gate and the socat gate used `ss -tln | grep -q :5225`, an + unanchored substring test that also matches 15225, 52250, 52251…. Under + `network_mode: host` — which this project requires — `ss` lists the entire + host's listening sockets, so an unrelated service could satisfy the gate and + report the WebSocket API ready while the daemon was dead. A shared + `port_listening()` helper now anchors on an exact port on **both** code paths: + the `sport = :PORT` filter is re-verified with awk rather than trusted via + `grep -q .`, because an `ss` that does not understand the filter prints the + whole socket table and `grep -q .` would match any line. +- **First-run auto-accept targeted a hardcoded id and never checked the + result.** `/_address_settings 1 …` used a literal `1`; the daemon's contract + is `/_address_settings ` (bots/api/COMMANDS.md), and + the id is now read from the `/user` (`activeUser`) response, with a + `userContactLinkCreated` / `usersList` fallback. Success was detected by + searching any event for the substring `userContactLinkUpdated`; it now + correlates the `corrId` the daemon echoes (`Server.hs` wraps every response + as `{corrId, resp}`). When no id can be determined the step is skipped with a + warning rather than applied to a guessed profile. +- **`base-refresh.yml` could not succeed.** The digest step called `skopeo`, + which is not installed on `ubuntu-latest` and had no install step, so the + output could be empty — and with no `set -e` that failure was silent. It now + uses the preinstalled `docker buildx imagetools inspect`, sets + `set -euo pipefail`, and fails loudly on an empty digest instead of opening a + PR with a blank value. +- **README contradicted itself about the installer.** Line 196 still described + the removed `sed -i` adapter patching, fourteen lines below the note saying it + was removed. Rewritten to match, plus a new warning explaining why the + installer targets the gateway venv. +- **Unraid template floated `:latest`** while compose pinned a digest, so + Unraid's Update button could silently jump versions. Now pinned to `v1.3.0` + (a tag rather than a digest: the Unraid template schema does not reliably + round-trip a digest through `Repository`). README install instructions updated + to match. + +### Added + +- **`SECURITY.md`** — private vulnerability-reporting path, plus an explicit + statement of the deployment model: the WebSocket API has **no + authentication**, the default bind is loopback-only, and setting + `SIMPLEX_SOCAT_PORT` removes that protection. +- **Regression tests** (`tests/`, no Docker required) covering the two High and + one Medium code fixes, wired into the `lint` CI job so they cannot rot: + - `test-port-gate.sh` — creates **real** TCP listeners and asserts + `port_listening()` matches an exact port, including the decoy-port case and + the no-`ss` fail-closed case. + - `test-installer-interpreter.sh` — stubs `docker` and `pip` and asserts the + installer uses only absolute venv paths, pins the version, finds an adapter + outside the venv, and **fails** on an unrecognised adapter. + - `test-setup-userid.py` — drives the real setup block from `entrypoint.sh` + against a fake WebSocket daemon, including a wrong-`corrId` decoy that the + old substring match would have accepted as success. + - `check-docs.py` — version/digest pin agreement across README, compose, and + the Unraid template, and that documented env vars exist in code. + - `run-all.sh` — runs every gate. + +### Verified + +- All three behavioural test suites were checked against deliberately + re-introduced versions of each bug (mutation testing) and failed as expected, + then passed again on restore — so they detect the defects they guard. +- `shellcheck` clean across `entrypoint.sh`, `install-websockets.sh`, and + `tests/*.sh`. + +### Not verified + +- No container was built or booted for this batch (no Docker CLI available). + Runtime behaviour of the fixed entrypoint paths — socat bridge startup, + first-run setup against live SMP servers, real-silicon ARM64 — is unproven + here and remains covered only by the existing CI smoke matrix. + ## [1.3.0] — 2026-10-01 Multi-arch release plus remediation of a full code review (2026-09-29). diff --git a/README.md b/README.md index c737e0b..facfd39 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Hermes Agent v0.16.0+ (v2026.6.5+) ships a SimpleX Chat platform plugin. **Herme For older Hermes builds (v0.16.x–v0.19.x), the adapter had a bug ([upstream issue #46265](https://github.com/NousResearch/hermes-agent/issues/46265)): outbound DMs used the CLI shortcut format `@ text`, which the simplex-chat daemon silently rejects over WebSocket — it resolves `@` as a display-name lookup, not a contactId lookup. Replies appeared in the Hermes WebUI but never reached the SimpleX app. -The one-command setup script below installs `websockets` and, **only when the bug is still present** (pre-0.20.0 Hermes), applies the two-line fix to the adapter. On Hermes 0.20.0+ it detects the native fix and skips the patch. +This one-command setup script installs `websockets` and **verifies** the adapter's DM send path already uses the structured `/_send … json` format. It never modifies the adapter: on an unrecognised version it **exits non-zero** and tells you to upgrade Hermes. See [One-command setup](#one-command-setup) below. ### One-command setup @@ -203,10 +203,12 @@ docker exec hermes-webui /app/venv/bin/hermes gateway restart ``` The script: -1. Installs the `websockets` Python package (not bundled in the Hermes image) +1. Installs the `websockets` Python package (not bundled in the Hermes image), **pinned to the same version the bridge image ships** (`17.0.1`; override with `WEBSOCKETS_VERSION=`) 2. Verifies the adapter's DM send path already uses the structured `/_send … json` format, and **exits non-zero** if the adapter is an unrecognised version 3. Verifies the plugin is discoverable +> ⚠️ **The script installs into the gateway's virtualenv, not the system Python.** The Hermes gateway runs `/app/venv/bin/python3`, and that venv is created with `include-system-site-packages = false`. Installing `websockets` with a bare `pip` (or into `~/.local`) puts it somewhere the gateway **cannot import**, so the platform fails to load even though the install appeared to succeed. The script therefore resolves the venv interpreter explicitly (`/app/venv`, override with `HERMES_VENV=`) and uses it for both the install and the verification. If it cannot find that interpreter it says so loudly rather than silently falling back. + > **The script no longer patches the adapter.** Older versions of this script rewrote `adapter.py` in place with `sed -i` to work around [upstream issue #46265](https://github.com/NousResearch/hermes-agent/issues/46265). That patching was removed: it silently no-op'd once the adapter was refactored (so it "succeeded" while doing nothing), a bad edit could raise a `SyntaxError` that breaks the gateway's plugin import, and container rebuilds reverted it anyway. The upstream fix has shipped natively since Hermes 0.20.0 — upgrade rather than patch. **Re-run after every Hermes container update or rebuild** — `websockets` is installed into the ephemeral container image and the auto-detect/patch step re-evaluates the installed adapter. @@ -235,7 +237,7 @@ Or read `/mnt/user/appdata/simplex-bridge/bot_address.txt`. Both containers require host networking — simplex-bridge **and** Hermes Agent must share the loopback interface. -This mirrors the [`docker-compose.yml`](docker-compose.yml) shipped in the repo, pinned to the immutable v1.2.0 release by digest: +This mirrors the [`docker-compose.yml`](docker-compose.yml) shipped in the repo, pinned to the immutable v1.3.0 release by digest: ```yaml services: @@ -278,7 +280,7 @@ volumes: | Key | Value | |-----|-------| | Name | `simplex-bridge` | -| Repository | `ghcr.io/libre-7/simplex-bridge:latest` | +| Repository | `ghcr.io/libre-7/simplex-bridge:v1.3.0` | | Network Type | **Host** | | Post Arguments | (leave blank) | @@ -323,6 +325,23 @@ Port 5225 for the WebSocket API. Host networking is required — no port mapping **Q: Can I run multiple bots?** Not within one container. The WebSocket port is fixed at 5225 — there is no port variable. To run multiple bots, run separate containers, each with its own `/data` volume and its own network namespace (e.g. separate hosts/VMs). If you use the bridge networking mode, distinct published ports via `SIMPLEX_SOCAT_PORT` are possible (use a port other than 5225; see Network Configuration) — but note that socat mode is unauthenticated; see the warnings in Network Configuration. +## Testing + +The regression tests need neither Docker nor network access: + +```bash +bash tests/run-all.sh +``` + +This runs shellcheck, syntax and parse checks, and four suites that cover the +startup readiness gate, the Hermes installer, the first-run setup handshake, and +documentation/pin consistency. `tests/test-installer-interpreter.sh` needs a +Hermes installation to exercise interpreter detection and **skips** (exit 77) +elsewhere; the rest run anywhere. + +They are also wired into the `lint` CI job, so a regression fails the build +rather than waiting for a runtime symptom. + ## Building from Source ```bash diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..41584bc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,64 @@ +# Security Policy + +## Reporting a Vulnerability + +**Please report security issues privately.** Do not open a public GitHub issue +for a vulnerability you have found. + +Use GitHub's private reporting on the Security tab +(**Security → Report a vulnerability**), or email the maintainer via the address +listed on the repository profile. + +Please include: + +- What the issue is, and which component it affects (image, entrypoint, + `healthcheck.py`, `install-websockets.sh`, or CI) +- The image tag or commit you tested (`docker buildx imagetools inspect + ghcr.io/libre-7/simplex-bridge:`) +- Steps to reproduce, and the impact you believe it has +- Any proof-of-concept output you are willing to share + +You can expect an acknowledgement within a few days. Confirmed issues are +fixed in a follow-up release, and the reporter is credited in the CHANGELOG +unless they prefer otherwise. + +## Deployment model and threat surface + +This project ships a **network service with no authentication of its own**. +Understanding that is the most important part of using it safely. + +| Property | Status | +|---|---| +| WebSocket API authentication | **None.** Anyone who can reach the port can read and send as the bot. | +| Default bind | `127.0.0.1:5225` — loopback only. | +| Default exposure | None. Host networking keeps the port on the host's loopback interface. | +| `SIMPLEX_SOCAT_PORT` | **Disables the loopback-only default.** Publishes `0.0.0.0:` with no authentication. | +| Container privileges | `cap_drop: ALL` plus only `CHOWN`, `SETUID`, `SETGID`; `no-new-privileges:true`; non-root runtime via `gosu`. | +| Supply chain | Digest-pinned base image; `simplex-chat` and `gosu` binaries SHA256-verified at build time; CI actions pinned by commit SHA; SBOM and provenance attestations published. | + +### Guidance + +- **Keep the default.** Use host networking and leave `SIMPLEX_SOCAT_PORT` empty. + Both the bridge and Hermes Agent must share loopback for this to work. +- **Never expose the port directly to an untrusted network.** If a remote client + must connect, put an authenticating reverse proxy in front of it. The README's + [Securing the socat port](README.md#securing-the-socat-port) section gives + working nginx basic-auth and firewall-allowlist recipes — and notes that Basic + auth over plain HTTP is base64, not encryption, so terminate TLS in front. +- **Verify what you are pulling.** Prefer a `sha-` tag or a + `@sha256:` digest. The `latest` tag moves with `main`. +- **Understand the bundled components.** The image redistributes `simplex-chat` + and `simplexmq`, both **AGPL-3.0**. Vulnerabilities in those binaries are + upstream's to fix; report them to + [simplex-chat/simplex-chat](https://github.com/simplex-chat/simplex-chat/issues) + as well. See the README's bundled-components table for versions and sources. + +## Out of scope + +- Exposure caused by a user deliberately setting `SIMPLEX_SOCAT_PORT` without a + firewall or authenticating proxy. +- Vulnerabilities in upstream `simplex-chat`, `simplexmq`, or `gosu` — please + report those upstream (though you are welcome to tell us how they affect this + image). +- Weaknesses in SimpleX Chat's own protocol design, which are inherent to the + upstream design rather than to this packaging. diff --git a/entrypoint.sh b/entrypoint.sh index 138b08b..cb6a756 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -36,6 +36,35 @@ else useradd --system --no-log-init -g simplex -u "$PUID" --create-home simplex fi +# ── Port-readiness helper ────────────────────────────────────────── +# Match a LISTEN socket on an EXACT port. Never use a bare `grep -q :5225` +# against `ss` output: that is an unanchored substring test, so it also +# matches 15225, 52250, 52251… Under `network_mode: host` — which this +# project REQUIRES, because the daemon binds 127.0.0.1 and Hermes must +# share that loopback — `ss` lists the entire host's listening sockets, not +# just this container's. An unrelated host service on a port containing +# "5225" would therefore satisfy the startup gate and declare the +# WebSocket API ready while simplex-chat is still starting or already dead. +# +# BOTH branches below anchor on the exact port, and that is deliberate on +# both paths: +# * `sport = :PORT` is a server-side filter, but we still re-check the +# port in awk rather than trusting `grep -q .`. An `ss` that does not +# understand the filter prints the WHOLE socket table and exits 0, so +# `grep -q .` would match any line and report the wrong port as ready — +# the very bug this helper exists to prevent. +# * the no-filter call is the same story with the filter removed. +# Field 4 is Local Address:Port, so requiring the port to end the field is +# an exact match that tolerates any address form (IPv4, IPv6, wildcard). +port_listening() { + local port="$1" + ss -tlnH "sport = :$port" 2>/dev/null | + awk -v p=":$port" '$4 ~ p"$" { found=1 } END { exit !found }' && + return 0 + ss -tlnH 2>/dev/null | + awk -v p=":$port" '$4 ~ p"$" { found=1 } END { exit !found }' +} + # ── Graceful shutdown handler ────────────────────────────────────── shutdown() { local signal=$1 @@ -148,7 +177,7 @@ if [ "$STARTUP_TIMEOUT" -lt 1 ]; then fi for i in $(seq 1 "$STARTUP_TIMEOUT"); do - if ss -tln 2>/dev/null | grep -q :5225; then + if port_listening 5225; then echo "[entrypoint] WebSocket API ready on port 5225" break fi @@ -182,11 +211,17 @@ import websockets async def setup(): async with websockets.connect('ws://127.0.0.1:5225', open_timeout=10) as ws: + # Ask for the active user first: /user returns activeUser with the + # full User object, whose userId is what /_address_settings takes as + # its first argument (bots/api/COMMANDS.md: + # `/_address_settings `). Hardcoding an id + # here silently targets the wrong profile whenever it isn't 1. await ws.send(json.dumps({'corrId': 's1', 'cmd': '/user'})) await asyncio.sleep(1) await ws.send(json.dumps({'corrId': 's2', 'cmd': '/ad'})) await asyncio.sleep(2) address = None + user_id = None for _ in range(10): try: evt = await asyncio.wait_for(ws.recv(), timeout=1) @@ -194,19 +229,57 @@ async def setup(): break data = json.loads(evt) resp = data.get('resp', {}) - if resp.get('type') == 'userContactLinkCreated': + rtype = resp.get('type') + if rtype == 'activeUser': + # activeUser carries the profile; prefer the active profile's + # id, and only fall back to a single-user profile. + user = resp.get('user', {}) + if user.get('activeUser'): + user_id = user.get('userId') + elif user_id is None: + user_id = user.get('userId') + elif rtype == 'usersList': + users = resp.get('users', []) + active = [u for u in users if u.get('activeUser')] + if active and user_id is None: + user_id = active[0].get('userId') + elif rtype == 'userContactLinkCreated': link = resp.get('connLinkContact', {}) - address = link.get('connFullLink', link.get('connShortLink', '')) + address = link.get('connFullLink') or link.get('connShortLink') + if user_id is None: + # The creation event also carries the user. + user_id = (resp.get('user') or {}).get('userId') if os.environ.get('SIMPLEX_AUTO_ACCEPT', 'true') == 'true': - settings = json.dumps({'businessAddress': False, 'autoAccept': {'acceptIncognito': False}}) - await ws.send(json.dumps({'corrId': 's3', 'cmd': f'/_address_settings 1 {settings}'})) - await asyncio.sleep(1) - try: - evt = await asyncio.wait_for(ws.recv(), timeout=2) - if 'userContactLinkUpdated' in evt: + if user_id is None: + # Do NOT guess an id: applying settings to the wrong profile + # is worse than leaving auto-accept off, and it fails silently. + print('[setup] WARNING: could not determine userId — auto-accept NOT configured') + print('[setup] (accept contact requests manually in the SimpleX app)') + else: + settings = json.dumps({'businessAddress': False, + 'autoAccept': {'acceptIncognito': False}}) + await ws.send(json.dumps({'corrId': 's3', + 'cmd': f'/_address_settings {user_id} {settings}'})) + await asyncio.sleep(1) + # Correlate on corrId: the daemon echoes it (Server.hs wraps + # every response as {corrId, resp}). Matching on the substring + # 'userContactLinkUpdated' alone would accept an unrelated + # event and report success for a command that never applied. + confirmed = False + try: + evt = await asyncio.wait_for(ws.recv(), timeout=3) + r = json.loads(evt) + if r.get('corrId') == 's3' and r.get('resp', {}).get('type') == 'userContactLinkUpdated': + confirmed = True + except asyncio.TimeoutError: + pass + except json.JSONDecodeError: + pass + if confirmed: print('[setup] Auto-accept enabled') - except asyncio.TimeoutError: - pass + else: + print('[setup] WARNING: /_address_settings was not confirmed for ' + f'userId {user_id} — auto-accept may not be active') if address: print(f'[setup] Bot address: {address[:80]}...') with open('/data/bot_address.txt', 'w') as f: @@ -264,7 +337,7 @@ if [ -n "$SIMPLEX_SOCAT_PORT" ]; then # Confirm the listener actually came up; otherwise the bridge is dead # and only a manual `ss -tln | grep $SIMPLEX_SOCAT_PORT` would reveal it. for i in $(seq 1 10); do - if ss -tln 2>/dev/null | grep -q ":$SIMPLEX_SOCAT_PORT"; then + if port_listening "$SIMPLEX_SOCAT_PORT"; then echo "[entrypoint] socat bridge listening on 0.0.0.0:$SIMPLEX_SOCAT_PORT" break fi diff --git a/install-websockets.sh b/install-websockets.sh index 19af3fa..8b04bb5 100644 --- a/install-websockets.sh +++ b/install-websockets.sh @@ -31,44 +31,96 @@ set -e C="${1:-hermes-webui}" +# ── Resolve the interpreter that actually runs the gateway ────────── +# The gateway is `/app/venv/bin/hermes`, so it runs under /app/venv's +# Python, NOT whatever `python3` resolves to on PATH. The venv is created +# with `include-system-site-packages = false`, so packages installed into +# the system interpreter (/usr/local/lib/python3.12/site-packages) or into +# a user's ~/.local tree are INVISIBLE to it. Installing via bare `pip` +# and then verifying via bare `python3` therefore "succeeds" while the +# gateway is still missing the dependency — the exact silent-failure this +# script exists to avoid. Resolve the venv explicitly and use it for both +# the install and the verification. +# +# If the venv is missing (a non-standard Hermes install), fall back to +# bare `python3` but say so loudly, so the result is never mistaken for a +# verified one. +GATEWAY_VENV="${HERMES_VENV:-/app/venv}" +if docker exec "$C" test -x "$GATEWAY_VENV/bin/python3" 2>/dev/null; then + PY="$GATEWAY_VENV/bin/python3" +else + PY="python3" + echo "⚠ $GATEWAY_VENV/bin/python3 not found in '$C' — falling back to bare 'python3'." >&2 + echo " If the gateway runs from a virtualenv, websockets may have been" >&2 + echo " installed somewhere it cannot import. Set HERMES_VENV to override." >&2 +fi + echo "=== Installing websockets for SimpleX Chat on container: $C ===" +echo "=== Target interpreter: $PY ===" echo "" # 1. Install websockets (universal — needed by any WebSocket client) echo "[1/3] Installing websockets..." +# Pin to the same version the bridge image ships so the client library +# the bot uses matches the one its own healthcheck/setup code was tested +# against. Override with WEBSOCKETS_VERSION= to track a different pin. +WEBSOCKETS_VERSION="${WEBSOCKETS_VERSION:-17.0.1}" +# Prefer the venv's own pip so the install lands on the gateway's path. +PIP="" +if [ "$PY" != "python3" ]; then + if docker exec "$C" test -x "$(dirname "$PY")/pip" 2>/dev/null; then + PIP="$(dirname "$PY")/pip" + fi +fi +[ -n "$PIP" ] || PIP="pip" + # Images that mark Python externally-managed (PEP 668) reject a plain # `pip install`; the Hermes image is one of them. Try the normal form # first, then --break-system-packages, and fail loudly if both fail # rather than dying silently under `set -e`. -if ! docker exec "$C" pip install -q websockets 2>/dev/null; then - if ! docker exec "$C" pip install -q --break-system-packages websockets 2>/dev/null; then +if ! docker exec "$C" "$PIP" install -q "websockets==$WEBSOCKETS_VERSION" 2>/dev/null; then + if ! docker exec "$C" "$PIP" install -q --break-system-packages "websockets==$WEBSOCKETS_VERSION" 2>/dev/null; then # Show the real error now that we've exhausted the fallbacks - docker exec "$C" pip install --break-system-packages websockets || { + docker exec "$C" "$PIP" install --break-system-packages "websockets==$WEBSOCKETS_VERSION" || { echo " ✗ Failed to install websockets in container '$C'." >&2 - echo " Check the container is running and pip is available: docker exec $C which pip" >&2 + echo " Check the container is running and pip is available: docker exec $C which $PIP" >&2 exit 1 } fi fi -docker exec "$C" python3 -c \ - "import websockets; print(' → websockets', websockets.__version__)" 2>/dev/null +# Verify with the SAME interpreter the gateway uses. Verifying with a +# different one is what made this script report success over a broken +# gateway — so this check is load-bearing, not cosmetic. +if ! docker exec "$C" "$PY" -c \ + "import websockets; print(' → websockets', websockets.__version__, '->', websockets.__file__)" 2>/dev/null; then + echo " ✗ websockets installed but NOT importable by $PY." >&2 + echo " The gateway will fail to load the SimpleX platform." >&2 + exit 1 +fi # 2. Check if this is a Hermes Agent container echo "[2/3] Checking for Hermes Agent..." IS_HERMES=false -docker exec "$C" python3 -c "import hermes_cli" 2>/dev/null && IS_HERMES=true +docker exec "$C" "$PY" -c "import hermes_cli" 2>/dev/null && IS_HERMES=true if [ "$IS_HERMES" = true ]; then echo " → Hermes Agent detected" - # Locate the adapter - ADAPTER=$(docker exec "$C" python3 -c " + # Locate the adapter — with the gateway interpreter, which is the one + # that can import it. The plugin may live in the venv's site-packages + # OR in an editable/source checkout (hermes installs itself as an + # editable package pointing at a source tree), so the find fallback + # deliberately searches more than just the venv. + ADAPTER=$(docker exec "$C" "$PY" -c " import plugins.platforms.simplex.adapter as m print(m.__file__) -" 2>/dev/null) || ADAPTER=$(docker exec "$C" find /app/venv -path "*/simplex/adapter.py" -type f 2>/dev/null | head -1) +" 2>/dev/null) || ADAPTER=$(docker exec "$C" sh -c ' +find / -name adapter.py -path "*simplex*" -type f 2>/dev/null | head -1 +' 2>/dev/null) if [ -n "$ADAPTER" ]; then + echo " → adapter: $ADAPTER" # Read-only structural check. Two shapes are known-good: # (a) current adapter: a `_send_cmd()` helper returning # f"/_send {target} json {json.dumps(items)}"; @@ -88,12 +140,15 @@ print(m.__file__) exit 1 fi else - echo " ⚠ Simplex adapter not found — skipping DM send verification" + echo " ⚠ Simplex adapter not found — skipping DM send verification" >&2 + # Not fatal (a bot may be installed without the plugin), but it is + # NOT a verified state either — say so, and say it on stderr. + echo " (verification skipped: this is not a confirmed-good adapter)" >&2 fi # 3. Verify plugin is discoverable echo "[3/3] Verifying plugin..." - if ! docker exec "$C" python3 -c " + if ! docker exec "$C" "$PY" -c " from hermes_cli.gateway import _all_platforms simplex = [p for p in _all_platforms() if p['key'] == 'simplex'] if simplex: diff --git a/templates/simplex-bridge.xml b/templates/simplex-bridge.xml index e8bdbca..08384e1 100644 --- a/templates/simplex-bridge.xml +++ b/templates/simplex-bridge.xml @@ -1,7 +1,17 @@ simplex-bridge - ghcr.io/libre-7/simplex-bridge:latest + + ghcr.io/libre-7/simplex-bridge:v1.3.0 https://ghcr.io host @@ -76,6 +86,11 @@ 3 +### 2026.10.03 +- Template now pins v1.3.0 instead of floating :latest, so Update cannot silently jump versions. Bump the Repository tag when upgrading. +- Startup readiness checks now match the exact port. Previously a service on a port merely CONTAINING 5225 (e.g. 15225) could satisfy the check and report the API ready while the daemon was dead — reachable under host networking, where the container sees the whole host's listening sockets. +- First-run auto-accept now targets the real userId read from the daemon, and confirms the change by correlating the response id instead of matching an event substring. It warns rather than reporting success when the id cannot be determined or the update is not confirmed. + ### 2026.09.29 - Multi-arch: image now publishes linux/amd64 AND linux/arm64 (upstream simplex-chat ships an aarch64 binary; the Dockerfile picks the per-arch asset and verifies its SHA256). - Added SIMPLEX_STARTUP_TIMEOUT (default 15s) for slow or emulated ARM hosts. diff --git a/tests/check-docs.py b/tests/check-docs.py new file mode 100644 index 0000000..9862de5 --- /dev/null +++ b/tests/check-docs.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Documentation-consistency checks. + +Catches the class of drift this repo has actually suffered: a README that +describes behaviour the code no longer has, and version/digest pins that +disagree between the README, compose file, and Unraid template. +""" +import re +import sys + +BAD = [] + + +def check(cond, msg): + if cond: + print(f" ✓ {msg}") + else: + print(f" ✗ {msg}") + BAD.append(msg) + + +readme = open("README.md").read() +compose = open("docker-compose.yml").read() +template = open("templates/simplex-bridge.xml").read() +installer = open("install-websockets.sh").read() + +# 1. The installer does not patch; the README must not say it does. +check("applies the two-line fix to the adapter" not in readme, + "README does not claim the installer patches the adapter") +check("no longer patches the adapter" in readme, + "README states the installer is read-only") +# Strip comments before looking for an actual sed -i edit — the file +# deliberately mentions `sed -i` in prose explaining why it was removed. +installer_code = "\n".join( + ln for ln in installer.splitlines() if not ln.lstrip().startswith("#")) +check("sed -i" not in installer_code, + "installer executes no sed -i edit (only mentions it in comments)") +check(not re.search(r">\s*\S*adapter\.py", installer_code), + "installer never redirects into adapter.py") + +# 2. Version pins must agree everywhere they appear. +m = re.search(r"pinned to the immutable (v[\d.]+) release by digest", readme) +check(m is not None, "README names the pinned release version") +if m: + ver = m.group(1) + check(f"# {ver}" in compose, + f"compose pin comment agrees with README ({ver})") + check(ver in template, + f"Unraid template pins the same version ({ver})") + +# 3. The digest in compose and in the README's embedded compose block. +digests = set(re.findall(r"sha256:([0-9a-f]{64})", readme)) | \ + set(re.findall(r"sha256:([0-9a-f]{64})", compose)) +check(len(digests) == 1, + f"one digest used across README and compose (found {len(digests)})") + +# 4. The Unraid template's must not float :latest. Match the +# element itself, not the prose around it. +repo = re.search(r"([^<]+)", template) +check(repo is not None, "Unraid template has a element") +if repo: + check(":latest" not in repo.group(1), + f"Unraid pins a version, not :latest ({repo.group(1)})") + +# 5. Documented env vars must exist in the Dockerfile/entrypoint. +doc_vars = set(re.findall(r"\|\s*`(SIMPLEX_[A-Z_]+|PUID|PGID|TZ)`\s*\|", readme)) +combined = open("Dockerfile").read() + open("entrypoint.sh").read() +missing = [v for v in sorted(doc_vars) if v not in combined] +check(not missing, + f"every documented env var is referenced in code (missing: {missing or 'none'})") + +# 6. SECURITY.md must exist now that we claim a reporting path. +try: + sec = open("SECURITY.md").read() + check("Report a vulnerability" in sec or "reporting" in sec.lower(), + "SECURITY.md documents a reporting path") +except FileNotFoundError: + check(False, "SECURITY.md exists") + +print() +if BAD: + print(f"=== {len(BAD)} doc check(s) failed ===") + sys.exit(1) +print("=== all doc checks passed ===") diff --git a/tests/run-all.sh b/tests/run-all.sh new file mode 100755 index 0000000..61b6c05 --- /dev/null +++ b/tests/run-all.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Run every static gate and regression test for this repo. +# No Docker required. Exits non-zero if anything fails. +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 2 + +FAIL=0 +step() { printf '\n\033[1m=== %s ===\033[0m\n' "$1"; } +verdict() { if [ "$1" -eq 0 ]; then echo " ✓ $2"; else echo " ✗ $2"; FAIL=1; fi; } + +step "shellcheck" +shellcheck entrypoint.sh install-websockets.sh tests/*.sh +verdict $? "shell scripts clean" + +step "bash syntax" +for f in entrypoint.sh install-websockets.sh tests/*.sh; do bash -n "$f" || exit 1; done +verdict $? "shell syntax OK" + +step "python compile" +python3 -m py_compile healthcheck.py tests/test-setup-userid.py tests/ss_from_proc.py +verdict $? "python sources compile" + +step "yaml / xml parse" +python3 - <<'PY' +import yaml, xml.dom.minidom as md +for f in ["docker-compose.yml", ".github/workflows/docker-publish.yml", + ".github/workflows/base-refresh.yml", ".hadolint.yaml"]: + yaml.safe_load(open(f)); print(" OK", f) +for f in ["templates/simplex-bridge.xml", "ca_profile.xml"]: + md.parse(f); print(" OK", f) +PY +verdict $? "structured files parse" + +step "no unanchored port grep in the entrypoint" +if grep -nE 'ss -tln[^H]*.*grep -q "?:?\$?[A-Za-z_]*port' entrypoint.sh; then + echo " ✗ found an unanchored port match"; FAIL=1 +else + echo " ✓ no substring port matching"; verdict 0 "" +fi + +step "F2 — readiness gate matches an exact port" +bash tests/test-port-gate.sh +verdict $? "port gate regression tests" + +step "F1 — installer targets the gateway interpreter" +bash tests/test-installer-interpreter.sh +rc=$? +if [ "$rc" -eq 77 ]; then echo " ~ skipped (no Hermes interpreter on this host)"; verdict 0 "" +else verdict "$rc" "installer interpreter tests"; fi + +step "F3 — first-run setup uses the real userId" +python3 tests/test-setup-userid.py +verdict $? "setup userId tests" + +step "docs consistency" +python3 tests/check-docs.py +verdict $? "README matches reality" + +printf '\n' +if [ "$FAIL" -eq 0 ]; then + printf '\033[32mALL GATES PASSED\033[0m\n' +else + printf '\033[31mSOME GATES FAILED\033[0m\n' +fi +exit "$FAIL" diff --git a/tests/ss_from_proc.py b/tests/ss_from_proc.py new file mode 100755 index 0000000..566b5b7 --- /dev/null +++ b/tests/ss_from_proc.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Create real TCP listeners, then emit `ss -tlnH`-format output from +/proc/net/tcp + /proc/net/tcp6. + +Real socket state (so the match logic is tested against reality), with only +the text formatting reconstructed to match iproute2's documented +`ss -tlnH` column layout: + + LISTEN 0 4096 127.0.0.1:5225 0.0.0.0:* + +Field 4 is Local Address:Port — which is what entrypoint.sh's awk anchors on. +""" +import os +import socket +import subprocess +import sys +import threading +import time + +def _ports(spec): + if not spec: + return [] + return [int(p) for p in spec.split(",") if p.strip()] + +LISTEN_PORTS = _ports(sys.argv[1]) if len(sys.argv) > 1 else [] +FAKE_PORTS = _ports(sys.argv[2]) if len(sys.argv) > 2 else [] +socks = [] + +for port in LISTEN_PORTS: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("0.0.0.0", port)) + s.listen(8) + socks.append(s) + except OSError as e: + # Port already occupied on this host (e.g. 5225 may be in use) — + # that is itself the decoy scenario, so report and continue. + print(f"# could not bind {port}: {e.strerror}", file=sys.stderr) + +def hold(): + while True: + time.sleep(3600) + +for s in socks: + threading.Thread(target=hold, daemon=True).start() + +time.sleep(float(os.environ.get("SS_HOLD", "0.4"))) + + +def hexport(p): + return f"{p:04X}" + + +def parse(path, family): + out = [] + try: + lines = open(path).read().splitlines()[1:] + except OSError: + return out + for ln in lines: + f = ln.split() + if len(f) < 4: + continue + local, state = f[1], f[3] + if state != "0A": # 0A = TCP_LISTEN + continue + addr, _, port = local.partition(":") + port = int(port, 16) + if family == "v4": + ip = ".".join(str(int(addr[i:i+2], 16)) for i in (6, 4, 2, 0)) + else: + b = [addr[i:i+4] for i in range(0, 32, 4)] + b = [x for grp in b for x in (int(grp[2:4], 16), int(grp[0:2], 16))] + ip = "[" + ":".join(f"{x:x}" for x in b) + "]" + out.append((ip, port)) + return out + +rows = parse("/proc/net/tcp", "v4") + parse("/proc/net/tcp6", "v6") +rows.sort(key=lambda r: r[1]) +have = {p for _, p in rows} +# Synthesise lines for ports we could not bind (already occupied on this +# host) so the "daemon IS listening" case can still be exercised. Only the +# presence of a LISTEN row matters to the match logic under test. +for p in FAKE_PORTS: + if p not in have: + rows.append(("127.0.0.1", p)) +rows.sort(key=lambda r: r[1]) +for ip, port in rows: + # Mirror iproute2 spacing closely enough that field 4 is always + # ":" — that is the only column the logic depends on. + local = f"{ip}:{port}" + print("LISTEN 0 4096 " + local.ljust(36) + "0.0.0.0:*") diff --git a/tests/test-installer-interpreter.sh b/tests/test-installer-interpreter.sh new file mode 100755 index 0000000..390eec5 --- /dev/null +++ b/tests/test-installer-interpreter.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# F1 verification — proves install-websockets.sh targets the interpreter that +# actually runs the gateway, pins the version, locates the adapter outside the +# venv, and that its verification CAN fail. +# +# `docker` is stubbed to exec commands locally, so the real script body runs. +# `pip` is stubbed, so nothing touches the network or mutates this container. +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$HERE/../install-websockets.sh" +STUB="$(mktemp -d)"; WORK="$(mktemp -d)" +trap 'rm -rf "$STUB" "$WORK"' EXIT + +PASS=0; FAIL=0 +ck() { if [ "$2" = "$3" ]; then echo " ✓ $1 ($2)"; PASS=$((PASS+1)); + else echo " ✗ $1 (got '$2', want '$3')"; FAIL=$((FAIL+1)); fi; } + +# ── docker stub: drop "exec ", run the rest locally ──────── +# Two traces: PIP_TRACE records the arguments the SCRIPT passed to pip (the +# thing under test). STUB_TRACE records docker invocations. Keeping them +# separate stops the pip stub's own logging from looking like a script bug. +cat > "$STUB/docker" <<'STUBEOF' +#!/bin/bash +[ "$1" = "exec" ] || { echo "stub: only 'exec' supported" >&2; exit 2; } +shift 2 +[ -n "${STUB_TRACE:-}" ] && printf '%s\n' "$*" >> "$STUB_TRACE" +exec "$@" +STUBEOF +chmod +x "$STUB/docker" +export PATH="$STUB:$PATH" STUB_TRACE="$WORK/trace" PIP_TRACE="$WORK/piptrace" + +# ── A gateway-shaped venv ─────────────────────────────────────────── +# The interpreter must be a REAL Hermes interpreter so `import hermes_cli` +# succeeds (that is how the script detects Hermes). pip is stubbed. +REAL_PY="" +for cand in /app/venv/bin/python3 "$(command -v python3)"; do + if [ -x "$cand" ] && "$cand" -c "import hermes_cli" 2>/dev/null; then + REAL_PY="$cand"; break + fi +done +if [ -z "$REAL_PY" ]; then + # Exit 77 is the automake convention for "skipped". The test needs a real + # Hermes install to exercise the venv-detection path; on a plain CI runner + # there isn't one, and failing the build for that would be a false alarm. + echo "SKIP: no Hermes-capable interpreter on this host" >&2 + echo " (the venv-detection path can only be tested on a Hermes container)" >&2 + exit 77 +fi +echo " (using interpreter: $REAL_PY)" + +FAKE_VENV="$WORK/venv"; mkdir -p "$FAKE_VENV/bin" +ln -s "$REAL_PY" "$FAKE_VENV/bin/python3" +cat > "$FAKE_VENV/bin/pip" <<'PIPEOF' +#!/bin/bash +printf '%s\n' "$*" >> "${PIP_TRACE:-/dev/null}" +spec=""; brk=0 +for a in "$@"; do + case "$a" in websockets==*) spec="$a" ;; --break-system-packages) brk=1 ;; esac +done +[ -n "$spec" ] || { echo "stub pip: no websockets spec in: $*" >&2; exit 1; } +# Emulate PEP 668 so the script's --break-system-packages fallback is used. +[ "$brk" = "1" ] || { echo "stub pip: externally-managed-environment" >&2; exit 1; } +exit 0 +PIPEOF +chmod +x "$FAKE_VENV/bin/pip" +export HERMES_VENV="$FAKE_VENV" + +# ── A gateway-shaped plugin tree, OUTSIDE the venv ────────────────── +# This is the layout that made the old script skip verification silently: +# `find /app/venv -path '*/simplex/adapter.py'` finds nothing here. +SRC="$WORK/src"; ADIR="$SRC/plugins/platforms/simplex" +GW="$WORK/gwstub" +mkdir -p "$ADIR" "$GW/hermes_cli" +: > "$SRC/plugins/__init__.py"; : > "$SRC/plugins/platforms/__init__.py" +: > "$GW/hermes_cli/__init__.py" +# Minimal gateway registry so step 3 ("is the plugin discoverable?") can pass. +# Without this the check depends on the host's real Hermes install, which is +# not what this test is about. +cat > "$GW/hermes_cli/gateway.py" <<'PYEOF' +def _all_platforms(): + return [{'key': 'simplex', 'label': 'SimpleX Chat'}] +PYEOF +good_adapter() { + cat > "$ADIR/adapter.py" <<'PYEOF' +import json +def _send_cmd(chat_id: str, items: list) -> str: + target = f"#{chat_id[6:]}" if chat_id.startswith("group:") else f"@{chat_id}" + return f"/_send {target} json {json.dumps(items)}" +PYEOF +} +good_adapter +# Gateway stub first on the path so `hermes_cli.gateway` resolves to the +# stub; the plugin tree provides the adapter. +export PYTHONPATH="$GW:$SRC" + +echo "=== A. resolves the gateway venv, not bare python3 ===" +: > "$STUB_TRACE" +bash "$SCRIPT" testcontainer > "$WORK/out" 2>&1; rc=$? +ck "exit status" "$rc" "0" +grep -E "Target interpreter" "$WORK/out" | sed 's/^/ /' +if grep -q "Target interpreter: $FAKE_VENV/bin/python3" "$WORK/out"; then + echo " ✓ used the gateway venv interpreter"; PASS=$((PASS+1)) +else + echo " ✗ did NOT use the gateway venv interpreter"; FAIL=$((FAIL+1)) +fi +# The script must invoke pip/python3 ONLY as absolute venv paths. Any bare +# `pip`/`python3` in the docker trace means it fell back to PATH — the exact +# defect F1 describes. +strays=$(grep -E '(^| )(pip|python3)( |$)' "$STUB_TRACE" 2>/dev/null | grep -v 'test -x' || true) +if [ -z "$strays" ]; then echo " ✓ no bare pip/python3 invocations"; PASS=$((PASS+1)); +else echo " ✗ bare interpreter invocations remain:"; printf '%s\n' "$strays" | sed 's/^/ /'; FAIL=$((FAIL+1)); fi + +# pip must have been called with the version pin (matching the image). +if grep -q 'websockets==17.0.1' "$PIP_TRACE" 2>/dev/null; then + echo " ✓ install pinned to the image version (17.0.1)"; PASS=$((PASS+1)) +else + echo " ✗ install not pinned; pip saw:"; sed 's/^/ /' "$PIP_TRACE" 2>/dev/null; FAIL=$((FAIL+1)) +fi + +# pip must have been reached via the venv, and the PEP 668 fallback used. +if grep -q -- '--break-system-packages' "$PIP_TRACE" 2>/dev/null; then + echo " ✓ PEP 668 fallback exercised"; PASS=$((PASS+1)) +else + echo " • PEP 668 fallback not exercised (pip stub accepted plain install)" +fi + +echo +echo "=== B. adapter found outside the venv; DM check actually runs ===" +grep -E "adapter:|DM send path|not found" "$WORK/out" | sed 's/^/ /' +if grep -q "DM send path uses the structured" "$WORK/out"; then + echo " ✓ verification executed (it did not silently skip)"; PASS=$((PASS+1)) +else + echo " ✗ verification skipped or failed"; FAIL=$((FAIL+1)) +fi + +echo +echo "=== C. verification CAN fail (a check that cannot fail is not a check) ===" +cat > "$ADIR/adapter.py" <<'PYEOF' +# legacy adapter: broken CLI shortcut, no _send_cmd helper +class A: + def send(self, chat_id, content): + return f"@{chat_id} {content}" +PYEOF +bash "$SCRIPT" testcontainer > "$WORK/out2" 2>&1; rc2=$? +grep -E "Unrecognised adapter" "$WORK/out2" | head -1 | sed 's/^/ /' +if [ "$rc2" -ne 0 ]; then + echo " ✓ unrecognised adapter → exit $rc2 (no false 'verified')"; PASS=$((PASS+1)) +else + echo " ✗ exited 0 on a broken adapter — the check is theatre"; FAIL=$((FAIL+1)) +fi +if grep -q "=== Done ===" "$WORK/out2"; then + echo " ✗ still printed 'Done' despite the failure"; FAIL=$((FAIL+1)) +else + echo " ✓ did not print 'Done'"; PASS=$((PASS+1)) +fi +good_adapter + +echo +echo "=== D. missing venv → loud warning, not a silent fallback ===" +rm -rf "$FAKE_VENV" +bash "$SCRIPT" testcontainer > "$WORK/out3" 2>&1 +if grep -q "falling back to bare 'python3'" "$WORK/out3"; then + echo " ✓ warns when the gateway venv is absent"; PASS=$((PASS+1)) +else + echo " ✗ no warning when the venv is missing"; FAIL=$((FAIL+1)) +fi + +echo +echo "=== RESULT: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] diff --git a/tests/test-port-gate.sh b/tests/test-port-gate.sh new file mode 100755 index 0000000..d6b105f --- /dev/null +++ b/tests/test-port-gate.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# F2 verification harness — tests the REAL port_listening() body, extracted +# from entrypoint.sh at runtime (no copy that can drift). +# +# Real TCP listeners are created by ss_from_proc.py, which emits ss -tlnH +# -format output derived from /proc/net/tcp. So the socket state is real; +# only the column formatting is reconstructed. +set -u + +# Resolve repo root from this script's own location so the harness works +# from any cwd and cannot be pointed at a stale copy by accident. +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRYPOINT="$HERE/../entrypoint.sh" +EXTRACT="$(mktemp)" +MOCKDIR="" +cleanup() { rm -rf "$MOCKDIR" "$EXTRACT"; } +trap cleanup EXIT + +# ── Extract the shipped function verbatim ────────────────────────── +awk '/^port_listening\(\) \{/,/^\}/' "$ENTRYPOINT" > "$EXTRACT" +if ! grep -q "sport = :" "$EXTRACT"; then + echo "FATAL: could not extract port_listening() from $ENTRYPOINT" >&2 + echo "extracted:" >&2; cat "$EXTRACT" >&2 + exit 2 +fi +echo "=== extracted from entrypoint.sh ===" +sed 's/^/ /' "$EXTRACT" +echo + +PASS=0; FAIL=0 +check() { + local desc="$1" port="$2" expect="$3" + # shellcheck disable=SC1090 + . "$EXTRACT" + if port_listening "$port"; then actual=0; else actual=1; fi + unset -f port_listening + if [ "$actual" = "$expect" ]; then + echo " ✓ $desc (query=$port exit=$actual)"; PASS=$((PASS+1)) + else + echo " ✗ $desc (query=$port exit=$actual expected=$expect)"; FAIL=$((FAIL+1)) + fi +} + +MOCKDIR=$(mktemp -d); cat > "$MOCKDIR/ss" <<'MOCK' +#!/bin/bash +# Emulate `ss -tlnH [sport = :PORT]`. When SS_MOCK_NAIVE=1 the filter is +# ignored (old iproute2) so entrypoint's awk fallback is exercised. +# +# NOTE: the filter arrives as ONE argument ("sport = :5225"), because the +# caller quotes it. Split on whitespace rather than assuming three argv +# entries — verified against real bash argv semantics. +want="" +for a in "$@"; do + case "$a" in + *"sport = :"*) want="${a##*sport = :}"; break ;; + esac +done +# SS_MOCK_OUTPUT holds the ss-format TEXT itself, not a path to it. +data="${SS_MOCK_OUTPUT}" +if [ -n "$want" ] && [ "${SS_MOCK_NAIVE:-0}" != "1" ]; then + printf '%s\n' "$data" | awk -v p=":$want" '$4 ~ p"$"' +else + printf '%s\n' "$data" +fi +MOCK +chmod +x "$MOCKDIR/ss" +export PATH="$MOCKDIR:$PATH" + +run_ss() { python3 "$HERE/ss_from_proc.py" "$1" "${2:-}" 2>/dev/null; } + +# This host already has a REAL service listening on 127.0.0.1:5225 (the +# user's SimpleX bridge), so /proc-derived output can never be "5225 +# absent" without filtering. Restrict the fixture to the ports each case +# cares about, so the decoy-only scenario is genuinely decoy-only. +# Field 4 is the Local Address:Port column — the same column the fix anchors +# on, so use awk here too (shell ##*: would grab the LAST colon, i.e. "*"). +only_ports() { + printf '%s\n' "$1" | awk -v keep=",$2," ' + NF >= 4 { + local = $4 + sub(/^.*:/, "", local) + if (index(keep, "," local ",")) print + }' +} +set_fixture() { SS_MOCK_OUTPUT=$(only_ports "$(run_ss "$1" "${3:-}")" "$2"); export SS_MOCK_OUTPUT; } + +echo "=== A. daemon bound on 5225 alongside decoy ports ===" +set_fixture 15225,52250 "5225,15225,52250" 5225 +while IFS= read -r l; do echo " $l"; done <<< "$SS_MOCK_OUTPUT" +check "exact 5225 detected" 5225 0 +check "15225 not read as 5225" 5225 0 # sanity: both true +check "query 15225 finds 15225" 15225 0 +check "query 52250 finds 52250" 52250 0 +check "query 5226 finds nothing" 5226 1 +check "query 52251 finds nothing" 52251 1 + +echo +echo "=== B. THE F2 BUG: daemon dead, only decoy ports listening ===" +set_fixture 15225,52250 "15225,52250" +while IFS= read -r l; do echo " $l"; done <<< "$SS_MOCK_OUTPUT" +check "must NOT report 5225 ready" 5225 1 +check "15225 is genuinely listening" 15225 0 +check "52250 is genuinely listening" 52250 0 + +echo +echo "=== C. awk fallback (old iproute2, filter unsupported) ===" +set_fixture 15225,52250 "5225,15225,52250" 5225; export SS_MOCK_NAIVE=1 +check "fallback finds real 5225" 5225 0 +set_fixture 15225,52250 "15225,52250"; export SS_MOCK_NAIVE=1 +check "fallback still rejects decoy-only" 5225 1 +SS_MOCK_OUTPUT=""; export SS_MOCK_OUTPUT SS_MOCK_NAIVE=1 +check "fallback: empty socket table → exit 1" 5225 1 +unset SS_MOCK_NAIVE + +echo +echo "=== D. socat gate (F2 second site) ===" +set_fixture 15226 "5226,15226" 5226 +check "socat 5226 detected" 5226 0 +set_fixture 15226 "15226" +check "decoy 15226 must not satisfy 5226" 5226 1 + +echo +echo "=== E. fail-closed when ss is absent ===" +rm -f "$MOCKDIR/ss" +# shellcheck disable=SC1090 # $EXTRACT is generated at runtime by design +if . "$EXTRACT" 2>/dev/null && port_listening 5225; then + echo " ✗ reported ready with no ss"; FAIL=$((FAIL+1)) +else + echo " ✓ no ss → not ready (fails closed)"; PASS=$((PASS+1)) +fi + +echo +echo "=== RESULT: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] diff --git a/tests/test-setup-userid.py b/tests/test-setup-userid.py new file mode 100755 index 0000000..2847992 --- /dev/null +++ b/tests/test-setup-userid.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""F3 verification — drives the REAL setup block from entrypoint.sh against a +fake WebSocket daemon that speaks simplex-chat's documented envelope. + +The daemon replays scripted responses ({corrId, resp} — per Server.hs) so we +can prove: + * the userId passed to /_address_settings is the REAL one, not a literal 1; + * the confirmation is correlated on corrId, so an unrelated event with the + right substring does NOT count as success; + * when no userId can be determined, auto-accept is skipped with a warning + rather than applied to a guessed profile. + +The setup code is extracted from entrypoint.sh at runtime, so this cannot +drift from what ships. +""" +import asyncio +import json +import os +import re +import subprocess +import sys +import tempfile +import types + +HERE = os.path.dirname(os.path.abspath(__file__)) +ENTRYPOINT = os.path.join(HERE, "..", "entrypoint.sh") + +PASS = [] +FAIL = [] + + +def ck(desc, cond, detail=""): + if cond: + PASS.append(desc) + print(f" ✓ {desc}") + else: + FAIL.append(desc) + print(f" ✗ {desc}" + (f" [{detail}]" if detail else "")) + + +def extract_setup(): + """Pull the python heredoc body out of entrypoint.sh.""" + src = open(ENTRYPOINT).read() + m = re.search(r"<<'PYEOF' > \"\$SETUP_LOG\" 2>&1\n(.*?)\nPYEOF", src, re.S) + if not m: + print("FATAL: could not extract the setup heredoc from entrypoint.sh") + sys.exit(2) + return m.group(1) + + +def run_case(name, script, user_id, expect_confirmed, expect_cmd_id, + auto_accept="true", expect_no_settings=False): + """Run the extracted setup against a fake daemon replaying `script`.""" + received = [] + replies = script["replies"] + reply_idx = [0] + + def fake_ws(url, **kw): + """Stand in for websockets.connect(...). + + websockets.connect returns an awaitable async-context-manager, and the + setup code uses it as `async with websockets.connect(...) as ws`, so the + replacement exposes __aenter__/__aexit__ directly (it is NOT a + coroutine function). + + Two SEPARATE queues model the socket directions: the daemon must read + what the client sent and write replies the client then reads. Sharing + one queue lets each side consume the other's traffic. + """ + class FakeWS: + def __init__(self): + self.to_daemon = asyncio.Queue() # client -> daemon + self.to_client = asyncio.Queue() # daemon -> client + self._task = None + + async def send(self, data): + await self.to_daemon.put(data) + + async def recv(self): + # The client wraps recv() in asyncio.wait_for(...), so this + # must yield; a fake that blocks forever defeats wait_for. + return await asyncio.wait_for(self.to_client.get(), timeout=5) + + async def __aenter__(self): + self._task = asyncio.ensure_future(handler(self)) + return self + + async def __aexit__(self, *exc): + if self._task: + self._task.cancel() + return False + + return FakeWS() + + async def handler(ws): + """Fake daemon: answer each command as the client sends it. + + Replies are pushed in the order scripted, one per received command, so + the client's recv() sees them in the intended sequence. + """ + try: + while True: + raw = await ws.to_daemon.get() + msg = json.loads(raw) + received.append(msg) + if len(replies) > reply_idx[0]: + await ws.to_client.put(json.dumps(replies[reply_idx[0]])) + reply_idx[0] += 1 + except asyncio.CancelledError: + raise + except Exception: + return + + # Neutralise the on-disk write; we only care about the logic. + tmpdata = tempfile.mkdtemp() + src = extract_setup().replace("/data/bot_address.txt", os.path.join(tmpdata, "addr.txt")) + + # Inject a stub module named `websockets` so the extracted setup code's + # `import websockets` succeeds. The real library is NOT required: the code + # under test only calls websockets.connect(...), which is replaced with the + # fake below. Depending on the real package would make this suite fail on + # any runner that doesn't have it installed. + stub_mod = types.ModuleType("websockets") + stub_mod.connect = fake_ws + stub_mod.__version__ = "stub" + prev_mod = sys.modules.get("websockets", "") + sys.modules["websockets"] = stub_mod + + import io + import contextlib + buf = io.StringIO() + code = 0 + g = {"__name__": "__main__", "asyncio": asyncio, "json": json, "os": os, + "sys": sys, "websockets": stub_mod, "fake_ws": fake_ws} + try: + with contextlib.redirect_stdout(buf): + exec(compile(src, "setup", "exec"), g) + except SystemExit as e: + code = e.code or 0 + finally: + if prev_mod == "": + sys.modules.pop("websockets", None) + else: + sys.modules["websockets"] = prev_mod + out = buf.getvalue() + + print(f"\n--- {name} ---") + for line in out.strip().splitlines(): + print(f" {line}") + + cmds = [m["cmd"] for m in received if "cmd" in m] + settings_cmd = next((c for c in cmds if c.startswith("/_address_settings")), None) + if expect_no_settings: + ck(f"{name}: sent NO /_address_settings (no id to target)", + settings_cmd is None, f"cmds={cmds}") + else: + ck(f"{name}: sent /_address_settings", settings_cmd is not None, + f"cmds={cmds}") + if expect_cmd_id is None: + ck(f"{name}: did NOT guess an id", settings_cmd is None, + f"got {settings_cmd!r}") + else: + ck(f"{name}: used real userId {expect_cmd_id}", + settings_cmd is not None and settings_cmd.split()[1] == str(expect_cmd_id), + f"got {settings_cmd!r}") + if expect_confirmed is True: + ck(f"{name}: reported auto-accept enabled", "Auto-accept enabled" in out) + elif expect_confirmed is False: + ck(f"{name}: did NOT falsely claim success", + "Auto-accept enabled" not in out and "WARNING" in out) + return out + + +def main(): + body = extract_setup() + ck("setup block extracted from entrypoint.sh", "activeUser" in body) + ck("no hardcoded '/_address_settings 1 ' remains", + "/_address_settings 1 " not in body) + ck("confirmation correlates on corrId", "corrId') == 's3'" in body) + + link = "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2Ffake" + + # 1. Normal case: userId is 42, and the daemon confirms with corrId s3. + run_case("userId=42 confirmed", { + "replies": [ + {"corrId": "s1", "resp": {"type": "activeUser", + "user": {"userId": 42, "activeUser": True}}}, + {"corrId": "s2", "resp": {"type": "userContactLinkCreated", + "user": {"userId": 42}, + "connLinkContact": {"connFullLink": link}}}, + {"corrId": "s3", "resp": {"type": "userContactLinkUpdated", + "user": {"userId": 42}}}, + ]}, 42, True, 42) + + # 2. The decoy: an unrelated event that merely CONTAINS the substring + # 'userContactLinkUpdated' but has the wrong corrId. The old code + # matched on substring and would have claimed success. + run_case("wrong corrId decoy", { + "replies": [ + {"corrId": "s1", "resp": {"type": "activeUser", + "user": {"userId": 7, "activeUser": True}}}, + {"corrId": "s2", "resp": {"type": "userContactLinkCreated", + "user": {"userId": 7}, + "connLinkContact": {"connFullLink": link}}}, + {"corrId": "s99", "resp": {"type": "userContactLinkUpdated", + "user": {"userId": 7}}}, + ]}, 7, False, 7) + + # 3. No user info at all — must not guess, must warn. + run_case("no userId available", { + "replies": [ + {"corrId": "s1", "resp": {"type": "cmdOk"}}, + {"corrId": "s2", "resp": {"type": "userContactLinkCreated", + "connLinkContact": {"connFullLink": link}}}, + ]}, None, False, None, expect_no_settings=True) + + # 4. userId comes from the creation event when /user is unhelpful. + run_case("userId from creation event", { + "replies": [ + {"corrId": "s1", "resp": {"type": "cmdOk"}}, + {"corrId": "s2", "resp": {"type": "userContactLinkCreated", + "user": {"userId": 1234}, + "connLinkContact": {"connFullLink": link}}}, + {"corrId": "s3", "resp": {"type": "userContactLinkUpdated", + "user": {"userId": 1234}}}, + ]}, 1234, True, 1234) + + # 5. usersList fallback (multiple profiles). + run_case("usersList fallback", { + "replies": [ + {"corrId": "s1", "resp": {"type": "usersList", "users": [ + {"userId": 5, "activeUser": False}, + {"userId": 9, "activeUser": True}]}}, + {"corrId": "s2", "resp": {"type": "userContactLinkCreated", + "user": {"userId": 9}, + "connLinkContact": {"connFullLink": link}}}, + {"corrId": "s3", "resp": {"type": "userContactLinkUpdated", + "user": {"userId": 9}}}, + ]}, 9, True, 9) + + print(f"\n=== RESULT: {len(PASS)} passed, {len(FAIL)} failed ===") + for f in FAIL: + print(f" FAILED: {f}") + sys.exit(1 if FAIL else 0) + + +if __name__ == "__main__": + main()