commit 0e793197bfa07108bd10d4632868fd76b5df1679 Author: Beda Schmid Date: Sun Feb 15 16:28:38 2026 +0000 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..242a996 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +APP_USERNAME=admin +APP_PASSWORD=change-me-now +# APP_PASSWORD_HASH=$2b$12$replace_with_bcrypt_hash_if_you_prefer +SESSION_SECRET=replace-with-a-long-random-secret +SESSION_COOKIE_SECURE=false +RPC_TIMEOUT_SECONDS=15 +METRICS_SAMPLER_INTERVAL_SECONDS=60 +DATA_DIR=./data +DB_PATH=./data/dashboard.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ce028b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +data/dashboard.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eda3a24 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog. + +## [Unreleased] + +### Added + +- Added a structured `/doc` documentation set with indexed, cross-referenced guides for architecture, API contracts, data models, build and deployment rules, environment requirements, and coding conventions. + +### Changed + +- Refactored `README.md` into a repository landing page focused on project overview, quick start, and links to detailed documentation. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..911a3fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY data ./data + +EXPOSE 8080 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080" diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a12edf --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Bitcoin Core Admin Dashboard + +Web dashboard for operating and inspecting a Bitcoin Core node over RPC and SSH. + +## Features + +- Session-based dashboard login. +- Persistent node settings in SQLite. +- Live node summary cards and history charts. +- RPC explorer with command catalog from node `help`. +- Node control actions: + - stop via RPC + - start via SSH + - restart via RPC stop plus SSH start + +## Tech Stack + +- FastAPI + Jinja templates +- Vanilla JavaScript + Chart.js +- SQLite persistence +- `requests` for JSON-RPC +- `paramiko` for SSH control + +## Quick Start (Docker) + +1. Create environment file: + +```bash +cp .env.example .env +``` + +2. Update at minimum: + +- `APP_USERNAME` +- `APP_PASSWORD` or `APP_PASSWORD_HASH` +- `SESSION_SECRET` + +3. Start: + +```bash +docker compose up --build +``` + +4. Open: + +- `http://localhost:8080` + +## Quick Start (Local Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +uvicorn app.main:app --reload --port 8080 +``` + +## Runtime Notes + +- Settings and metrics history persist in `data/dashboard.db`. +- Background metric sampling runs every `METRICS_SAMPLER_INTERVAL_SECONDS` (minimum 15 seconds). +- Start and restart actions require working SSH configuration. + +## Docker RPC Connectivity Note + +If the dashboard runs in Docker and Bitcoin Core runs on the Docker host, use: + +- `http://{HOST_IP}:8332` + +Using `http://127.0.0.1:8332` inside the container points to the container itself. + +Make sure your node allows connections on that port from the Docker IP/Subnet + +## Documentation + +See the full documentation set in `doc/README.md`: + +- Architecture: `doc/architecture.md` +- API reference: `doc/api.md` +- Data models: `doc/data-models.md` +- Build and deploy: `doc/build-and-deploy.md` +- Environment config: `doc/environment.md` +- Conventions: `doc/conventions.md` + +## Security + +- Use strong credentials and a random session secret. +- Prefer password hash (`APP_PASSWORD_HASH`) over plaintext password. +- Restrict dashboard network access to trusted operators. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..c04b077 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,34 @@ +import secrets + +from passlib.context import CryptContext +from starlette.requests import Request +from fastapi import HTTPException, status + +from app.config import get_config + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + + +def verify_credentials(username: str, password: str) -> bool: + cfg = get_config() + username_ok = secrets.compare_digest(username, cfg.app_username) + if not username_ok: + return False + + if cfg.app_password_hash: + return pwd_context.verify(password, cfg.app_password_hash) + + return secrets.compare_digest(password, cfg.app_password) + + + +def require_auth(request: Request) -> str: + username = request.session.get("username") + cfg = get_config() + if not username or username != cfg.app_username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + return username diff --git a/app/bitcoin_rpc.py b/app/bitcoin_rpc.py new file mode 100644 index 0000000..407ea22 --- /dev/null +++ b/app/bitcoin_rpc.py @@ -0,0 +1,126 @@ +import json +import re +from typing import Any +from urllib.parse import quote + +import requests + +from app.config import get_config + + +class BitcoinRPCError(RuntimeError): + pass + + +class BitcoinRPCClient: + def __init__( + self, + rpc_url: str, + username: str, + password: str, + wallet: str = "", + timeout: int | None = None, + ) -> None: + cfg = get_config() + self.rpc_url = rpc_url.rstrip("/") + self.username = username + self.password = password + self.wallet = wallet.strip() + self.timeout = timeout or cfg.request_timeout_seconds + + def _endpoint(self, wallet_override: str | None = None) -> str: + wallet = self.wallet if wallet_override is None else wallet_override.strip() + if wallet: + return f"{self.rpc_url}/wallet/{quote(wallet, safe='')}" + return self.rpc_url + + def call( + self, + method: str, + params: list[Any] | None = None, + wallet_override: str | None = None, + ) -> Any: + payload = { + "jsonrpc": "1.0", + "id": "tellscoin-dashboard", + "method": method, + "params": params or [], + } + try: + response = requests.post( + self._endpoint(wallet_override), + json=payload, + auth=(self.username, self.password), + timeout=self.timeout, + ) + response.raise_for_status() + body = response.json() + except requests.RequestException as exc: + raise BitcoinRPCError(f"RPC request failed: {exc}") from exc + except json.JSONDecodeError as exc: + raise BitcoinRPCError("RPC response was not valid JSON") from exc + + if body.get("error"): + raise BitcoinRPCError(str(body["error"])) + return body.get("result") + + def batch_call(self, calls: list[tuple[str, list[Any]]]) -> dict[str, Any]: + payload = [] + for index, (method, params) in enumerate(calls): + payload.append( + { + "jsonrpc": "1.0", + "id": str(index), + "method": method, + "params": params, + } + ) + + try: + response = requests.post( + self._endpoint(), + json=payload, + auth=(self.username, self.password), + timeout=self.timeout, + ) + response.raise_for_status() + body = response.json() + except requests.RequestException as exc: + raise BitcoinRPCError(f"Batch RPC request failed: {exc}") from exc + + by_id = {entry.get("id"): entry for entry in body} + results = {} + for index, (method, _) in enumerate(calls): + key = str(index) + entry = by_id.get(key) + if not entry: + raise BitcoinRPCError(f"Missing batch result for method {method}") + if entry.get("error"): + raise BitcoinRPCError(f"{method}: {entry['error']}") + results[method] = entry.get("result") + return results + + +COMMAND_LINE_RE = re.compile(r"^([a-z][a-z0-9_]*)\b") + + + +def parse_help_output(help_text: str) -> list[dict[str, str]]: + commands: list[dict[str, str]] = [] + seen = set() + + for raw in help_text.splitlines(): + line = raw.strip() + if not line or line.startswith("=="): + continue + match = COMMAND_LINE_RE.match(line) + if not match: + continue + method = match.group(1) + if method in seen: + continue + seen.add(method) + commands.append({"method": method, "synopsis": line}) + + commands.sort(key=lambda item: item["method"]) + return commands diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..061a5a1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,45 @@ +import os +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + + +@dataclass(frozen=True) +class AppConfig: + app_username: str + app_password: str + app_password_hash: str | None + session_secret: str + database_path: Path + request_timeout_seconds: int + session_cookie_secure: bool + metrics_sampler_interval_seconds: int + + + +def _bool_env(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +@lru_cache(maxsize=1) +def get_config() -> AppConfig: + data_dir = Path(os.getenv("DATA_DIR", "./data")) + data_dir.mkdir(parents=True, exist_ok=True) + + return AppConfig( + app_username=os.getenv("APP_USERNAME", "admin"), + app_password=os.getenv("APP_PASSWORD", "changeme"), + app_password_hash=os.getenv("APP_PASSWORD_HASH"), + session_secret=os.getenv("SESSION_SECRET", "change-this-secret"), + database_path=Path(os.getenv("DB_PATH", str(data_dir / "dashboard.db"))), + request_timeout_seconds=int(os.getenv("RPC_TIMEOUT_SECONDS", "15")), + session_cookie_secure=_bool_env("SESSION_COOKIE_SECURE", False), + metrics_sampler_interval_seconds=max(15, int(os.getenv("METRICS_SAMPLER_INTERVAL_SECONDS", "60"))), + ) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..7fd4d10 --- /dev/null +++ b/app/db.py @@ -0,0 +1,233 @@ +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +from app.config import get_config + + +SETTINGS_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS node_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + rpc_url TEXT NOT NULL DEFAULT '', + rpc_username TEXT NOT NULL DEFAULT '', + rpc_password TEXT NOT NULL DEFAULT '', + rpc_wallet TEXT NOT NULL DEFAULT '', + config_path TEXT NOT NULL DEFAULT '', + ssh_host TEXT NOT NULL DEFAULT '', + ssh_port INTEGER NOT NULL DEFAULT 22, + ssh_username TEXT NOT NULL DEFAULT '', + ssh_password TEXT NOT NULL DEFAULT '', + ssh_key_path TEXT NOT NULL DEFAULT '', + bitcoin_binary TEXT NOT NULL DEFAULT 'bitcoind', + updated_at TEXT NOT NULL +); +""" + + +METRICS_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS metrics_history ( + ts INTEGER PRIMARY KEY, + blocks INTEGER NOT NULL, + headers INTEGER NOT NULL, + mempool_bytes INTEGER NOT NULL, + peers INTEGER NOT NULL +); +""" + + +METRICS_INDEX_SQL = """ +CREATE INDEX IF NOT EXISTS idx_metrics_history_ts ON metrics_history (ts); +""" + + +DEFAULT_SETTINGS = { + "rpc_url": "http://127.0.0.1:8332", + "rpc_username": "", + "rpc_password": "", + "rpc_wallet": "", + "config_path": "", + "ssh_host": "", + "ssh_port": 22, + "ssh_username": "", + "ssh_password": "", + "ssh_key_path": "", + "bitcoin_binary": "bitcoind", + "updated_at": "", +} + +METRICS_RETENTION_SECONDS = 60 * 60 * 24 * 30 +METRICS_MAX_ROWS = 20000 + + + +def _ensure_db_parent(db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + + +@contextmanager +def _connect() -> sqlite3.Connection: + cfg = get_config() + db_path = cfg.database_path + _ensure_db_parent(db_path) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + + +def init_db() -> None: + with _connect() as conn: + conn.execute(SETTINGS_TABLE_SQL) + conn.execute(METRICS_TABLE_SQL) + conn.execute(METRICS_INDEX_SQL) + conn.commit() + + + +def get_settings() -> dict: + with _connect() as conn: + row = conn.execute("SELECT * FROM node_settings WHERE id = 1").fetchone() + + if not row: + return DEFAULT_SETTINGS.copy() + + data = dict(row) + for key, value in DEFAULT_SETTINGS.items(): + data.setdefault(key, value) + return data + + + +def save_settings(values: dict) -> dict: + current = get_settings() + merged = {**current, **values} + merged["updated_at"] = datetime.now(timezone.utc).isoformat() + + with _connect() as conn: + conn.execute( + """ + INSERT INTO node_settings ( + id, + rpc_url, + rpc_username, + rpc_password, + rpc_wallet, + config_path, + ssh_host, + ssh_port, + ssh_username, + ssh_password, + ssh_key_path, + bitcoin_binary, + updated_at + ) + VALUES ( + 1, + :rpc_url, + :rpc_username, + :rpc_password, + :rpc_wallet, + :config_path, + :ssh_host, + :ssh_port, + :ssh_username, + :ssh_password, + :ssh_key_path, + :bitcoin_binary, + :updated_at + ) + ON CONFLICT(id) DO UPDATE SET + rpc_url = excluded.rpc_url, + rpc_username = excluded.rpc_username, + rpc_password = excluded.rpc_password, + rpc_wallet = excluded.rpc_wallet, + config_path = excluded.config_path, + ssh_host = excluded.ssh_host, + ssh_port = excluded.ssh_port, + ssh_username = excluded.ssh_username, + ssh_password = excluded.ssh_password, + ssh_key_path = excluded.ssh_key_path, + bitcoin_binary = excluded.bitcoin_binary, + updated_at = excluded.updated_at + """, + merged, + ) + conn.commit() + + return merged + + + +def save_metric_point( + *, + timestamp: int, + blocks: int, + headers: int, + mempool_bytes: int, + peers: int, +) -> None: + with _connect() as conn: + conn.execute( + """ + INSERT INTO metrics_history (ts, blocks, headers, mempool_bytes, peers) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(ts) DO UPDATE SET + blocks = excluded.blocks, + headers = excluded.headers, + mempool_bytes = excluded.mempool_bytes, + peers = excluded.peers + """, + (timestamp, blocks, headers, mempool_bytes, peers), + ) + + cutoff = timestamp - METRICS_RETENTION_SECONDS + conn.execute("DELETE FROM metrics_history WHERE ts < ?", (cutoff,)) + + conn.execute( + """ + DELETE FROM metrics_history + WHERE ts NOT IN ( + SELECT ts FROM metrics_history ORDER BY ts DESC LIMIT ? + ) + """, + (METRICS_MAX_ROWS,), + ) + conn.commit() + + + +def get_metric_history(*, since_ts: int | None = None, limit: int = 5000) -> list[dict]: + if limit < 1: + limit = 1 + if limit > METRICS_MAX_ROWS: + limit = METRICS_MAX_ROWS + + with _connect() as conn: + if since_ts is None: + rows = conn.execute( + """ + SELECT ts, blocks, headers, mempool_bytes, peers + FROM metrics_history + ORDER BY ts DESC + LIMIT ? + """, + (limit,), + ).fetchall() + rows = list(reversed(rows)) + else: + rows = conn.execute( + """ + SELECT ts, blocks, headers, mempool_bytes, peers + FROM metrics_history + WHERE ts >= ? + ORDER BY ts ASC + LIMIT ? + """, + (since_ts, limit), + ).fetchall() + + return [dict(row) for row in rows] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7acae2c --- /dev/null +++ b/app/main.py @@ -0,0 +1,413 @@ +import time +import threading +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field +from starlette.middleware.sessions import SessionMiddleware + +from app.auth import require_auth, verify_credentials +from app.bitcoin_rpc import BitcoinRPCClient, BitcoinRPCError, parse_help_output +from app.config import get_config +from app.db import get_metric_history, get_settings, init_db, save_metric_point, save_settings +from app.ssh_control import SSHControlError, build_start_command, run_remote_command + + +cfg = get_config() +CHART_WINDOW_SECONDS = { + "5m": 5 * 60, + "30m": 30 * 60, + "2h": 2 * 60 * 60, + "6h": 6 * 60 * 60, + "all": None, +} +sampler_stop_event = threading.Event() +sampler_thread: threading.Thread | None = None + +app = FastAPI(title="Bitcoin Core Admin Dashboard") +app.add_middleware( + SessionMiddleware, + secret_key=cfg.session_secret, + same_site="lax", + https_only=cfg.session_cookie_secure, + max_age=60 * 60 * 8, +) +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + + +@app.on_event("startup") +def on_startup() -> None: + global sampler_thread + init_db() + if sampler_thread and sampler_thread.is_alive(): + return + sampler_stop_event.clear() + sampler_thread = threading.Thread( + target=_metrics_sampler_loop, + name="metrics-sampler", + daemon=True, + ) + sampler_thread.start() + + +@app.on_event("shutdown") +def on_shutdown() -> None: + global sampler_thread + sampler_stop_event.set() + if sampler_thread and sampler_thread.is_alive(): + sampler_thread.join(timeout=2) + sampler_thread = None + + +@app.middleware("http") +async def no_cache_for_api(request: Request, call_next): + response = await call_next(request) + if request.url.path.startswith("/api/") or request.url.path.startswith("/static/"): + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + +@app.exception_handler(BitcoinRPCError) +async def handle_rpc_error(_: Request, exc: BitcoinRPCError) -> JSONResponse: + return JSONResponse(status_code=502, content={"detail": str(exc)}) + + +@app.exception_handler(SSHControlError) +async def handle_ssh_error(_: Request, exc: SSHControlError) -> JSONResponse: + return JSONResponse(status_code=502, content={"detail": str(exc)}) + + +class LoginPayload(BaseModel): + username: str + password: str + + +class NodeSettingsPayload(BaseModel): + rpc_url: str = Field(default="http://127.0.0.1:8332") + rpc_username: str = "" + rpc_password: str = "" + rpc_wallet: str = "" + config_path: str = "" + ssh_host: str = "" + ssh_port: int = 22 + ssh_username: str = "" + ssh_password: str = "" + ssh_key_path: str = "" + bitcoin_binary: str = "bitcoind" + + +class RPCCallPayload(BaseModel): + method: str + params: list[Any] = Field(default_factory=list) + wallet: str | None = None + + +def _coerce_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _window_to_since(window: str) -> int | None: + if window not in CHART_WINDOW_SECONDS: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported window '{window}'", + ) + + seconds = CHART_WINDOW_SECONDS[window] + if seconds is None: + return None + + return int(time.time()) - seconds + + +def _collect_core_results(client: BitcoinRPCClient) -> dict[str, Any]: + return client.batch_call( + [ + ("getblockchaininfo", []), + ("getnetworkinfo", []), + ("getmempoolinfo", []), + ("getmininginfo", []), + ("uptime", []), + ] + ) + + +def _store_metrics_from_core(core_results: dict[str, Any], updated_at: int) -> None: + chain_data = core_results.get("getblockchaininfo") or {} + network_data = core_results.get("getnetworkinfo") or {} + mempool_data = core_results.get("getmempoolinfo") or {} + save_metric_point( + timestamp=updated_at, + blocks=_coerce_int(chain_data.get("blocks"), 0), + headers=_coerce_int(chain_data.get("headers"), 0), + mempool_bytes=_coerce_int(mempool_data.get("bytes"), 0), + peers=_coerce_int(network_data.get("connections"), 0), + ) + + +def _metrics_sampler_loop() -> None: + interval = cfg.metrics_sampler_interval_seconds + while not sampler_stop_event.is_set(): + try: + settings = get_settings() + client = _rpc_client_from_settings(settings) + core_results = _collect_core_results(client) + _store_metrics_from_core(core_results, int(time.time())) + except Exception: + # Background sampler is best-effort. API requests surface actionable errors. + pass + sampler_stop_event.wait(interval) + + +def _rpc_client_from_settings(settings: dict) -> BitcoinRPCClient: + rpc_url = (settings.get("rpc_url") or "").strip() + rpc_username = settings.get("rpc_username") or "" + rpc_password = settings.get("rpc_password") or "" + rpc_wallet = settings.get("rpc_wallet") or "" + + if not rpc_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Node RPC URL is not configured", + ) + if not rpc_username: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Node RPC username is not configured", + ) + if not rpc_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Node RPC password is not configured", + ) + + return BitcoinRPCClient( + rpc_url=rpc_url, + username=rpc_username, + password=rpc_password, + wallet=rpc_wallet, + ) + + +@app.get("/", response_class=HTMLResponse) +def index(request: Request) -> HTMLResponse: + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/api/health") +def health() -> dict: + return {"ok": True} + + +@app.get("/api/auth/me") +def auth_me(request: Request) -> dict: + username = request.session.get("username") + return { + "authenticated": bool(username and username == cfg.app_username), + "username": username if username == cfg.app_username else None, + } + + +@app.post("/api/auth/login") +def auth_login(payload: LoginPayload, request: Request) -> dict: + if not verify_credentials(payload.username, payload.password): + raise HTTPException(status_code=401, detail="Invalid username or password") + request.session["username"] = payload.username + return {"ok": True, "username": payload.username} + + +@app.post("/api/auth/logout") +def auth_logout(request: Request, _: str = Depends(require_auth)) -> dict: + request.session.clear() + return {"ok": True} + + +@app.get("/api/settings") +def read_settings(_: str = Depends(require_auth)) -> dict: + return get_settings() + + +@app.put("/api/settings") +def update_settings(payload: NodeSettingsPayload, _: str = Depends(require_auth)) -> dict: + values = payload.model_dump() + values["rpc_url"] = values["rpc_url"].strip() + for key in [ + "rpc_username", + "rpc_password", + "rpc_wallet", + "config_path", + "ssh_host", + "ssh_username", + "ssh_password", + "ssh_key_path", + "bitcoin_binary", + ]: + values[key] = str(values.get(key, "")).strip() + + if values["ssh_port"] < 1 or values["ssh_port"] > 65535: + raise HTTPException(status_code=422, detail="SSH port must be between 1 and 65535") + + return save_settings(values) + + +@app.post("/api/rpc/call") +def rpc_call(payload: RPCCallPayload, _: str = Depends(require_auth)) -> dict: + settings = get_settings() + client = _rpc_client_from_settings(settings) + method = payload.method.strip() + if not method: + raise HTTPException(status_code=422, detail="RPC method is required") + + result = client.call(method=method, params=payload.params, wallet_override=payload.wallet) + return {"method": method, "result": result} + + +@app.get("/api/rpc/commands") +def rpc_commands(_: str = Depends(require_auth)) -> dict: + settings = get_settings() + client = _rpc_client_from_settings(settings) + help_text = client.call("help", []) + if not isinstance(help_text, str): + raise HTTPException(status_code=500, detail="Unexpected help response from node") + commands = parse_help_output(help_text) + return {"count": len(commands), "commands": commands} + + +@app.get("/api/rpc/help/{method_name}") +def rpc_method_help(method_name: str, _: str = Depends(require_auth)) -> dict: + settings = get_settings() + client = _rpc_client_from_settings(settings) + details = client.call("help", [method_name]) + return {"method": method_name, "help": details} + + +@app.get("/api/dashboard/summary") +def dashboard_summary(_: str = Depends(require_auth)) -> dict: + settings = get_settings() + client = _rpc_client_from_settings(settings) + + core_results = _collect_core_results(client) + + chain_data = core_results.get("getblockchaininfo") or {} + network_data = core_results.get("getnetworkinfo") or {} + mempool_data = core_results.get("getmempoolinfo") or {} + mining_data = core_results.get("getmininginfo") or {} + uptime_data = core_results.get("uptime") + updated_at = int(time.time()) + + _store_metrics_from_core(core_results, updated_at) + + wallet_data = None + wallet_name = settings.get("rpc_wallet") or "" + if wallet_name: + try: + wallet_data = client.call("getwalletinfo", [], wallet_override=wallet_name) + except BitcoinRPCError: + wallet_data = None + + return { + "blockchain": chain_data, + "network": network_data, + "mempool": mempool_data, + "mining": mining_data, + "uptime": uptime_data, + "wallet": wallet_data, + "updated_at": updated_at, + } + + +@app.get("/api/dashboard/history") +def dashboard_history( + window: str = "30m", + limit: int = 5000, + _: str = Depends(require_auth), +) -> dict: + since_ts = _window_to_since(window) + points = get_metric_history(since_ts=since_ts, limit=limit) + return { + "window": window, + "count": len(points), + "points": points, + } + + +@app.get("/api/dashboard/history/{window}") +def dashboard_history_by_window( + window: str, + limit: int = 5000, + _: str = Depends(require_auth), +) -> dict: + since_ts = _window_to_since(window) + points = get_metric_history(since_ts=since_ts, limit=limit) + return { + "window": window, + "count": len(points), + "points": points, + } + + +@app.post("/api/actions/stop") +def stop_node(_: str = Depends(require_auth)) -> dict: + settings = get_settings() + client = _rpc_client_from_settings(settings) + result = client.call("stop", []) + return {"action": "stop", "result": result} + + +@app.post("/api/actions/start") +def start_node(_: str = Depends(require_auth)) -> dict: + settings = get_settings() + start_command = build_start_command(settings) + remote = run_remote_command(settings, start_command) + + if remote["exit_code"] != 0: + raise HTTPException( + status_code=502, + detail=f"Remote start command failed (exit {remote['exit_code']}): {remote['stderr'] or remote['stdout']}", + ) + + return { + "action": "start", + "command": start_command, + "remote": remote, + } + + +@app.post("/api/actions/restart") +def restart_node(_: str = Depends(require_auth)) -> dict: + settings = get_settings() + + stop_response = None + stop_error = None + try: + client = _rpc_client_from_settings(settings) + stop_response = client.call("stop", []) + time.sleep(2) + except (BitcoinRPCError, HTTPException) as exc: + stop_error = str(exc) + + start_command = build_start_command(settings) + remote = run_remote_command(settings, start_command) + + if remote["exit_code"] != 0: + raise HTTPException( + status_code=502, + detail=f"Remote restart command failed (exit {remote['exit_code']}): {remote['stderr'] or remote['stdout']}", + ) + + return { + "action": "restart", + "stop_result": stop_response, + "stop_error": stop_error, + "start_command": start_command, + "remote": remote, + } diff --git a/app/ssh_control.py b/app/ssh_control.py new file mode 100644 index 0000000..40c2196 --- /dev/null +++ b/app/ssh_control.py @@ -0,0 +1,82 @@ +import shlex +from urllib.parse import urlparse + +import paramiko + + +class SSHControlError(RuntimeError): + pass + + + +def _derive_host_from_url(rpc_url: str) -> str: + try: + parsed = urlparse(rpc_url) + return parsed.hostname or "" + except ValueError: + return "" + + + +def run_remote_command(settings: dict, command: str) -> dict: + ssh_host = (settings.get("ssh_host") or "").strip() or _derive_host_from_url(settings.get("rpc_url", "")) + ssh_username = (settings.get("ssh_username") or "").strip() + ssh_password = settings.get("ssh_password") or "" + ssh_key_path = (settings.get("ssh_key_path") or "").strip() + ssh_port = int(settings.get("ssh_port") or 22) + + if not ssh_host: + raise SSHControlError("SSH host is required to run start/restart commands") + if not ssh_username: + raise SSHControlError("SSH username is required to run start/restart commands") + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + connect_args = { + "hostname": ssh_host, + "port": ssh_port, + "username": ssh_username, + "timeout": 10, + "look_for_keys": False, + "allow_agent": False, + } + + if ssh_key_path: + connect_args["key_filename"] = ssh_key_path + if ssh_password: + connect_args["passphrase"] = ssh_password + elif ssh_password: + connect_args["password"] = ssh_password + else: + connect_args["look_for_keys"] = True + connect_args["allow_agent"] = True + + try: + client.connect(**connect_args) + _, stdout, stderr = client.exec_command(command, timeout=20) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode("utf-8", errors="replace").strip() + err = stderr.read().decode("utf-8", errors="replace").strip() + except Exception as exc: # noqa: BLE001 + raise SSHControlError(f"SSH command failed: {exc}") from exc + finally: + client.close() + + return { + "exit_code": exit_code, + "stdout": out, + "stderr": err, + "host": ssh_host, + } + + + +def build_start_command(settings: dict) -> str: + config_path = (settings.get("config_path") or "").strip() + bitcoin_binary = (settings.get("bitcoin_binary") or "bitcoind").strip() or "bitcoind" + + if not config_path: + raise SSHControlError("Config file path is required to start bitcoind") + + return f"{shlex.quote(bitcoin_binary)} -daemon -conf={shlex.quote(config_path)}" diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..08d8344 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,893 @@ +const CHART_WINDOWS = { + "5m": 5 * 60 * 1000, + "30m": 30 * 60 * 1000, + "2h": 2 * 60 * 60 * 1000, + "6h": 6 * 60 * 60 * 1000, + all: null, +}; +const chartWindowSetting = window.localStorage.getItem("chartWindow"); +const state = { + settings: null, + commands: [], + charts: {}, + chartWindow: chartWindowSetting in CHART_WINDOWS ? chartWindowSetting : "30m", + history: { + timestamps: [], + blocks: [], + headers: [], + mempool: [], + peers: [], + }, + pollTimer: null, +}; + +const numberFmt = new Intl.NumberFormat(); +const MAX_HISTORY_POINTS = 20000; +const HISTORY_CACHE_KEY = "tellscoin_history_cache_v1"; +const GAP_BREAK_MS = 10 * 60 * 1000; + +const el = { + loginView: document.getElementById("login-view"), + appView: document.getElementById("app-view"), + loginForm: document.getElementById("login-form"), + loginUser: document.getElementById("login-username"), + loginPass: document.getElementById("login-password"), + loginError: document.getElementById("login-error"), + logoutBtn: document.getElementById("logout-btn"), + refreshBtn: document.getElementById("refresh-btn"), + settingsBtn: document.getElementById("settings-btn"), + settingsModal: document.getElementById("settings-modal"), + settingsClose: document.getElementById("settings-close"), + settingsForm: document.getElementById("settings-form"), + settingsError: document.getElementById("settings-error"), + liveStatus: document.getElementById("live-status"), + syncTrack: document.getElementById("sync-track"), + syncFill: document.getElementById("sync-fill"), + syncPercent: document.getElementById("sync-percent"), + syncSummary: document.getElementById("sync-summary"), + syncDetails: document.getElementById("sync-details"), + commandList: document.getElementById("command-list"), + commandSearch: document.getElementById("command-search"), + commandCount: document.getElementById("command-count"), + rpcForm: document.getElementById("rpc-form"), + rpcMethod: document.getElementById("rpc-method"), + rpcParams: document.getElementById("rpc-params"), + rpcWallet: document.getElementById("rpc-wallet"), + rpcHelpBtn: document.getElementById("rpc-help-btn"), + rpcError: document.getElementById("rpc-error"), + rpcOutput: document.getElementById("rpc-output"), + actionStop: document.getElementById("action-stop"), + actionStart: document.getElementById("action-start"), + actionRestart: document.getElementById("action-restart"), + chartWindow: document.getElementById("chart-window"), + chartHistoryInfo: document.getElementById("chart-history-info"), +}; + +function showToast(message, isError = false) { + const node = document.createElement("div"); + node.textContent = message; + node.style.position = "fixed"; + node.style.right = "1rem"; + node.style.bottom = "1rem"; + node.style.padding = "0.7rem 0.9rem"; + node.style.maxWidth = "340px"; + node.style.borderRadius = "12px"; + node.style.fontSize = "0.86rem"; + node.style.fontWeight = "600"; + node.style.background = isError ? "#9f2525" : "#005c53"; + node.style.color = "#fff"; + node.style.zIndex = "30"; + node.style.boxShadow = "0 14px 30px rgba(0,0,0,0.2)"; + document.body.appendChild(node); + window.setTimeout(() => node.remove(), 3200); +} + +function formatNumber(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return "-"; + } + return numberFmt.format(Number(value)); +} + +function formatBytes(value) { + const bytes = Number(value); + if (!Number.isFinite(bytes)) { + return "-"; + } + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + return `${size.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`; +} + +function formatUptime(seconds) { + const sec = Number(seconds); + if (!Number.isFinite(sec)) { + return "-"; + } + const days = Math.floor(sec / 86400); + const hours = Math.floor((sec % 86400) / 3600); + const mins = Math.floor((sec % 3600) / 60); + return `${days}d ${hours}h ${mins}m`; +} + +function formatChartTick(valueMs) { + const ts = Number(valueMs); + if (!Number.isFinite(ts)) { + return "-"; + } + const date = new Date(ts); + if (state.chartWindow === "all" || state.chartWindow === "6h") { + return date.toLocaleString(undefined, { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function getWindowMs(windowKey) { + if (Object.prototype.hasOwnProperty.call(CHART_WINDOWS, windowKey)) { + return CHART_WINDOWS[windowKey]; + } + return CHART_WINDOWS["30m"]; +} + +async function api(path, options = {}) { + const opts = { + method: options.method || "GET", + cache: "no-store", + credentials: "same-origin", + headers: { + ...(options.body ? { "Content-Type": "application/json" } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }; + + const response = await fetch(path, opts); + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + if (response.status === 401) { + moveToLogin(); + throw new Error("Session expired, please sign in again."); + } + const detail = payload && payload.detail ? payload.detail : `HTTP ${response.status}`; + throw new Error(detail); + } + + return payload; +} + +function moveToLogin() { + el.appView.classList.add("hidden"); + el.loginView.classList.remove("hidden"); + if (state.pollTimer) { + window.clearInterval(state.pollTimer); + state.pollTimer = null; + } +} + +function moveToApp() { + el.loginView.classList.add("hidden"); + el.appView.classList.remove("hidden"); +} + +function setStat(id, text) { + const node = document.getElementById(id); + if (node) { + node.textContent = text; + } +} + +function updateSyncPanel(chain) { + if (!el.syncFill || !el.syncPercent || !el.syncSummary || !el.syncDetails) { + return; + } + + const rawProgress = Number(chain.verificationprogress); + const hasProgress = Number.isFinite(rawProgress); + const clampedPercent = hasProgress ? Math.max(0, Math.min(rawProgress * 100, 100)) : 0; + const blocks = Number(chain.blocks); + const headers = Number(chain.headers); + const blockGap = Number.isFinite(blocks) && Number.isFinite(headers) ? Math.max(headers - blocks, 0) : 0; + const inIbd = Boolean(chain.initialblockdownload); + + el.syncFill.style.width = `${clampedPercent.toFixed(3)}%`; + el.syncPercent.textContent = hasProgress ? `${clampedPercent.toFixed(2)}%` : "-"; + if (el.syncTrack) { + el.syncTrack.setAttribute("aria-valuenow", clampedPercent.toFixed(3)); + } + + if (!hasProgress) { + el.syncSummary.textContent = "Sync progress unavailable."; + } else if (inIbd || clampedPercent < 99.999) { + el.syncSummary.textContent = `Syncing: ${formatNumber(blocks)} / ${formatNumber(headers)} blocks`; + } else { + el.syncSummary.textContent = "Node reports fully synced."; + } + + const details = []; + details.push(`${formatNumber(blockGap)} headers behind`); + details.push(`IBD: ${inIbd ? "yes" : "no"}`); + + const tipTime = Number(chain.mediantime); + if (Number.isFinite(tipTime)) { + details.push(`Tip time: ${new Date(tipTime * 1000).toLocaleString()}`); + } + + el.syncDetails.textContent = details.join(" | "); +} + +function resetHistory() { + state.history.timestamps = []; + state.history.blocks = []; + state.history.headers = []; + state.history.mempool = []; + state.history.peers = []; +} + +function getHistoryPoints() { + const points = []; + const length = state.history.timestamps.length; + for (let index = 0; index < length; index += 1) { + points.push({ + ts: Math.floor(state.history.timestamps[index] / 1000), + blocks: state.history.blocks[index], + headers: state.history.headers[index], + mempool_bytes: state.history.mempool[index], + peers: state.history.peers[index], + }); + } + return points; +} + +function saveHistoryCache() { + try { + const points = getHistoryPoints(); + const tail = points.slice(Math.max(0, points.length - 12000)); + window.localStorage.setItem(HISTORY_CACHE_KEY, JSON.stringify(tail)); + } catch { + // best-effort cache only + } +} + +function loadHistoryCache() { + try { + const raw = window.localStorage.getItem(HISTORY_CACHE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function setHistoryInfo(message) { + if (el.chartHistoryInfo) { + el.chartHistoryInfo.textContent = message; + } +} + +function describeHistory(points, source) { + if (!points.length) { + return `History status: no points (${source}).`; + } + + const firstTs = Number(points[0].ts); + const lastTs = Number(points[points.length - 1].ts); + const firstLabel = Number.isFinite(firstTs) ? new Date(firstTs * 1000).toLocaleString() : "-"; + const lastLabel = Number.isFinite(lastTs) ? new Date(lastTs * 1000).toLocaleString() : "-"; + const parts = [`History status: ${points.length} points (${source}) from ${firstLabel} to ${lastLabel} (browser local time).`]; + + const windowMs = getWindowMs(state.chartWindow); + if (windowMs !== null && Number.isFinite(firstTs)) { + const expectedStartMs = Date.now() - windowMs; + if (firstTs * 1000 > expectedStartMs + 60_000) { + parts.push("No samples exist for earlier part of selected window."); + } + } + + return parts.join(" "); +} + +function filterPointsForWindow(points, windowKey) { + const windowMs = getWindowMs(windowKey); + if (windowMs === null) { + return points; + } + const threshold = Date.now() - windowMs; + return points.filter((point) => Number(point.ts) * 1000 >= threshold); +} + +function setHistoryFromPoints(points) { + resetHistory(); + const sortedPoints = [...points].sort((a, b) => Number(a.ts) - Number(b.ts)); + sortedPoints.forEach((point) => { + state.history.timestamps.push(Number(point.ts) * 1000); + state.history.blocks.push(Number(point.blocks || 0)); + state.history.headers.push(Number(point.headers || 0)); + state.history.mempool.push(Number(point.mempool_bytes || 0)); + state.history.peers.push(Number(point.peers || 0)); + }); + saveHistoryCache(); +} + +async function loadHistory() { + const params = new URLSearchParams(); + params.set("limit", state.chartWindow === "all" ? "20000" : "8000"); + params.set("_", String(Date.now())); + const payload = await api(`/api/dashboard/history/${encodeURIComponent(state.chartWindow)}?${params.toString()}`); + const points = Array.isArray(payload.points) ? payload.points : []; + if (points.length > 0) { + setHistoryFromPoints(points); + setHistoryInfo(describeHistory(points, "server")); + refreshCharts(); + return; + } + + const cachedPoints = filterPointsForWindow(loadHistoryCache(), state.chartWindow); + setHistoryFromPoints(cachedPoints); + setHistoryInfo(describeHistory(cachedPoints, "browser cache")); + refreshCharts(); +} + +function updateHistory(summary) { + const timestamp = Number(summary.updated_at || Math.floor(Date.now() / 1000)) * 1000; + const chain = summary.blockchain || {}; + const net = summary.network || {}; + const mem = summary.mempool || {}; + const blocks = Number(chain.blocks || 0); + const headers = Number(chain.headers || 0); + const mempoolBytes = Number(mem.bytes || 0); + const peers = Number(net.connections || 0); + + const lastIndex = state.history.timestamps.length - 1; + if (lastIndex >= 0 && state.history.timestamps[lastIndex] === timestamp) { + state.history.blocks[lastIndex] = blocks; + state.history.headers[lastIndex] = headers; + state.history.mempool[lastIndex] = mempoolBytes; + state.history.peers[lastIndex] = peers; + return; + } + + state.history.timestamps.push(timestamp); + state.history.blocks.push(blocks); + state.history.headers.push(headers); + state.history.mempool.push(mempoolBytes); + state.history.peers.push(peers); + + if (state.history.timestamps.length > MAX_HISTORY_POINTS) { + Object.keys(state.history).forEach((key) => { + state.history[key].shift(); + }); + } + saveHistoryCache(); +} + +function getChartSlice() { + const timestamps = state.history.timestamps; + if (!timestamps.length) { + return { + timestamps: [], + blocks: [], + headers: [], + mempool: [], + peers: [], + }; + } + + const windowMs = getWindowMs(state.chartWindow); + let startIndex = 0; + if (windowMs !== null) { + const threshold = Date.now() - windowMs; + while (startIndex < timestamps.length && timestamps[startIndex] < threshold) { + startIndex += 1; + } + if (startIndex >= timestamps.length) { + return { + timestamps: [], + blocks: [], + headers: [], + mempool: [], + peers: [], + }; + } + } + + return { + timestamps: timestamps.slice(startIndex), + blocks: state.history.blocks.slice(startIndex), + headers: state.history.headers.slice(startIndex), + mempool: state.history.mempool.slice(startIndex), + peers: state.history.peers.slice(startIndex), + }; +} + +function createChart(nodeId, datasets, yScale = {}) { + const context = document.getElementById(nodeId); + return new Chart(context, { + type: "line", + data: { + labels: [], + datasets, + }, + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + spanGaps: false, + scales: { + x: { + type: "linear", + ticks: { + maxTicksLimit: 6, + callback: (value) => formatChartTick(value), + }, + }, + y: yScale, + }, + plugins: { + legend: { + labels: { + boxWidth: 12, + }, + }, + }, + }, + }); +} + +function ensureCharts() { + if (state.charts.blocks) { + return; + } + + state.charts.blocks = createChart("chart-blocks", [ + { + label: "Blocks", + data: [], + borderColor: "#005c53", + backgroundColor: "rgba(0,92,83,0.16)", + borderWidth: 2, + tension: 0, + pointRadius: 1.2, + pointHoverRadius: 3, + }, + { + label: "Headers", + data: [], + borderColor: "#cf6f1b", + backgroundColor: "rgba(207,111,27,0.14)", + borderWidth: 2, + tension: 0, + pointRadius: 1.2, + pointHoverRadius: 3, + }, + ]); + + state.charts.mempool = createChart("chart-mempool", [ + { + label: "Bytes", + data: [], + borderColor: "#234b9b", + backgroundColor: "rgba(35,75,155,0.14)", + borderWidth: 2, + tension: 0, + pointRadius: 1.2, + pointHoverRadius: 3, + }, + ], { + min: 0, + beginAtZero: true, + ticks: { + callback: (value) => formatBytes(value), + }, + }); + + state.charts.peers = createChart("chart-peers", [ + { + label: "Connections", + data: [], + borderColor: "#8f3c23", + backgroundColor: "rgba(143,60,35,0.14)", + borderWidth: 2, + tension: 0, + pointRadius: 1.2, + pointHoverRadius: 3, + }, + ], { + min: 0, + beginAtZero: true, + ticks: { + precision: 0, + stepSize: 1, + callback: (value) => Math.round(Number(value)).toString(), + }, + }); +} + +function refreshCharts() { + ensureCharts(); + const slice = getChartSlice(); + const toSeries = (values) => { + const points = []; + for (let index = 0; index < slice.timestamps.length; index += 1) { + const ts = slice.timestamps[index]; + const y = values[index]; + if (index > 0) { + const prevTs = slice.timestamps[index - 1]; + if (ts - prevTs > GAP_BREAK_MS) { + points.push({ x: prevTs + 1, y: null }); + points.push({ x: ts - 1, y: null }); + } + } + points.push({ x: ts, y }); + } + return points; + }; + const windowMs = getWindowMs(state.chartWindow); + const now = Date.now(); + let xMin = null; + let xMax = null; + + if (windowMs === null) { + if (slice.timestamps.length > 0) { + xMin = slice.timestamps[0]; + xMax = slice.timestamps[slice.timestamps.length - 1]; + } + } else { + xMin = now - windowMs; + xMax = now; + } + + state.charts.blocks.data.labels = []; + state.charts.blocks.data.datasets[0].data = toSeries(slice.blocks); + state.charts.blocks.data.datasets[1].data = toSeries(slice.headers); + + state.charts.mempool.data.labels = []; + state.charts.mempool.data.datasets[0].data = toSeries(slice.mempool); + + state.charts.peers.data.labels = []; + state.charts.peers.data.datasets[0].data = toSeries(slice.peers); + + Object.values(state.charts).forEach((chart) => { + chart.options.scales.x.min = xMin; + chart.options.scales.x.max = xMax; + }); + + Object.values(state.charts).forEach((chart) => chart.update("none")); +} + +async function refreshSummary(showSuccess = false) { + try { + const summary = await api("/api/dashboard/summary"); + const chain = summary.blockchain || {}; + const net = summary.network || {}; + const mem = summary.mempool || {}; + + setStat("stat-chain", chain.chain || "-"); + setStat("stat-blocks", formatNumber(chain.blocks)); + setStat("stat-headers", formatNumber(chain.headers)); + setStat("stat-peers", formatNumber(net.connections)); + setStat("stat-mempool-tx", formatNumber(mem.size)); + setStat("stat-mempool-size", formatBytes(mem.bytes)); + setStat("stat-difficulty", formatNumber(chain.difficulty)); + setStat("stat-uptime", formatUptime(summary.uptime)); + updateSyncPanel(chain); + + updateHistory(summary); + refreshCharts(); + setHistoryInfo(describeHistory(getHistoryPoints(), "live")); + + const now = new Date().toLocaleTimeString(); + el.liveStatus.textContent = `Connected - updated ${now}`; + if (showSuccess) { + showToast("Dashboard refreshed"); + } + } catch (error) { + el.liveStatus.textContent = `Connection issue: ${error.message}`; + if (showSuccess) { + showToast(error.message, true); + } + } +} + +function populateSettingsForm(settings) { + Object.entries(settings).forEach(([key, value]) => { + const input = el.settingsForm.elements.namedItem(key); + if (input) { + input.value = value ?? ""; + } + }); +} + +async function loadSettings() { + state.settings = await api("/api/settings"); + populateSettingsForm(state.settings); +} + +function openSettings() { + el.settingsError.textContent = ""; + el.settingsModal.classList.remove("hidden"); +} + +function closeSettings() { + el.settingsModal.classList.add("hidden"); +} + +function renderCommandList() { + const filter = (el.commandSearch.value || "").trim().toLowerCase(); + const filtered = state.commands.filter((item) => item.method.includes(filter)); + el.commandList.innerHTML = ""; + + filtered.forEach((item) => { + const button = document.createElement("button"); + button.className = "command-item"; + button.type = "button"; + + const method = document.createElement("span"); + method.className = "command-method"; + method.textContent = item.method; + + const synopsis = document.createElement("span"); + synopsis.className = "command-synopsis"; + synopsis.textContent = item.synopsis; + + button.append(method, synopsis); + button.addEventListener("click", () => { + el.rpcMethod.value = item.method; + el.rpcError.textContent = ""; + Array.from(el.commandList.children).forEach((child) => child.classList.remove("active")); + button.classList.add("active"); + }); + el.commandList.appendChild(button); + }); + + el.commandCount.textContent = `${filtered.length} shown / ${state.commands.length} total`; +} + +async function loadCommandCatalog() { + const payload = await api("/api/rpc/commands"); + state.commands = payload.commands || []; + renderCommandList(); +} + +function parseParams(text) { + const trimmed = (text || "").trim(); + if (!trimmed) { + return []; + } + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error("Params must be valid JSON."); + } + + if (!Array.isArray(parsed)) { + throw new Error("Params must be a JSON array, e.g. [] or [\"value\", 1]."); + } + return parsed; +} + +function writeOutput(label, data) { + const timestamp = new Date().toLocaleString(); + el.rpcOutput.textContent = `${label} @ ${timestamp}\n\n${JSON.stringify(data, null, 2)}`; +} + +async function executeRpc(method, params, wallet) { + const result = await api("/api/rpc/call", { + method: "POST", + body: { + method, + params, + wallet: wallet || null, + }, + }); + writeOutput(`RPC ${method}`, result.result); +} + +async function handleNodeAction(action, button) { + const confirmations = { + stop: "Stop bitcoind via RPC?", + restart: "Restart bitcoind now?", + }; + + if (confirmations[action] && !window.confirm(confirmations[action])) { + return; + } + + const originalText = button.textContent; + button.disabled = true; + button.textContent = "Working..."; + + try { + const result = await api(`/api/actions/${action}`, { method: "POST" }); + writeOutput(`Action ${action}`, result); + showToast(`Action ${action} completed`); + window.setTimeout(() => refreshSummary(false), 1500); + } catch (error) { + showToast(error.message, true); + } finally { + button.disabled = false; + button.textContent = originalText; + } +} + +async function bootDashboard() { + moveToApp(); + if (el.chartWindow) { + el.chartWindow.value = state.chartWindow; + } + await loadSettings(); + await loadCommandCatalog(); + await loadHistory(); + await refreshSummary(false); + + if (state.pollTimer) { + window.clearInterval(state.pollTimer); + } + state.pollTimer = window.setInterval(() => { + refreshSummary(false); + }, 15000); + + if (!state.settings.rpc_username || !state.settings.rpc_password) { + openSettings(); + showToast("Configure node settings to connect.", true); + } +} + +el.loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + el.loginError.textContent = ""; + + try { + await api("/api/auth/login", { + method: "POST", + body: { + username: el.loginUser.value, + password: el.loginPass.value, + }, + }); + await bootDashboard(); + } catch (error) { + el.loginError.textContent = error.message; + } +}); + +el.logoutBtn.addEventListener("click", async () => { + try { + await api("/api/auth/logout", { method: "POST" }); + } catch { + // ignore; local state still transitions to login + } + moveToLogin(); +}); + +el.refreshBtn.addEventListener("click", async () => { + await refreshSummary(true); +}); + +el.settingsBtn.addEventListener("click", openSettings); +el.settingsClose.addEventListener("click", closeSettings); +el.settingsModal.addEventListener("click", (event) => { + if (event.target === el.settingsModal) { + closeSettings(); + } +}); + +el.settingsForm.addEventListener("submit", async (event) => { + event.preventDefault(); + el.settingsError.textContent = ""; + + const body = Object.fromEntries(new FormData(el.settingsForm).entries()); + body.ssh_port = Number(body.ssh_port || 22); + + try { + state.settings = await api("/api/settings", { + method: "PUT", + body, + }); + populateSettingsForm(state.settings); + closeSettings(); + showToast("Settings saved"); + await refreshSummary(false); + await loadCommandCatalog(); + } catch (error) { + el.settingsError.textContent = error.message; + } +}); + +el.commandSearch.addEventListener("input", renderCommandList); + +el.rpcForm.addEventListener("submit", async (event) => { + event.preventDefault(); + el.rpcError.textContent = ""; + + try { + const params = parseParams(el.rpcParams.value); + await executeRpc(el.rpcMethod.value.trim(), params, el.rpcWallet.value.trim()); + } catch (error) { + el.rpcError.textContent = error.message; + } +}); + +el.rpcHelpBtn.addEventListener("click", async () => { + el.rpcError.textContent = ""; + const method = el.rpcMethod.value.trim(); + if (!method) { + el.rpcError.textContent = "Enter a method first."; + return; + } + + try { + const result = await api(`/api/rpc/help/${encodeURIComponent(method)}`); + writeOutput(`Help ${method}`, result.help); + } catch (error) { + el.rpcError.textContent = error.message; + } +}); + +document.querySelectorAll(".quick-rpc").forEach((button) => { + button.addEventListener("click", async () => { + const method = button.dataset.method; + el.rpcMethod.value = method; + el.rpcParams.value = "[]"; + el.rpcError.textContent = ""; + + try { + await executeRpc(method, [], el.rpcWallet.value.trim()); + } catch (error) { + el.rpcError.textContent = error.message; + } + }); +}); + +el.actionStop.addEventListener("click", () => handleNodeAction("stop", el.actionStop)); +el.actionStart.addEventListener("click", () => handleNodeAction("start", el.actionStart)); +el.actionRestart.addEventListener("click", () => handleNodeAction("restart", el.actionRestart)); +if (el.chartWindow) { + el.chartWindow.addEventListener("change", async () => { + state.chartWindow = el.chartWindow.value in CHART_WINDOWS ? el.chartWindow.value : "30m"; + window.localStorage.setItem("chartWindow", state.chartWindow); + try { + await loadHistory(); + } catch (error) { + showToast(error.message, true); + } + }); +} + +window.addEventListener("load", async () => { + try { + const auth = await api("/api/auth/me"); + if (auth.authenticated) { + await bootDashboard(); + } else { + moveToLogin(); + } + } catch { + moveToLogin(); + } +}); diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..78ed2ca --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,466 @@ +:root { + --bg: #f3efe8; + --ink: #17120f; + --surface: rgba(255, 255, 255, 0.78); + --surface-solid: #fffaf2; + --stroke: rgba(104, 74, 41, 0.24); + --accent: #005c53; + --accent-2: #cf6f1b; + --danger: #9f2525; + --muted: #665a50; + --mono: "IBM Plex Mono", monospace; + --sans: "Space Grotesk", sans-serif; + --shadow: 0 16px 35px rgba(59, 41, 23, 0.14); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + font-family: var(--sans); + color: var(--ink); + background: radial-gradient(circle at 8% 14%, #ffe0af 0%, transparent 46%), + radial-gradient(circle at 85% 20%, #c6f4e3 0%, transparent 42%), + linear-gradient(160deg, #f8f4ed 0%, #f0e8dc 52%, #ece3d6 100%); +} + +.background-layer { + position: fixed; + inset: 0; + background: repeating-linear-gradient( + -32deg, + rgba(145, 107, 77, 0.04), + rgba(145, 107, 77, 0.04) 2px, + transparent 2px, + transparent 18px + ); + pointer-events: none; +} + +.container { + width: min(1200px, 100% - 2rem); + margin: 2rem auto 4rem; + position: relative; + z-index: 2; +} + +.panel { + background: var(--surface); + backdrop-filter: blur(10px); + border: 1px solid var(--stroke); + border-radius: 20px; + box-shadow: var(--shadow); + padding: 1rem 1.1rem; +} + +.hidden { + display: none !important; +} + +.login-panel { + max-width: 420px; + margin: 8vh auto; +} + +h1, +h2, +h3 { + margin: 0; + letter-spacing: 0.01em; +} + +h1 { + font-size: clamp(1.45rem, 1.2rem + 1.15vw, 2.1rem); +} + +h2 { + font-size: 1rem; + margin-bottom: 0.6rem; +} + +.eyebrow { + text-transform: uppercase; + font-family: var(--mono); + letter-spacing: 0.12em; + font-size: 0.74rem; + margin: 0 0 0.35rem; + color: var(--accent); +} + +.subtle { + color: var(--muted); + font-size: 0.9rem; +} + +.error { + color: var(--danger); + min-height: 1.2rem; + margin: 0.7rem 0 0; + white-space: pre-wrap; +} + +form { + display: grid; + gap: 0.8rem; +} + +label { + display: grid; + gap: 0.4rem; + font-size: 0.88rem; + color: #2f2822; +} + +input, +textarea, +button { + font: inherit; +} + +input, +textarea { + border: 1px solid rgba(74, 56, 34, 0.26); + background: var(--surface-solid); + border-radius: 12px; + padding: 0.62rem 0.75rem; + width: 100%; +} + +textarea { + resize: vertical; + min-height: 90px; + font-family: var(--mono); + font-size: 0.86rem; +} + +button { + border: 1px solid transparent; + border-radius: 12px; + padding: 0.55rem 0.88rem; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; + transition: transform 120ms ease, opacity 120ms ease; +} + +button:hover { + transform: translateY(-1px); +} + +button:disabled { + opacity: 0.6; + cursor: wait; +} + +button.secondary { + background: transparent; + color: #18312f; + border-color: rgba(7, 70, 65, 0.37); +} + +button.danger { + background: var(--danger); +} + +.app-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.header-actions { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; +} + +.sync-panel { + margin-top: 1rem; +} + +.sync-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.7rem; +} + +.sync-percent { + font-family: var(--mono); + font-weight: 600; + color: #1f3f3b; +} + +.sync-track { + margin-top: 0.6rem; + width: 100%; + height: 12px; + border-radius: 999px; + overflow: hidden; + border: 1px solid rgba(74, 56, 34, 0.22); + background: rgba(255, 250, 242, 0.8); +} + +.sync-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #005c53 0%, #cf6f1b 100%); + transition: width 220ms ease; +} + +.sync-summary { + margin: 0.55rem 0 0; + font-family: var(--mono); + font-size: 0.86rem; +} + +.cards-grid { + margin-top: 1rem; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.8rem; +} + +.stat-card { + min-height: 112px; +} + +.stat-value { + font-family: var(--mono); + font-size: clamp(1.1rem, 1rem + 0.8vw, 1.65rem); + margin: 0.7rem 0 0; + word-break: break-word; +} + +.charts-grid { + margin-top: 1rem; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; +} + +.chart-toolbar { + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.chart-toolbar label { + max-width: 260px; +} + +.chart-history-info { + margin-top: 0.25rem; +} + +.chart-toolbar select { + border: 1px solid rgba(74, 56, 34, 0.26); + background: var(--surface-solid); + border-radius: 12px; + padding: 0.62rem 0.75rem; + width: 100%; +} + +.chart-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.chart-frame { + position: relative; + height: 240px; + width: 100%; +} + +.chart-frame canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.actions-grid { + margin-top: 1rem; + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 0.8rem; +} + +.button-row { + display: flex; + gap: 0.55rem; + flex-wrap: wrap; +} + +.button-row.wrap button { + font-size: 0.86rem; +} + +.explorer-panel { + margin-top: 1rem; +} + +.result-panel { + margin-top: 1rem; +} + +.explorer-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.explorer-grid { + margin-top: 0.8rem; + display: grid; + grid-template-columns: minmax(220px, 340px) minmax(0, 1fr); + gap: 0.9rem; +} + +.command-list-wrap { + display: grid; + gap: 0.6rem; +} + +.command-list { + max-height: 360px; + overflow: auto; + border: 1px solid rgba(74, 56, 34, 0.2); + background: var(--surface-solid); + border-radius: 12px; +} + +.command-item { + border: none; + border-bottom: 1px solid rgba(74, 56, 34, 0.12); + width: 100%; + text-align: left; + background: transparent; + color: #1a1713; + border-radius: 0; + padding: 0.52rem 0.6rem; + display: grid; + gap: 0.15rem; +} + +.command-item:last-child { + border-bottom: none; +} + +.command-item:hover, +.command-item.active { + background: rgba(0, 92, 83, 0.12); +} + +.command-method { + font-family: var(--mono); + font-size: 0.8rem; +} + +.command-synopsis { + font-size: 0.74rem; + color: #544941; +} + +.rpc-form { + display: grid; + gap: 0.72rem; +} + +#rpc-output { + margin: 0.65rem 0 0; + background: #110f0d; + color: #d7f5dd; + border-radius: 12px; + padding: 0.88rem; + max-height: 360px; + overflow: auto; + font-family: var(--mono); + font-size: 0.78rem; +} + +.modal { + position: fixed; + inset: 0; + background: rgba(21, 17, 13, 0.45); + display: grid; + place-items: center; + z-index: 15; + padding: 1rem; +} + +.modal-panel { + width: min(900px, 100%); + max-height: calc(100vh - 2rem); + overflow: auto; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; + margin-top: 0.8rem; +} + +.modal-actions { + grid-column: 1 / -1; +} + +@media (max-width: 980px) { + .cards-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .charts-grid { + grid-template-columns: 1fr; + } + + .chart-frame { + height: 220px; + } + + .actions-grid { + grid-template-columns: 1fr; + } + + .chart-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .explorer-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 680px) { + .container { + width: min(1200px, 100% - 1rem); + margin-top: 1rem; + } + + .app-header { + flex-direction: column; + } + + .cards-grid { + grid-template-columns: 1fr; + } + + .settings-grid { + grid-template-columns: 1fr; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..5a0104b --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,261 @@ + + + + + + Bitcoin Core Admin Dashboard + + + + + + + + +
+ +
+ + + +
+ + + + diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..3f2b99b --- /dev/null +++ b/doc/README.md @@ -0,0 +1,25 @@ +# Documentation Index + +This directory contains project documentation for the Bitcoin Core Admin Dashboard. + +## Document Map + +- `doc/architecture.md` - System architecture, module responsibilities, and runtime flow. +- `doc/api.md` - HTTP API reference, authentication model, and endpoint contracts. +- `doc/data-models.md` - SQLite schema, data invariants, and retention behavior. +- `doc/build-and-deploy.md` - Build, runtime, and deployment rules for local and Docker usage. +- `doc/environment.md` - Environment variables, defaults, and production guidance. +- `doc/conventions.md` - Coding patterns and implementation conventions for this repository. + +## Reading Order + +1. Start with `doc/architecture.md`. +2. Use `doc/api.md` and `doc/data-models.md` for implementation details. +3. Use `doc/build-and-deploy.md` and `doc/environment.md` for operations. +4. Use `doc/conventions.md` when adding or modifying code. + +## Cross-References + +- API endpoints depend on persisted settings described in `doc/data-models.md`. +- Runtime behavior and deployment constraints are described in `doc/architecture.md` and `doc/build-and-deploy.md`. +- Configuration contracts in `doc/environment.md` are consumed by modules listed in `doc/architecture.md`. diff --git a/doc/api.md b/doc/api.md new file mode 100644 index 0000000..dcb0162 --- /dev/null +++ b/doc/api.md @@ -0,0 +1,133 @@ +# API Reference + +## Base Behavior + +- Base URL: same origin as UI host. +- UI route: + - `GET /` returns the dashboard HTML shell. +- API route namespace: + - All API endpoints are under `/api/...`. +- Cache behavior: + - Responses under `/api/` and `/static/` include no-store cache headers. + +## Authentication Model + +- Login endpoint sets a session value on success. +- Protected endpoints require `Depends(require_auth)`. +- Session cookie behavior: + - `same_site=lax` + - max age: 8 hours + - secure flag controlled by `SESSION_COOKIE_SECURE` + +Related references: + +- Configuration: `doc/environment.md` +- Auth implementation: `app/auth.py` + +## Error Contract + +- `401` for authentication failures. +- `400` for missing required node configuration values. +- `422` for request validation and unsupported chart window values. +- `502` for RPC or SSH operation failures. +- Error shape for handled exceptions: + - `{ "detail": "" }` + +## Endpoint Contracts + +### Health and Session + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/api/health` | No | Returns service liveness: `{ "ok": true }`. | +| `GET` | `/api/auth/me` | No | Returns current authentication state and username if authenticated. | +| `POST` | `/api/auth/login` | No | Validates credentials and creates session. | +| `POST` | `/api/auth/logout` | Yes | Clears session. | + +`POST /api/auth/login` request body: + +```json +{ + "username": "admin", + "password": "secret" +} +``` + +### Node Settings + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/api/settings` | Yes | Returns persisted node connection and control settings. | +| `PUT` | `/api/settings` | Yes | Validates and persists node settings. | + +`PUT /api/settings` request body fields: + +- `rpc_url` string +- `rpc_username` string +- `rpc_password` string +- `rpc_wallet` string +- `config_path` string +- `ssh_host` string +- `ssh_port` integer `1..65535` +- `ssh_username` string +- `ssh_password` string +- `ssh_key_path` string +- `bitcoin_binary` string + +All string fields are trimmed before persistence. + +### Dashboard + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/api/dashboard/summary` | Yes | Returns chain, network, mempool, mining, uptime, optional wallet data, and `updated_at`. | +| `GET` | `/api/dashboard/history` | Yes | Returns metric history for query-param `window` and `limit`. | +| `GET` | `/api/dashboard/history/{window}` | Yes | Same as above with `window` as a path parameter. | + +Supported `window` values: + +- `5m` +- `30m` +- `2h` +- `6h` +- `all` + +`limit` is clamped server-side to `[1, 20000]`. + +### RPC Explorer + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `POST` | `/api/rpc/call` | Yes | Executes a JSON-RPC method with params and optional wallet override. | +| `GET` | `/api/rpc/commands` | Yes | Calls node `help` and returns parsed method catalog. | +| `GET` | `/api/rpc/help/{method_name}` | Yes | Returns detailed `help` output for one method. | + +`POST /api/rpc/call` request body: + +```json +{ + "method": "getblockchaininfo", + "params": [], + "wallet": null +} +``` + +### Node Actions + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `POST` | `/api/actions/stop` | Yes | Calls RPC `stop`. | +| `POST` | `/api/actions/start` | Yes | Runs SSH start command built from persisted settings. | +| `POST` | `/api/actions/restart` | Yes | Attempts RPC stop then runs SSH start command. | + +Action responses include action metadata and remote execution payload when SSH is involved. + +## Operational Notes + +- Startup and restart require: + - valid SSH connectivity + - `config_path` + - SSH user credentials or key access +- When running in Docker, `rpc_url` should usually target `host.docker.internal` if the node is on the Docker host. + +See `doc/build-and-deploy.md` for Docker network details. diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..62d6ce6 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,98 @@ +# Architecture + +## Overview + +The project is a single-service web application that provides operational control and observability for a remote Bitcoin Core node. + +Core runtime components: + +- FastAPI server for UI and API delivery. +- Session-based authentication middleware. +- SQLite persistence for node settings and sampled metrics. +- Bitcoin Core JSON-RPC client for node data and RPC execution. +- SSH command runner for daemon start and restart operations. +- Vanilla JavaScript frontend with Chart.js visualizations. + +Related references: + +- API contracts: `doc/api.md` +- Persistence schema: `doc/data-models.md` +- Deployment rules: `doc/build-and-deploy.md` + +## Component Boundaries + +### Backend Modules + +- `app/main.py` + - Composes FastAPI app, middleware, startup/shutdown hooks, and all route handlers. + - Validates payloads via Pydantic models. + - Aggregates dashboard summary data and persists metric samples. + - Starts a background sampler thread that periodically stores metrics. +- `app/auth.py` + - Verifies login credentials. + - Enforces authenticated API access through `require_auth`. +- `app/config.py` + - Loads and validates environment-driven runtime configuration. + - Creates the data directory when needed. +- `app/db.py` + - Initializes schema. + - Persists and reads node settings. + - Persists and reads metric history with retention trimming. +- `app/bitcoin_rpc.py` + - Encapsulates JSON-RPC request and batch request behavior. + - Normalizes RPC errors through `BitcoinRPCError`. + - Parses `help` output into command catalog entries. +- `app/ssh_control.py` + - Resolves SSH host and connection parameters. + - Executes remote commands and returns execution result payloads. + - Builds safe daemon start command strings. + +### Frontend Assets + +- `app/templates/index.html` + - Declares login, dashboard, settings modal, control actions, and RPC explorer regions. +- `app/static/app.js` + - Owns client state, auth transitions, API calls, chart hydration, and polling. + - Caches metric history in browser local storage. +- `app/static/styles.css` + - Provides responsive layout and UI styling. + +## Runtime Flow + +1. Application startup calls `init_db()` and starts the metrics sampler thread. +2. Browser loads `/`, then frontend checks `/api/auth/me`. +3. After login, frontend loads settings, RPC command catalog, history, and summary. +4. Frontend refreshes summary every 15 seconds while page is open. +5. Backend stores sampled metrics: +- on each summary API call. +- in the background sampler at `METRICS_SAMPLER_INTERVAL_SECONDS` cadence. + +## Data and Control Paths + +- Read-only node visibility: + - `/api/dashboard/summary` uses batch RPC to fetch chain, network, mempool, mining, and uptime data. + - `/api/dashboard/history` and `/api/dashboard/history/{window}` read persisted metric points. +- Node management: + - Stop uses Bitcoin RPC `stop`. + - Start uses SSH execution of `bitcoind -daemon -conf=`. + - Restart attempts RPC stop, waits briefly, then uses SSH start. +- RPC explorer: + - Command catalog derives from `help` output. + - Arbitrary RPC call endpoint accepts method and JSON-array params. + +## Error and Resilience Model + +- `BitcoinRPCError` and `SSHControlError` are mapped to HTTP `502`. +- Missing required node settings are surfaced as HTTP `400`. +- Validation issues return HTTP `422`. +- Background sampler is best-effort and suppresses internal exceptions to avoid service interruption. + +## Security Model + +- Authentication is session-cookie based using `SessionMiddleware`. +- Access to operational endpoints requires `Depends(require_auth)`. +- Credential verification supports: + - plaintext password via `APP_PASSWORD` + - bcrypt hash via `APP_PASSWORD_HASH` + +See `doc/environment.md` for security-relevant configuration fields. diff --git a/doc/build-and-deploy.md b/doc/build-and-deploy.md new file mode 100644 index 0000000..25f3601 --- /dev/null +++ b/doc/build-and-deploy.md @@ -0,0 +1,85 @@ +# Build and Deployment + +## Runtime Targets + +- Local Python process using Uvicorn. +- Docker container via `Dockerfile`. +- Docker Compose service via `docker-compose.yml`. + +Related references: + +- Configuration fields: `doc/environment.md` +- Architecture: `doc/architecture.md` + +## Local Run + +Typical local launch command: + +```bash +uvicorn app.main:app --reload --port 8080 +``` + +Service endpoint: + +- `http://localhost:8080` + +Notes: + +- The app reads environment variables at startup. +- Database parent directory is created automatically. + +## Docker Image Build Rules + +Build file: `Dockerfile` + +Current build behavior: + +1. Base image: `python:3.12-slim` +2. Install Python dependencies from `requirements.txt` +3. Copy `app/` and `data/` into image +4. Expose container port `8080` +5. Start with Uvicorn on `0.0.0.0:8080` + +## Docker Compose Rules + +Compose file: `docker-compose.yml` + +Dashboard service settings: + +- Publishes `8080:8080` +- Loads environment from `.env` +- Sets: + - `DATA_DIR=/app/data` + - `DB_PATH=/app/data/dashboard.db` +- Mounts volume: + - `./data:/app/data` +- Adds host gateway alias: + - `host.docker.internal:host-gateway` +- Restart policy: + - `unless-stopped` + +## Persistence Rules + +- Persist `./data` on the host to retain: + - node settings + - chart metric history +- If `data/dashboard.db` is not persisted, settings and history reset when the container is recreated. + +## Network Rules + +If the Bitcoin node runs on the Docker host: + +- Prefer RPC URL `http://host.docker.internal:8332`. + +If using remote node hosts: + +- Set RPC URL directly to the node endpoint. +- Ensure SSH host/credentials are valid for start/restart actions. + +## Deployment Checklist + +1. Set strong `APP_PASSWORD` or provide `APP_PASSWORD_HASH`. +2. Set a long random `SESSION_SECRET`. +3. Set `SESSION_COOKIE_SECURE=true` behind HTTPS. +4. Persist the database volume. +5. Restrict dashboard network exposure to trusted operators. diff --git a/doc/conventions.md b/doc/conventions.md new file mode 100644 index 0000000..176800a --- /dev/null +++ b/doc/conventions.md @@ -0,0 +1,54 @@ +# Conventions and Coding Patterns + +This document defines repository conventions to preserve existing architecture and behavior. + +## Module Boundaries + +- Keep backend concerns separated by module: + - auth in `app/auth.py` + - configuration in `app/config.py` + - persistence in `app/db.py` + - RPC transport in `app/bitcoin_rpc.py` + - SSH control in `app/ssh_control.py` + - route composition in `app/main.py` +- Do not collapse these responsibilities into one file. + +## API and Error Patterns + +- Protected routes must use `Depends(require_auth)`. +- Domain errors should raise module-specific exceptions: + - `BitcoinRPCError` + - `SSHControlError` +- Domain exceptions are converted to HTTP `502` in centralized exception handlers. +- Input validation should use Pydantic models and explicit HTTP errors for invalid state. + +## Persistence Patterns + +- Access SQLite through `app/db.py` helpers. +- Keep node settings as a single upserted row with `id = 1`. +- Preserve metrics retention behavior: + - 30-day time window + - 20000 row cap + +## Frontend Patterns + +- Keep frontend implementation in vanilla JavaScript plus Chart.js. +- Maintain centralized client `state` object in `app/static/app.js`. +- Use existing API helpers (`api`, error handling, auth transitions). +- Keep responsive behavior aligned with current CSS breakpoints. + +## Security and Operational Patterns + +- Require authentication for all state-changing and node-control APIs. +- Keep SSH command composition shell-safe using quoting. +- Treat background sampling as best-effort to avoid impacting request flow. + +## Documentation and Change Tracking + +When behavior, contracts, or operations change: + +1. Update affected docs in `/doc`. +2. Update `README.md` if repository landing information changes. +3. Record meaningful changes in `CHANGELOG.md` under `[Unreleased]`. + +Reference index: `doc/README.md`. diff --git a/doc/data-models.md b/doc/data-models.md new file mode 100644 index 0000000..c3785cd --- /dev/null +++ b/doc/data-models.md @@ -0,0 +1,108 @@ +# Data Models and Schema + +## Storage Backend + +- Database engine: SQLite. +- Default file path: `./data/dashboard.db`. +- Effective path is controlled by `DB_PATH`. + +Related references: + +- Configuration values: `doc/environment.md` +- Persistence implementation: `app/db.py` + +## Tables + +### `node_settings` + +Single-row settings table used as the source of truth for RPC and SSH connectivity. + +Schema fields: + +- `id INTEGER PRIMARY KEY CHECK (id = 1)` +- `rpc_url TEXT NOT NULL` +- `rpc_username TEXT NOT NULL` +- `rpc_password TEXT NOT NULL` +- `rpc_wallet TEXT NOT NULL` +- `config_path TEXT NOT NULL` +- `ssh_host TEXT NOT NULL` +- `ssh_port INTEGER NOT NULL` +- `ssh_username TEXT NOT NULL` +- `ssh_password TEXT NOT NULL` +- `ssh_key_path TEXT NOT NULL` +- `bitcoin_binary TEXT NOT NULL` +- `updated_at TEXT NOT NULL` (UTC ISO 8601 string) + +Invariants: + +- Exactly one logical settings record exists with `id = 1`. +- `save_settings` performs an upsert. +- Missing settings row is represented by in-code defaults. + +### `metrics_history` + +Time-series table for chart data and historical dashboard views. + +Schema fields: + +- `ts INTEGER PRIMARY KEY` (Unix timestamp, seconds) +- `blocks INTEGER NOT NULL` +- `headers INTEGER NOT NULL` +- `mempool_bytes INTEGER NOT NULL` +- `peers INTEGER NOT NULL` + +Index: + +- `idx_metrics_history_ts` on `ts` + +Invariants: + +- A timestamp has at most one row. +- Inserts on duplicate timestamp overwrite existing point. +- Returned history is chronological for UI consumption. + +## Retention and Limits + +Retention behavior is enforced during each metric insert: + +- Time retention: rows older than 30 days are deleted. +- Count retention: rows beyond the newest 20000 samples are deleted. +- Query limit input is clamped to `[1, 20000]`. + +## Metric Sampling Sources + +A metric point is composed from RPC responses: + +- `blocks` and `headers` from `getblockchaininfo` +- `mempool_bytes` from `getmempoolinfo.bytes` +- `peers` from `getnetworkinfo.connections` + +Points are written: + +- On each `/api/dashboard/summary` request. +- In a background sampler loop at configured interval. + +## Settings Defaults + +Default values used when no persisted row exists: + +- `rpc_url`: `http://127.0.0.1:8332` +- `rpc_username`: empty string +- `rpc_password`: empty string +- `rpc_wallet`: empty string +- `config_path`: empty string +- `ssh_host`: empty string +- `ssh_port`: `22` +- `ssh_username`: empty string +- `ssh_password`: empty string +- `ssh_key_path`: empty string +- `bitcoin_binary`: `bitcoind` +- `updated_at`: empty string + +## Data Consumers + +- `/api/settings` and `/api/settings` (PUT) read and write `node_settings`. +- `/api/dashboard/history*` reads `metrics_history`. +- `/api/dashboard/summary` and sampler thread write `metrics_history`. + +See `doc/api.md` for endpoint-level contracts. diff --git a/doc/environment.md b/doc/environment.md new file mode 100644 index 0000000..84f662f --- /dev/null +++ b/doc/environment.md @@ -0,0 +1,49 @@ +# Environment Requirements + +## Environment Loading + +- Environment values are loaded from process environment. +- `.env` is supported through `python-dotenv` at app startup. +- Configuration is cached in memory after first load. + +Source implementation: `app/config.py` + +## Variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `APP_USERNAME` | `admin` | Login username accepted by the dashboard. | +| `APP_PASSWORD` | `changeme` | Plaintext password used when hash is not provided. | +| `APP_PASSWORD_HASH` | unset | Bcrypt hash used instead of plaintext password when present. | +| `SESSION_SECRET` | `change-this-secret` | Session signing secret for cookie middleware. | +| `SESSION_COOKIE_SECURE` | `false` | When true, session cookie is sent only over HTTPS. | +| `RPC_TIMEOUT_SECONDS` | `15` | Timeout for Bitcoin RPC HTTP requests. | +| `METRICS_SAMPLER_INTERVAL_SECONDS` | `60` | Background metrics sampling interval in seconds. | +| `DATA_DIR` | `./data` | Data directory root. Created if missing. | +| `DB_PATH` | `./data/dashboard.db` | SQLite file path. Overrides default path under `DATA_DIR`. | + +## Validation and Normalization Rules + +- `METRICS_SAMPLER_INTERVAL_SECONDS` minimum is clamped to `15`. +- `SESSION_COOKIE_SECURE` accepts truthy values: + - `1` + - `true` + - `yes` + - `on` +- `DB_PATH` parent directory is created automatically when needed. + +## Security Guidance + +Production baseline: + +1. Do not use default `APP_USERNAME`. +2. Use `APP_PASSWORD_HASH` instead of plaintext password where possible. +3. Use a long random `SESSION_SECRET`. +4. Set `SESSION_COOKIE_SECURE=true` when served over HTTPS. +5. Scope dashboard network access to trusted hosts only. + +## Example `.env` + +Reference example is provided in `.env.example`. + +See `doc/build-and-deploy.md` for environment injection in Docker Compose. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2aee9fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + dashboard: + build: . + container_name: tellscoin-dashboard + #ports: + # - "8080:8080" + env_file: + - /srv/docker/secrets/tellscoin/.env + environment: + DATA_DIR: /app/data + DB_PATH: /app/data/dashboard.db + volumes: + - ./data:/app/data + restart: unless-stopped + networks: + npm_proxy: + ipv4_address: 192.168.100.13 + +networks: + npm_proxy: + external: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5adf853 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +requests +python-dotenv +passlib[bcrypt] +paramiko +jinja2 +itsdangerous