Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.
|
||||
|
||||
The connector⇄gateway channel is authenticated (the gateway may be
|
||||
customer-managed and internet-exposed). This command is the gateway half of the
|
||||
zero-touch enrollment in the connector repo's
|
||||
``docs/connector-gateway-auth-design.md``:
|
||||
|
||||
1. Resolve a fresh Nous Portal access token from the existing login
|
||||
(``~/.hermes/auth.json``) — the same path ``hermes dashboard register``
|
||||
uses (``resolve_nous_access_token``). This proves *which Nous org (tenant)*
|
||||
the caller owns; the connector derives the authoritative tenant from it via
|
||||
``GET /api/oauth/account`` (never from anything the gateway asserts).
|
||||
2. POST ``{enrollmentToken, gatewayId}`` to the connector's ``/relay/enroll``
|
||||
with that token in the ``Authorization`` header, over TLS.
|
||||
3. The connector verifies the enrollment token (signature + single-use +
|
||||
tenant match), mints a per-gateway secret, get-or-creates the per-tenant
|
||||
delivery key, and returns both ONCE.
|
||||
4. Persist ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` /
|
||||
``GATEWAY_RELAY_DELIVERY_KEY`` (+ ``GATEWAY_RELAY_URL`` if supplied) into
|
||||
``~/.hermes/.env``. The per-gateway secret authenticates the WS upgrade;
|
||||
the per-tenant delivery key verifies signed inbound deliveries.
|
||||
|
||||
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.parse
|
||||
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 identifies this enrolled instance for kill-switch granularity
|
||||
(the connector indexes its secret verify list by it). Default to the host
|
||||
name so a human can recognize it; overridable via ``--gateway-id``.
|
||||
"""
|
||||
host = ""
|
||||
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
|
||||
raw = raw.rstrip("/")
|
||||
# The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll.
|
||||
if raw.startswith("ws://"):
|
||||
raw = "http://" + raw[len("ws://"):]
|
||||
elif raw.startswith("wss://"):
|
||||
raw = "https://" + raw[len("wss://"):]
|
||||
# Strip a trailing /relay path segment if the user pasted the dial URL.
|
||||
if raw.endswith("/relay"):
|
||||
raw = raw[: -len("/relay")]
|
||||
return raw
|
||||
|
||||
|
||||
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. The connector returns ``{secret, deliveryKey, tenant, gatewayId}``
|
||||
on success, ``{error}`` at 400/401/403.
|
||||
"""
|
||||
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:
|
||||
raise RuntimeError(
|
||||
"Connector rejected the caller identity (401). Your Nous Portal "
|
||||
"token could not be verified — try `hermes auth add nous` and retry."
|
||||
) from exc
|
||||
if exc.code == 403:
|
||||
raise RuntimeError(
|
||||
detail
|
||||
or "Enrollment token invalid, expired, already used, or tenant mismatch (403)."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
|
||||
) 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 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():
|
||||
print(
|
||||
"✗ `hermes gateway enroll` is not available in a managed/hosted install.\n"
|
||||
" The relay gateway secret is provisioned by the hosting platform."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip()
|
||||
if not enrollment_token:
|
||||
print(
|
||||
"✗ 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."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None))
|
||||
if not connector_base_url:
|
||||
print(
|
||||
"✗ No connector URL. Pass --connector-url <url> (or set GATEWAY_RELAY_URL "
|
||||
"/ gateway.relay_url in config.yaml)."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
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):
|
||||
print("✗ You're not logged into Nous Portal.")
|
||||
print(" Run `hermes setup` (or `hermes auth add nous`) first, then retry.")
|
||||
else:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"✗ Could not resolve a caller-identity token: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# 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:
|
||||
print(f"✗ Enrollment failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
secret = str(result.get("secret") or "")
|
||||
delivery_key = str(result.get("deliveryKey") or "")
|
||||
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": secret,
|
||||
"GATEWAY_RELAY_DELIVERY_KEY": delivery_key,
|
||||
}
|
||||
# Persist the connector URL too (as the ws(s):// dial target) when supplied
|
||||
# explicitly, so the runtime can dial without re-specifying it.
|
||||
explicit_url = (getattr(args, "connector_url", None) or "").strip()
|
||||
if explicit_url:
|
||||
to_write["GATEWAY_RELAY_URL"] = explicit_url.rstrip("/")
|
||||
|
||||
# Phase 5 §5.2: persist 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_wake_url = (getattr(args, "wake_url", None) or "").strip()
|
||||
if explicit_wake_url:
|
||||
to_write["GATEWAY_RELAY_WAKE_URL"] = explicit_wake_url.rstrip("/")
|
||||
|
||||
for key, value in to_write.items():
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
save_env_value(key, value)
|
||||
except Exception as exc:
|
||||
print(f"✗ Failed to write {key} to .env: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
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()}:")
|
||||
print(f" GATEWAY_RELAY_ID={resolved_gateway_id}")
|
||||
print(" GATEWAY_RELAY_SECRET=<hidden>")
|
||||
print(" GATEWAY_RELAY_DELIVERY_KEY=<hidden>")
|
||||
if explicit_url:
|
||||
print(f" GATEWAY_RELAY_URL={explicit_url.rstrip('/')}")
|
||||
if explicit_wake_url:
|
||||
print(f" GATEWAY_RELAY_WAKE_URL={explicit_wake_url.rstrip('/')}")
|
||||
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.
|
||||
warned_secondary = False
|
||||
if explicit_url or explicit_wake_url:
|
||||
warned_secondary = _warn_if_secondary_multiplex_profile()
|
||||
if not warned_secondary:
|
||||
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).
|
||||
|
||||
The topology decision is owned by the DEFAULT root, not the active
|
||||
profile home: ``multiplex_profiles`` normally lives in
|
||||
``<default_root>/config.yaml`` (or the GATEWAY_MULTIPLEX_PROFILES env
|
||||
override), and the secondary check is the resolved-path relationship to
|
||||
``<default_root>/profiles/`` — mirroring the multiplexer-conflict guard
|
||||
in hermes_cli/gateway.py. Best-effort: any failure to determine the
|
||||
topology stays silent (the credential write itself succeeded).
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user