gateway setup: offer self-signed TLS cert generation (no openssl needed)
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:
1 parent
b1c9bac7d8
commit
c7a16d51e3
3 files changed
+338
-4
No files matched your search
+10
-4
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user