Files
hermes-agent/hermes_cli/gateway_enroll.py

301 lines
13 KiB
Python

"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.
Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the secret directly and
stamps it into the container env, so this command refuses to run under ``is_managed()`` (mirrors
``dashboard register``).
EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until ≥2 Class-1
platforms validate the contract.
"""
from __future__ import annotations
import json
import os
import socket
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
def _default_gateway_id() -> str:
"""A stable-ish default gateway instance id: ``<hostname>-<pid-free slug>``.
The gatewayId gives kill-switch granularity (the connector indexes its secret verify list by
it), so default to the host name for recognizability; override via ``--gateway-id``.
"""
try:
host = socket.gethostname().strip()
except Exception:
host = ""
return f"gw-{host or 'hermes'}"
def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
"""Resolve the connector base URL (no trailing slash) for enrollment.
Precedence: explicit ``--connector-url`` flag > ``GATEWAY_RELAY_URL`` env >
``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial target; enrollment is
an ``http(s)://`` POST to the same host, so we map the scheme. Returns None when nothing is
configured (the user must supply one).
"""
raw = (override or os.environ.get("GATEWAY_RELAY_URL", "")).strip()
if not raw:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
raw = str(cfg.get("relay_url", "") or "").strip()
except Exception:
raw = ""
if not raw:
return None
# The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll.
# Strip a trailing /relay path segment if the user pasted the dial URL.
for ws_scheme, http_scheme in (("ws://", "http://"), ("wss://", "https://")):
if raw.startswith(ws_scheme):
raw = http_scheme + raw[len(ws_scheme):]
break
return raw.rstrip("/").removesuffix("/relay")
def _resolve_identity_token() -> str:
"""Resolve the caller-identity bearer token (generic-OIDC or Nous Portal).
Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and the runtime self-
provision path share ONE implementation (generic OAuth2 client-credentials when
``gateway.idp.token_url`` is set — the air-gapped / self-hosted-IdP path; otherwise Nous
Portal). Raises RuntimeError on failure.
"""
from gateway.relay import _resolve_relay_identity_token
return _resolve_relay_identity_token()
def _post_enroll(
*,
connector_base_url: str,
access_token: str,
enrollment_token: str,
gateway_id: str,
timeout: float = 15.0,
) -> dict:
"""POST to the connector's ``/relay/enroll`` and return the JSON body.
Raises RuntimeError with a user-facing message on any non-2xx / transport failure. Success
returns ``{secret, deliveryKey, tenant, gatewayId}``; 400/401/403 return ``{error}``.
"""
url = f"{connector_base_url.rstrip('/')}/relay/enroll"
data = json.dumps({"enrollmentToken": enrollment_token, "gatewayId": gateway_id}).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
except Exception:
pass
if exc.code == 401:
message = (
"Connector rejected the caller identity (401). Your Nous Portal "
"token could not be verified — try `hermes auth add nous` and retry."
)
elif exc.code == 403:
message = detail or "Enrollment token invalid, expired, already used, or tenant mismatch (403)."
else:
message = f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
raise RuntimeError(message) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Could not reach the connector at {connector_base_url}: {exc.reason}"
) from exc
if not isinstance(payload, dict) or not payload.get("secret"):
raise RuntimeError("Connector returned an unexpected response (no secret).")
return payload
def _fail(*lines: str) -> None:
for line in lines:
print(line)
sys.exit(1)
def cmd_gateway_enroll(args) -> None:
"""Enroll this gateway with a relay connector; persist the auth creds to .env."""
from hermes_cli.auth import AuthError
from hermes_cli.config import is_managed, save_env_value
# Managed installs get GATEWAY_RELAY_* stamped in by the orchestrator (NAS
# mints the secret directly per the design's managed shape). Self-enrolling
# from inside such a container is a mistake — and save_env_value refuses to
# write anyway.
if is_managed():
_fail(
"✗ `hermes gateway enroll` is not available in a managed/hosted install.\n"
" The relay gateway secret is provisioned by the hosting platform."
)
enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip()
if not enrollment_token:
_fail(
"✗ No enrollment token. Pass --token <token> (or set "
"GATEWAY_RELAY_ENROLL_TOKEN).\n"
" The connector mints this single-use token when your tenant's route "
"is provisioned; it is delivered with your gateway config."
)
connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None))
if not connector_base_url:
_fail(
"✗ No connector URL. Pass --connector-url <url> (or set GATEWAY_RELAY_URL "
"/ gateway.relay_url in config.yaml)."
)
gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()
# 1. Resolve the caller-identity token (the tenant-proving identity). Generic
# OIDC client-credentials when an IdP token endpoint is configured (air-
# gapped / self-hosted-IdP, NO Nous Portal); otherwise the Nous Portal token.
try:
access_token = _resolve_identity_token()
except AuthError as exc:
if getattr(exc, "relogin_required", False):
_fail(
"✗ You're not logged into Nous Portal.",
" Run `hermes setup` (or `hermes auth add nous`) first, then retry.",
)
_fail(f"✗ Could not resolve a Nous Portal access token: {exc}")
except Exception as exc:
_fail(f"✗ Could not resolve a caller-identity token: {exc}")
# 2-3. Redeem the enrollment token at the connector.
try:
result = _post_enroll(
connector_base_url=connector_base_url,
access_token=access_token,
enrollment_token=enrollment_token,
gateway_id=gateway_id,
)
except RuntimeError as exc:
_fail(f"✗ Enrollment failed: {exc}")
tenant = str(result.get("tenant") or "")
resolved_gateway_id = str(result.get("gatewayId") or gateway_id)
# 4. Persist the creds idempotently. The secret + delivery key are sensitive;
# save_env_value writes them to ~/.hermes/.env (0600 dir) and never logs.
to_write = {
"GATEWAY_RELAY_ID": resolved_gateway_id,
"GATEWAY_RELAY_SECRET": str(result.get("secret") or ""),
"GATEWAY_RELAY_DELIVERY_KEY": str(result.get("deliveryKey") or ""),
}
# Persist the connector URL too (as the ws(s):// dial target) when supplied
# explicitly, so the runtime can dial without re-specifying it. Phase 5
# §5.2: likewise the wake URL, so self_provision_relay forwards it to the
# connector (which pokes it to wake this gateway when buffered work arrives
# while it's idle). Optional — omitted ⇒ the connector can't wake it, but
# the gateway still drains on its next reconnect.
explicit_urls = {
env_key: (getattr(args, arg, None) or "").strip()
for arg, env_key in (("connector_url", "GATEWAY_RELAY_URL"), ("wake_url", "GATEWAY_RELAY_WAKE_URL"))
}
to_write.update({k: v.rstrip("/") for k, v in explicit_urls.items() if v})
for key, value in to_write.items():
if not value:
continue
try:
save_env_value(key, value)
except Exception as exc:
_fail(f"✗ Failed to write {key} to .env: {exc}")
from hermes_cli.config import get_env_path
print(f'✓ Enrolled gateway "{resolved_gateway_id}"' + (f" for tenant {tenant}" if tenant else ""))
print()
print(f" Wrote to {get_env_path()}:")
for key, value in to_write.items():
shown = "<hidden>" if key in ("GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY") else value
print(f" {key}={shown}")
print()
# GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL are process-global deployment
# stamps (agent/secret_scope.py): a multiplexed gateway resolves them from
# the PROCESS environment only, never from a secondary profile's .env
# (which is loaded into an isolated secret scope, not exported). The .env
# write above works for a single-profile gateway and for the profile the
# process is launched under (load_hermes_dotenv exports that .env), so
# warn rather than refuse — but don't let a secondary-profile enroll claim
# a config that will silently never activate. Emitted BEFORE the generic
# restart line so the two don't contradict each other.
if not (any(explicit_urls.values()) and _warn_if_secondary_multiplex_profile()):
print(
" The gateway now authenticates its relay WS upgrade with the per-gateway\n"
" secret and verifies signed inbound deliveries with the tenant delivery\n"
" key. Restart the gateway to pick up the new env."
)
def _warn_if_secondary_multiplex_profile() -> bool:
"""Warn when relay routing stamps were written to a secondary profile's .env that a multiplexed
gateway will never read them from. Returns True when the warning fired (the caller suppresses
the generic restart text).
"""
try:
from hermes_constants import get_default_hermes_root
from hermes_cli.config import get_hermes_home
default_root = Path(get_default_hermes_root()).resolve()
home = Path(get_hermes_home()).resolve()
try:
home.relative_to(default_root / "profiles")
except ValueError:
return False # default profile or custom layout — not a secondary
# Multiplex flag precedence mirrors gateway.config: recognized env
# override wins, else the DEFAULT root's config.yaml (raw read — the
# active profile's load_gateway_config() is the wrong owner AND runs
# the full enablement pass, including the relay-exclusive sweep's own
# log output, which has no place in enroll output).
from gateway.config import _env_multiplex_profiles_override
env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is False:
return False
if env_multiplex is not True:
cfg_path = default_root / "config.yaml"
if not cfg_path.exists():
return False
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path) or {}
if not bool(
cfg.get("multiplex_profiles")
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
):
return False
print(
" ⚠ This profile is a SECONDARY profile of a multiplexed gateway.\n"
" GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL are process-level\n"
" deployment settings: the gateway reads them from the process\n"
" environment (or the default profile's .env), not from this\n"
" profile's .env. Set them in the environment the gateway process\n"
" is launched with, or enroll from the default profile. The\n"
" relay credentials written above are valid either way."
)
return True
except Exception:
return False