Files
routstr-core/scripts/node_setup.py
redshift 72b1504662 fix(env): keep the upstream pair commented so a fresh node seeds no provider
`.env.example` shipped an uncommented `UPSTREAM_BASE_URL=https://api.openai.com/v1`
plus `UPSTREAM_API_KEY=your-upstream-api-key`, and every documented path to a new
node copies that file verbatim (`cp .env.example .env`, `node_setup.py`'s
`prepare_env`, `compose.node.yml`'s `env_file: .env`). On the first boot
`_seed_providers_from_settings` treats a non-empty pair as configuration and
creates an enabled `custom` provider from the placeholder, saving the values in
Settings — so a node nobody configured shows a bogus enabled upstream, and it
comes back whenever the provider list is emptied.

Leave the pair commented in the template and say why, document that upstreams are
dashboard-owned, and have `prepare_env` comment out active `UPSTREAM_*`
assignments in a `.env` it creates, so an older template cannot reintroduce the
placeholder seed either.

Operators who really want to seed an upstream from `.env` uncomment the pair.
2026-10-09 11:21:46 +08:00

557 lines
20 KiB
Python

#!/usr/bin/env python3
"""First boot and read-only readiness checks for a provider node.
Public mode (the default, selected with ``--public-url``) assumes DNS, TLS and a
reverse proxy already forward an HTTPS origin to the loopback port. The node
generates its secrets on first boot, publishes its Nostr listing immediately and
is reachable from the network. ``--private`` keeps the node on loopback and
publishes nothing until a public URL is configured later.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_PORT = 8000
COMPOSE = ("docker", "compose", "-f", "compose.node.yml")
# The node saves these on first boot; afterwards the saved value wins over .env.
SAVED_SETTING_KEYS = ("RECEIVE_LN_ADDRESS", "MIN_PAYOUT_SAT", "PAYOUT_INTERVAL_SECONDS")
# A non-empty UPSTREAM_BASE_URL + UPSTREAM_API_KEY seeds an enabled "custom"
# upstream provider on the node's first boot and saves the pair in Settings, so a
# template placeholder would silently configure an upstream (and keep coming back
# once the provider list is emptied). Upstreams belong to the dashboard, so the
# .env we emit never activates this pair.
DASHBOARD_ONLY_KEYS = ("UPSTREAM_BASE_URL", "UPSTREAM_API_KEY")
def _env_value(text: str, key: str) -> str | None:
match = re.search(rf"^\s*{re.escape(key)}\s*=\s*(.*)$", text, re.MULTILINE)
if not match:
return None
return match.group(1).strip().strip('"').strip("'")
def _apply_overrides(text: str, overrides: dict[str, str]) -> str:
remaining = dict(overrides)
lines: list[str] = []
for line in text.splitlines():
match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=", line)
if match and match.group(1) in remaining:
key = match.group(1)
lines.append(f"{key}={remaining.pop(key)}")
else:
lines.append(line)
for key, value in remaining.items():
lines.append(f"{key}={value}")
return "\n".join(lines) + "\n"
def _comment_out(text: str, keys: tuple[str, ...]) -> str:
"""Comment out live assignments to ``keys``, leaving commented lines as they are."""
pattern = re.compile(rf"^\s*({'|'.join(re.escape(key) for key in keys)})\s*=")
return (
"\n".join(
f"# {line}" if pattern.match(line) else line for line in text.splitlines()
)
+ "\n"
)
def _create_private(path: Path, content: str) -> None:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(content)
def _write_private(path: Path, content: str) -> None:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(content)
def prepare_env(root: Path, overrides: dict[str, str] | None = None) -> bool:
"""Create or update ``.env``; return True when it was created.
Only the keys in ``overrides`` are written, and ``DASHBOARD_ONLY_KEYS`` are
commented out in a newly created ``.env``. An existing ``.env`` is never
blindly overwritten: identity/onion settings are refused outright and a
conflicting ``HTTP_URL`` is an error, so an operator edit is never silently
clobbered.
"""
overrides = dict(overrides or {})
env = root / ".env"
if env.is_symlink():
raise RuntimeError("Refusing symlinked .env")
if env.exists():
if not env.is_file():
raise RuntimeError(".env must be a regular file")
text = env.read_text()
if re.search(r"^\s*(?:NSEC|ONION_URL)\s*=\s*[^\s#]+", text, re.MULTILINE):
raise RuntimeError(
"Existing .env sets identity/onion values; manage those from the deployment guide"
)
existing_url = _env_value(text, "HTTP_URL")
requested_url = overrides.get("HTTP_URL")
if existing_url and requested_url and existing_url != requested_url:
raise RuntimeError("Existing .env sets a different HTTP_URL; update it explicitly")
updated = _apply_overrides(text, overrides)
if updated != text:
_write_private(env, updated)
return False
# O_EXCL avoids overwriting an existing deployment or a concurrent first run.
with (root / ".env.example").open() as source:
content = _apply_overrides(source.read(), overrides)
content = _comment_out(content, DASHBOARD_ONLY_KEYS)
try:
_create_private(env, content)
except BaseException:
env.unlink(missing_ok=True)
raise
return True
def get_json(url: str) -> dict[str, Any]:
request = urllib.request.Request(url, headers={"Accept": "application/json"})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args: object, **kwargs: object) -> None:
return None
with urllib.request.build_opener(NoRedirect()).open(request, timeout=5) as response:
if response.status != 200:
raise RuntimeError("Node returned a non-200 status")
data = json.load(response)
if not isinstance(data, dict):
raise RuntimeError("Expected a JSON object")
return data
def _fetch_json(url: str) -> Any:
request = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(request, timeout=5) as response:
if response.status != 200:
return None
return json.load(response)
def probe(url: str) -> tuple[str, int]:
info = get_json(url + "/v1/info")
models = get_json(url + "/v1/models")
if not isinstance(info.get("name"), str):
raise RuntimeError("Invalid node info")
if not isinstance(models.get("data"), list):
raise RuntimeError("Invalid model list")
return info["name"], len(models["data"])
def public_url(value: str) -> str:
parsed = urllib.parse.urlsplit(value)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or parsed.path not in ("", "/")
):
raise ValueError("Public URL must be an HTTPS origin without credentials, path or query")
return value.rstrip("/")
def validate_payout_address(value: str) -> tuple[str, bool]:
"""Return ``(address, verified)``; raise ``ValueError`` when malformed.
``verified`` is False when the address is well-formed but could not be
resolved live (network failure or a bech32 ``lnurl1`` value), so callers can
warn without blocking onboarding.
"""
address = value.strip()
candidate = (
address[len("lightning:") :]
if address.lower().startswith("lightning:")
else address
)
if "@" in candidate:
user, _, host = candidate.partition("@")
if not user or not host or "@" in host or "/" in host:
raise ValueError("Lightning address must be user@host")
probe_url = f"https://{host}/.well-known/lnurlp/{urllib.parse.quote(user)}"
elif candidate.startswith("https://"):
probe_url = candidate
elif candidate.lower().startswith("lnurl1"):
return address, False
else:
raise ValueError(
"Payout address must be user@host, lnurl1…, or an https:// LNURL-pay URL"
)
try:
data = _fetch_json(probe_url)
except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError):
return address, False
if isinstance(data, dict) and data.get("tag") == "payRequest":
return address, True
raise ValueError("Payout address does not resolve to a Lightning payRequest")
def preflight_origin(origin: str) -> None:
"""Require an HTTPS answer from the public origin before we publish to it.
Any HTTP status counts (a proxy returning 502 while the node boots is fine);
a DNS/TLS/connection failure means the origin is not ready and would
advertise a dead endpoint.
"""
request = urllib.request.Request(
origin + "/v1/info", headers={"Accept": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
response.read(1)
except urllib.error.HTTPError:
return
except OSError as exc:
raise RuntimeError(
f"Public origin {origin} is not reachable over HTTPS; "
"configure DNS, TLS and the reverse proxy first, or use --private"
) from exc
def _create_cli_token(
compose_env: dict[str, str], name: str, expires_in_days: int | None
) -> str:
"""Mint a long-lived CLI token inside the running container."""
command = [
*COMPOSE,
"exec",
"-T",
"routstr",
"/.venv/bin/python",
"scripts/create_cli_token.py",
"--name",
name,
"--print-token",
"--replace",
]
if expires_in_days is not None:
command += ["--expires-in-days", str(expires_in_days)]
result = subprocess.run(
command,
cwd=ROOT,
env=compose_env,
check=True,
capture_output=True,
text=True,
)
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if not lines:
raise RuntimeError("create_cli_token.py produced no token")
return lines[-1]
def _configured_node_url(path: Path) -> str | None:
"""Return the node URL an existing CLI config points at, if any."""
try:
loaded = json.loads(path.read_text())
except (OSError, ValueError):
return None
url = loaded.get("node_url") if isinstance(loaded, dict) else None
return url if isinstance(url, str) and url else None
def _write_cli_config(path: Path, node_url: str, token: str) -> None:
"""Merge the node URL and token into the Routstr CLI config (mode 0600)."""
data: dict[str, Any] = {}
try:
loaded = json.loads(path.read_text())
if isinstance(loaded, dict):
data = loaded
except (OSError, ValueError):
data = {}
data["node_url"] = node_url
data["token"] = token
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(json.dumps(data, indent=2) + "\n")
os.chmod(path, 0o600)
def _wait_ready(local_url: str, timeout: float = 180.0) -> tuple[str, int]:
deadline = time.monotonic() + timeout
while True:
try:
return probe(local_url)
except (OSError, ValueError, RuntimeError, KeyError):
if time.monotonic() >= deadline:
raise RuntimeError(
"Node did not become ready in 180s; inspect container status privately"
) from None
time.sleep(3)
def _start(args: argparse.Namespace, local_url: str) -> int:
if not shutil.which("docker"):
raise RuntimeError("Docker Compose is required")
public_origin = public_url(args.public_url) if args.public_url else None
overrides: dict[str, str] = {}
if public_origin:
preflight_origin(public_origin)
overrides["HTTP_URL"] = public_origin
payout_verified = True
if args.ln_address:
address, payout_verified = validate_payout_address(args.ln_address)
overrides["RECEIVE_LN_ADDRESS"] = address
if args.min_payout_sat is not None:
overrides["MIN_PAYOUT_SAT"] = str(args.min_payout_sat)
if args.payout_interval is not None:
overrides["PAYOUT_INTERVAL_SECONDS"] = str(args.payout_interval)
env_created = prepare_env(ROOT, overrides)
if env_created:
print("Created .env with owner-only permissions. Review it before public deployment.")
compose_env = {**os.environ, "ROUTSTR_NODE_PORT": str(args.port)}
subprocess.run(
[*COMPOSE, "config", "--quiet"],
cwd=ROOT,
env=compose_env,
check=True,
stdout=subprocess.DEVNULL,
)
subprocess.run(
[*COMPOSE, "up", "-d", "--build"],
cwd=ROOT,
env=compose_env,
check=True,
stdout=subprocess.DEVNULL,
)
name, count = _wait_ready(local_url)
mode = "public" if public_origin else "private (nothing published)"
print(f"Node ready ({mode}): {name}; {count} models.")
if public_origin:
try:
advertised = get_json(local_url + "/v1/info").get("http_url")
except (OSError, ValueError, RuntimeError):
advertised = public_origin
if advertised != public_origin:
print(
f"Warning: the node advertises {advertised!r}, not {public_origin}; "
"a URL saved in Settings wins over .env. Change it in the dashboard.",
file=sys.stderr,
)
try:
pub_name, pub_count = probe(public_origin)
print(f"Public endpoint OK: {public_origin} → {pub_name}; {pub_count} models.")
if pub_count == 0:
print(
"Listed but not yet selling: add an upstream and enable a model "
"in the dashboard."
)
except (OSError, ValueError, RuntimeError, KeyError):
print(
f"Warning: {public_origin} did not serve /v1/info yet; the listing is "
"corrected automatically once the proxy reaches the node.",
file=sys.stderr,
)
else:
print(
"Private mode: the node is loopback-only and has published nothing. "
"Set a public HTTP URL later to go live."
)
if not env_created and any(key in overrides for key in SAVED_SETTING_KEYS):
print(
"Note: payout settings were written to .env, but a node that has booted "
"before keeps its saved values. Check them in Settings and change them "
"there if they differ.",
file=sys.stderr,
)
if args.ln_address and not payout_verified:
print(
"Note: payout address saved but could not be resolved live; it is not a "
"tested payout.",
file=sys.stderr,
)
node_url = public_origin or local_url
existing_node_url = None if args.no_cli_token else _configured_node_url(args.cli_config)
if existing_node_url and existing_node_url != node_url:
print(
f"Warning: {args.cli_config} already points the Routstr CLI at "
f"{existing_node_url}; left it unchanged and created no token. To keep "
"both, pass --cli-config DIR/.routstr/config.json and run the CLI "
"as HOME=DIR routstr ...",
file=sys.stderr,
)
elif not args.no_cli_token:
try:
token = _create_cli_token(
compose_env, args.cli_token_name, args.cli_token_expires_in_days
)
_write_cli_config(args.cli_config, node_url, token)
print(
f"CLI configured: {args.cli_config} → {node_url} "
f"(token '{args.cli_token_name}')."
)
print(
" This token is full node admin; revoke it in Settings → CLI Tokens "
"when you are done."
)
except (
subprocess.CalledProcessError,
OSError,
ValueError,
RuntimeError,
) as exc:
reason = type(exc).__name__
if isinstance(exc, subprocess.CalledProcessError) and exc.stderr:
# The last stderr line names the failure (e.g. ModuleNotFoundError);
# it never contains the token, which only goes to stdout.
reason = exc.stderr.strip().splitlines()[-1][:200]
print(
f"Warning: could not create a CLI token automatically ({reason}); "
"create one from the dashboard (Settings → CLI Tokens).",
file=sys.stderr,
)
print(
"Phase 2 — back up before configuring: routstr_secret.key, keys.db and "
".wallet/. The key decrypts the nsec stored in keys.db."
)
print(
" Reveal the nsec: docker compose -f compose.node.yml exec routstr "
"/.venv/bin/python scripts/reveal_nsec.py"
)
admin_url = public_origin or local_url
print(
f"Phase 3 — dashboard: {admin_url}/admin — rotate the admin password, "
"add an upstream, review payout/pricing."
)
check_hint = "python3 scripts/node_setup.py check"
if public_origin:
check_hint += f" --public-url {public_origin}"
elif args.port != DEFAULT_PORT:
check_hint += f" --port {args.port}"
print(f"Phase 4 — verify: {check_hint}")
return 0
def _check(args: argparse.Namespace, local_url: str) -> int:
url = public_url(args.public_url) if args.public_url else local_url
name, count = probe(url)
print(f"Node reachable at {url}: {name}; {count} public models.")
if count == 0:
print("Not configured: add an upstream and enable at least one routable model.")
return 2
print("Discovery passes; this does not prove paid inference, payouts, or backup recovery.")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("action", choices=("start", "check"))
parser.add_argument(
"--public-url",
help="HTTPS origin: publish on start, or verify from outside with check",
)
parser.add_argument(
"--private",
action="store_true",
help="start loopback-only and publish nothing until a public URL is set later",
)
parser.add_argument(
"--ln-address",
help="payout Lightning address (user@host, lnurl1…, or https:// LNURL-pay)",
)
parser.add_argument(
"--min-payout-sat", type=int, help="minimum payout balance in sats (default 210)"
)
parser.add_argument(
"--payout-interval", type=int, help="seconds between payout checks (default 900)"
)
parser.add_argument(
"--port", type=int, default=DEFAULT_PORT, help="loopback host port (default: 8000)"
)
parser.add_argument(
"--no-cli-token",
action="store_true",
help="do not create a long-lived CLI token or write the CLI config",
)
parser.add_argument(
"--cli-token-name",
default="node_setup",
help="name for the generated CLI token (default: node_setup)",
)
parser.add_argument(
"--cli-token-expires-in-days",
type=int,
help="expire the generated CLI token after N days (default: never)",
)
parser.add_argument(
"--cli-config",
type=Path,
default=Path.home() / ".routstr" / "config.json",
help="where to write the Routstr CLI config",
)
args = parser.parse_args()
if not 1 <= args.port <= 65535:
parser.error("--port must be between 1 and 65535")
if args.min_payout_sat is not None and args.min_payout_sat <= 0:
parser.error("--min-payout-sat must be positive")
if args.payout_interval is not None and args.payout_interval <= 0:
parser.error("--payout-interval must be positive")
if (
args.cli_token_expires_in_days is not None
and args.cli_token_expires_in_days <= 0
):
parser.error("--cli-token-expires-in-days must be positive")
if args.action == "check" and (
args.private
or args.ln_address
or args.min_payout_sat
or args.payout_interval
or args.no_cli_token
or args.cli_token_expires_in_days
):
parser.error("check accepts only --public-url and --port")
if args.action == "start" and args.private and args.public_url:
parser.error("--private and --public-url are mutually exclusive")
if args.action == "start" and not args.private and not args.public_url:
parser.error("choose --public-url https://your.node (default) or --private")
local_url = f"http://127.0.0.1:{args.port}"
if args.action == "start":
return _start(args, local_url)
return _check(args, local_url)
if __name__ == "__main__":
try:
sys.exit(main())
except (RuntimeError, ValueError, OSError, subprocess.CalledProcessError, urllib.error.URLError) as exc:
# HTTP response bodies and Docker logs may contain credentials.
print(
f"Setup check failed: {type(exc).__name__}. Inspect locally without sharing secrets.",
file=sys.stderr,
)
sys.exit(1)