diff --git a/dappnode/bootstrap-env.py b/dappnode/bootstrap-env.py new file mode 100644 index 0000000..317b421 --- /dev/null +++ b/dappnode/bootstrap-env.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Repair DAppNode-specific Hermes .env settings before services start.""" +from __future__ import annotations + +import os +import secrets +from pathlib import Path + + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", "/opt/data")) +ENV_NAME = ".env" +PLACEHOLDER_VALUES = { + "", + "changeme", + "change-me", + "default", + "dappnode", + "example", + "none", + "null", + "password", + "placeholder", + "secret", + "todo", + "your-token-here", +} +TRUE_VALUES = {"1", "true", "yes", "on"} + + +def parse_env_line(line: str) -> tuple[str | None, str | None]: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + return None, None + if stripped.startswith("export "): + stripped = stripped[7:].lstrip() + key, _, value = stripped.partition("=") + key = key.strip() + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + return key, value + + +def read_env(path: Path) -> tuple[list[str], dict[str, str]]: + try: + lines = path.read_text(encoding="utf-8-sig", errors="replace").splitlines() + except FileNotFoundError: + lines = [] + + env: dict[str, str] = {} + for line in lines: + key, value = parse_env_line(line) + if key: + env[key] = value or "" + return lines, env + + +def write_env(path: Path, lines: list[str], updates: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + written: set[str] = set() + out: list[str] = [] + + for line in lines: + key, _ = parse_env_line(line) + if key and key in updates: + out.append(f"{key}={updates[key]}") + written.add(key) + else: + out.append(line) + + for key, value in updates.items(): + if key not in written: + out.append(f"{key}={value}") + + path.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + path.chmod(0o600) + + +def has_usable_secret(value: str, min_length: int = 16) -> bool: + cleaned = (value or "").strip() + if len(cleaned) < min_length: + return False + return cleaned.lower() not in PLACEHOLDER_VALUES + + +def has_whatsapp_creds(profile_home: Path) -> bool: + candidates = [ + profile_home / "platforms" / "whatsapp" / "session" / "creds.json", + profile_home / "whatsapp" / "session" / "creds.json", + ] + return any(path.is_file() for path in candidates) + + +def repair_profile_env(profile_home: Path, *, is_default: bool) -> dict[str, str]: + env_path = profile_home / ENV_NAME + lines, env = read_env(env_path) + updates: dict[str, str] = {} + + if is_default: + if not has_usable_secret(env.get("API_SERVER_KEY", "")): + updates["API_SERVER_KEY"] = secrets.token_hex(32) + else: + # DAppNode exposes a single API server on port 3000. Named profile + # gateways can still run messaging/cron, but must not each bind 3000. + if env.get("API_SERVER_ENABLED", "").strip().lower() not in {"false", "0", "no"}: + updates["API_SERVER_ENABLED"] = "false" + if env.get("API_SERVER_KEY", ""): + updates["API_SERVER_KEY"] = "" + + whatsapp_enabled = env.get("WHATSAPP_ENABLED", "").strip().lower() + if whatsapp_enabled in TRUE_VALUES and not has_whatsapp_creds(profile_home): + updates["WHATSAPP_ENABLED"] = "false" + + if updates: + write_env(env_path, lines, updates) + changed = ", ".join(sorted(updates)) + label = "default" if is_default else profile_home.name + print(f"[dappnode] Repaired {label} .env: {changed}") + + env.update(updates) + return env + + +def iter_profile_homes() -> list[Path]: + profiles_root = HERMES_HOME / "profiles" + if not profiles_root.is_dir(): + return [] + return sorted(path for path in profiles_root.iterdir() if path.is_dir()) + + +def main() -> None: + repair_profile_env(HERMES_HOME, is_default=True) + for profile_home in iter_profile_homes(): + repair_profile_env(profile_home, is_default=False) + + +if __name__ == "__main__": + main() diff --git a/dappnode/patch-config.py b/dappnode/patch-config.py index 9b93d10..1e83669 100644 --- a/dappnode/patch-config.py +++ b/dappnode/patch-config.py @@ -7,11 +7,16 @@ """ import json import os +import secrets import urllib.request +from pathlib import Path import yaml -config_path = os.path.join(os.environ.get("HERMES_HOME", "/opt/data"), "config.yaml") +hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) +config_path = hermes_home / "config.yaml" +dashboard_login_path = hermes_home / "dashboard-login.txt" +skip_dashboard_auth = os.environ.get("DAPPNODE_SKIP_DASHBOARD_AUTH") == "1" def fetch_nexus_context_size(base_url, model_id): @@ -33,6 +38,75 @@ def fetch_nexus_context_size(base_url, model_id): return int(size) if size else None return None + +def read_dashboard_password(username): + try: + values = {} + for line in dashboard_login_path.read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition(":") + values[key.strip().lower()] = value.strip() + if values.get("username") == username and values.get("password"): + return values["password"] + except Exception: + return None + return None + + +def write_dashboard_password(username, password): + dashboard_login_path.write_text( + "\n".join( + [ + "Hermes dashboard login", + "URL: http://hermes-agent.dappnode:8081", + f"Username: {username}", + f"Password: {password}", + "", + ] + ), + encoding="utf-8", + ) + dashboard_login_path.chmod(0o600) + + +def has_whatsapp_creds(profile_home): + candidates = [ + profile_home / "platforms" / "whatsapp" / "session" / "creds.json", + profile_home / "whatsapp" / "session" / "creds.json", + ] + return any(path.is_file() for path in candidates) + + +def configure_dashboard_auth(config): + if skip_dashboard_auth: + return False + + dashboard = config.setdefault("dashboard", {}) + basic = dashboard.setdefault("basic_auth", {}) + + username = str(basic.get("username") or "").strip() or "dappnode" + basic["username"] = username + + if not str(basic.get("secret") or "").strip(): + basic["secret"] = secrets.token_urlsafe(32) + + if str(basic.get("password_hash") or "").strip() or str(basic.get("password") or "").strip(): + return False + + password = read_dashboard_password(username) or secrets.token_urlsafe(24) + + try: + from plugins.dashboard_auth.basic import hash_password + + basic["password_hash"] = hash_password(password) + basic["password"] = "" + except Exception: + # The bundled provider can hash plaintext at load time. This fallback + # keeps the dashboard gated even if the helper import moves upstream. + basic["password"] = password + + write_dashboard_password(username, password) + return True + try: with open(config_path) as f: config = yaml.safe_load(f) or {} @@ -55,17 +129,27 @@ def fetch_nexus_context_size(base_url, model_id): term = config.setdefault("terminal", {}) term["cwd"] = os.environ.get("HERMES_HOME", "/opt/data") +generated_dashboard_auth = configure_dashboard_auth(config) + platforms = config.setdefault("platforms", {}) if isinstance(platforms, dict): whatsapp = platforms.setdefault("whatsapp", {}) if isinstance(whatsapp, dict): + if not has_whatsapp_creds(hermes_home): + whatsapp["enabled"] = False + else: + whatsapp.setdefault("enabled", False) extra = whatsapp.setdefault("extra", {}) if isinstance(extra, dict) and extra.get("bridge_port") in (None, 3000, "3000"): extra["bridge_port"] = 3010 with open(config_path, "w") as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) -print("Patched config.yaml for DAppNode (api_port=3000, whatsapp_bridge_port=3010)") +dashboard_auth_status = "skipped" if skip_dashboard_auth else "basic" +msg = f"Patched config.yaml for DAppNode (api_port=3000, dashboard_auth={dashboard_auth_status}, whatsapp_bridge_port=3010)" +if generated_dashboard_auth: + msg += "; dashboard credentials saved to /opt/data/dashboard-login.txt" +print(msg) # --- Nexus context length: source the real value from /v1/models --- # nexus-api.dappnode.com is not in Hermes' URL-to-provider map, so the agent diff --git a/docker-compose.yml b/docker-compose.yml index 80b709e..2843e57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,14 +16,11 @@ services: API_SERVER_ENABLED: "true" API_SERVER_PORT: "3000" API_SERVER_HOST: 0.0.0.0 - API_SERVER_KEY: dappnode API_SERVER_CORS_ORIGINS: "*" GATEWAY_ALLOW_ALL_USERS: "true" - # Enable the upstream s6 dashboard service on the DAppNode port (8081). HERMES_DASHBOARD: "true" HERMES_DASHBOARD_HOST: 0.0.0.0 HERMES_DASHBOARD_PORT: "8081" - HERMES_DASHBOARD_INSECURE: "true" volumes: - hermes_data:/opt/data logging: diff --git a/rootfs/etc/cont-init.d/10-dappnode-setup b/rootfs/etc/cont-init.d/10-dappnode-setup index 45a88bd..a71e2e7 100644 --- a/rootfs/etc/cont-init.d/10-dappnode-setup +++ b/rootfs/etc/cont-init.d/10-dappnode-setup @@ -50,9 +50,24 @@ if [ ! -d "$HERMES_HOME/skills/devops/dappnode-nexus" ] && [ -d /opt/dappnode/da echo "[dappnode] Seeded DAppNode Nexus skill" fi +# --- Repair .env for DAppNode defaults and older wizard output ------------- +# Upstream now rejects weak API_SERVER_KEY values and refuses to start +# WhatsApp when WHATSAPP_ENABLED=true but no QR pairing exists yet. +as_hermes "$INSTALL_DIR/.venv/bin/python3" /opt/dappnode/bootstrap-env.py \ + || echo "[dappnode] Warning: could not repair .env, continuing with defaults" + # --- Patch config.yaml for DAppNode (LAN bind, ports, Nexus ctx length) ----- # Run as hermes with the venv python so config.yaml stays hermes-owned. if [ -f "$HERMES_HOME/config.yaml" ]; then as_hermes "$INSTALL_DIR/.venv/bin/python3" /opt/dappnode/patch-config.py \ || echo "[dappnode] Warning: could not patch config.yaml, continuing with defaults" fi +if [ -d "$HERMES_HOME/profiles" ]; then + for profile_home in "$HERMES_HOME"/profiles/*; do + if [ -d "$profile_home" ] && [ -f "$profile_home/config.yaml" ]; then + as_hermes env HERMES_HOME="$profile_home" DAPPNODE_SKIP_DASHBOARD_AUTH=1 \ + "$INSTALL_DIR/.venv/bin/python3" /opt/dappnode/patch-config.py \ + || echo "[dappnode] Warning: could not patch profile config.yaml at $profile_home" + fi + done +fi diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 3af1e1c..64655ad 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -589,6 +589,10 @@

Quick Links

Web Terminal Run hermes CLI commands — hermes model, hermes doctor, hermes config, etc. + + Hermes Dashboard + Credentials are saved in /opt/data/dashboard-login.txt + Health Check Verify the API server is running @@ -650,13 +654,14 @@

Integrations

Connects via the built-in Baileys bridge.
@@ -1197,7 +1202,6 @@

Configure ${p.name}

if (tgUsers) env.TELEGRAM_ALLOWED_USERS = tgUsers; } if (document.getElementById("whatsapp-enabled").checked) { - env.WHATSAPP_ENABLED = "true"; const waUsers = (document.getElementById("whatsapp-users").value || "").trim(); if (waUsers) env.WHATSAPP_ALLOWED_USERS = waUsers; } @@ -1265,7 +1269,7 @@

Configure ${p.name}

lines.push("", "# Gateway settings (DAppNode)", "gateway:", " port: 3000", " bind: lan"); lines.push(" controlUi:", " dangerouslyAllowHostHeaderOriginFallback: true"); lines.push(" allowInsecureAuth: true", " dangerouslyDisableDeviceAuth: true"); - lines.push("", "# Platform settings (DAppNode)", "platforms:", " whatsapp:", " extra:", " bridge_port: 3010"); + lines.push("", "# Platform settings (DAppNode)", "platforms:", " whatsapp:", " enabled: false", " extra:", " bridge_port: 3010"); lines.push("", "terminal:", ' backend: "local"', ' cwd: "."', " timeout: 180"); lines.push("", "agent:", " max_turns: 60", ' reasoning_effort: "medium"'); lines.push("", "memory:", " memory_enabled: true", " user_profile_enabled: true"); @@ -1321,4 +1325,4 @@

Configure ${p.name}

- \ No newline at end of file +