Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions dappnode/bootstrap-env.py
Original file line number Diff line number Diff line change
@@ -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()
88 changes: 86 additions & 2 deletions dappnode/patch-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 {}
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions rootfs/etc/cont-init.d/10-dappnode-setup
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 9 additions & 5 deletions setup-wizard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,10 @@ <h3 style="margin-bottom:0.75rem">Quick Links</h3>
Web Terminal
<small>Run hermes CLI commands — hermes model, hermes doctor, hermes config, etc.</small>
</a>
<a href="http://hermes-agent.dappnode:8081" target="_blank">
Hermes Dashboard
<small>Credentials are saved in /opt/data/dashboard-login.txt</small>
</a>
<a href="http://hermes-agent.dappnode:3000/health" target="_blank">
Health Check
<small>Verify the API server is running</small>
Expand Down Expand Up @@ -650,13 +654,14 @@ <h2>Integrations</h2>
<div class="hint">Connects via the built-in Baileys bridge.</div>
<label style="display:flex;align-items:center;gap:8px;font-weight:normal;margin-top:6px;">
<input type="checkbox" id="whatsapp-enabled" style="width:auto;">
Enable WhatsApp
Configure WhatsApp pairing
</label>
<div id="whatsapp-pair-notice"
style="display:none; margin-top:10px; padding:10px 12px; background:rgba(255,215,0,0.12); border:1px solid var(--primary); border-radius:8px; font-size:0.85rem; line-height:1.5;">
<strong style="color:var(--primary);">→ Pairing required</strong><br>
After the Setup, open the <strong>Terminal</strong> tab and run <code>hermes whatsapp</code> to choose a
mode and pair via QR code. You will need to manually restart the package after pairing to apply the changes.
mode and pair via QR code. Hermes enables WhatsApp only after pairing succeeds. You will need to manually
restart the package after pairing to apply the changes.
<br><br>
</div>
</div>
Expand Down Expand Up @@ -1197,7 +1202,6 @@ <h2>Configure ${p.name}</h2>
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;
}
Expand Down Expand Up @@ -1265,7 +1269,7 @@ <h2>Configure ${p.name}</h2>
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");
Expand Down Expand Up @@ -1321,4 +1325,4 @@ <h2>Configure ${p.name}</h2>
</script>
</body>

</html>
</html>
Loading