Compare commits

...
Author SHA1 Message Date
Bailey Dixon 0327012666 release(android): android-v1.2.4 (#130)
release(android): android-v1.2.4
2026-06-25 21:36:31 -04:00
Bailey DixonandClaude Opus 4.8 2e58449aec release(android): android-v1.2.4
Crash fix (#129): currentSession() over a flaky Tailscale dashboard route
could re-throw a transient connect/abort onto the main thread and force-close
the app. Now degrades gracefully. Also ships the connection security indicator
across the chat chip, connection card, and route picker.

Android surface only (appVersionName 1.2.4, appVersionCode 18). Desktop CLI
items stay in [Unreleased] for a future cli-v* release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:23:56 -04:00
Bailey Dixon 41ffe0ce1c Merge pull request #127 from Codename-11/feature/connection-security-indicator
feat(android): connection security indicator (spec + implementation)
2026-06-25 08:51:38 -04:00
Bailey Dixon 81418d71b8 Merge pull request #128 from Codename-11/worktree-fix-currentsession-crash
fix(android): currentSession() must not re-throw network errors (crash)
2026-06-24 17:01:44 -04:00
Bailey DixonandClaude Opus 4.8 99b9cf1704 fix(android): currentSession() must not re-throw network errors (crash)
An on-device crash (FATAL EXCEPTION: main, SocketTimeoutException,
Caused by SocketException "Software caused connection abort") over a
Tailscale connection. Full trace recovered from a background logcat
capture pinned it to DashboardApiClient.currentSession().

Root cause: currentSession() returns Result<DashboardAuthSession> but did
a raw okHttpClient.newCall(req).execute() with NO try/catch — the lone
outlier among the client's methods (executeJson/executeJsonElement/
audioRoutesPresent all catch). The execute() ran on Dispatchers.IO
(correct), but a transient stale-pooled-connection abort re-threw out of
withContext(IO). The caller chain — ConnectionViewModel.probeStandardVoice()
-> viewModelScope.launch (Dispatchers.Main.immediate, the Suppressed frame
in the trace) — used try/finally with no catch, so the exception was
uncaught on the main thread and killed the app. (execute() being off-main
is why StrictMode never fired; the uncaught propagation was the bug.)

Fix:
- currentSession() wraps its request in try/catch -> Result.failure on any
  exception, honoring the Result contract callers rely on (mirrors
  executeJson()).
- Defense-in-depth: probeStandardVoice() gains a catch (rethrowing
  CancellationException) that degrades availability state instead of
  letting any probe sub-call crash the Main coroutine.

Test: DashboardApiClientTest.currentSession_onConnectionAbort_returnsFailure_doesNotThrow
(MockWebServer DISCONNECT_AT_START) asserts a connection abort yields
Result.failure, not a throw. :app:testSideloadDebugUnitTest green (25/25).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:51:51 -04:00
Bailey DixonandClaude Opus 4.8 f1e8bfd7ac feat(android): connection security indicator across all surfaces
Implements the spec in docs/plans/2026-06-24-connection-security-indicator.md
(decisions: Tailscale=green, ship all surfaces, "Encrypted · <mechanism>").

Single source of truth: data/ConnectionSecurity.kt computes a per-surface +
rollup verdict (TLS / Overlay / Mixed / Plain) from the active route's
schemes; ConnectionViewModel exposes it as a StateFlow. Overlay transports
(Tailscale/WireGuard/plugin proxy) count as encrypted, not just TLS — so a
ws:// route over a tailnet reads "Encrypted · Tailscale" (green), fixing the
old badge's hardcoded "Secure — TLS" lie.

Surfaces (all read the one flow):
- Chat status chip: leading security glyph (RelayStatusStrip slot).
- Connection card: full-width badge promoted out of the Advanced fold.
- Route picker: per-route glyph on each candidate.
- New ConnectionSecuritySheet: tap any badge for the per-transport
  breakdown + mechanism explainer + docs link.

Removed the duplicated, buried security computation from
ActiveConnectionSections (now delegates to the shared model).

Docs: new user-docs "Is my connection secure?" page; fixes the
Tailscale=TLS conflation in decisions.md / security.md / remote-access.md;
first user-facing mention of TOFU cert pinning.

Verified: ./gradlew :app:testSideloadDebugUnitTest (ConnectionSecurityTest
7/7) + :app:lintSideloadDebug both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:40:19 -04:00
Bailey DixonandClaude Opus 4.8 75e617bfb1 docs(plan): connection security indicator — surfacing, wording & docs spec
Design spec for making connection security legible at a glance. Companion
to docs/plans/2026-06-18-native-secure-routes.md (which owns the routes /
plugin-proxy mechanics).

Key findings from the UI/code/docs audit:
- The security model already exists (TransportSecurityBadge tri-state,
  isEncryptedOverlayRoute, ActiveCardSecurityPosture) but is buried under
  Manage > Connections > Advanced and absent from every at-a-glance surface.
- The badge hardcodes "Secure - TLS" even for Tailscale/WireGuard routes
  (the "TLS lie") - likely why users keep asking "is it secure?".
- Security is inherently per-surface (gateway/API/dashboard/relay schemes
  are independent), so a binary verdict can't be honest - propose a
  connection rollup for the glance + per-surface truth on tap.

Spec covers: corrected mechanism-first wording (TLS / Tailscale / Mixed /
Not encrypted, with overlay = secure), placement (chat status chip, header,
route picker, new detail sheet) with mockups, the secure-proxy stub status,
a documentation plan to fix the Tailscale=TLS conflation, open decisions
for review, and tiered implementation with effort sizing.

No implementation yet - placement/wording decisions pending review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:15:47 -04:00
Bailey Dixon ee0591457b Merge pull request #126 from Codename-11/dev
release(android): android-v1.2.3
2026-06-23 22:04:36 -04:00
Bailey DixonandClaude Opus 4.8 26811f0eb8 release(android): android-v1.2.3
Connection-stability hotfix. Promotes the TLS/Tailscale connect-crash fix
(#118, #124; likely #70) from [Unreleased] to [1.2.3]. appVersionName
1.2.3 / appVersionCode 17. Desktop CLI entries stay under [Unreleased] for
their own cli-v* cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:35:16 -04:00
Bailey Dixon eafdb4efe2 Merge pull request #125 from Codename-11/fix/evictall-network-on-main-thread
fix(android): close TLS sockets off the main thread on client shutdown
2026-06-23 21:31:04 -04:00
Bailey DixonandClaude Opus 4.8 802385c65c fix(android): close TLS sockets off the main thread on client shutdown
Connecting over an encrypted link (Tailscale Serve / public HTTPS) could
hard-close the app with NetworkOnMainThreadException. HermesApiClient,
DashboardApiClient and ConnectionManager all call ConnectionPool.evictAll()
inline in shutdown(); evictAll() closes pooled sockets synchronously, and a
live https/wss keep-alive close drains a TLS close-notify through
SSLOutputStream -- a real network write StrictMode forbids on the main
thread. Several call sites reach shutdown() from a viewModelScope
(Dispatchers.Main.immediate) coroutine -- probeStandardVoice()'s finally
block on every connect, and onCleared()'s connectionManager.shutdown() --
so the process was killed on connect over TLS. (Plaintext closes write
nothing, which is why every report is on Tailscale/public TLS.)

Push the guard into the leaf: a shared shutdownOffMainThread() runs the
executor-shutdown + evictAll() on a short-lived daemon thread when called
from the main thread, and inline otherwise (preserving the blocking
awaitTermination semantics for callers already on IO). Every shutdown()
call site is now safe regardless of dispatcher; the redundant
withContext(IO)/Thread wrappers in onCleared() are removed.

Adds a Robolectric NetworkShutdownTest asserting the teardown never runs on
the main thread when invoked from the main looper, and runs inline off it.

Fixes #118, #124. Likely resolves the v1.1.0/Tailscale crash in #70.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:58:17 -04:00
Bailey DixonandClaude Opus 4.8 ec05643b6b docs(devlog): record android-v1.2.2 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:58:55 -04:00
29 changed files with 1308 additions and 153 deletions
+16
View File
@@ -20,6 +20,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
## [1.2.4] - 2026-06-25
### Added
- **Connection security indicator.** The chat status chip, the connection card, and the route picker now show at a glance whether your connection is encrypted — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale/WireGuard route is now correctly shown as encrypted rather than implied insecure. Adds a new "Is my connection secure?" docs page explaining the difference between TLS and overlay (WireGuard) encryption.
### Fixed
- **Crash when a dashboard connection drops mid-check.** A transient network blip on the dashboard session check (e.g. a pooled connection aborting or timing out over Tailscale) could close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly, and the connection probe degrades gracefully instead of ever crashing. (#129)
## [1.2.3] - 2026-06-23
### Fixed
- **Crash on connect over TLS / Tailscale.** Connecting to a server over an encrypted link (Tailscale Serve or public HTTPS) could hard-close the app with `NetworkOnMainThreadException`. Tearing down an HTTP client closed live SSL sockets on the main thread, and a TLS socket close performs a network write — which Android forbids on the main thread. Client shutdown now always closes sockets off the main thread, so connecting over a secured link no longer crashes. (#118, #124; likely the v1.1.0 / Tailscale crash in #70)
## [1.2.2] - 2026-06-22
### Added
+24
View File
@@ -1,5 +1,29 @@
# Hermes-Relay — Dev Log
## 2026-06-24 — Fix SocketTimeoutException crash from DashboardApiClient.currentSession()
**Why.** An in-app crash report (`FATAL EXCEPTION: main`, `SocketTimeoutException`, `Caused by: java.net.SocketException: Software caused connection abort`) captured on-device over a Tailscale connection. The visible dialog truncated the trace; the full stack was recovered from a background `adb logcat` capture that happened to be running when it fired.
**Root cause.** `DashboardApiClient.currentSession()` declared `Result<DashboardAuthSession>` but performed a **raw `okHttpClient.newCall(req).execute()` with no try/catch** — the lone outlier among the client's methods (`executeJson`/`executeJsonElement`/`audioRoutesPresent` all catch). Its `.execute()` correctly ran on `Dispatchers.IO`, but a transient network failure (a stale pooled connection aborting over Tailscale) **re-threw** out of `withContext(IO)`. The caller chain — `ConnectionViewModel.probeStandardVoice()` → `viewModelScope.launch` (`Dispatchers.Main.immediate`, the `Suppressed` frame in the trace) — used `try/finally` with **no `catch`**, so the exception was uncaught on the main thread and killed the app. The `.execute()` being off-main is why StrictMode never fired; the uncaught *propagation* to the Main coroutine was the bug.
**Fix.** (1) `currentSession()` now wraps its request in `try/catch`, returning `Result.failure` on any exception — honoring the `Result` contract every caller relies on (mirrors `executeJson`). (2) Defense-in-depth: `probeStandardVoice()` gained a `catch` (rethrowing `CancellationException`) that degrades the voice/gateway availability state instead of letting any probe sub-call crash the Main coroutine.
**Verification.** New `DashboardApiClientTest.currentSession_onConnectionAbort_returnsFailure_doesNotThrow` (MockWebServer `DISCONNECT_AT_START`) asserts a connection abort yields `Result.failure`, not a throw. `:app:testSideloadDebugUnitTest` + `:app:lintSideloadDebug` green. On-device confirmation pending a build.
## 2026-06-23 — Fix NetworkOnMainThreadException crash on TLS connect
**Why.** Two external bug reports (#118, #124) and the later comment on #70 reported the app hard-closing on connect over an encrypted link (Tailscale Serve / public HTTPS). The auto-captured traces were identical: `android.os.NetworkOnMainThreadException` from `okhttp3.ConnectionPool.evictAll()`, with a suppressed `Dispatchers.Main.immediate [Cancelling]` frame — i.e. a `viewModelScope` coroutine.
**Root cause.** `HermesApiClient.shutdown()`, `DashboardApiClient.shutdown()`, and `ConnectionManager.shutdown()` each call `connectionPool.evictAll()` inline. `evictAll()` closes pooled sockets synchronously; for a live `https`/`wss` keep-alive connection a TLS close drains a close-notify through `SSLOutputStream` — a real network write StrictMode forbids on the main thread. Several call sites reach `shutdown()` from a `viewModelScope` (`Dispatchers.Main.immediate`) coroutine: `probeStandardVoice()`'s `finally { client.shutdown() }` fires on every connect/voice probe, and `onCleared()` called `connectionManager.shutdown()` directly on the main thread. The off-main handling existed only as scattered per-call-site `withContext(Dispatchers.IO)` / background-`Thread` wrappers, so the unwrapped paths still crashed. TLS-only because a plaintext socket close writes nothing — matching every report being on Tailscale/public TLS.
**Fix.** Pushed the guard into the leaf. New `network/NetworkShutdown.kt#shutdownOffMainThread(name, block)` runs the executor-shutdown + `evictAll()` on a short-lived daemon thread when called from the main thread, and inline otherwise (preserving the blocking `awaitTermination` semantics for callers already on IO). Wrapped all three `shutdown()` bodies with it, so every call site is safe regardless of dispatcher. Simplified `ConnectionViewModel.onCleared()` — its now-redundant manual `Thread` wrappers were removed and `connectionManager.shutdown()` is no longer an unguarded main-thread `evictAll()`.
**Verification.** New Robolectric `NetworkShutdownTest` (2 cases) asserts the teardown runs off the main thread when invoked from the main looper, and inline when invoked off it. `./gradlew :app:testSideloadDebugUnitTest --tests NetworkShutdownTest` green (compiles the full module + both cases pass). On-device confirmation over a real Tailscale/TLS connection pending a Studio build.
## 2026-06-22 — Released android-v1.2.2
Cut Android **1.2.2** (appVersionName 1.2.2 / appVersionCode 16) — "Multi-profile polish". The version bump + release docs were already on `dev`; the cut first integrated `origin/dev`, which had advanced to **compileSdk 37** (`206d182`) and typed `stream.event` passthrough (PR #120) — dropping the temporary 1.2.2-prep `markdown-renderer 0.41.0` / `lifecycle 2.10.0` pins (a compileSdk-36 workaround) for compileSdk 37 + the `0.42.0` / `2.11.0` deps. `dev` CI (Android build + tests on compileSdk 37) green; release PR #122 (`dev` → `main`, `--no-ff`, merge `984d9a2`) merged on green Required-checks + claude-review; `android-v1.2.2` tagged from the `main` tip triggered `release-android.yml` → signed APK/AAB (googlePlay + sideload) + `SHA256SUMS.txt` → GitHub Release **Hermes-Relay-Android v1.2.2** (published, not draft). Headline 1.2.2: session-delete persists on non-default profiles, cold-start profile isolation for the session drawer, full-screen Diagnostics status timeline, "Hermes"/"Relay" connection wording, and the clean-chat layout + scrollable history; also ships the typed `stream.event` relay passthrough (first slice) integrated from `dev`. Post-cut: `main` back-merged into `dev` (fast-forward) so they stay aligned. Follow-up: CLAUDE.md still says "Compile SDK 36" — update to 37 to match the build.
## 2026-06-22 — Outstanding-TODO batch (orchestration): four User-Added fixes
**Why.** Four open User-Added TODO items, resolved in one 4-worker orchestration pass with disjoint file ownership and coordinator-serialized commits (workers edited only; the coordinator committed each task's files by pathspec to avoid the shared-index race). A read-only Explore pass mapped each task to its files first, surfacing the two collision hubs (`ChatScreen.kt`, `RelayApp.kt`) so ownership could be partitioned to keep all four file sets disjoint. All changes are client-side Kotlin. **Unbuilt at time of writing — pending Studio build + `./gradlew lint`.**
+16 -20
View File
@@ -1,22 +1,22 @@
# Hermes-Relay-Android v1.2.2
# Hermes-Relay-Android v1.2.4
**Release Date:** June 22, 2026
**Since v1.2.1:** A multi-profile reliability pass. Switching agent profiles now keeps your chats straight — **deleting a session on a non-default profile sticks**, and a **cold start opens the session list on the right profile** instead of flashing the default one — plus a **full-screen Diagnostics** view that leads with subsystem health checks, simpler connection wording, and a roomier distraction-free chat mode.
**Release Date:** June 25, 2026
**Since v1.2.3:** A second connection-stability fix plus a new way to see whether your connection is encrypted. A transient blip on the dashboard session check — a pooled connection aborting or timing out over Tailscale — could still hard-close the app even after the v1.2.3 fix; that path is now handled cleanly. And the app now shows, at a glance, whether each transport is encrypted.
v1.2.2 is a focused follow-up to 1.2.1, sharpened by real multi-profile use. The two profile fixes remove the most confusing rough edges when you run more than one agent profile, Diagnostics becomes a place you can actually read at a glance, and a couple of small touches make the default path clearer.
v1.2.4 is recommended for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected by the crash.
---
## Download
v1.2.2 ships in two Android build flavors. APK and AAB filenames are version-tagged:
v1.2.4 ships in two Android build flavors. APK and AAB filenames are version-tagged:
| Flavor | File | Who it's for |
|---|---|---|
| Google Play | `hermes-relay-1.2.2-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.2.2-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.2.2-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.2.2-sideload-release.aab` | Parity/testing artifact. |
| Google Play | `hermes-relay-1.2.4-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.2.4-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.2.4-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.2.4-sideload-release.aab` | Parity/testing artifact. |
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
@@ -24,19 +24,15 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
## Highlights
### Profiles that behave
- **Deleting a session on a non-default profile now sticks.** A non-default profile keeps its sessions in its own store; the delete now scopes to the active profile, so a removed chat no longer reappears after the list refreshes.
- **Cold start opens on the right profile.** Launching with a non-default profile selected used to briefly show the default profile's chats before snapping to the correct ones. The session list (and the restored session context) now wait for the profile to resolve and load the right list directly.
### Fixed
- **No more crash when the dashboard connection drops mid-check.** A transient network failure on the dashboard session check — for example a pooled connection aborting or timing out over Tailscale — could still force-close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly and the connection probe degrades gracefully, so a flaky link can no longer crash the app. (#129)
### Clearer diagnostics
- **Diagnostics status timeline.** Diagnostics is now a full screen that leads with a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong. Tap a failing check for full detail; the recent-activity log stays below.
### Small touches
- **Simpler connection wording.** The default connection is now just **"Hermes"** (previously "Vanilla" / "Standard Hermes"), and the optional power features are labelled **"Relay"** / "Relay plugin", across setup, the switcher, voice, and permissions.
- **Roomier clean chat.** Distraction-free chat mode gives its text a taller, scrollable area instead of capping it near a third of the screen.
### Added
- **See whether your connection is encrypted.** The chat status chip, the connection card, and the route picker now show your encryption state at a glance — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure. A new ["Is my connection secure?"](https://codename-11.github.io/hermes-relay/architecture/connection-security.html) docs page explains the difference between TLS and overlay (WireGuard) encryption.
---
## Upgrade notes
- All changes are client-side and available on **both** flavors (no Device Control needed).
- `appVersionCode` is **16**.
- This is an app-side release on **both** flavors — no Device Control or server changes needed.
- If you connect over Tailscale or HTTPS, update and reconnect.
- `appVersionCode` is **18**.
@@ -1,7 +1,4 @@
v1.2.2 — Multi-profile polish.
v1.2.4 — Stability + connection security.
• Deleting a session on a non-default profile now sticks — no more reappearing.
• Cold start opens the session list on the right profile instead of flashing the default one.
• Diagnostics is now a full-screen health-check list with clear status and the reason when something's wrong.
• The default connection is simply "Hermes" (power features are "Relay").
• Distraction-free chat mode gives its text more room and scrolls.
• Fixed a crash that could close the app when the dashboard connection dropped mid-check (e.g. a brief Tailscale blip) — it now fails gracefully instead of force-closing.
• New: see whether your connection is encrypted at a glance (TLS or Tailscale) from the chat chip, connection card, and route picker, with a per-transport breakdown on tap.
+32
View File
@@ -1,5 +1,37 @@
{
"versions": [
{
"version": "1.2.4",
"title": "Stability + connection security",
"date": "2026-06-25",
"sections": [
{
"header": "Stability",
"bullets": [
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
]
},
{
"header": "See if you're secure",
"bullets": [
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
]
}
]
},
{
"version": "1.2.3",
"title": "Connection crash fix",
"date": "2026-06-23",
"sections": [
{
"header": "Stability",
"bullets": [
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
]
}
]
},
{
"version": "1.2.2",
"title": "Multi-profile polish",
+9 -15
View File
@@ -1,17 +1,11 @@
v1.2.2 - Multi-profile polish
v1.2.4 - Stability + connection security
Profiles that behave
* Deleting a session on a non-default agent profile now sticks — it no longer
reappears after the list refreshes.
* On a cold start with a non-default profile selected, the session list opens
on that profile's chats directly instead of flashing the default profile's.
Stability
* Fixed a crash that could close the app when the dashboard connection
dropped mid-check (e.g. a brief Tailscale blip). The check now fails
gracefully instead of force-closing.
Clearer diagnostics
* Diagnostics is now a full screen that leads with a top-to-bottom list of
health checks — network, server, chat, pairing, relay, and voice — each with
a clear status and the reason when something's wrong. Tap a failing check for
detail; the recent-activity log stays below.
Small touches
* The default connection is simply "Hermes" now (power features are "Relay").
* Distraction-free chat mode gives its text a taller, scrollable area.
New
* See whether your connection is encrypted at a glance — the chat chip,
connection card, and route picker now show TLS or Tailscale encryption,
with a per-transport breakdown on tap.
@@ -0,0 +1,156 @@
package com.hermesandroid.relay.data
/**
* Single source of truth for "is this connection encrypted, and by what?"
*
* Security is **per-surface**: a single paired connection fans out to several
* transports (chat/gateway + Manage over the dashboard, API/sessions, relay
* tools) and each can independently be TLS, overlay-encrypted, or plain (see
* [computeConnectionSecurity]). Every UI surface — the chat status chip, the
* connection header, the route picker, the detail sheet — renders the same
* derived [ConnectionSecurity] so no two places disagree about what "secure"
* means.
*
* Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard,
* the plugin secure proxy), not just TLS. A `ws://` link over a tailnet is
* WireGuard-encrypted end-to-end — genuinely secure, just not TLS — so it is
* never labelled "insecure". Only a plain scheme with no overlay warns.
*/
enum class SurfaceSecurityKind { Tls, Overlay, Plain }
/** Connection-level rollup across the surfaces actually in use. */
enum class ConnectionSecurityLevel { Tls, Overlay, Mixed, Plain, Unknown }
/** Security verdict for one transport surface of a connection. */
data class SurfaceSecurity(
val label: String,
val kind: SurfaceSecurityKind,
/** Human mechanism: "TLS", "Tailscale", "WireGuard", "Proxy", "Plain". */
val mechanism: String,
val url: String,
)
data class ConnectionSecurity(
val level: ConnectionSecurityLevel,
/** Dominant mechanism for the at-a-glance label. */
val mechanism: String,
val surfaces: List<SurfaceSecurity>,
) {
/** True when every in-use surface is encrypted (TLS or overlay). */
val isEncrypted: Boolean
get() = level == ConnectionSecurityLevel.Tls || level == ConnectionSecurityLevel.Overlay
companion object {
val UNKNOWN = ConnectionSecurity(ConnectionSecurityLevel.Unknown, "", emptyList())
}
}
/** True when the URL scheme is TLS (`wss://` / `https://`). */
fun isTlsUrl(url: String?): Boolean {
if (url.isNullOrBlank()) return false
val lower = url.trim().lowercase()
return lower.startsWith("wss://") || lower.startsWith("https://")
}
/**
* True when the active route is encrypted by an overlay network (Tailscale /
* WireGuard) or the plugin secure proxy, even if its scheme is plain. Mirrors
* the logic that previously lived privately in `ActiveConnectionSections`.
*/
fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
if (this == null) return false
val r = role.lowercase()
val hint = security.orEmpty().lowercase()
return r == "tailscale" ||
(isTailscaleDetected && hint.contains("tailscale")) ||
r == "plugin_proxy" ||
r == "plugin-proxy" ||
hasSecureProxy() ||
hint.contains("wireguard") ||
hint.contains("https") ||
hint.contains("tls")
}
/** Human label for the overlay mechanism encrypting a route. */
fun EndpointCandidate?.overlayMechanism(isTailscaleDetected: Boolean): String {
if (this == null) return "Encrypted"
val r = role.lowercase()
val hint = security.orEmpty().lowercase()
return when {
r == "tailscale" || (isTailscaleDetected && hint.contains("tailscale")) -> "Tailscale"
r == "plugin_proxy" || r == "plugin-proxy" || hasSecureProxy() -> "Proxy"
hint.contains("wireguard") -> "WireGuard"
hint.contains("https") || hint.contains("tls") -> "TLS"
else -> "Encrypted"
}
}
/** Classify a single surface URL against the active route. */
fun classifySurfaceSecurity(
label: String,
url: String,
activeEndpoint: EndpointCandidate?,
isTailscaleDetected: Boolean,
): SurfaceSecurity {
val (kind, mechanism) = when {
isTlsUrl(url) -> SurfaceSecurityKind.Tls to "TLS"
activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected) ->
SurfaceSecurityKind.Overlay to activeEndpoint.overlayMechanism(isTailscaleDetected)
else -> SurfaceSecurityKind.Plain to "Plain"
}
return SurfaceSecurity(label = label, kind = kind, mechanism = mechanism, url = url)
}
/**
* Roll up the per-surface verdicts into one connection-level [ConnectionSecurity].
* Pure + side-effect free so it is unit-testable without Android.
*/
fun computeConnectionSecurity(
apiUrl: String,
dashboardUrl: String,
relayUrl: String,
relayConfigured: Boolean,
activeEndpoint: EndpointCandidate?,
isTailscaleDetected: Boolean,
): ConnectionSecurity {
val surfaces = buildList {
dashboardUrl.trim().takeIf { it.isNotBlank() }?.let {
add(classifySurfaceSecurity("Chat & Manage", it, activeEndpoint, isTailscaleDetected))
}
apiUrl.trim().takeIf { it.isNotBlank() }?.let {
add(classifySurfaceSecurity("API / sessions", it, activeEndpoint, isTailscaleDetected))
}
if (relayConfigured) {
relayUrl.trim().takeIf { it.isNotBlank() }?.let {
add(classifySurfaceSecurity("Relay tools", it, activeEndpoint, isTailscaleDetected))
}
}
}
if (surfaces.isEmpty()) return ConnectionSecurity.UNKNOWN
val kinds = surfaces.map { it.kind }.toSet()
val hasPlain = SurfaceSecurityKind.Plain in kinds
val hasSecure = kinds.any { it != SurfaceSecurityKind.Plain }
val level = when {
!hasSecure -> ConnectionSecurityLevel.Plain
hasPlain -> ConnectionSecurityLevel.Mixed
kinds == setOf(SurfaceSecurityKind.Tls) -> ConnectionSecurityLevel.Tls
else -> ConnectionSecurityLevel.Overlay
}
val mechanism = when (level) {
ConnectionSecurityLevel.Tls -> "TLS"
ConnectionSecurityLevel.Overlay ->
surfaces.firstOrNull { it.kind == SurfaceSecurityKind.Overlay }?.mechanism ?: "Encrypted"
ConnectionSecurityLevel.Mixed -> "Mixed"
ConnectionSecurityLevel.Plain -> when (activeEndpoint?.role?.lowercase()) {
"lan" -> "LAN"
"public" -> "Public"
null, "" -> "Plain"
else -> activeEndpoint.role
}
ConnectionSecurityLevel.Unknown -> ""
}
return ConnectionSecurity(level = level, mechanism = mechanism, surfaces = surfaces)
}
@@ -0,0 +1,29 @@
package com.hermesandroid.relay.network
import android.os.Looper
/**
* Run an OkHttp teardown [block] without ever performing a network write on
* the main thread.
*
* [okhttp3.ConnectionPool.evictAll] closes pooled sockets synchronously. For
* a live `https`/`wss` keep-alive connection that close drains the SSL output
* queue — a real network write (`SSLOutputStream.writeInternal`) — which trips
* StrictMode's [android.os.NetworkOnMainThreadException]. Reported as a hard
* crash on connect over TLS/Tailscale (issues #70 / #118 / #124): a
* `viewModelScope` (i.e. `Dispatchers.Main.immediate`) coroutine resumes on the
* main thread and shuts a dashboard/API client down in a `finally` block.
*
* Client shutdown is fire-and-forget cleanup, so when the caller is on the main
* thread we hand [block] to a short-lived daemon thread. Off the main thread
* (already on `Dispatchers.IO` or a background thread) we run it inline so
* callers that deliberately moved off main keep their ordering and any blocking
* `awaitTermination` waits stay where the caller put them.
*/
internal fun shutdownOffMainThread(threadName: String, block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
Thread({ runCatching(block) }, threadName).apply { isDaemon = true }.start()
} else {
block()
}
}
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.shutdownOffMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -705,8 +706,12 @@ class ConnectionManager(
disconnect()
unregisterNetworkCallback()
supervisorJob.cancel()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
// evictAll() closes live wss sockets synchronously; on a TLS keep-alive
// that close is a network write, so keep it off the main thread.
shutdownOffMainThread("ConnectionManager-shutdown") {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
fun send(envelope: Envelope) {
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.shutdownOffMainThread
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -513,15 +514,26 @@ class DashboardApiClient(
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
return@withContext Result.success(DashboardAuthSession(authenticated = false))
// try/catch is NOT optional here: currentSession() returns a Result and
// callers (probeStandardVoice on a viewModelScope/Main coroutine) rely
// on it NEVER throwing. A raw execute() re-threw transient network
// failures — e.g. a stale pooled connection over Tailscale aborting
// ("Software caused connection abort") — straight past withContext(IO)
// and crashed the app on the main thread. Mirror executeJson()'s
// contract: every failure becomes Result.failure.
try {
okHttpClient.newCall(request).execute().use { response ->
when {
response.code == 401 || response.code == 403 ->
Result.success(DashboardAuthSession(authenticated = false))
!response.isSuccessful ->
Result.failure(apiFailure(response, "Dashboard session"))
else ->
Result.success(parseAuthSession(response.readJsonObject(json)))
}
}
if (!response.isSuccessful) {
return@withContext Result.failure(apiFailure(response, "Dashboard session"))
}
val root = response.readJsonObject(json)
Result.success(parseAuthSession(root))
} catch (e: Exception) {
Result.failure(e)
}
}
@@ -574,7 +586,7 @@ class DashboardApiClient(
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
fun shutdown() {
fun shutdown() = shutdownOffMainThread("DashboardApiClient-shutdown") {
okHttpClient.dispatcher.executorService.shutdown()
okHttpClient.connectionPool.evictAll()
}
@@ -5,6 +5,7 @@ import android.os.Looper
import android.util.Log
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.network.shutdownOffMainThread
import com.hermesandroid.relay.network.upstream.models.CreateSessionRequest
import com.hermesandroid.relay.network.upstream.models.HermesSseEvent
import com.hermesandroid.relay.network.upstream.models.MessageItem
@@ -1343,7 +1344,7 @@ class HermesApiClient(
// --- Lifecycle ---
fun shutdown() {
fun shutdown() = shutdownOffMainThread("HermesApiClient-shutdown") {
client.dispatcher.executorService.shutdown()
try {
if (!client.dispatcher.executorService.awaitTermination(2, TimeUnit.SECONDS)) {
@@ -86,6 +86,7 @@ import com.hermesandroid.relay.ui.components.ConnectionStatusToast
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
import com.hermesandroid.relay.ui.components.ChatTransportStatusBadge
import com.hermesandroid.relay.ui.components.ChatTransportTier
import com.hermesandroid.relay.ui.components.ConnectionSecurityGlyph
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
import com.hermesandroid.relay.ui.components.RelayStatusStrip
@@ -962,6 +963,7 @@ fun RelayApp() {
val relayReady by connectionViewModel.relayReady.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val appReady by connectionViewModel.isReady.collectAsState()
@@ -1410,6 +1412,11 @@ fun RelayApp() {
// Connections — preserves the affordance the dropped
// header endpoint chip used to provide.
onClick = openConnections,
securityGlyph = if (transportStatus.tier != ChatTransportTier.Offline) {
{ ConnectionSecurityGlyph(connectionSecurity) }
} else {
null
},
)
}
}
@@ -116,6 +116,15 @@ fun ActiveCardStandardStatusSection(
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
// At-a-glance security rollup, promoted out of the Advanced fold. Tap for
// the per-surface breakdown. Single source of truth: ConnectionSecurity.
ConnectionSecurityBadgeWithSheet(
security = connectionSecurity,
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "API Server",
@@ -1110,56 +1119,19 @@ fun ActiveCardSecurityPosture(
connectionViewModel: ConnectionViewModel,
onNavigateToPairedDevices: () -> Unit,
) {
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val insecureReason by connectionViewModel.insecureReason.collectAsState()
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
// ADR 24 — surface the live endpoint role so the insecure badge can
// say "Plain (on LAN)" instead of "Insecure (network unknown)" when
// the resolver already knows which candidate we're on.
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val selectedRouteUrls = buildList {
effectiveApiServerUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
effectiveDashboardUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
val selectedRelayUrl = effectiveRelayUrl.ifBlank { relayUrl }
if (relayConfigured || selectedRelayUrl.isNotBlank()) {
selectedRelayUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
}
}
val secureUrlCount = selectedRouteUrls.count { url ->
isSelectedRouteUrlSecure(
url = url,
activeEndpoint = activeEndpoint,
isTailscaleDetected = isTailscaleDetected,
)
}
val transportState = when {
selectedRouteUrls.isEmpty() -> null
secureUrlCount == selectedRouteUrls.size -> TransportSecurityState.AllSecure
secureUrlCount > 0 -> TransportSecurityState.Mixed
else -> TransportSecurityState.AllInsecure
}
if (transportState != null) {
TransportSecurityBadge(
state = transportState,
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
)
} else {
TransportSecurityBadge(
isSecure = isUrlSecure(relayUrl),
reason = insecureReason.ifBlank { null },
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
activeRole = activeEndpoint?.role,
)
}
// Connection-level security rollup (single source of truth —
// ConnectionSecurity). Tap for the per-surface breakdown + the
// mechanism explainer (TLS vs Tailscale/WireGuard vs plain).
ConnectionSecurityBadgeWithSheet(
security = connectionSecurity,
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
)
if (isTailscaleDetected) {
Row(
@@ -1230,29 +1202,6 @@ fun ActiveCardSecurityPosture(
}
}
private fun isSelectedRouteUrlSecure(
url: String,
activeEndpoint: EndpointCandidate?,
isTailscaleDetected: Boolean,
): Boolean {
if (isUrlSecure(url)) return true
return activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected)
}
private fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
if (this == null) return false
val role = role.lowercase()
val securityHint = security.orEmpty().lowercase()
return role == "tailscale" ||
(isTailscaleDetected && securityHint.contains("tailscale")) ||
role == "plugin_proxy" ||
role == "plugin-proxy" ||
hasSecureProxy() ||
securityHint.contains("wireguard") ||
securityHint.contains("https") ||
securityHint.contains("tls")
}
/**
* Numbered step row for the Manual pairing code fallback. Tightly
* coupled to its Card 3 layout — step badge sizing + content shape —
@@ -0,0 +1,161 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurity
private const val LEARN_MORE_URL =
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
/**
* Per-surface "Connection security" detail sheet — the tap target for the
* connection-security badge. Shows the rollup, the per-transport breakdown,
* and a one-line explainer of the mechanism so the at-a-glance badge never
* has to lie about a mixed connection.
*/
/**
* Self-contained badge that opens the [ConnectionSecuritySheet] on tap. Drop
* it on any surface (connection header, posture strip) without threading sheet
* state through the caller.
*/
@Composable
fun ConnectionSecurityBadgeWithSheet(
security: ConnectionSecurity,
modifier: Modifier = Modifier,
size: TransportSecuritySize = TransportSecuritySize.Chip,
) {
var show by remember { mutableStateOf(false) }
ConnectionSecurityBadge(
security = security,
modifier = modifier,
size = size,
onClick = { show = true },
)
if (show) {
ConnectionSecuritySheet(security = security, onDismiss = { show = false })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionSecuritySheet(
security: ConnectionSecurity,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val uriHandler = LocalUriHandler.current
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = "Connection security",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
ConnectionSecurityBadge(
security = security,
size = TransportSecuritySize.Large,
)
HorizontalDivider()
if (security.surfaces.isEmpty()) {
Text(
text = "No active route yet. Connect to a server to see how each " +
"part of the connection is protected.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
security.surfaces.forEach { SurfaceSecurityRow(it) }
}
HorizontalDivider()
Text(
text = explainer(security.level),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { uriHandler.openUri(LEARN_MORE_URL) }) {
Text("Learn about connection security →")
}
}
}
}
@Composable
private fun SurfaceSecurityRow(surface: SurfaceSecurity) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SurfaceSecurityGlyph(kind = surface.kind, modifier = Modifier.size(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = surface.label,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = surface.url,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
Text(
text = surface.mechanism,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun explainer(level: ConnectionSecurityLevel): String = when (level) {
ConnectionSecurityLevel.Tls ->
"Encrypted with TLS. The server's certificate is pinned on first connect."
ConnectionSecurityLevel.Overlay ->
"Encrypted by your overlay network (e.g. Tailscale/WireGuard), not TLS. " +
"Cert pinning applies only to TLS routes."
ConnectionSecurityLevel.Mixed ->
"Some parts of this connection are encrypted and some are plain. The app " +
"prefers a secure route when one is reachable."
ConnectionSecurityLevel.Plain ->
"Not encrypted. Only safe on a network you fully trust — anyone in between " +
"could read this traffic."
ConnectionSecurityLevel.Unknown -> ""
}
@@ -48,8 +48,11 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.SurfaceSecurityKind
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
import com.hermesandroid.relay.data.isKnownRole
import com.hermesandroid.relay.data.isTlsUrl
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -241,6 +244,7 @@ private fun EndpointRow(
text = candidate.displayLabel(),
style = MaterialTheme.typography.bodyMedium,
)
SurfaceSecurityGlyph(kind = candidate.routeSecurityKind())
if (isActive) {
ActiveChip()
} else if (isPreferred) {
@@ -498,6 +502,18 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
else -> Icons.Filled.Shield
}
/**
* Per-route security classification for the picker glyph. Keyed on the
* candidate's own scheme + role (no device-level Tailscale detection needed —
* a `tailscale`/`plugin_proxy` role is encrypted regardless), so each row can
* be classified independently before it's the active route.
*/
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
else -> SurfaceSecurityKind.Plain
}
/**
* Add/edit dialog for an extra fallback route — the manual counterpart of a
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
@@ -33,6 +33,8 @@ fun RelayStatusStrip(
trailing: String,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
/** Optional security marker rendered just before the route label. */
securityGlyph: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier
@@ -65,6 +67,9 @@ fun RelayStatusStrip(
verticalAlignment = Alignment.CenterVertically,
) {
leadingBadge()
if (securityGlyph != null) {
securityGlyph()
}
if (routeLabel.isNotBlank()) {
Text(
text = "· $routeLabel",
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -22,6 +23,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurityKind
/**
* Visual badge for the current relay transport security posture.
@@ -294,3 +298,123 @@ fun isUrlSecure(url: String?): Boolean {
val lower = url.trim().lowercase()
return lower.startsWith("wss://") || lower.startsWith("https://")
}
// ---------------------------------------------------------------------------
// ConnectionSecurity-driven badge (single source of truth — see
// data/ConnectionSecurity.kt). Mechanism-first copy: a Tailscale/WireGuard
// route reads "Encrypted · Tailscale", NOT "Secure — TLS". Both TLS and
// overlay are green; only true plaintext-without-overlay warns.
// ---------------------------------------------------------------------------
private data class ConnSecAppearance(
val label: String,
val icon: ImageVector,
val bg: Color,
val fg: Color,
)
@Composable
private fun connSecAppearance(security: ConnectionSecurity): ConnSecAppearance {
val green = Color(0xFF2E7D32)
val amber = Color(0xFFF9A825)
val red = MaterialTheme.colorScheme.error
return when (security.level) {
ConnectionSecurityLevel.Tls -> ConnSecAppearance(
label = "Encrypted · TLS",
icon = Icons.Filled.Lock,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Overlay -> ConnSecAppearance(
label = "Encrypted · ${security.mechanism}",
icon = Icons.Filled.Shield,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Mixed -> ConnSecAppearance(
label = "Mixed routes",
icon = Icons.Filled.Shield,
bg = amber.copy(alpha = 0.16f),
fg = amber,
)
ConnectionSecurityLevel.Plain -> ConnSecAppearance(
label = if (security.mechanism.isNotBlank() && security.mechanism != "Plain") {
"Not encrypted · ${security.mechanism}"
} else {
"Not encrypted"
},
icon = Icons.Filled.LockOpen,
bg = red.copy(alpha = 0.16f),
fg = red,
)
ConnectionSecurityLevel.Unknown -> ConnSecAppearance(
label = "Checking…",
icon = Icons.Filled.Shield,
bg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
fg = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* The connection-level security badge every surface should use. Renders the
* rollup from [ConnectionSecurity]; tap (when [onClick] is set) opens the
* per-surface detail sheet. Renders nothing while the verdict is Unknown.
*/
@Composable
fun ConnectionSecurityBadge(
security: ConnectionSecurity,
modifier: Modifier = Modifier,
size: TransportSecuritySize = TransportSecuritySize.Chip,
onClick: (() -> Unit)? = null,
) {
if (security.level == ConnectionSecurityLevel.Unknown) return
val a = connSecAppearance(security)
RenderBadge(
label = a.label,
bg = a.bg,
fg = a.fg,
icon = a.icon,
size = size,
modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier,
)
}
/** Icon-only security marker for tight spots (chat status strip). */
@Composable
fun ConnectionSecurityGlyph(
security: ConnectionSecurity,
modifier: Modifier = Modifier,
) {
if (security.level == ConnectionSecurityLevel.Unknown) return
val a = connSecAppearance(security)
Icon(
imageVector = a.icon,
contentDescription = a.label,
tint = a.fg,
modifier = modifier.size(14.dp),
)
}
/** Per-route security glyph for the route picker (one [SurfaceSecurityKind]). */
@Composable
fun SurfaceSecurityGlyph(
kind: SurfaceSecurityKind,
modifier: Modifier = Modifier,
) {
val green = Color(0xFF2E7D32)
val amber = Color(0xFFF9A825)
val (icon, tint, desc) = when (kind) {
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, "Encrypted (TLS)")
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, "Encrypted")
// Per-route plaintext is amber (informational), not red — a secure
// route may exist alongside it.
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, "Not encrypted")
}
Icon(
imageVector = icon,
contentDescription = desc,
tint = tint,
modifier = modifier.size(14.dp),
)
}
@@ -26,8 +26,10 @@ import com.hermesandroid.relay.data.MediaSettingsRepository
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.ConnectionValidation
import com.hermesandroid.relay.data.computeConnectionSecurity
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SessionTransport
@@ -1122,6 +1124,33 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
)
val isTailscaleDetected: StateFlow<Boolean> = tailscaleDetector.isTailscaleDetected
/**
* Single source of truth for the connection-security indicator (chat
* status chip, connection header, route picker, detail sheet). Rolls up
* the per-surface scheme of API / dashboard / relay against the active
* route — overlay transports (Tailscale/WireGuard/proxy) count as
* encrypted, not just TLS. Declared after [isTailscaleDetected] because it
* reads it. See `data/ConnectionSecurity.kt`.
*/
val connectionSecurity: StateFlow<ConnectionSecurity> = combine(
effectiveApiServerUrl,
effectiveDashboardUrl,
effectiveRelayUrl,
relayConfigured,
activeEndpoint,
) { api, dashboard, relay, relayCfg, endpoint ->
arrayOf(api, dashboard, relay, relayCfg, endpoint)
}.combine(isTailscaleDetected) { values, tailscale ->
computeConnectionSecurity(
apiUrl = values[0] as String,
dashboardUrl = values[1] as String,
relayUrl = values[2] as String,
relayConfigured = values[3] as Boolean,
activeEndpoint = values[4] as EndpointCandidate?,
isTailscaleDetected = tailscale,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectionSecurity.UNKNOWN)
// What's New tracking
private val _showWhatsNew = MutableStateFlow(false)
val showWhatsNew: StateFlow<Boolean> = _showWhatsNew.asStateFlow()
@@ -3446,6 +3475,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
url = dashboardUrl,
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
// Defense-in-depth: this runs in a viewModelScope (Main) coroutine,
// so an unexpected throw from any probe sub-call would crash the
// app (see the currentSession() stale-connection crash). A probe
// failure must only degrade the UI, never be fatal.
android.util.Log.w("ConnectionVM", "probeStandardVoice failed: ${e.message}")
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unreachable)
} finally {
client.shutdown()
}
@@ -5366,21 +5407,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
override fun onCleared() {
super.onCleared()
connectionManager.shutdown()
// ViewModel.onCleared runs on the main thread and viewModelScope
// is already being cancelled — fire-and-forget the client
// shutdown on a plain background Thread so
// ConnectionPool.evictAll doesn't trip
// onCleared runs on the main thread, but every client's shutdown()
// routes ConnectionPool.evictAll() (a synchronous TLS socket close /
// network write) off the main thread internally via
// shutdownOffMainThread, so these direct calls can't trip
// NetworkOnMainThreadException on live SSL sockets.
_apiClient.value?.let { client ->
Thread({ runCatching { client.shutdown() } }, "HermesApiClient-shutdown").start()
}
profileChatApiClient?.let { client ->
Thread(
{ runCatching { client.shutdown() } },
"HermesProfileApiClient-shutdown",
).start()
}
connectionManager.shutdown()
_apiClient.value?.shutdown()
profileChatApiClient?.shutdown()
tailscaleDetector.shutdown()
// Release the cached VirtualDisplay + ImageReader + HandlerThread
// built by ScreenCapture on the first /screenshot call. Without
@@ -0,0 +1,120 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Locks the connection-security rollup (the single source of truth behind the
* in-app indicator). The headline correctness property: a plaintext route over
* an overlay network (Tailscale/WireGuard) is **encrypted**, not "insecure".
*/
class ConnectionSecurityTest {
private fun endpoint(role: String, security: String? = null) = EndpointCandidate(
role = role,
api = ApiEndpoint(host = "h", port = 8642),
relay = RelayEndpoint(url = "ws://h:8767"),
security = security,
)
@Test
fun allTlsSurfaces_rollUpToTls() {
val result = computeConnectionSecurity(
apiUrl = "https://h:8642",
dashboardUrl = "https://h:9119",
relayUrl = "wss://h:8767",
relayConfigured = true,
activeEndpoint = endpoint("public"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Tls, result.level)
assertEquals("TLS", result.mechanism)
assertEquals(3, result.surfaces.size)
}
@Test
fun plaintextOverTailscale_isEncryptedOverlay_notPlain() {
val result = computeConnectionSecurity(
apiUrl = "http://100.71.0.1:8642",
dashboardUrl = "http://100.71.0.1:9119",
relayUrl = "ws://100.71.0.1:8767",
relayConfigured = true,
activeEndpoint = endpoint("tailscale"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
assertEquals("Tailscale", result.mechanism)
// The whole point: overlay counts as encrypted.
assertEquals(true, result.isEncrypted)
}
@Test
fun someTlsSomePlain_isMixed() {
val result = computeConnectionSecurity(
apiUrl = "https://h:8642",
dashboardUrl = "https://h:9119",
relayUrl = "ws://h:8767",
relayConfigured = true,
activeEndpoint = endpoint("lan"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Mixed, result.level)
assertEquals(false, result.isEncrypted)
}
@Test
fun allPlainLan_isPlain_withRoleMechanism() {
val result = computeConnectionSecurity(
apiUrl = "http://192.168.1.10:8642",
dashboardUrl = "http://192.168.1.10:9119",
relayUrl = "ws://192.168.1.10:8767",
relayConfigured = true,
activeEndpoint = endpoint("lan"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Plain, result.level)
assertEquals("LAN", result.mechanism)
}
@Test
fun relayNotConfigured_excludesRelaySurface() {
val result = computeConnectionSecurity(
apiUrl = "https://h:8642",
dashboardUrl = "https://h:9119",
relayUrl = "ws://h:8767", // plain, but relay not configured → ignored
relayConfigured = false,
activeEndpoint = endpoint("public"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Tls, result.level)
assertEquals(2, result.surfaces.size)
}
@Test
fun noSurfaces_isUnknown() {
val result = computeConnectionSecurity(
apiUrl = "",
dashboardUrl = "",
relayUrl = "",
relayConfigured = false,
activeEndpoint = null,
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Unknown, result.level)
assertEquals(ConnectionSecurity.UNKNOWN, result)
}
@Test
fun deviceTailscaleDetected_withSecurityHint_classifiesOverlay() {
val result = computeConnectionSecurity(
apiUrl = "http://host:8642",
dashboardUrl = "http://host:9119",
relayUrl = "ws://host:8767",
relayConfigured = false,
activeEndpoint = endpoint(role = "custom", security = "tailscale-magicdns"),
isTailscaleDetected = true,
)
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
assertEquals("Tailscale", result.mechanism)
}
}
@@ -0,0 +1,63 @@
package com.hermesandroid.relay.network
import android.os.Looper
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* Guards the fix for the `NetworkOnMainThreadException` crash (issues #70 /
* #118 / #124): client `shutdown()` reaches `ConnectionPool.evictAll()`, which
* closes live TLS sockets with a synchronous network write. The teardown block
* must never execute on the main thread.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NetworkShutdownTest {
@Test
fun whenCalledOnMainThread_runsTeardownOffTheMainThread() {
// Robolectric drives the test body on the main looper — the same place
// a viewModelScope (Dispatchers.Main.immediate) coroutine resumes and
// shuts a dashboard/API client down in a `finally` block.
assertSame(Looper.myLooper(), Looper.getMainLooper())
val mainThread = Looper.getMainLooper().thread
val ranOn = AtomicReference<Thread>()
val latch = CountDownLatch(1)
shutdownOffMainThread("test-shutdown") {
ranOn.set(Thread.currentThread())
latch.countDown()
}
assertTrue("teardown block never ran", latch.await(5, TimeUnit.SECONDS))
assertNotSame(
"evictAll must not run on the main thread",
mainThread,
ranOn.get(),
)
}
@Test
fun whenCalledOffMainThread_runsTeardownInline() {
val ranOn = AtomicReference<Thread>()
val latch = CountDownLatch(1)
val worker = Thread {
shutdownOffMainThread("test-shutdown") { ranOn.set(Thread.currentThread()) }
latch.countDown()
}
worker.start()
assertTrue(latch.await(5, TimeUnit.SECONDS))
// Off the main thread the block runs inline (no extra hop), preserving
// the blocking awaitTermination semantics for callers already off main.
assertSame(worker, ranOn.get())
}
}
@@ -7,6 +7,7 @@ import kotlinx.serialization.json.jsonObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -56,6 +57,20 @@ class DashboardApiClientTest {
assertEquals("0.16.0", status.version)
}
@Test
fun currentSession_onConnectionAbort_returnsFailure_doesNotThrow() = runTest {
// Reproduces the crash: a stale pooled connection aborting mid-flight
// ("Software caused connection abort"). currentSession() returns a
// Result, so a network failure MUST surface as Result.failure — never a
// throw that escapes withContext(IO) and crashes the Main coroutine.
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val result = client.currentSession()
assertTrue("network abort must be Result.failure, not a throw", result.isFailure)
}
@Test
fun getStatus_acceptsProviderObjects() = runTest {
server.enqueue(
+9 -3
View File
@@ -438,7 +438,7 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
**Decision:** Replace the minimal pairing model (one-shot code → fixed-30-day session token → no channel separation → `EncryptedSharedPreferences` storage) with a layered architecture built around four ideas:
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. (Both `wss` and Tailscale are treated as *secure transports* here — but for different reasons: `wss` is TLS, while Tailscale's security comes from WireGuard end-to-end encryption, not TLS. See [`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).) Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
2. **Per-channel grants** — one session token, separate expiries for `chat` / `terminal` / `bridge`; later releases added `tui` and split voice grants (`voice:config`, `voice:stt`, `voice:tts`). Blast-radius-heavy channels can have shorter caps, and all grants are clamped to the session lifetime. Chat runs through the hermes-agent API server rather than the relay, so the chat grant is informational only (used by the phone UI to show scope).
3. **Hardware-backed token storage with graceful fallback** — `KeystoreTokenStore` requests StrongBox-backed keys via `setRequestStrongBoxBacked(true)` on Android 9+ devices that advertise `FEATURE_STRONGBOX_KEYSTORE`. Falls back to the existing `LegacyEncryptedPrefsTokenStore` (TEE-backed `EncryptedSharedPreferences`) on older devices or when the Keystore path throws. Migration is one-shot and lossless — users never lose a session to an app upgrade.
4. **TOFU cert pinning with explicit reset on re-pair** — `CertPinStore` records SHA-256 SPKI fingerprints per `host:port` on the first successful wss connect. Subsequent connects build an OkHttp `CertificatePinner` from the stored pin. A user-initiated QR re-pair (`applyServerIssuedCodeAndReset(code, relayUrl)`) wipes the pin for the target host — re-pair is explicit consent to potentially-new cert material. Plaintext ws:// short-circuits pinning entirely.
@@ -1077,8 +1077,14 @@ priority-0 candidate from the top-level fields when `endpoints` is absent.
phone falls through to the next candidate in priority order.
- **TTL defaults by role** (informational — operator can override at
pair time): `lan` → 7 days, `tailscale` → 30 days, `public` → 30 days,
unknown role → 7 days (conservative). Plaintext-`ws://` consent still
gates any candidate with `transport_hint = "ws"`.
unknown role → 7 days (conservative). The longer `tailscale` default
reflects that the tailnet is a *secure transport* (WireGuard end-to-end
encryption + device identity) — not that the link is TLS. A
`tailscale` candidate can carry a plain `transport_hint = "ws"` and
still be encrypted; that's WireGuard, not TLS. See
[`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).
Plaintext-`ws://` consent still gates any candidate with
`transport_hint = "ws"`.
**Canonicalization for the HMAC signature:** `canonicalize()` in
`plugin/relay/qr_sign.py` uses `json.dumps(sort_keys=True,
@@ -0,0 +1,199 @@
# Connection Security Indicator — Surfacing, Wording & Docs Plan
**Status:** Draft for review (no implementation yet — placement decisions pending)
**Date:** 2026-06-24
**Owner surface:** Android app (UI), user docs, engineering docs
**Companion to:** [`docs/plans/2026-06-18-native-secure-routes.md`](2026-06-18-native-secure-routes.md) (Features-vs-Routes split + plugin secure proxy mechanics). That plan owns *how routes work*; **this plan owns how security is communicated** to the user across every surface.
**Goal:** Let a user tell, at a glance and without ambiguity, whether their connection to Hermes is encrypted — and by what (TLS, Tailscale/WireGuard, or not at all) — without the app lying or scaring people who are already secure.
---
## Bottom line
Users keep asking "is this secure?" The honest answer today is *"yes, but the app barely tells you, and where it does, it sometimes lies."* The security **model already exists** in code — it's just (a) buried in `Manage → Connections → Advanced`, (b) mislabelled (a Tailscale route is reported as **"Secure — TLS"** when it's actually WireGuard, not TLS), and (c) absent from every at-a-glance surface (the chat status chip, the connection header, the route picker).
This is a **surfacing + wording + docs** task, not a greenfield feature. We promote the existing computation to a single source of truth, correct the copy, place a glanceable badge on the high-traffic surfaces, add a tap-through "Connection security" explainer, and fix the docs that conflate "Tailscale" with "TLS."
---
## What already exists (do not rebuild)
| Asset | File | What it does |
|---|---|---|
| Tri-state model | `ui/components/TransportSecurityBadge.kt` — `TransportSecurityState { AllSecure, Mixed, AllInsecure }` | Badge with lock/shield/lock-open icons + 3 size variants. |
| Overlay-aware "is this route encrypted" | `ActiveConnectionSections.kt:1233` `isSelectedRouteUrlSecure()` → `isEncryptedOverlayRoute()` (`:1242`) | **Already** treats `role=="tailscale"`, `plugin_proxy`, WireGuard/HTTPS security hints, and `hasSecureProxy()` as encrypted — not just `wss`/`https`. |
| Security posture strip | `ActiveConnectionSections.kt:1109` `ActiveCardSecurityPosture` | Renders the badge + "Tailscale detected" + "hardware keystore" + relay-sessions row. **Buried** under the Advanced section. |
| Insecure consent | `ui/components/InsecureConnectionAckDialog.kt`; `ConnectionManager.kt:156–325` (`insecureMode`/`isInsecureConnection`, ws:// block) | Threat-model dialog + reason picker; blocks `ws://` unless insecure mode is on. |
| TOFU cert pinning | `auth/CertPinStore.kt` (TLS-only, per `host:port`) | Pins on first `wss`/`https` connect. Not surfaced to users. |
| Per-endpoint label | `data/Endpoint.kt:136` `displayLabel()` | "LAN" / "Tailscale" / "HTTPS" / "Plugin proxy". |
**The three concrete defects to fix:**
1. **Buried** — the only real security readout lives below `Advanced` on the Manage tab. Most users never see it.
2. **The "TLS lie"** — `resolveStateAppearance(AllSecure)` hardcodes the label **"Secure — TLS"** even when the secure-ness comes from Tailscale/WireGuard (`isEncryptedOverlayRoute` returned true for a `ws://` Tailscale route). Saying "TLS" for a non-TLS link is wrong and erodes trust.
3. **No glanceable surface** — the chat status chip (`RelayApp.kt` ~`920–975`, `ChatTransportStatusBadge.kt`), the connection card header, and the route picker (`EndpointsCard.kt`) show the *route name* but never its *security*.
---
## The hard question: 3 transports → is "secure" even well-defined?
**You asked: does having 3 potential transports make this hard to call "secure"? Yes — and that's the core design problem.** A single paired connection fans out to several surfaces, each with an **independent** scheme (confirmed in `Endpoint.kt:37–94` + `ConnectionViewModel.kt:746–820`):
| Surface | Client | Scheme source | Can be plain while others are TLS? |
|---|---|---|---|
| Gateway chat (`/api/ws`) | `GatewayChatClient` | dashboard URL scheme | yes |
| API / sessions (SSE) | `HermesApiClient` | `endpoint.api.tls` | yes |
| Dashboard (Manage/voice) | `DashboardApiClient` | `endpoint.dashboard.url` ∨ derived from `api.tls` | yes |
| Relay (terminal/bridge/tools) | `ConnectionManager` | `endpoint.relay.url` (`ws`/`wss`) | yes |
So **a connection is not uniformly secure** — relay can be `ws://` while the API is `https://`. (Concretely: one paired connection can carry API `https://host:8642`, dashboard derived to `https://host:9119`, and relay `ws://host:8767` — secure chat/Manage, plain relay — at the same time.) A single binary "Secure" badge would lie. The existing `AllSecure / Mixed / AllInsecure` rollup is the right instinct; we keep it but make it **honest and overlay-aware**.
**Decision (proposed):** show a **connection-level rollup for the glance, per-surface truth on tap.**
- **Glance badge** = worst-case across the surfaces *actually in use*: all encrypted → secure; some plain → "Mixed"; all plain with no overlay → "Not encrypted."
- **Tap → detail sheet** = the per-surface breakdown (Chat/API: 🔒, Relay: ⚠️, …) so power users get the truth without the chip having to.
- Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard/plugin proxy), not just TLS — because for the user those *are* secure end-to-end.
---
## The wording model (the part that fixes the trust problem)
Reframe from a binary "Secure/Insecure" to **mechanism-first, 4 outcomes**. The key correction: **Tailscale is secure** — WireGuard gives end-to-end encryption + device identity, arguably stronger than TOFU-pinned TLS. Telling a Tailscale user they're "insecure/plain" is both wrong and the likely reason they keep asking.
| State | When | Icon | Chip copy | Tone |
|---|---|---|---|---|
| **TLS** | every in-use surface is `wss`/`https` | 🔒 Lock | `Encrypted · TLS` | green |
| **Private network** | plain scheme, but route is Tailscale / WireGuard / plugin proxy | 🛡️ Shield | `Encrypted · Tailscale` (or `· WireGuard` / `· Proxy`) | green |
| **Mixed** | some surfaces encrypted, some plain (a secure fallback exists) | 🛡️ Shield | `Mixed routes` | amber |
| **Not encrypted** | plain `ws`/`http`, no overlay | ⚠️ Lock-open | `Not encrypted · LAN` | amber→red by context |
Notes:
- Both 🔒 and 🛡️ are **green/"secure"** — only true plaintext-without-overlay is a warning. This is the single most important copy change.
- Keep "Plain"/"Not encrypted" (never a blank); avoid the word "Insecure" in the chip (reserve it for the consent dialog where the threat model is explained).
- The detail sheet spells out the distinction in one line each: *"TLS — encrypted to this server's certificate (pinned on first connect)."* / *"Tailscale — encrypted by your tailnet (WireGuard), not TLS."* / *"Not encrypted — only safe on a network you fully trust."*
- **Code change:** replace the hardcoded `"Secure — TLS"` label (`TransportSecurityBadge.kt:219`) with mechanism-derived copy, and split `AllSecure` into `Tls` vs `Overlay` so the badge can say which.
---
## Placement audit & recommendation
Full surface inventory in the appendix. Recommended placements, highest-traffic first:
### P1 — Chat bottom status chip (the one everyone sees)
`RelayApp.kt` ~`920–975`, beside `ChatTransportStatusBadge` + route label. Today: `⚡ Gateway · Tailscale gpt-5.5 / profile: default`. Add a leading security glyph:
```
┌─────────────────────────────────────────────────────────┐
│ ⚡ Gateway 🛡️ Tailscale gpt-5.5 / profile: default │ ← encrypted via Tailscale (green shield)
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ⚡ Gateway 🔒 TLS gpt-5.5 / profile: default │ ← encrypted via TLS (green lock)
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ⚡ Gateway ⚠️ Not encrypted gpt-5.5 / profile: default │ ← plain LAN, no overlay (amber)
└─────────────────────────────────────────────────────────┘
```
Glyph replaces/precedes the bare route word so "Tailscale" now reads as *secure-Tailscale*. Tap the chip → **Connection security** detail sheet.
### P2 — Connection card header (Manage → Connections)
`ActiveConnectionSections.kt` card header — add the same badge next to the `Active` pill so the connection list communicates security without expanding Advanced. Promotes the existing `ActiveCardSecurityPosture` logic up out of the Advanced fold.
### P3 — Route picker (`EndpointsCard.kt`)
Per-route security glyph on each candidate row, so when a user switches routes they see which are encrypted *before* committing:
```
○ LAN ⚠️ Not encrypted 192.168.x.x · Probe ✓
● Tailscale 🛡️ Encrypted 100.x.y.z · Active
○ Public 🔒 TLS <host>.ts.net · Probe ✓
```
### P4 — "Connection security" detail sheet (new, the tap target for P1/P2)
A small bottom sheet that is the single place the per-surface truth + the explainer lives:
```
Connection security — <your server>
────────────────────────────────────
Overall 🛡️ Encrypted (Tailscale)
Chat (gateway) 🛡️ Tailscale http://100.x.y.z:9119
API / sessions 🛡️ Tailscale http://100.x.y.z:8642
Relay tools 🛡️ Tailscale ws://100.x.y.z:8767
────────────────────────────────────
🛡️ Tailscale encrypts this with WireGuard (not TLS).
Cert pinning applies only to TLS routes.
[ Learn about connection security → ] (docs link)
```
> **For your review:** P1 + P4 are the must-haves (glance + truth-on-tap). P2/P3 are high-value but optional for a first cut. The detail sheet is also the natural home for the **TOFU pin** ("Server identity pinned ✓") and the hardware-keystore line that currently sit in the buried posture strip.
---
## Secure proxy: status and how it fits
The **plugin secure proxy** (the "Secure proxy — Not advertised" row) is a **stub today**: the Android side models it (`Endpoint.kt` `ProxyEndpoint`, `plugin_proxy` role, `hasSecureProxy()`), and `isEncryptedOverlayRoute()` already treats it as encrypted — but **the relay has no proxy-forward implementation, pairing never emits a `plugin_proxy` candidate, and no cert/pin is generated.** Enabling it end-to-end is the unbuilt **Phase 4** of `2026-06-18-native-secure-routes.md` (relay HTTP-forward routes + `RELAY_SSL_*` cert + pairing emission; ~2–3 wk).
**Implication for this plan:** the indicator must **not block** on the proxy. We design the wording/placement so that *when* a `plugin_proxy` route is advertised it slots in as a 🔒 **TLS (pinned)** route automatically (it already would, via `isEncryptedOverlayRoute`). Until then it stays honestly "Not advertised." Recommend a separate spike to stand it up + test on the server (tracked in `TODO.md`), independent of this UX work.
---
## Documentation plan
The docs currently **conflate "Tailscale" with "TLS/secure"** in several places — fixing this is half the user-facing win.
**New page:** `user-docs/architecture/connection-security.md` — "Is my connection secure?" Covers: `ws`/`wss` & `http`/`https`; what Tailscale actually does (WireGuard VPN, encrypted + identity, *plus* optional Serve-HTTPS); TLS + TOFU pinning; the per-surface model; how to read the in-app badge; how to get a TLS route (Tailscale Serve `--https`, reverse proxy, or the future plugin proxy). Add to the `/architecture/` sidebar after `security.md`. (Pairs 1:1 with the in-app detail-sheet "Learn more" link.)
**Conflation fixes (call out "WireGuard ≠ TLS, both are secure"):**
- `docs/decisions.md` §15 (`:441` TTL `wss or Tailscale → 30d`) and §24 — annotate that Tailscale's security is WireGuard, separate from `wss`.
- `user-docs/architecture/security.md:62–69` — split "Tailscale (VPN + optional managed TLS)" from "reverse proxy (TLS only)."
- `user-docs/guide/remote-access.md:27–32, 70–84` — distinguish *who terminates TLS* from *Tailscale provides the network*.
- Document TOFU pinning for users for the first time (currently code-only).
---
## Open decisions (your call before implementation)
1. **Is "plain over Tailscale" green or amber?** Recommendation: **green 🛡️ "Encrypted · Tailscale"** (WireGuard is genuinely secure). This is the crux of the trust fix. (Alternative: amber, treating only TLS as fully green — more conservative, but keeps confusing Tailscale users.)
2. **Glance scope:** connection-rollup badge + per-surface on tap (recommended), vs. always show per-surface inline (busier).
3. **First-cut scope:** P1 (chat chip) + P4 (detail sheet) + wording fix + docs page — vs. also P2/P3 in the same PR.
4. **Word choice:** "Encrypted" vs "Secure" vs "Private" for the overlay state. Recommendation: **"Encrypted · <mechanism>"** (concrete, non-marketing).
5. **Proxy:** confirm we keep it out of scope here (separate Phase-4 spike).
---
## Implementation tiers (after decisions land)
> Scope: **A** = ship-now UX · **Doc** = docs · effort **S/M/L**.
### A1 — Single source of truth: `ConnectionSecurity` model · M
Lift `isSelectedRouteUrlSecure`/`isEncryptedOverlayRoute` + the per-surface URL scheme reads into a ViewModel-exposed `StateFlow<ConnectionSecurity>` (`{ overall: Tls|Overlay|Mixed|Plain, perSurface: Map<Surface, SecurityKind>, mechanism: String }`). Every surface reads this one flow.
**Files:** new `viewmodel/ConnectionSecurity.kt`; `ConnectionViewModel.kt`; refactor `ActiveConnectionSections.kt:1109–1254`.
### A2 — Fix the wording / split `AllSecure` into Tls vs Overlay · S
Replace hardcoded `"Secure — TLS"`; mechanism-derived copy; new state for overlay. Pure `TransportSecurityBadge.kt` change + tests.
### A3 — P1 chat status chip glyph · S
Add the security glyph to the chat bottom strip; tap → detail sheet. **Files:** `RelayApp.kt`, `ChatTransportStatusBadge.kt`.
### A4 — P4 "Connection security" detail sheet · M
New bottom sheet; per-surface rows + explainer + TOFU/keystore lines + docs link. **Files:** new `ui/components/ConnectionSecuritySheet.kt`.
### A5 — P2 header badge + P3 route-picker glyphs · M (optional first cut)
**Files:** `ActiveConnectionSections.kt`, `EndpointsCard.kt`.
### Doc1 — `connection-security.md` + conflation fixes · M
New user-docs page + the four conflation edits + TOFU documentation.
### Spike — stand up & test the plugin secure proxy · L (separate, not blocking)
Phase 4 of `2026-06-18-native-secure-routes.md`. Tracked in `TODO.md`.
---
## Appendix — full UI surface inventory
| Surface | File:area | Shows today | Security data available |
|---|---|---|---|
| Chat status chip | `RelayApp.kt` ~920–975; `ChatTransportStatusBadge.kt` | transport tier + route + model | route role + per-surface URL schemes |
| Connection card header | `ActiveConnectionSections.kt` (card header) | name + Active + route summary | full per-surface |
| Status rows (API/Dashboard/Relay/Session) | `ActiveConnectionSections.kt:108–219` | reachable/connected + "Connected · Tailscale" | role known, security not rendered |
| Feature rows (incl. "Secure proxy") | `ActiveConnectionSections.kt:227–380` | Ready/Configured/Not advertised | proxy advertise flag |
| Security posture strip | `ActiveConnectionSections.kt:1109–1231` | **the existing badge** (buried under Advanced) | full (this is the source to promote) |
| Route picker | `EndpointsCard.kt:79–194` | per-route role + health | per-candidate scheme |
| Insecure toggle + ack | `ActiveConnectionSections.kt:806–866`; `InsecureConnectionAckDialog.kt` | warning + reason picker | `isInsecureConnection` |
| Connection info sheet | `ConnectionInfoSheet.kt` | session state | session relay URL |
| Pair wizard confirm | `OnboardingScreen.kt` / pairing flow | route candidates | candidate schemes (good place for per-route glyph at commit time) |
+3 -6
View File
@@ -83,13 +83,10 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.2.1 — Polish & control.
v1.2.4 — Stability + connection security.
• Lock the app to a single agent profile and hide the rest.
• In-app "What's New" with current and past release notes.
• Diagnostics with clean error titles and one-tap reporting.
• A tasteful, dismissable "update available" nudge.
• Voice fixes: Stop halts speech instantly, steadier hold-to-talk, a more readable overlay, and chosen voices apply in Auto mode.
• Fixed a crash that could close the app when the dashboard connection dropped mid-check (e.g. a brief Tailscale blip) — it now fails gracefully instead of force-closing.
• New: see whether your connection is encrypted at a glance (TLS or Tailscale) from the chat chip, connection card, and route picker, with a per-transport breakdown on tap.
```
## Category
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.2.2"
appVersionCode = "16"
appVersionName = "1.2.4"
appVersionCode = "18"
agp = "9.2.1"
kotlin = "2.4.0"
compose-bom = "2026.06.00"
+1
View File
@@ -137,6 +137,7 @@ export default defineConfig({
{ text: 'Flavor Differences', link: '/architecture/flavor-differences' },
{ text: 'Decisions', link: '/architecture/decisions' },
{ text: 'Security', link: '/architecture/security' },
{ text: 'Is my connection secure?', link: '/architecture/connection-security' },
{ text: 'Privacy', link: '/architecture/privacy' },
],
},
@@ -0,0 +1,172 @@
# Is my connection secure?
Short answer: **probably yes** — and Hermes-Relay now tells you at a glance, without
overstating or understating it.
This page explains what "encrypted" actually means for your connection, why a Tailscale
link is genuinely secure even when it looks like plain `http://`, and how to read the
in-app security indicator. If you just want the quick version, jump to
[How to read the indicator](#how-to-read-the-in-app-indicator).
## Plain vs. encrypted: `ws://` vs `wss://`, `http://` vs `https://`
Every connection uses a URL scheme, and the scheme tells you whether the link is
encrypted by **TLS** (the same technology the lock icon in your browser refers to):
| Scheme | What it is | Privacy |
|---|---|---|
| `http://` / `ws://` | **Plaintext.** No TLS. | Anyone on the network path can read the traffic. |
| `https://` / `wss://` | **TLS-encrypted.** | The traffic is encrypted to the server's certificate. |
On its own, a plain `http://` or `ws://` link is readable by anyone between your phone
and the server — your home router, the coffee-shop Wi-Fi, an upstream ISP. That's why
plaintext is only safe on a network you fully trust.
**But scheme isn't the whole story.** A plain `http://` link can still be fully encrypted
if it rides inside an encrypted overlay network like Tailscale. That's the part people
get wrong — including, until now, our own docs.
## What Tailscale actually is
[Tailscale](https://tailscale.com/) is a **WireGuard-based VPN**. When your phone and your
Hermes host are both on your tailnet, every byte between them is **encrypted and
authenticated end-to-end by WireGuard** — before it ever touches the URL scheme. This is
genuinely secure transport: strong modern encryption plus device identity (only enrolled
devices on your tailnet can talk to each other).
So a connection to `http://100.x.y.z:8642` **over your tailnet is encrypted** — by
WireGuard, not by TLS. It is *not* plaintext-on-the-wire even though the scheme says
`http`. **Tailscale plaintext is secure; it's just not TLS.** Hermes-Relay treats it as a
green/secure route, and never labels a Tailscale route "insecure."
Tailscale can *also*, separately, terminate TLS for you. Running
`tailscale serve --https=<port>` puts a real TLS certificate in front of a service, so the
same connection becomes `https://<host>.ts.net:<port>` — now you have **both** WireGuard
encryption *and* TLS. You don't need the TLS layer for the link to be secure over a
tailnet, but it's there if you want a `wss://`/`https://` route (some tools and proxies
expect one).
::: tip The one thing to remember
WireGuard encryption ≠ TLS, but **both are secure transports.** A Tailscale route is
encrypted whether or not TLS is also in play. The app's 🛡️ shield means "encrypted by your
private network" — it is a green/secure state, not a warning.
:::
## TLS + certificate pinning (TOFU)
When Hermes-Relay connects over a TLS route (`wss://`/`https://`) for the **first** time,
it records a fingerprint of the server's certificate — its SHA‑256 SPKI. This is
**trust-on-first-use (TOFU) pinning**: every later connection to that same host must
present the *same* certificate, or the app refuses to connect.
What this buys you:
- After the first connect, a man-in-the-middle can't swap in a different certificate to
intercept your traffic — the pin won't match.
- The pin is per `host:port`, stored on-device.
- Re-pairing the device (scanning a fresh QR) intentionally resets the pin for that host,
because re-pairing is explicit consent to potentially new certificate material.
Two honest caveats:
- **Pinning only applies to TLS routes.** A Tailscale-over-`http` route has no TLS
certificate to pin — its security comes from WireGuard instead, which provides its own
device identity.
- **TOFU can't protect the very first connect.** By definition it trusts whatever
certificate is present on the initial handshake, so do your first connection over a path
you trust (LAN, Tailscale, or VPN). It protects every connection after that.
## Why one connection has several security states
A single paired connection isn't one pipe — it fans out to several **surfaces**, and each
one can independently be TLS, overlay-encrypted, or plain:
| Surface | What it carries | Typical port |
|---|---|---|
| **Chat (gateway)** | Live chat, thinking/reasoning | dashboard `:9119` |
| **API / sessions** | Chat fallback, session history | API `:8642` |
| **Dashboard (Manage + voice)** | Settings, model config, vanilla voice | dashboard `:9119` |
| **Relay tools** | Terminal, bridge, device control | relay `:8767` |
Because each surface has its own URL, **a connection can be partly encrypted and partly
plain at the same time** — for example chat and Manage on `https://`, but relay tools on
plain `ws://`. There's no single true/false answer to "is it secure," so a single binary
badge would lie.
Hermes-Relay handles this with a **rollup at a glance, the full truth on tap**:
- The **glance badge** reflects the worst case across the surfaces actually in use.
- **Tapping it** opens a per-surface breakdown so you can see exactly which routes are
encrypted and how.
## How to read the in-app indicator
The security glyph appears next to the route on the chat status chip and the connection
card. There are four outcomes:
| Indicator | Meaning | Tone |
|---|---|---|
| 🔒 `Encrypted · TLS` | Every in-use surface is `wss`/`https`, pinned on first connect. | **Secure (green)** |
| 🛡️ `Encrypted · Tailscale` | Plain scheme, but the route is Tailscale / WireGuard / a secure proxy — encrypted by the overlay. | **Secure (green)** |
| 🛡️ `Mixed routes` | Some surfaces are encrypted, some are plain (a secure fallback exists). | Amber — review the breakdown |
| ⚠️ `Not encrypted` | Plain `ws`/`http` with no overlay. | Warning — only safe on a network you fully trust |
The key idea: **both 🔒 and 🛡️ are green/secure.** Only true plaintext with no overlay is a
warning. Tap the indicator for the per-surface detail, where each route is spelled out in
one line:
- *TLS — encrypted to this server's certificate (pinned on first connect).*
- *Tailscale — encrypted by your tailnet (WireGuard), not TLS.*
- *Not encrypted — only safe on a network you fully trust.*
If you see ⚠️ **Not encrypted**, you're on a plain `ws://`/`http://` route with nothing
wrapping it. That's fine on a home LAN or a trusted VPN, but you should add an encrypted
route before using it over public Wi-Fi or the open internet.
## How to get a TLS (or otherwise encrypted) route
You have a few ways to make a connection secure. Pick whichever fits your setup:
### 1. Tailscale (recommended)
Putting both devices on a tailnet gives you WireGuard encryption immediately — you're
secure (🛡️) with no certificates to manage. If you also want TLS-fronted `https://`/`wss://`
routes, run Tailscale Serve:
```bash
tailscale serve --https=<port> http://127.0.0.1:<port>
```
The `hermes-relay-tailscale` helper fronts the two relay-owned services for you — relay
(`:8767`) and the Hermes API server (`:8642`):
```bash
hermes-relay-tailscale enable
```
The **dashboard** (`:9119`, used for Manage and vanilla voice) is **not** fronted by the
helper — if you want a TLS route to the dashboard, you front it yourself with
`tailscale serve --https=9119 http://127.0.0.1:9119`. Without that, your dashboard surface
rides plain `http` over the tailnet — which is still WireGuard-encrypted and secure, just
not TLS.
### 2. A public reverse proxy
A proxy like **Caddy**, **nginx**, or **Cloudflare** can terminate TLS in front of your
services and expose `https://`/`wss://` routes to the open internet. The proxy holds the
certificate; your phone pins it on first connect. This is the path to use when you're
exposing Hermes beyond a private network — never expose plain `ws://`/`http://` ports
directly.
### 3. The plugin secure proxy *(not yet available)*
A future relay-built secure proxy will mint and front its own TLS for the relay surfaces,
so you get a pinned `wss://` route without standing up Tailscale Serve or an external
proxy. It is **not implemented yet** — when it ships, the app will slot it in
automatically as a 🔒 TLS (pinned) route. Until then, use Tailscale or a reverse proxy.
## See also
- [Security](./security.md) — full security model: key storage, auth flow, the bridge safety gate.
- [Remote access](../guide/remote-access.md) — step-by-step Tailscale and reverse-proxy setup.
- [Privacy](./privacy.md) — what data the app stores and where.
+13 -2
View File
@@ -103,8 +103,19 @@ Every command is logged to the Bridge tab's activity log (timestamp, status, res
## Recommendations
1. **Use HTTPS** in production — the network security config enforces it by default
1. **Use an encrypted route** in production. Two independent ways to get there, both secure:
- **TLS** (`https://`/`wss://`) — via a reverse proxy (Caddy/nginx/Cloudflare) or
`tailscale serve --https`. Pinned on first connect (TOFU).
- **Tailscale / WireGuard** — even a plain `http://`/`ws://` route over your tailnet is
**encrypted end-to-end by WireGuard**. This is *not* TLS, but it *is* secure transport;
a Tailscale link is not "plaintext on the wire."
Don't conflate the two: TLS and WireGuard are different mechanisms that both make a
connection secure. See [Is my connection secure?](./connection-security.md) for how the
app reports each (🔒 TLS vs 🛡️ Tailscale, both green).
2. **Rotate API keys** periodically in your Hermes server config
3. **Disconnect when idle** — especially if bridge is enabled (or let the auto-disable timer handle it)
4. **Avoid public WiFi** for relay connections without additional encryption
4. **Avoid plaintext on untrusted networks** — a plain `ws://`/`http://` route with no
Tailscale/WireGuard or TLS wrapping it is readable on public Wi-Fi. The app shows
⚠️ **Not encrypted** for exactly this case.
5. **Keep the app updated** — security patches ship with new releases
+17 -4
View File
@@ -1,6 +1,6 @@
# Remote Access
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, gives you managed TLS, and keeps access inside your tailnet ACLs.
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, encrypts traffic end-to-end (WireGuard), keeps access inside your tailnet ACLs, and can *optionally* front TLS for you. (Note: the WireGuard encryption is what makes a tailnet link secure — TLS via `tailscale serve --https` is a separate, optional layer on top. See [Is my connection secure?](../architecture/connection-security.md).)
## What Uses Which Connection
@@ -24,7 +24,7 @@ hermes-relay-tailscale enable
hermes pair --mode auto --prefer tailscale
```
The Tailscale helper publishes both required loopback services:
The Tailscale helper publishes both required loopback services, fronting each with TLS:
```bash
tailscale serve --bg --https=8767 http://127.0.0.1:8767
@@ -33,6 +33,15 @@ tailscale serve --bg --https=8642 http://127.0.0.1:8642
Port `8767` carries relay WSS and relay HTTP routes. Port `8642` carries the Hermes API server for chat, API-key voice auth, and endpoint health probes. If only `8767` is served, terminal/bridge may work while chat and API-key voice still fail remotely.
::: tip Two layers, both optional-to-stack
Your tailnet is already encrypted by WireGuard, so even a plain `http://100.x.y.z` route is
secure over Tailscale. `tailscale serve --https` adds a *separate* TLS layer on top, giving
you a `wss://`/`https://` route fronted by a real certificate (the dashboard on `:9119` is
not fronted by the helper — front it yourself if you want TLS there). See
[Is my connection secure?](../architecture/connection-security.md) for which the app reports
as 🔒 TLS vs 🛡️ Tailscale (both secure).
:::
Check the served ports with:
```bash
@@ -75,10 +84,14 @@ Pick the scheme by how the server is reached:
The Hermes API server speaks plain HTTP; an `https://` route against it
fails its TLS handshake on every probe and never wins. This also requires
the API server to listen beyond loopback (`0.0.0.0:8642` or the tailnet
interface).
interface). Note that an `http://` route over a raw Tailscale IP is **not
plaintext on the wire** — WireGuard encrypts it end-to-end. It's secure
transport, just not TLS (the app reports it as 🛡️ Tailscale, not ⚠️ Not
encrypted). A plain LAN IP, by contrast, has no such wrapping.
- **`*.ts.net` hostname fronted by `hermes-relay-tailscale enable`** →
`https://` — Tailscale terminates TLS for the MagicDNS hostname (the cert
is only valid for that name, not for the raw `100.x` IP).
is only valid for that name, not for the raw `100.x` IP). This adds TLS
*on top of* the WireGuard encryption you already had over the tailnet.
- **Public reverse proxy** → `https://` with whatever host/port the proxy
exposes.