docs+plugin: HTTP-only transport cleanup, install guide, review fixes
- docs/install.md: new end-to-end guide for non-technical users (gateway install, app install, LAN/TLS/remote connection, push, options, troubleshooting); docs/setup.md now points to it - README: new 'Install the gateway' section; pairing section updated for HTTP transport (8791, QR scan on Android) - rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat fallback); drop dead DEFAULT_PORT=8790 - setup.py: advertise https:// in the printed/QR server URL when IRIS_HTTP_CERT is set - ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme - plugin.yaml: IRIS_HTTP_* env names, description no longer says 'WebSocket server' - docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites, 8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the only transport) - AGENTS.md: symlink name android -> iris (matches actual install) - test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy IRIS_WS_* names are not consulted (95/95 pass)
This commit is contained in:
1 parent
a61b47a947
commit
b1c9bac7d8
18 files changed
+472
-317
No files matched your search
+36
-36
@@ -27,13 +27,13 @@ requires_env:
|
||||
prompt: "Iris pairing token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: IRIS_WS_HOST
|
||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||
prompt: "WS host"
|
||||
- name: IRIS_HTTP_HOST
|
||||
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||
prompt: "HTTP host"
|
||||
password: false
|
||||
- name: IRIS_WS_PORT
|
||||
description: "WS port (default 8790)"
|
||||
prompt: "WS port"
|
||||
- name: IRIS_HTTP_PORT
|
||||
description: "HTTP port (default 8791)"
|
||||
prompt: "HTTP port"
|
||||
password: false
|
||||
- name: IRIS_HOME_CHANNEL
|
||||
description: "Default chat id for cron/notification delivery (default default)"
|
||||
@@ -67,13 +67,13 @@ optional_env:
|
||||
description: "ntfy server URL (default https://ntfy.sh)"
|
||||
prompt: "ntfy server URL"
|
||||
password: false
|
||||
- name: IRIS_WS_CERT
|
||||
description: "TLS cert path for WSS (optional)"
|
||||
prompt: "WSS cert"
|
||||
- name: IRIS_HTTP_CERT
|
||||
description: "TLS cert path for HTTPS (optional)"
|
||||
prompt: "HTTPS cert"
|
||||
password: false
|
||||
- name: IRIS_WS_KEY
|
||||
description: "TLS key path for WSS (optional)"
|
||||
prompt: "WSS key"
|
||||
- name: IRIS_HTTP_KEY
|
||||
description: "TLS key path for HTTPS (optional)"
|
||||
prompt: "HTTPS key"
|
||||
password: false
|
||||
```
|
||||
|
||||
@@ -215,27 +215,26 @@ The adapter tracks per-chat **turn state** (in-turn, current streaming
|
||||
`message` vs `tool.*` vs `commentary`. The exact classification markers are
|
||||
verified empirically in M2 (see `13-testing.md`).
|
||||
|
||||
## 3.4 WebSocket server (`ws_server.py`)
|
||||
## 3.4 HTTP server (`http_server.py`)
|
||||
|
||||
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host,
|
||||
port, ssl=ctx)`.
|
||||
- **Handler** per connection:
|
||||
1. Await first frame; must be `hello {token, device_id, device_name, caps,
|
||||
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
|
||||
`error {code:"auth"}` and close.
|
||||
2. On success: register in connection registry
|
||||
(`device_id → {ws, caps, fcm_token}`), send
|
||||
`hello.ack {server_caps, sync_cursor, channels[]}`.
|
||||
3. Loop: decode frames, dispatch to adapter inbound handlers.
|
||||
4. On close: deregister; if no devices remain, ensure pending outbox
|
||||
frames have push fired.
|
||||
- Library: **stdlib `http.server`** (`ThreadingHTTPServer` +
|
||||
`BaseHTTPRequestHandler`) in a daemon thread; bridges into the gateway's
|
||||
asyncio loop via `asyncio.run_coroutine_threadsafe`. Optional TLS via
|
||||
`ssl.SSLContext` (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`). Full design:
|
||||
`19-http-fallback-transport.md`.
|
||||
- **Auth:** `Authorization: Bearer <token>` (constant-time `verify_token`)
|
||||
- device allowlist via `X-Iris-Device`; `401` on failure.
|
||||
- **Endpoints:** `GET /v1/health` (unauthenticated liveness),
|
||||
`POST /v1/frame` (any JSON frame the protocol accepts),
|
||||
`GET /v1/events?cursor=N` (SSE: outbox catch-up + live frames),
|
||||
`GET /v1/poll?cursor=N` (long-poll fallback), `POST /v1/media` +
|
||||
`GET /v1/media/{id}` (media upload/pull).
|
||||
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
||||
devices (no per-chat subscribe; single-user model). Global frames
|
||||
(`channel.*`, `status`) also broadcast to all.
|
||||
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
|
||||
- **Backpressure:** per-connection send queue with a bounded buffer; drop
|
||||
`message.update` (coalesce to latest) under pressure, never drop
|
||||
`message`/`tool.end`/`notification`.
|
||||
- **Limits:** 64 KiB request body cap, per-device token-bucket rate limit
|
||||
(20/s, burst 40) → `429`; media uploads bounded by the per-upload total
|
||||
cap. No CORS (app clients only).
|
||||
|
||||
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
|
||||
|
||||
@@ -253,18 +252,19 @@ verified empirically in M2 (see `13-testing.md`).
|
||||
## 3.6 Config resolution
|
||||
|
||||
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
|
||||
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
||||
`IRIS_FCM_SERVER_KEY`, `IRIS_HTTP_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
||||
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
|
||||
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
||||
`max_upload_bytes`, `tls`.
|
||||
`http_port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
||||
`max_upload_bytes`, `http_cert`/`http_key`.
|
||||
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
||||
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
||||
so multiplexed profiles don't leak each other's tokens.
|
||||
|
||||
## 3.7 Failure & lifecycle safety
|
||||
|
||||
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
|
||||
- All outbound sends are best-effort; a dead socket latches and the frame falls
|
||||
to the outbox.
|
||||
- `disconnect()` cancels the server task and closes sockets cleanly.
|
||||
- HTTP server bind failure → non-fatal: log a warning, disable the HTTP leg,
|
||||
show it in the inspector (the plugin keeps working for other platforms).
|
||||
- All outbound sends are best-effort; a dead stream latches and the frame
|
||||
falls to the outbox.
|
||||
- `disconnect()` stops the HTTP server and closes streams cleanly.
|
||||
- Token/PII redaction in all logs (hermes PII policy).
|
||||
+15
-15
@@ -87,9 +87,9 @@ security principal (the token is).
|
||||
|
||||
## 9.4 Transport security
|
||||
|
||||
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
|
||||
- **Default (LAN/dev):** plain `http://` on the trusted LAN. Fine for a home
|
||||
network.
|
||||
- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY`
|
||||
- **HTTPS (recommended for remote):** set `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`
|
||||
(self-signed or CA-signed). CA-signed certs work out of the box.
|
||||
For a **self-signed** cert the app shows its SHA-256 fingerprint on first
|
||||
pair (like a SSH host key); once the user confirms it, the fingerprint is
|
||||
@@ -105,14 +105,14 @@ security principal (the token is).
|
||||
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
|
||||
app connects over the private mesh. No public exposure.
|
||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
||||
at the edge, forward WS to `127.0.0.1:8790`.
|
||||
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
||||
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
|
||||
over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's
|
||||
fallback transport. It is a *second door with the same lock*: the same
|
||||
Bearer token (constant-time `verify_token`) + the same device allowlist
|
||||
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
|
||||
the WS. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
||||
at the edge, forward to `127.0.0.1:8791`.
|
||||
- **Public bind** (`0.0.0.0`) + HTTPS + strong token — last resort.
|
||||
- **HTTP transport (docs/19):** the gateway serves the same frames over plain
|
||||
HTTP (`IRIS_HTTP_PORT`, default 8791) — the only device-facing transport.
|
||||
It shares the same lock as everything else: the same Bearer token
|
||||
(constant-time `verify_token`) + the same device allowlist
|
||||
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit.
|
||||
Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
||||
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
||||
not reflect tokens, device ids, or versions).
|
||||
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
||||
@@ -159,14 +159,14 @@ M7 research pass. "verified" = implemented and covered by
|
||||
| # | Item | Status | Evidence / mitigation |
|
||||
| --- | ------ | -------- | ----------------------- |
|
||||
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
|
||||
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) |
|
||||
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
|
||||
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`http_server.py`, `broadcast`/`send_to`). Inbound: per-device token bucket on JSON frames (20/s, burst 40) → `429` on exceed (`http_server.py`, `_rate_limited`); media uploads bounded by the per-request body cap + per-upload total cap (see gap 1) |
|
||||
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | Per-request body cap in `http_server.py`; per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
|
||||
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
|
||||
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
|
||||
| 6 | WSS + cert pinning for remote | implemented | WSS supported server-side (`IRIS_WS_CERT`/`IRIS_WS_KEY`, `ws_server.py:122`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `ws://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
|
||||
| 6 | HTTPS + cert pinning for remote | implemented | TLS supported server-side (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`, `http_server.py`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `http://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
|
||||
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
|
||||
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_WS_CERT`/`IRIS_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
||||
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap |
|
||||
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
||||
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: per-device token bucket in `http_server.py` (`_rate_limited`). Media uploads are bounded by the per-request body cap + the per-upload total cap |
|
||||
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
|
||||
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
|
||||
| 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
|
||||
|
||||
+14
-15
@@ -101,8 +101,8 @@ IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
||||
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
||||
# NTFY_TOPIC=iris-push # when ntfy
|
||||
# NTFY_SERVER_URL=https://ntfy.sh
|
||||
# IRIS_WS_CERT=/path/cert.pem # WSS
|
||||
# IRIS_WS_KEY=/path/key.pem
|
||||
# IRIS_HTTP_CERT=/path/cert.pem # HTTPS
|
||||
# IRIS_HTTP_KEY=/path/key.pem
|
||||
```
|
||||
|
||||
**Behavioral (`~/.hermes/config.yaml`):**
|
||||
@@ -114,7 +114,7 @@ gateway:
|
||||
enabled: true
|
||||
extra:
|
||||
host: 127.0.0.1 # 0.0.0.0 for LAN
|
||||
port: 8790
|
||||
http_port: 8791
|
||||
home_channel: default
|
||||
push_backend: fcm
|
||||
outbox_retention_hours: 72
|
||||
@@ -134,18 +134,17 @@ display:
|
||||
# 1. gateway up with plugin
|
||||
hermes gateway status | grep -i iris
|
||||
|
||||
# 2. a raw WS client can pair + echo
|
||||
python - <<'PY'
|
||||
import asyncio, json, websockets
|
||||
async def main():
|
||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
||||
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
|
||||
"caps":{"min_protocol":1}}}))
|
||||
print("recv:", await ws.recv())
|
||||
asyncio.run(main())
|
||||
PY
|
||||
# 2. the HTTP server answers (unauthenticated liveness)
|
||||
curl -s http://127.0.0.1:8791/v1/health
|
||||
# -> {"ok": true}
|
||||
|
||||
# 3. a frame round-trip with the pairing token
|
||||
curl -s -X POST http://127.0.0.1:8791/v1/frame \
|
||||
-H "Authorization: Bearer <IRIS_TOKEN>" -H "X-Iris-Device: probe" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"v":1,"type":"commands.catalog","id":1,"payload":{}}'
|
||||
```
|
||||
|
||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
||||
Expect `{"ok": true}` from the health probe and a `commands.catalog` reply
|
||||
frame from the POST. If you get `401`, the token/host/port is
|
||||
wrong.
|
||||
@@ -113,13 +113,13 @@ to the WS server.
|
||||
scale). The handler thread never touches adapter state directly; it bridges
|
||||
into the gateway's asyncio loop with
|
||||
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
||||
start, same loop the WS server runs on).
|
||||
- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
|
||||
(`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
||||
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
|
||||
trusted LAN by default, TLS for remote/Tailscale setups.
|
||||
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
|
||||
HTTP leg, show it in the inspector. The plugin must keep working WS-only.
|
||||
start).
|
||||
- **Config:** `IRIS_HTTP_PORT` (default **8791**), bind host `IRIS_HTTP_HOST`.
|
||||
Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
||||
(`ssl.SSLContext` on the server): plaintext on a trusted LAN by default,
|
||||
TLS for remote/Tailscale setups.
|
||||
- **Bind failure is NON-fatal:** log a warning, disable the HTTP leg, show it
|
||||
in the inspector.
|
||||
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
|
||||
|
||||
### Endpoints
|
||||
|
||||
@@ -18,6 +18,11 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
||||
|
||||
---
|
||||
|
||||
## User-facing guides
|
||||
|
||||
- [`install.md`](install.md) — **install the gateway + connect the app** (non-technical walkthrough, all options, TLS, push).
|
||||
- [`setup.md`](setup.md) — moved; pointer to `install.md`.
|
||||
|
||||
## Reading order
|
||||
|
||||
| # | File | When to read |
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
# Install — Gateway & App
|
||||
|
||||
A step-by-step guide for getting **Iris** (the Android / Desktop app) talking
|
||||
to your **hermes gateway**. Written for people who just want to *use* it, not
|
||||
build it. If you only want the short version, the [README](../README.md) has
|
||||
the three commands that matter.
|
||||
|
||||
The whole setup has two halves:
|
||||
|
||||
1. **The gateway** — a small plugin that runs *inside* your existing hermes
|
||||
install and opens a door for the app to connect through.
|
||||
2. **The app** — on your phone or desktop, where you enter the gateway's
|
||||
address and a pairing token.
|
||||
|
||||
> **Old guides?** Earlier versions of Iris used a WebSocket on port `8790`
|
||||
> (`ws://…/ws`). The transport is now plain HTTP on port **`8791`**
|
||||
> (see [`19-http-fallback-transport.md`](19-http-fallback-transport.md)).
|
||||
> The app still accepts old `ws://` URLs and converts them automatically, but
|
||||
> new setups should use the `http://` URL printed by `hermes gateway setup`.
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
| Where | What |
|
||||
| --- | --- |
|
||||
| Gateway host (any always-on computer: home server, Raspberry Pi, laptop) | [hermes-agent](https://github.com/NousResearch/hermes-agent) installed with its venv (`cd hermes-agent && uv sync`) |
|
||||
| Phone / desktop | Android 8+ or Linux / macOS / Windows |
|
||||
| Only if you build the app yourself | JDK 17 (+ Android SDK for Android) — see [`12-toolchain.md`](12-toolchain.md) |
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Install the gateway plugin (one-time)
|
||||
|
||||
Iris is a regular hermes **platform plugin**, so it installs with the normal
|
||||
plugin command. This repo is a *monorepo* (the plugin lives in the
|
||||
`gateway-plugin/` subfolder, next to the app), so you point the installer at
|
||||
that subfolder with a `#subfolder` suffix:
|
||||
|
||||
```bash
|
||||
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
|
||||
```
|
||||
|
||||
That's it. The installer clones the repo, copies just the `gateway-plugin/`
|
||||
folder into `~/.hermes/plugins/`, and asks whether to enable it now (say
|
||||
**yes**).
|
||||
|
||||
Notes:
|
||||
|
||||
- Any git URL works with the `#gateway-plugin` suffix — e.g.
|
||||
`https://gitea.zephyre.one/ARIA/iris_x_hermes.git#gateway-plugin` if you
|
||||
prefer HTTPS.
|
||||
- **Developing from a checkout?** Skip the install and symlink instead — the
|
||||
plugin then always tracks your working tree:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||
```
|
||||
|
||||
- Check it was picked up:
|
||||
|
||||
```bash
|
||||
hermes gateway status # the Iris platform should be listed
|
||||
```
|
||||
|
||||
## Part 2 — Gateway setup (one-time)
|
||||
|
||||
Run the interactive setup:
|
||||
|
||||
```bash
|
||||
hermes gateway setup
|
||||
```
|
||||
|
||||
It walks you through four things:
|
||||
|
||||
| Prompt | What it means | Default |
|
||||
| --- | --- | --- |
|
||||
| **Iris pairing token** | A long random secret the app must present to connect. Generated for you; stored in `~/.hermes/.env` as `IRIS_TOKEN`. **It is printed only once** — write it down. | auto-generated |
|
||||
| **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` |
|
||||
| **Port** | The port the app connects to. | `8791` |
|
||||
| **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` |
|
||||
|
||||
When it finishes it prints two things you need for the app:
|
||||
|
||||
- **Server URL** — e.g. `http://192.168.1.10:8791`
|
||||
- **Pairing QR + URL** — an `iris://pair?…` string with a scannable QR code
|
||||
|
||||
Then start the gateway:
|
||||
|
||||
```bash
|
||||
hermes gateway # (or: hermes gateway restart after changes)
|
||||
```
|
||||
|
||||
## Part 3 — Install the app
|
||||
|
||||
### Android
|
||||
|
||||
Build a debug APK on any machine with JDK 17 + the Android SDK:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :androidApp:assembleDebug
|
||||
# → app/androidApp/build/outputs/apk/debug/androidApp-debug.apk
|
||||
```
|
||||
|
||||
Copy the APK to the phone (cable, LAN share, or any file transfer) and open
|
||||
it — Android will ask to allow installs from unknown sources.
|
||||
|
||||
*Shortcut for developers with a USB-connected phone:*
|
||||
`./gradlew :androidApp:installDebug` installs it directly.
|
||||
|
||||
### Desktop
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :desktopApp:jpackage # → app/desktopApp/build/…/ (native app, JRE bundled)
|
||||
```
|
||||
|
||||
On Linux the launcher may print a `pure virtual method called` warning —
|
||||
it's a known, harmless jpackage bug (JDK-8348560); the app works fine.
|
||||
|
||||
## Part 4 — Connect the app
|
||||
|
||||
Open the app. The first screen is **Connect**. You need the **Server URL**
|
||||
and the **pairing token** from Part 2.
|
||||
|
||||
### Option A — Same home network (no encryption, simplest)
|
||||
|
||||
Works out of the box on a trusted home network:
|
||||
|
||||
1. **Server URL:** the one printed by `hermes gateway setup`,
|
||||
e.g. `http://192.168.1.10:8791`.
|
||||
- On a phone, use the gateway's **LAN IP** — not `127.0.0.1` (that only
|
||||
means "this device" and won't reach your server).
|
||||
- If you set the host to `127.0.0.1` during setup, re-run
|
||||
`hermes gateway setup` and enter the LAN IP instead.
|
||||
2. **Pairing token:** the long token from the setup output (or
|
||||
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host).
|
||||
3. **Test & Connect.**
|
||||
|
||||
> **Android shortcut:** the Connect screen has a **Scan QR** button — point
|
||||
> the camera at the QR printed by `hermes gateway setup` and the URL + token
|
||||
> fill themselves in. (Desktop has no camera, so it's manual entry.)
|
||||
|
||||
### Option B — Encrypted (TLS) — recommended for anything beyond your LAN
|
||||
|
||||
Plain `http://` is fine on a home network you trust, but for remote access
|
||||
you want the traffic encrypted. The gateway can serve `https://` itself:
|
||||
|
||||
1. Create a certificate + key. Two flavors:
|
||||
- **CA-signed** (Let's Encrypt, or your own CA): works out of the box.
|
||||
- **Self-signed** (e.g. `openssl req -x509 -newkey rsa:2048 -nodes
|
||||
-keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris"
|
||||
-addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`):
|
||||
the certificate **must** carry a SAN entry matching the host you'll
|
||||
type in the app.
|
||||
2. Put the paths in `~/.hermes/.env` on the gateway host:
|
||||
|
||||
```ini
|
||||
IRIS_HTTP_CERT=/path/to/iris.crt
|
||||
IRIS_HTTP_KEY=/path/to/iris.key
|
||||
```
|
||||
|
||||
3. `hermes gateway restart`.
|
||||
4. In the app, use the **`https://`** URL, e.g. `https://iris.example.com:8791`.
|
||||
|
||||
**Self-signed certificates:** the app won't trust them automatically (by
|
||||
design). On first connect it shows the certificate's SHA-256 fingerprint and
|
||||
asks you to confirm it — exactly like an SSH host key. Compare the
|
||||
fingerprint with the one on the gateway host
|
||||
(`openssl x509 -fingerprint -sha256 -noout -in iris.crt`), confirm, and it's
|
||||
pinned in the app's secure storage from then on. If the certificate ever
|
||||
changes, you'll be asked to confirm again. No system trust-store installs
|
||||
needed.
|
||||
|
||||
### Reaching the gateway from outside your home network
|
||||
|
||||
Pick one (in order of preference):
|
||||
|
||||
- **Tailscale / WireGuard (recommended).** Install Tailscale on the gateway
|
||||
host; the app connects to the stable tailnet IP, e.g.
|
||||
`http://100.x.y.z:8791`. No public exposure at all — and since the traffic
|
||||
travels inside the encrypted mesh, plain `http://` is acceptable here.
|
||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok). Terminate TLS
|
||||
at the edge and forward to `127.0.0.1:8791` on the gateway host.
|
||||
- **Public bind + TLS + strong token** (`IRIS_HTTP_HOST=0.0.0.0` + Option B).
|
||||
Last resort — the port is then reachable from the internet; the token and
|
||||
TLS are what protect it.
|
||||
|
||||
## Part 5 — Push notifications (optional)
|
||||
|
||||
Push wakes a backgrounded or offline phone so you see replies even when the
|
||||
app is closed. Nothing is lost either way — on reconnect the app syncs its
|
||||
outbox.
|
||||
|
||||
- **ntfy (default)** — the phone generates its own topic automatically; the
|
||||
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy**
|
||||
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata
|
||||
stays on your own infrastructure — this is the private option.
|
||||
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
|
||||
metadata (notification title, device token) is routed through **Google's
|
||||
servers**. Needs a Firebase project + `google-services.json` in the app
|
||||
build. Without it, FCM is inert and ntfy is the path.
|
||||
|
||||
Details: [`08-push.md`](08-push.md).
|
||||
|
||||
---
|
||||
|
||||
## Gateway options (reference)
|
||||
|
||||
Everything is configured in `~/.hermes/.env` on the gateway host (or via the
|
||||
prompts of `hermes gateway setup`). After changes: `hermes gateway restart`.
|
||||
|
||||
| Variable | What it does | Default |
|
||||
| --- | --- | --- |
|
||||
| `IRIS_TOKEN` | Pairing token the app must present. | — (required) |
|
||||
| `IRIS_HTTP_HOST` | Bind address. `127.0.0.1` = local only; LAN IP = same network; `0.0.0.0` = all interfaces. | `127.0.0.1` |
|
||||
| `IRIS_HTTP_PORT` | Port the app connects to. | `8791` |
|
||||
| `IRIS_HOME_CHANNEL` | Default chat for cron/notification delivery. | `default` |
|
||||
| `IRIS_ALLOWED_USERS` | Comma-separated device ids allowed to connect (empty = token-only auth). | empty |
|
||||
| `IRIS_ALLOW_ALL_USERS` | Allow any paired device (**dev only**). | `false` |
|
||||
| `IRIS_PUSH_BACKEND` | `ntfy` or `fcm`. | `ntfy` |
|
||||
| `IRIS_FCM_SERVICE_ACCOUNT` | Path to Firebase service-account JSON (FCM). | — |
|
||||
| `IRIS_FCM_SERVER_KEY` | Legacy FCM server key (fallback). | — |
|
||||
| `NTFY_SERVER_URL` | ntfy server. Self-hosting recommended. | `https://ntfy.sh` |
|
||||
| `NTFY_AUTH_TOKEN` | Auth token for a private ntfy topic (real trust boundary). | — |
|
||||
| `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY` | TLS cert/key paths → serves `https://` (see Part 4, Option B). | — |
|
||||
|
||||
Security model (tokens, device allowlist, transport):
|
||||
[`09-pairing-security.md`](09-pairing-security.md).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
| --- | --- |
|
||||
| `auth failed` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` (setup prints it only when it generates it). |
|
||||
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8791`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
||||
| Phone can't reach the gateway | Gateway bound to `127.0.0.1` — re-run `hermes gateway setup` and set the LAN IP; or the phone is on a different network/VLAN. |
|
||||
| TLS handshake fails | Cert has no SAN matching the URL host; or the pinned fingerprint is stale after a cert change (re-confirm in the app). |
|
||||
| Push not arriving | Backend not configured (check `~/.hermes/logs/gateway.log`); ntfy.sh flakiness — self-host ntfy. |
|
||||
| Start over on a phone | `adb shell pm clear dev.iris.app` wipes the app's pairing state. |
|
||||
|
||||
Logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
|
||||
+8
-178
@@ -1,180 +1,10 @@
|
||||
# Setup — Pairing a Device
|
||||
|
||||
User-facing guide: get a phone or desktop talking to your hermes gateway in
|
||||
under 10 minutes. Design rationale lives in the numbered docs
|
||||
([`09-pairing-security.md`](09-pairing-security.md),
|
||||
[`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is
|
||||
just the steps.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Where | You need |
|
||||
| --- | --- |
|
||||
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) |
|
||||
| Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device |
|
||||
| Desktop build machine | JDK 17 only |
|
||||
|
||||
Gradle needs no system install — both apps use the project wrapper
|
||||
(`./gradlew`). First-time machine setup: [`12-toolchain.md`](12-toolchain.md).
|
||||
|
||||
## 1. Gateway setup (on the gateway host)
|
||||
|
||||
Install the plugin into the live hermes home (dev: a symlink from the monorepo
|
||||
root):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||
hermes gateway status # should list "iris"
|
||||
```
|
||||
|
||||
Run the interactive setup:
|
||||
|
||||
```bash
|
||||
hermes gateway setup
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
- Generates `IRIS_TOKEN` (64 hex chars) if none exists and stores it in
|
||||
`~/.hermes/.env` (it prints the token once, at generation).
|
||||
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
|
||||
and push backend (`ntfy` or `fcm`, default `ntfy`); warns when `fcm` is
|
||||
chosen (push metadata via Google's servers).
|
||||
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
|
||||
string), a scannable QR of that payload, and the server URL
|
||||
(`ws://<host>:8790/ws`).
|
||||
|
||||
Then start the gateway:
|
||||
|
||||
```bash
|
||||
hermes gateway # or: hermes gateway restart after config changes
|
||||
```
|
||||
|
||||
> **Note:** the default bind host `127.0.0.1` only accepts connections from the
|
||||
> gateway host itself (e.g. a desktop app on the same machine). For a phone on
|
||||
> the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set
|
||||
> `IRIS_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
|
||||
|
||||
## 2. Iris app (Android)
|
||||
|
||||
Build and install (ADB device connected):
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :androidApp:installDebug
|
||||
```
|
||||
|
||||
First run opens the **Connect** screen:
|
||||
|
||||
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (the URL printed by
|
||||
`hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone).
|
||||
2. **Pairing token** — from the `hermes gateway setup` output, or
|
||||
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host.
|
||||
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
|
||||
pairing and connects.
|
||||
|
||||
> **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX +
|
||||
> ML Kit) that reads the QR printed by `hermes gateway setup` and pre-fills the
|
||||
> URL + token. Desktop has no camera, so it uses manual entry. An `iris://pair`
|
||||
> deep link (from any scanner) pre-fills the same way.
|
||||
|
||||
## 3. Desktop app
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :desktopApp:run # dev run
|
||||
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
|
||||
```
|
||||
|
||||
Pairing is the same Connect screen (URL + token); the token is stored in the OS
|
||||
keyring (with an encrypted-file fallback). Desktop push is tray icon + OS
|
||||
notifications (no FCM).
|
||||
|
||||
> **Known issue:** on Linux with JDK 17 the jpackage launcher prints a
|
||||
> non-fatal `pure virtual method called` warning (JDK-8348560, a
|
||||
> jpackage/Linux launcher bug). The app runs and connects regardless.
|
||||
|
||||
## 4. Push notifications
|
||||
|
||||
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
||||
outbox, so nothing is lost. Push fires when the device is offline, plus for
|
||||
high-priority events (approvals, clarifies, cron) even when a device is live.
|
||||
|
||||
### ntfy (default; zero-config)
|
||||
|
||||
```
|
||||
IRIS_PUSH_BACKEND=ntfy # the default — can be left unset
|
||||
```
|
||||
|
||||
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
|
||||
the server publishes to it.
|
||||
- `NTFY_SERVER_URL` defaults to `https://ntfy.sh`. **Self-hosted ntfy is
|
||||
recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its
|
||||
web UI instead of the stream), while a self-hosted instance gives reliable
|
||||
SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`.
|
||||
- **Privacy:** ntfy keeps push metadata (title, topic) on your own
|
||||
infrastructure — this is the backend for truly private communication.
|
||||
|
||||
**What you see:** a low-priority foreground "ntfy listener" notification while
|
||||
the app is off; incoming pushes trigger a silent sync.
|
||||
|
||||
### FCM (opt-in; needs a Firebase project)
|
||||
|
||||
> **Privacy note:** FCM push metadata (notification title, device token) is
|
||||
> routed through **Google's servers**. If you want truly private
|
||||
> communication, use ntfy (self-hosted) instead — it is the default.
|
||||
|
||||
1. Create a Firebase project (console.firebase.google.com) and add an Android
|
||||
app with the app's applicationId; download `google-services.json` into
|
||||
`app/androidApp/`.
|
||||
2. Create a service account (Project settings → Service accounts → Generate new
|
||||
private key) and store the JSON path in `~/.hermes/.env`:
|
||||
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
|
||||
3. Set `IRIS_PUSH_BACKEND=fcm`.
|
||||
|
||||
Without a Firebase project the FCM path is **inert** (the app's FCM service
|
||||
does nothing) — use ntfy (the default), or add Firebase later.
|
||||
|
||||
## 5. Remote access
|
||||
|
||||
- **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP;
|
||||
the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
|
||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
|
||||
the edge, forward the WebSocket to `127.0.0.1:8790`.
|
||||
- **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in
|
||||
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
|
||||
|
||||
> **Self-signed certs:** the app has a fingerprint-confirm flow (docs/09
|
||||
> §9.4): on first pair it shows the gateway cert's SHA-256 fingerprint; once
|
||||
> you confirm it, the cert is pinned in secure storage (like an SSH host
|
||||
> key). The cert needs a SAN for the URL host. CA-signed certs work out of
|
||||
> the box. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the
|
||||
> default.
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
| --- | --- |
|
||||
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
|
||||
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
||||
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
|
||||
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
|
||||
|
||||
Smoke test without the app (from the gateway host):
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
import asyncio, json, websockets
|
||||
async def main():
|
||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
||||
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
|
||||
"caps":{"min_protocol":1}}}))
|
||||
print("recv:", await ws.recv())
|
||||
asyncio.run(main())
|
||||
PY
|
||||
```
|
||||
|
||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
||||
wrong.
|
||||
> **Moved.** The user-facing setup guide now lives in
|
||||
> [`install.md`](install.md) — gateway install, all options, app install,
|
||||
> and connecting (LAN / TLS / remote). This file is kept so old links keep
|
||||
> working.
|
||||
>
|
||||
> - Push details: [`08-push.md`](08-push.md)
|
||||
> - Security model: [`09-pairing-security.md`](09-pairing-security.md)
|
||||
> - Toolchain (first-time machine setup): [`12-toolchain.md`](12-toolchain.md)
|
||||
Reference in new issue
Block a user