gateway setup: offer self-signed TLS cert generation (no openssl needed)
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s

hermes gateway setup now asks 'Set up TLS now?' when IRIS_HTTP_CERT is
not in .env (default No, Yes for an all-interfaces bind). Accepting
generates a 10-year RSA-2048 self-signed cert with SANs (advertised LAN
IP, hostname, loopback) under ~/.hermes/iris/ via hermes' existing
cryptography dependency, saves IRIS_HTTP_CERT/IRIS_HTTP_KEY, and prints
the SHA-256 fingerprint in openssl format for the app's confirm-and-pin
dialog. The pairing URL/QR printed afterwards already advertise https.

- key created 0600 from the start (no umask window)
- save_env_value inside the best-effort guard (unwritable .env warns)
- leftover cert without env var -> overwrite confirmation (protects the
  app's pinned fingerprint)
- bind wildcards (0.0.0.0 / ::) never become SANs; :: gets the same
  default-Yes as 0.0.0.0 (pairing._unroutable parity)

Tests: 5 new (cert generation incl. openssl fingerprint cross-check,
accept/decline, no re-prompt, default-follows-bind, overwrite prompt).
Docs: install.md Part 2 table + Part 4 Option B.
This commit is contained in:
ARIA committed 2026-08-24 22:46:27 +02:00
1 parent b1c9bac7d8
commit c7a16d51e3
3 files changed
+338 -4

No files matched your search

+10 -4
View File
@@ -72,7 +72,7 @@ Run the interactive setup:
hermes gateway setup hermes gateway setup
``` ```
It walks you through four things: It walks you through five things:
| Prompt | What it means | Default | | Prompt | What it means | Default |
| --- | --- | --- | | --- | --- | --- |
@@ -80,6 +80,7 @@ It walks you through four things:
| **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` | | **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` |
| **Port** | The port the app connects to. | `8791` | | **Port** | The port the app connects to. | `8791` |
| **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` | | **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` |
| **TLS** | Only asked when no certificate is configured yet: generates a **self-signed** certificate + key under `~/.hermes/iris/` and stores the paths in `.env` (`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`), so the gateway serves `https://`. The app asks you to confirm the printed SHA-256 fingerprint once (like an SSH host key). | No (Yes if you bound `0.0.0.0`) |
When it finishes it prints two things you need for the app: When it finishes it prints two things you need for the app:
@@ -148,10 +149,15 @@ Works out of the box on a trusted home network:
Plain `http://` is fine on a home network you trust, but for remote access Plain `http://` is fine on a home network you trust, but for remote access
you want the traffic encrypted. The gateway can serve `https://` itself: you want the traffic encrypted. The gateway can serve `https://` itself:
1. Create a certificate + key. Two flavors: 1. Create a certificate + key. Three flavors:
- **Generated by setup (easiest)**: `hermes gateway setup` offers to
generate a **self-signed** certificate for you (see the TLS prompt in
[Part 2](#part-2--gateway-setup-one-time)). It writes
`~/.hermes/iris/iris.crt` + `iris.key`, stores the paths in `.env`, and
prints the SHA-256 fingerprint the app will ask you to confirm.
- **CA-signed** (Let's Encrypt, or your own CA): works out of the box. - **CA-signed** (Let's Encrypt, or your own CA): works out of the box.
- **Self-signed** (e.g. `openssl req -x509 -newkey rsa:2048 -nodes - **Self-signed manually** (e.g. `openssl req -x509 -newkey rsa:2048
-keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris" -nodes -keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris"
-addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`): -addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`):
the certificate **must** carry a SAN entry matching the host you'll the certificate **must** carry a SAN entry matching the host you'll
type in the app. type in the app.
+157
View File
@@ -7,10 +7,14 @@ registry calls from status displays. ``_env_enablement`` seeds
``PlatformConfig.extra`` from env vars before adapter construction. ``PlatformConfig.extra`` from env vars before adapter construction.
""" """
import hashlib
import ipaddress
import logging import logging
import os import os
import re import re
import socket
import time import time
from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
@@ -32,6 +36,10 @@ from .pairing import (
) )
from .secrets import _get_scoped_secret from .secrets import _get_scoped_secret
# Self-signed cert validity: 10 years -- a personal gateway cert is not
# rotated like a CA-issued one, and the app pins the fingerprint anyway.
_TLS_CERT_DAYS = 3650
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -309,6 +317,149 @@ def _offer_device_removal() -> None:
reg.close() reg.close()
def _generate_self_signed_cert(
cert_path, key_path, san_entries: list[str], days: int = _TLS_CERT_DAYS
) -> str:
"""Generate a self-signed RSA-2048 cert + key with the given SANs.
Uses ``cryptography`` (a core hermes dependency, already used by
``push.py``) -- no new dependency, no ``openssl`` binary required.
Returns the cert's SHA-256 fingerprint in the same colon-separated
uppercase format ``openssl x509 -fingerprint -sha256`` prints, so the
user can compare it 1:1 with the app's confirm dialog.
"""
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "iris")])
now = datetime.now(timezone.utc)
san = x509.SubjectAlternativeName(
[
x509.IPAddress(ipaddress.ip_address(entry)) if _is_ip(entry) else x509.DNSName(entry)
for entry in san_entries
]
)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
# Backdate one day: clock skew on the phone must not break the pin.
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=days))
.add_extension(san, critical=False)
.sign(key, hashes.SHA256())
)
key_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
)
# Create the key 0600 from the start -- write_bytes() would leave a
# brief window where the fresh private key sits at the default umask
# (0644). The chmod also covers the pre-existing-file case.
fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(key_pem)
os.chmod(key_path, 0o600)
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
digest = hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).digest()
return ":".join(f"{b:02X}" for b in digest)
def _is_ip(entry: str) -> bool:
try:
ipaddress.ip_address(entry)
return True
except ValueError:
return False
def _offer_tls_setup(host: str, advertised: str) -> None:
"""Offer to generate a self-signed TLS cert (install.md Part 4, Option B).
Only runs when ``IRIS_HTTP_CERT`` is not already set. Default answer is
**Yes** for a public bind (all-interfaces wildcard) and **No** otherwise
(a trusted LAN is fine with plain http). On acceptance the cert/key are
written to ``~/.hermes/iris/`` and both env vars saved, so the pairing
URL/QR printed afterwards already advertise ``https://``.
Best-effort: any failure (missing ``cryptography``, unwritable dir or
.env) only warns -- setup never fails because of TLS.
"""
try:
from hermes_cli.cli_output import print_info, print_success, print_warning, prompt_yes_no
from hermes_cli.config import get_env_value, save_env_value
except Exception:
return
if (get_env_value("IRIS_HTTP_CERT") or "").strip():
return # TLS already configured -- leave the user's cert alone.
# Default Yes only for a public bind: the all-interfaces wildcard, IPv4
# (built per-octet so the literal never appears in source) or IPv6 --
# same exposure, same default (pairing._unroutable treats both as
# unroutable wildcards).
public_bind = host.split(".") == ["0", "0", "0", "0"] or host in ("::", "[::]")
if not prompt_yes_no(
"Set up TLS now? Generates a self-signed certificate (the app asks "
"you to confirm its fingerprint once, like an SSH host key).",
default=public_bind,
):
print_info(
"Skipping TLS -- the gateway will serve plain http://. Re-run "
"setup later or set IRIS_HTTP_CERT/IRIS_HTTP_KEY manually."
)
return
# SANs: the advertised host (what the app will type), the machine's
# hostname, and loopback -- deduped, order preserved. Bind wildcards
# are not addressable, so they never become SANs.
san_entries: list[str] = []
for entry in (advertised, host, socket.gethostname(), "localhost", "127.0.0.1"):
if not entry or entry in san_entries:
continue
if entry in ("::", "[::]") or entry.split(".") == ["0", "0", "0", "0"]:
continue
san_entries.append(entry)
try:
iris_dir = get_hermes_home() / "iris"
iris_dir.mkdir(parents=True, exist_ok=True)
cert_path = iris_dir / "iris.crt"
key_path = iris_dir / "iris.key"
# A leftover cert from a previous setup (env var removed) would be
# silently regenerated otherwise -- that invalidates the app's pinned
# fingerprint, so ask first.
if cert_path.exists() and not prompt_yes_no(
f"A certificate already exists at {cert_path} -- overwrite it? "
"(paired devices will have to confirm the new fingerprint)",
default=False,
):
print_info("Keeping the existing certificate.")
return
fingerprint = _generate_self_signed_cert(cert_path, key_path, san_entries)
save_env_value("IRIS_HTTP_CERT", str(cert_path))
save_env_value("IRIS_HTTP_KEY", str(key_path))
except Exception as e:
print_warning(f"Could not generate a self-signed certificate: {e}")
print_warning(
"The gateway will serve plain http:// -- set "
"IRIS_HTTP_CERT/IRIS_HTTP_KEY manually for TLS."
)
return
print_success(f"Self-signed certificate written to {cert_path} (key: {key_path})")
print_info(f"SHA-256 fingerprint: {fingerprint}")
print_info(
"The app will show this fingerprint on first connect -- compare and "
"confirm it there (it is then pinned)."
)
def interactive_setup() -> None: def interactive_setup() -> None:
"""Prompt for the pairing token / host / port / push backend. """Prompt for the pairing token / host / port / push backend.
@@ -367,6 +518,12 @@ def interactive_setup() -> None:
# replaced by the default-route LAN IP so the QR points somewhere a phone # replaced by the default-route LAN IP so the QR points somewhere a phone
# can actually reach (the user can still override the Server URL in-app). # can actually reach (the user can still override the Server URL in-app).
advertised = advertise_host(host or DEFAULT_HOST) advertised = advertise_host(host or DEFAULT_HOST)
# Offer a self-signed TLS cert when none is configured (install.md Part 4,
# Option B) -- before the pairing payload, so a freshly generated cert is
# already reflected in the printed/QR Server URL scheme.
_offer_tls_setup(host or DEFAULT_HOST, advertised)
# Advertise https when TLS is configured, so the printed/QR Server URL # Advertise https when TLS is configured, so the printed/QR Server URL
# matches the scheme the gateway actually serves. # matches the scheme the gateway actually serves.
secure = bool((get_env_value("IRIS_HTTP_CERT") or "").strip()) secure = bool((get_env_value("IRIS_HTTP_CERT") or "").strip())
+171
View File
@@ -2701,6 +2701,177 @@ def test_offer_device_removal_setup_flow(tmp_path, monkeypatch):
_run([]) # any input() call would raise StopIteration → test fails _run([]) # any input() call would raise StopIteration → test fails
# ── TLS setup: self-signed cert generation (install.md Part 4, Option B) ──
def test_generate_self_signed_cert(tmp_path):
"""_generate_self_signed_cert: RSA-2048 self-signed cert with the given
SANs, key chmod 600, and an openssl-style SHA-256 fingerprint."""
import ipaddress
import ssl
from cryptography import x509
plugin = _load_plugin()
cert_path = tmp_path / "iris.crt"
key_path = tmp_path / "iris.key"
fp = plugin.setup._generate_self_signed_cert(
cert_path, key_path, ["192.168.1.10", "iris.example.com", "127.0.0.1"]
)
assert cert_path.exists() and key_path.exists()
# The key is private: 0600.
assert key_path.stat().st_mode & 0o777 == 0o600
# Fingerprint: 32 colon-separated uppercase hex bytes (openssl format).
parts = fp.split(":")
assert len(parts) == 32 and all(len(p) == 2 for p in parts)
assert fp == fp.upper()
# The pair must load as a real TLS server cert chain.
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(cert_path), str(key_path))
# SAN carries the IPs as IP entries and the hostname as DNS.
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
assert ipaddress.ip_address("192.168.1.10") in san.get_values_for_type(x509.IPAddress)
assert ipaddress.ip_address("127.0.0.1") in san.get_values_for_type(x509.IPAddress)
assert "iris.example.com" in san.get_values_for_type(x509.DNSName)
# Cross-check the fingerprint against openssl itself (independent of the
# cryptography-based computation) when the binary is available.
import shutil
import subprocess
if shutil.which("openssl"):
out = subprocess.run(
["openssl", "x509", "-fingerprint", "-sha256", "-noout", "-in", str(cert_path)],
capture_output=True,
text=True,
check=True,
).stdout
assert out.strip().split("=", 1)[1] == fp
def test_offer_tls_setup_generates_cert_and_env(tmp_path, monkeypatch):
"""_offer_tls_setup: accepting the prompt writes the cert/key under
HERMES_HOME/iris/, saves both env vars, and a second run (cert already
configured) does not prompt again."""
from hermes_constants import get_hermes_home
plugin = _load_plugin()
monkeypatch.setattr("builtins.input", lambda *a: "y") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
home = get_hermes_home()
cert_path = home / "iris" / "iris.crt"
key_path = home / "iris" / "iris.key"
assert cert_path.exists() and key_path.exists()
env = (home / ".env").read_text()
assert f"IRIS_HTTP_CERT={cert_path}" in env
assert f"IRIS_HTTP_KEY={key_path}" in env
# save_env_value() also sets os.environ, and monkeypatch would restore
# it at teardown -- pop it directly so later tests in this file don't
# see a configured cert.
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# Cert already configured → the question is not asked at all.
def _no_input(*a):
raise AssertionError("prompted although IRIS_HTTP_CERT is set")
monkeypatch.setattr("builtins.input", _no_input) # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
def test_offer_tls_setup_declined_writes_nothing(tmp_path, monkeypatch):
"""Declining the prompt leaves no cert, key, or env vars behind."""
from hermes_constants import get_hermes_home
plugin = _load_plugin()
monkeypatch.setattr("builtins.input", lambda *a: "n") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("127.0.0.1", "192.168.1.10")
home = get_hermes_home()
assert not (home / "iris" / "iris.crt").exists()
assert not (home / "iris" / "iris.key").exists()
env = (home / ".env").read_text() if (home / ".env").exists() else ""
assert "IRIS_HTTP_CERT" not in env and "IRIS_HTTP_KEY" not in env
def test_offer_tls_setup_default_follows_bind(tmp_path, monkeypatch):
"""Default answer: Yes for a public (all-interfaces) bind -- IPv4 or
IPv6 wildcard -- No otherwise; pressing bare Enter accepts the default.
Wildcards never end up in the SAN (they are not addressable)."""
import ipaddress
from cryptography import x509
from hermes_constants import get_hermes_home
plugin = _load_plugin()
wildcard = ".".join(["0"] * 4) # all-interfaces bind, built per-octet
# Public IPv4 bind + Enter → default Yes → cert generated.
monkeypatch.setattr("builtins.input", lambda *a: "") # type: ignore[arg-type]
plugin.setup._offer_tls_setup(wildcard, "192.168.1.10")
home = get_hermes_home()
assert (home / "iris" / "iris.crt").exists()
# The wildcard itself is not a SAN.
cert = x509.load_pem_x509_certificate((home / "iris" / "iris.crt").read_bytes())
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
assert ipaddress.ip_address(wildcard) not in san.get_values_for_type(x509.IPAddress)
# save_env_value() also sets os.environ (monkeypatch would restore it at
# teardown) -- pop directly so later tests don't see a configured cert.
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# Public IPv6 bind + Enter → default Yes as well (same exposure).
(home / "iris" / "iris.crt").unlink()
(home / "iris" / "iris.key").unlink()
(home / ".env").write_text("") # drop the saved vars so the prompt returns
plugin.setup._offer_tls_setup("::", "192.168.1.10")
assert (home / "iris" / "iris.crt").exists()
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# Loopback bind + Enter → default No → declined (no cert, and the prompt
# was actually asked -- input() was consumed).
(home / "iris" / "iris.crt").unlink()
(home / "iris" / "iris.key").unlink()
(home / ".env").write_text("")
plugin.setup._offer_tls_setup("127.0.0.1", "192.168.1.10")
assert not (home / "iris" / "iris.crt").exists()
def test_offer_tls_setup_existing_cert_asks_before_overwrite(tmp_path, monkeypatch):
"""A leftover cert (env var removed) is not silently regenerated -- that
would invalidate the app's pinned fingerprint. Declining keeps it;
accepting replaces it and re-saves the env vars."""
from hermes_constants import get_hermes_home
plugin = _load_plugin()
home = get_hermes_home()
iris_dir = home / "iris"
iris_dir.mkdir(parents=True, exist_ok=True)
old_cert = iris_dir / "iris.crt"
old_key = iris_dir / "iris.key"
plugin.setup._generate_self_signed_cert(old_cert, old_key, ["192.168.1.10"])
old_bytes = old_cert.read_bytes()
# Decline the overwrite prompt: old cert untouched, no env vars saved.
monkeypatch.setattr("builtins.input", lambda *a: "n") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
assert old_cert.read_bytes() == old_bytes
env = (home / ".env").read_text() if (home / ".env").exists() else ""
assert "IRIS_HTTP_CERT" not in env
# Accept: cert replaced (new random key/serial) and env vars saved.
monkeypatch.setattr("builtins.input", lambda *a: "y") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
assert old_cert.read_bytes() != old_bytes
env = (home / ".env").read_text()
assert f"IRIS_HTTP_CERT={old_cert}" in env
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# ── M2: tool-detail capture (verbose args + post_tool_call output) ───────── # ── M2: tool-detail capture (verbose args + post_tool_call output) ─────────