diff --git a/Makefile b/Makefile index 56b399ef..da575b8a 100644 --- a/Makefile +++ b/Makefile @@ -109,4 +109,22 @@ beta: clean_rootfs rootfs release.sh .PHONY: clean clean: stop_file_server @rm -rf out/ - @rm -rf release.sh \ No newline at end of file + @rm -rf release.sh + +# Run the web UI locally with a fully mocked backend (for UI development). +# See dev/dev-ui/README.md. UI available at http://localhost:8888 (pw: bolt). +.PHONY: dev-ui +dev-ui: + @cd dev/dev-ui && docker compose up --build + +.PHONY: dev-ui-build +dev-ui-build: + @cd dev/dev-ui && docker compose build + +.PHONY: dev-ui-shell +dev-ui-shell: + @cd dev/dev-ui && docker compose exec dev-ui bash + +.PHONY: dev-ui-clean +dev-ui-clean: + @cd dev/dev-ui && docker compose down -v --rmi local diff --git a/dev/dev-ui/Dockerfile b/dev/dev-ui/Dockerfile new file mode 100644 index 00000000..f2e56c43 --- /dev/null +++ b/dev/dev-ui/Dockerfile @@ -0,0 +1,23 @@ +# Mocked UI dev container for the myNode web UI. +# Runs the real Flask app from the repo (volume-mounted read-only at the same +# paths used on a device) with a mock shim layer so no real backend is needed. +FROM python:3.11-slim-bookworm + +RUN apt-get update && \ + apt-get install -y --no-install-recommends procps && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements-dev.txt /tmp/requirements-dev.txt +RUN pip install --no-cache-dir -r /tmp/requirements-dev.txt + +# Python path order matters: +# 1. /opt/mynode/dev (dev server + dev blueprint) +# 2. /opt/mynode/dev/mock_pynode (shim layer - shadows pynode/www module names) +# 3. /var/www/mynode (real Flask app) +# 4. /var/pynode (real backend library) +# This mirrors the pynode.pth mechanism used on a real device (setup_device.sh). +ENV PYTHONPATH=/opt/mynode/dev/mock_pynode:/var/www/mynode:/var/pynode \ + PYTHONUNBUFFERED=1 + +WORKDIR /opt/mynode/dev +CMD ["bash", "/opt/mynode/dev/entrypoint.sh"] diff --git a/dev/dev-ui/README.md b/dev/dev-ui/README.md new file mode 100644 index 00000000..9fb8c291 --- /dev/null +++ b/dev/dev-ui/README.md @@ -0,0 +1,107 @@ +# myNode UI Dev Mode (mocked backend) + +Run the real myNode web UI in a Docker container with a fully mocked backend — +no bitcoind, no LND, no systemd, no real device. For developing and testing UI +changes (templates, CSS, JS, Flask routes) with instant hot reload. + +## Quick start + +```bash +make dev-ui # from the repo root; builds + starts the container +``` + +Open **http://localhost:8888** and log in with password **`bolt`** +(configurable via `DEV_PASSWORD` in docker-compose.yml; set `DEV_AUTOLOGIN=1` +to skip login entirely). + +Other targets: `make dev-ui-build`, `make dev-ui-shell`, `make dev-ui-clean`. + +Edit any file under `rootfs/standard/var/www/mynode/` (templates, static +assets, python) or `rootfs/standard/var/pynode/` on the host — templates apply +on refresh, python triggers Flask auto-reload. The source is mounted +**read-only**, so nothing in this container can modify real code. `git status` +stays clean. + +## The DEV panel + +Every page gets a floating **DEV** button (bottom-right) that opens the state +switcher: + +- **Device state** — flip `/tmp/.mynode_status` to any state (`stable`, + `drive_missing`, `drive_formatting`, `choose_network`, `quicksync_*`, + `upgrading`, `shutting_down`, ...) to render every state page. +- **Bitcoin** — synced / syncing at an arbitrary progress (renders the real + syncing page), or the "Launching MyNode Services" starting page. +- **Lightning** — LND ready/not-ready, wallet exists/missing (wallet-create flow). +- **Warnings** — undervoltage / throttled / capped / fsck / usb warning pages. +- **System** — drive-almost-full error, upgrade banner, premium vs community + edition, simulated reboot/shutdown, and **RESET ALL** back to the golden state. +- **Apps** — one-click install/uninstall (marker files only, no real install). + +Everything the panel does is also available as plain HTTP endpoints under +`/dev/...` (no auth) — see `dev_blueprint.py`. Examples: + +```bash +curl 'localhost:8888/dev/state?value=drive_missing' +curl 'localhost:8888/dev/sync?synced=0&progress=0.62' +curl 'localhost:8888/dev/app?name=specter&installed=1' +curl 'localhost:8888/dev/service?name=electrs&status=failed' +curl 'localhost:8888/dev/reset' +``` + +The full install flow also works end-to-end from the normal UI: installing an +app from the marketplace shows the real "Installing..." page with a (fake) +live install log, a simulated reboot, and the app appears installed afterward. +Reboot/shutdown from settings play out the real reboot page (the mock resets a +fake boot-time file; the page redirects home when it sees uptime decrease). + +## How it works (architecture) + +**Zero changes to real code.** The container runs the unmodified Flask app +with three mock layers around it: + +1. **PYTHONPATH shim (`mock_pynode/`)** — sits before `/var/www/mynode` and + `/var/pynode` on the python path, so `import bitcoin_info` etc. resolve to + the mocks. Each mock exec's the real module, patches ONLY the functions + that touch the outside world *into the real module's namespace*, and + re-exports everything (see `_mockutil.py` for why patching the real + namespace matters). What gets mocked: + - `bitcoin_info` — fake `AuthServiceProxy` (bitcoind RPC) backed by + `fixtures/bitcoin.json`; the real update/caching code runs unchanged. + - `lightning_info` — `lnd_get`/`lnd_get_v2` return `fixtures/lightning.json`. + - `electrum_info` — fake `requests` serving prometheus metrics. + - `systemctl_info` — service states from `/tmp/mock_services/`. + - `device_info` — uptime/serial/temp + simulated reboot/upgrade. + - `application_info` — install/uninstall simulations (marker files only). + - `pam` — login accepts `DEV_PASSWORD`. + - `price_info`, `thread_functions`, `drive_info`, `transmissionrpc`. + +2. **Fixture filesystem (`seed_fixture_fs.py`)** — the real code reads inline + absolute paths (`/mnt/hdd/...`, `/home/bitcoin/.mynode/...`, + `/usr/share/mynode/...`, `/tmp/...`), so the container recreates a + provisioned device's filesystem. `/mnt/hdd` is a tmpfs *mount* because the + code greps `/proc/mounts` for it. + +3. **Fake binaries (`fake_bin/`)** — installed into `/usr/bin` in the + container (many calls use absolute paths). `systemctl` reads/writes the + same `/tmp/mock_services` files as the python mock; `journalctl` emits + canned logs; `mynode_*.sh` scripts are no-ops or tiny simulations. + +`dev_server.py` imports the real `app` from `mynode.py`, seeds the in-memory +data caches, registers the `/dev` blueprint + overlay injector, and runs Flask +in debug mode. A light 30s refresher keeps mocked values jittering; set +`DEV_REAL_THREADS=1` to run the app's real background threads instead. + +## Customizing sample data + +Edit the JSON files in `fixtures/` (block height, peers, channels, balances, +invoices, prices, service states, device identity) — they reload on next +refresh without restarting the container. Default-installed apps are listed in +`seed_fixture_fs.py` (`DEFAULT_INSTALLED_APPS`). + +## Known quirks + +- Port **8888** (not 8000) because the repo's release file server uses 8000. +- The QuickSync download page shows "Waiting on download client to start..." + (no transmission daemon; the stub raises, which the real page handles). +- `/dev/*` endpoints are unauthenticated; the container binds to 127.0.0.1 only. diff --git a/dev/dev-ui/dev_blueprint.py b/dev/dev-ui/dev_blueprint.py new file mode 100644 index 00000000..415b744b --- /dev/null +++ b/dev/dev-ui/dev_blueprint.py @@ -0,0 +1,615 @@ +"""Dev-only state switcher for the mocked UI dev container. + +Registered by dev_server.py (never by the real app). Provides: + - /dev/* endpoints that flip the same files/globals the real code reads + - a floating overlay panel injected into every HTML page (inject_overlay) +No auth: the container is bound to 127.0.0.1 and is dev-only.""" +import glob +import json +import os +import time + +from flask import Blueprint, Response, jsonify, request, session + +from _mockutil import real, get_knob, set_knob + +import seed_fixture_fs +import bitcoin_info +import lightning_info +import electrum_info +import price_info +import thread_functions +import application_info +import device_info +import systemctl_info + +mynode_dev = Blueprint("mynode_dev", __name__) + + +@mynode_dev.after_request +def _dev_no_cache(resp): + # The app applies a 24h Cache-Control to 200 responses (mynode.py); that + # would make the browser serve /dev/status, /dev/apps and the action + # endpoints from cache, so the panel would read stale state. Force + # no-store on every /dev/* response. This runs before the app-level + # header hook, which only sets a cache header when one isn't already set. + resp.headers["Cache-Control"] = "no-store, must-revalidate" + return resp + + +BITCOIN_SYNCED_FILE = "/mnt/hdd/mynode/.mynode_bitcoin_synced" + +DEVICE_STATES = [ + "stable", "drive_missing", "drive_format_confirm", "drive_formatting", + "drive_mounted", "drive_clone", "drive_full", "docker_reset", + "gen_dhparam", "choose_network", "quicksync_download", "quicksync_copy", + "quicksync_reset", "rootfs_read_only", "hdd_read_only", "shutting_down", + "upgrading", "unknown", +] + +THROTTLE_BITS = { + "undervoltage": 0x10000, # HAS_UNDERVOLTED + "capped": 0x20000, # HAS_CAPPED + "throttled": 0x40000, # HAS_THROTTLED +} + + +def seed_runtime_data(log_errors=False): + """Populate/refresh the module-global caches the pages read. The real + update functions run against the mocked data sources.""" + steps = [ + bitcoin_info.update_bitcoin_main_info, + bitcoin_info.update_bitcoin_other_info, + lightning_info.update_lightning_info, + electrum_info.update_electrs_info, + price_info.update_price_info, + thread_functions.update_device_info, + thread_functions.find_public_ip, + application_info.update_application_json_cache, + ] + for step in steps: + try: + step() + except Exception as e: + if log_errors: + print("[dev seed] {} failed: {}".format(step.__name__, e)) + else: + raise + if not get_knob("misc", {}).get("starting"): + real("thread_functions").has_updated_btc_info = True + + +def autologin(): + session["logged_in"] = True + session.permanent = True + + +def inject_overlay(response): + """after_request hook: append the dev overlay script to HTML pages.""" + try: + content_type = response.content_type or "" + if content_type.startswith("text/html") and not response.direct_passthrough: + body = response.get_data(as_text=True) + if "" in body and "/dev/overlay.js" not in body: + body = body.replace("", '', 1) + response.set_data(body) + except Exception: + pass + return response + + +def _ok(**extra): + data = {"result": "ok"} + data.update(extra) + return jsonify(data) + + +@mynode_dev.route("/dev/status") +def dev_status(): + status = "unknown" + try: + with open("/tmp/.mynode_status") as f: + status = f.read().strip() + except Exception: + pass + version = "?" + latest = "?" + try: + with open("/usr/share/mynode/version") as f: + version = f.read().strip() + with open("/usr/share/mynode/latest_version") as f: + latest = f.read().strip() + except Exception: + pass + return jsonify({ + "device_state": status, + "device_states": DEVICE_STATES, + "bitcoin_synced": os.path.isfile(BITCOIN_SYNCED_FILE), + "bitcoin_knob": get_knob("bitcoin", {"behind_blocks": 0}), + "starting": bool(get_knob("misc", {}).get("starting")), + "lnd_ready": bool(get_knob("lnd", {"ready": True}).get("ready", True)), + "lnd_wallet": lightning_info.lnd_wallet_exists(), + "drive": get_knob("drive", {"data": "61%", "os": "34%"}), + "upgrade_available": version != latest, + "warning": _current_warning_file(), + "autologin": os.environ.get("DEV_AUTOLOGIN") == "1", + "premium": not device_info.is_community_edition(), + "premium_plus_warning": _premium_plus_warning_state(), + }) + + +def _current_warning_file(): + try: + with open("/tmp/get_throttled_data") as f: + value = int(f.read().strip(), 16) + for name, bit in THROTTLE_BITS.items(): + if value & bit: + return name + except Exception: + pass + return "none" + + +CHECK_IN_FILE = "/tmp/check_in_response.json" + + +def _load_check_in(): + try: + with open(CHECK_IN_FILE) as f: + return json.load(f) + except Exception: + return {"status": "OK", "support": {"active": True, "days_remaining": 300}} + + +def _premium_plus_warning_state(): + pp = _load_check_in().get("premium_plus") + if isinstance(pp, dict) and "active" in pp and "days_remaining" in pp: + try: + days = int(pp["days_remaining"]) + except (TypeError, ValueError): + return "off" + if -45 <= days <= 45: + return "expiring" if pp["active"] else "expired" + return "off" + + +@mynode_dev.route("/dev/state") +def dev_state(): + value = request.args.get("value", "") + if value not in DEVICE_STATES: + return jsonify({"result": "error", "message": "unknown state", "valid": DEVICE_STATES}), 400 + with open("/tmp/.mynode_status", "w") as f: + f.write(value) + if value == "drive_clone": + clone_state = request.args.get("clone", "detecting") + with open("/tmp/.clone_state", "w") as f: + f.write(clone_state) + with open("/tmp/.clone_progress", "w") as f: + f.write("42.00% complete (289 GB / 689 GB) - mocked") + return _ok(state=value) + + +@mynode_dev.route("/dev/sync") +def dev_sync(): + synced = request.args.get("synced", "1") == "1" + if synced: + set_knob("bitcoin", {"behind_blocks": 0}) + if not os.path.isfile(BITCOIN_SYNCED_FILE): + open(BITCOIN_SYNCED_FILE, "a").close() + else: + knob = {"behind_blocks": int(request.args.get("behind", 340000))} + if request.args.get("progress"): + knob["progress"] = float(request.args.get("progress")) + set_knob("bitcoin", knob) + if os.path.isfile(BITCOIN_SYNCED_FILE): + os.remove(BITCOIN_SYNCED_FILE) + bitcoin_info.update_bitcoin_main_info() + open("/tmp/homepage_needs_refresh", "a").close() + return _ok(synced=synced) + + +@mynode_dev.route("/dev/starting") +def dev_starting(): + starting = request.args.get("value", "1") == "1" + misc = get_knob("misc", {}) + misc["starting"] = starting + set_knob("misc", misc) + real("thread_functions").has_updated_btc_info = not starting + return _ok(starting=starting) + + +@mynode_dev.route("/dev/apps") +def dev_apps(): + apps = [] + for app in application_info.get_all_applications(order_by="alphabetic"): + if app.get("show_on_marketplace_page") or app.get("show_on_application_page"): + apps.append({ + "short_name": app["short_name"], + "name": app["name"], + "is_installed": app["is_installed"], + "is_enabled": app["is_enabled"], + }) + return jsonify(apps) + + +@mynode_dev.route("/dev/app") +def dev_app(): + name = request.args.get("name", "") + installed = request.args.get("installed", "1") == "1" + if not application_info.is_application_valid(name): + return jsonify({"result": "error", "message": "unknown app"}), 400 + if installed: + application_info.mark_app_installed(name) + try: + info = application_info.get_application(name) + if info and info.get("latest_version") not in (None, "unknown", "error"): + with open("/home/bitcoin/.mynode/{}_version".format(name), "w") as f: + f.write(info["latest_version"]) + except Exception: + pass + application_info.enable_service(name) + else: + application_info.disable_service(name) + application_info.clear_app_installed(name) + application_info.clear_application_cache() + application_info.trigger_application_refresh() + open("/tmp/homepage_needs_refresh", "a").close() + return _ok(app=name, installed=installed) + + +@mynode_dev.route("/dev/service") +def dev_service(): + name = request.args.get("name", "") + status = request.args.get("status", "running") + if not name or status not in ("running", "stopped", "failed", "disabled"): + return jsonify({"result": "error", "message": "usage: /dev/service?name=x&status=running|stopped|failed|disabled"}), 400 + os.makedirs("/tmp/mock_services", exist_ok=True) + with open(os.path.join("/tmp/mock_services", name), "w") as f: + f.write(status) + systemctl_info.clear_service_enabled_cache() + return _ok(service=name, status=status) + + +@mynode_dev.route("/dev/warning") +def dev_warning(): + name = request.args.get("name", "clear") + dev_info_real = real("device_info") + if name in THROTTLE_BITS: + with open("/tmp/get_throttled_data", "w") as f: + f.write("0x{:x}".format(THROTTLE_BITS[name])) + # Re-arm in case the warning was previously skipped via the UI + for marker in glob.glob("/tmp/warning_skipped_*"): + os.remove(marker) + dev_info_real.reload_throttled_data() + elif name == "fsck": + open("/tmp/fsck_error", "a").close() + with open("/tmp/fsck_results", "w") as f: + f.write("(mock) fsck found and repaired 3 inode errors on /dev/sda1") + elif name == "usb": + open("/tmp/usb_error", "a").close() + elif name == "clear": + for path in ["/tmp/get_throttled_data", "/tmp/fsck_error", "/tmp/fsck_results", "/tmp/usb_error"]: + if os.path.exists(path): + os.remove(path) + for marker in glob.glob("/tmp/warning_skipped_*"): + os.remove(marker) + dev_info_real.cached_data["get_throttled_data"] = "" + else: + return jsonify({"result": "error", "message": "unknown warning", "valid": list(THROTTLE_BITS) + ["fsck", "usb", "clear"]}), 400 + return _ok(warning=name) + + +@mynode_dev.route("/dev/drive") +def dev_drive(): + knob = get_knob("drive", {"data": "61%", "os": "34%"}) + for key in ("data", "os"): + if request.args.get(key): + value = request.args.get(key) + knob[key] = value if value.endswith("%") else value + "%" + set_knob("drive", knob) + return _ok(drive=knob) + + +@mynode_dev.route("/dev/lnd") +def dev_lnd(): + knob = get_knob("lnd", {"ready": True}) + if request.args.get("ready") is not None: + knob["ready"] = request.args.get("ready") == "1" + set_knob("lnd", knob) + real("lightning_info").lnd_ready = knob["ready"] + lightning_info.update_lightning_info() + if request.args.get("wallet") is not None: + wallet_file = lightning_info.get_lightning_wallet_file() + if request.args.get("wallet") == "1": + open(wallet_file, "a").close() + elif os.path.isfile(wallet_file): + os.remove(wallet_file) + return _ok(lnd=knob, wallet=lightning_info.lnd_wallet_exists()) + + +@mynode_dev.route("/dev/edition") +def dev_edition(): + """Toggle between the premium default (product key present + healthy + check-in data) and community edition.""" + premium = request.args.get("premium", "1") == "1" + device_info.delete_product_key_error() + seed_fixture_fs.seed_edition(premium=premium, force=True) + return _ok(premium=premium) + + +@mynode_dev.route("/dev/premium_plus_warning") +def dev_premium_plus_warning(): + """Show/hide the homepage Premium+ subscription warning by setting the + premium_plus days_remaining in the mocked check-in data. + state=expiring -> active, 10 days left ('will expire in 10 days') + state=expired -> inactive, 5 days ago ('expired 5 days ago') + state=off -> remove the premium_plus block (no warning)""" + state = request.args.get("state", "off") + data = _load_check_in() + if state == "expiring": + data["premium_plus"] = {"active": True, "days_remaining": 10} + elif state == "expired": + data["premium_plus"] = {"active": False, "days_remaining": -5} + else: + state = "off" + data.pop("premium_plus", None) + with open(CHECK_IN_FILE, "w") as f: + json.dump(data, f) + # Un-dismiss so the banner is not suppressed by a prior dismissal + device_info.delete_file("/tmp/dismiss_expiration_warning") + return _ok(premium_plus_warning=state) + + +@mynode_dev.route("/dev/upgrade_available") +def dev_upgrade_available(): + with open("/usr/share/mynode/version") as f: + version = f.read().strip() + latest = "v99.9" if request.args.get("value", "1") == "1" else version + with open("/usr/share/mynode/latest_version", "w") as f: + f.write(latest) + return _ok(current=version, latest=latest) + + +@mynode_dev.route("/dev/reboot") +def dev_reboot(): + device_info.reboot_device() + return _ok(message="simulated reboot started") + + +@mynode_dev.route("/dev/shutdown") +def dev_shutdown(): + device_info.shutdown_device() + return _ok(message="simulated shutdown started (auto power-on in ~15s)") + + +@mynode_dev.route("/dev/reset") +def dev_reset(): + # ensure(force=True) restores the premium-edition default + seed_fixture_fs.ensure(force=True) + set_knob("lnd", {"ready": True}) + set_knob("misc", {"starting": False}) + real("lightning_info").lnd_ready = True + real("device_info").cached_data["get_throttled_data"] = "" + application_info.clear_application_cache() + systemctl_info.clear_service_enabled_cache() + seed_runtime_data(log_errors=True) + return _ok(message="fixture state restored") + + +@mynode_dev.route("/dev/panel") +def dev_panel(): + return Response( + "MyNode UI Dev Panel" + "" + "

MyNode UI dev panel

" + "

Use the floating DEV button (bottom-right). " + "It is injected into every page of the UI as well.

" + "", + mimetype="text/html") + + +@mynode_dev.route("/dev/overlay.js") +def dev_overlay_js(): + # Cache-Control: no-store is applied by the blueprint after_request hook. + return Response(_OVERLAY_JS, mimetype="application/javascript") + + +_OVERLAY_JS = r""" +(function () { + if (window.__mynodeDevPanel) return; + window.__mynodeDevPanel = true; + + var css = [ + "#mnDevBtn{position:fixed;bottom:14px;right:14px;z-index:2147483646;background:#e67e22;color:#fff;", + "font:bold 13px/1 monospace;padding:9px 12px;border-radius:6px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.5);}", + "#mnDevPanel{position:fixed;bottom:52px;right:14px;z-index:2147483647;width:290px;max-height:78vh;overflow-y:auto;", + "background:#20242c;color:#dce1e8;font:12px/1.5 monospace;border:1px solid #444;border-radius:8px;", + "box-shadow:0 4px 18px rgba(0,0,0,.6);padding:10px;display:none;text-align:left;}", + "#mnDevPanel h4{margin:10px 0 4px;color:#e67e22;font-size:12px;border-bottom:1px solid #333;}", + "#mnDevPanel button{background:#39404d;color:#dce1e8;border:1px solid #555;border-radius:4px;", + "margin:2px 2px 2px 0;padding:3px 7px;cursor:pointer;font:11px monospace;}", + "#mnDevPanel button:hover{background:#4a5364;}", + "#mnDevPanel select,#mnDevPanel input{background:#161a20;color:#dce1e8;border:1px solid #555;", + "border-radius:4px;font:11px monospace;padding:2px;max-width:120px;}", + "#mnDevPanel .on{background:#2e7d32;}", + "#mnDevApps div{display:flex;justify-content:space-between;align-items:center;padding:1px 0;}" + ].join(""); + var style = document.createElement("style"); + style.textContent = css; + document.head.appendChild(style); + + var btn = document.createElement("div"); + btn.id = "mnDevBtn"; + btn.textContent = "DEV"; + document.body.appendChild(btn); + + var panel = document.createElement("div"); + panel.id = "mnDevPanel"; + document.body.appendChild(panel); + + // Panel actions reload the page to re-render server-side content, which + // would normally close the panel. We persist the open/scroll state in + // sessionStorage (per-tab) and restore it on load so the panel appears to + // stay open across changes. + var PANEL_KEY = "mnDevPanelOpen"; + var SCROLL_KEY = "mnDevPanelScroll"; + + panel.addEventListener("scroll", function () { + sessionStorage.setItem(SCROLL_KEY, panel.scrollTop); + }); + + function restoreScroll() { + var s = sessionStorage.getItem(SCROLL_KEY); + if (s !== null) panel.scrollTop = parseInt(s, 10) || 0; + } + + function call(url) { + // no-store also bypasses any entry cached before the server sent no-store + return fetch(url, { cache: "no-store" }).then(function (r) { return r.json(); }); + } + function act(url) { + call(url).then(function () { location.reload(); }); + } + + function openPanel() { + panel.style.display = "block"; + sessionStorage.setItem(PANEL_KEY, "1"); + panel.innerHTML = "loading..."; + call("/dev/status").then(function (st) { + build(st); + restoreScroll(); + }).catch(function (e) { + panel.textContent = "failed to load /dev/status: " + e; + }); + } + + function closePanel() { + panel.style.display = "none"; + sessionStorage.removeItem(PANEL_KEY); + sessionStorage.removeItem(SCROLL_KEY); + } + + function section(title) { + var h = document.createElement("h4"); + h.textContent = title; + panel.appendChild(h); + var div = document.createElement("div"); + panel.appendChild(div); + return div; + } + function button(parent, label, fn, on) { + var b = document.createElement("button"); + b.textContent = label; + if (on) b.className = "on"; + b.onclick = fn; + parent.appendChild(b); + return b; + } + + function build(st) { + panel.innerHTML = ""; + + var head = document.createElement("div"); + head.innerHTML = "MyNode UI dev panel"; + panel.appendChild(head); + + // Device state + var s1 = section("Device state"); + var sel = document.createElement("select"); + st.device_states.forEach(function (s) { + var o = document.createElement("option"); + o.value = s; o.textContent = s; + if (s === st.device_state) o.selected = true; + sel.appendChild(o); + }); + sel.onchange = function () { act("/dev/state?value=" + sel.value); }; + s1.appendChild(sel); + + // Bitcoin + var s2 = section("Bitcoin"); + button(s2, "synced", function () { act("/dev/sync?synced=1"); }, st.bitcoin_synced); + button(s2, "syncing 62%", function () { act("/dev/sync?synced=0&progress=0.62&behind=344000"); }, !st.bitcoin_synced); + button(s2, "syncing 12%", function () { act("/dev/sync?synced=0&progress=0.12&behind=796000"); }); + button(s2, "starting page " + (st.starting ? "off" : "on"), function () { + act("/dev/starting?value=" + (st.starting ? "0" : "1")); + }, st.starting); + + // Lightning + var s3 = section("Lightning"); + button(s3, "ready", function () { act("/dev/lnd?ready=1"); }, st.lnd_ready); + button(s3, "not ready", function () { act("/dev/lnd?ready=0"); }, !st.lnd_ready); + button(s3, st.lnd_wallet ? "remove wallet" : "create wallet", function () { + act("/dev/lnd?wallet=" + (st.lnd_wallet ? "0" : "1")); + }); + + // Edition + var se = section("Edition"); + button(se, "premium", function () { act("/dev/edition?premium=1"); }, st.premium); + button(se, "community", function () { act("/dev/edition?premium=0"); }, !st.premium); + + // Warnings + var s4 = section("Warnings"); + ["undervoltage", "throttled", "capped", "fsck", "usb"].forEach(function (w) { + button(s4, w, function () { act("/dev/warning?name=" + w); }, st.warning === w); + }); + button(s4, "clear", function () { act("/dev/warning?name=clear"); }); + + // System + var s5 = section("System"); + button(s5, "drive 97% full", function () { act("/dev/drive?data=97%25"); }, + st.drive && st.drive.data === "97%"); + button(s5, "drive 61%", function () { act("/dev/drive?data=61%25"); }); + button(s5, "upgrade banner " + (st.upgrade_available ? "off" : "on"), function () { + act("/dev/upgrade_available?value=" + (st.upgrade_available ? "0" : "1")); + }, st.upgrade_available); + button(s5, "reboot", function () { act("/dev/reboot"); }); + button(s5, "shutdown", function () { act("/dev/shutdown"); }); + button(s5, "RESET ALL", function () { + if (confirm("Restore all mocked state to defaults?")) act("/dev/reset"); + }); + + // Premium+ subscription warning + var sp = section("Premium+ warning"); + button(sp, "expiring soon", function () { act("/dev/premium_plus_warning?state=expiring"); }, + st.premium_plus_warning === "expiring"); + button(sp, "expired", function () { act("/dev/premium_plus_warning?state=expired"); }, + st.premium_plus_warning === "expired"); + button(sp, "none", function () { act("/dev/premium_plus_warning?state=off"); }, + st.premium_plus_warning === "off"); + + // Apps + var s6 = section("Apps (install / uninstall)"); + var appsDiv = document.createElement("div"); + appsDiv.id = "mnDevApps"; + appsDiv.textContent = "loading..."; + s6.appendChild(appsDiv); + call("/dev/apps").then(function (apps) { + appsDiv.innerHTML = ""; + apps.forEach(function (a) { + var row = document.createElement("div"); + var name = document.createElement("span"); + name.textContent = a.short_name; + name.style.color = a.is_installed ? "#7bd88f" : "#8a919e"; + row.appendChild(name); + var b = document.createElement("button"); + b.textContent = a.is_installed ? "uninstall" : "install"; + b.onclick = function () { + act("/dev/app?name=" + a.short_name + "&installed=" + (a.is_installed ? "0" : "1")); + }; + row.appendChild(b); + appsDiv.appendChild(row); + }); + // Apps load asynchronously and grow the panel; re-apply saved scroll + restoreScroll(); + }).catch(function () { appsDiv.textContent = "failed to load apps"; }); + } + + btn.onclick = function () { + if (panel.style.display === "block") closePanel(); + else openPanel(); + }; + + // Re-open automatically after an action-triggered reload + if (sessionStorage.getItem(PANEL_KEY) === "1") openPanel(); +})(); +""" diff --git a/dev/dev-ui/dev_server.py b/dev/dev-ui/dev_server.py new file mode 100644 index 00000000..f066f98e --- /dev/null +++ b/dev/dev-ui/dev_server.py @@ -0,0 +1,56 @@ +"""Entrypoint for the mocked UI dev container. + +Imports the real Flask app (mynode.py) - every blueprint loads through the +mock shims on PYTHONPATH - seeds the module-global data caches the pages read, +registers the dev-only state-switcher blueprint, and runs Flask with debug +auto-reload. The real mynode.py is untouched: threads and dynamic-app +blueprints normally start in its __main__ block, which never runs here.""" +import os +import threading +import time + +import seed_fixture_fs + +seed_fixture_fs.ensure() + +from mynode import app, start_threads # noqa: E402 - imports the real app through the shims + +import utilities # noqa: E402 +from application_info import register_dynamic_app_flask_blueprints # noqa: E402 +import dev_blueprint # noqa: E402 + +utilities.set_logger(app.logger) + +# Populate the module-global caches so the very first page load is complete. +dev_blueprint.seed_runtime_data() + +# Blueprints for dynamic apps under /usr/share/mynode_apps (normally done in +# mynode.py's __main__ block). +register_dynamic_app_flask_blueprints(app) + +# Dev-only controls: /dev/* endpoints + floating overlay on every HTML page. +app.register_blueprint(dev_blueprint.mynode_dev) +app.after_request(dev_blueprint.inject_overlay) +if os.environ.get("DEV_AUTOLOGIN") == "1": + app.before_request(dev_blueprint.autologin) + + +def _refresher(): + """Lightweight stand-in for the app's background threads: refreshes the + mocked data every 30s (so values jitter and dev-panel knobs propagate).""" + while True: + time.sleep(30) + try: + dev_blueprint.seed_runtime_data(log_errors=True) + except Exception as e: + utilities.log_message("[dev refresher] {}".format(e)) + + +if __name__ == "__main__": + if os.environ.get("DEV_REAL_THREADS") == "1": + # Run the app's real background threads (all bodies are mock-safe) + start_threads() + else: + threading.Thread(target=_refresher, daemon=True).start() + + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/dev/dev-ui/docker-compose.yml b/dev/dev-ui/docker-compose.yml new file mode 100644 index 00000000..33fae833 --- /dev/null +++ b/dev/dev-ui/docker-compose.yml @@ -0,0 +1,30 @@ +services: + dev-ui: + build: . + container_name: mynode-dev-ui + ports: + # 8888 on the host: the repo's release file server uses 8000 + - "127.0.0.1:8888:8000" + volumes: + # Real app source, mounted read-only at the same paths as on a device. + # Read-only guarantees the mock layer can never modify real code, and + # host edits hot-reload in the container. + - ../../rootfs/standard/var/www/mynode:/var/www/mynode:ro + - ../../rootfs/standard/var/pynode:/var/pynode:ro + # Source for /usr/share/mynode fixture content (application_info.json, + # bitcoin.conf, version, ...). Copied into place by seed_fixture_fs.py. + - ../../rootfs/standard/usr/share/mynode:/opt/mynode/share:ro + # The dev harness itself (mocks, fixtures, fake bins, dev server). + - .:/opt/mynode/dev + tmpfs: + # /mnt/hdd must be a real mount: drive_info.is_mynode_drive_mounted() + # greps /proc/mounts for it, and df is used for drive size. + - /mnt/hdd:mode=0777 + environment: + # Password accepted by the mocked PAM login (myNode default). + - DEV_PASSWORD=bolt + # Set to 1 to skip the login page entirely. + - DEV_AUTOLOGIN=0 + # Set to 1 to run the app's real background threads instead of the + # lightweight dev refresher (all thread bodies are mock-safe). + - DEV_REAL_THREADS=0 diff --git a/dev/dev-ui/entrypoint.sh b/dev/dev-ui/entrypoint.sh new file mode 100644 index 00000000..6c240dcd --- /dev/null +++ b/dev/dev-ui/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Entrypoint for the mocked UI dev container. +set -e + +# Build the fixture filesystem (device paths, marker files, fake binaries). +python3 /opt/mynode/dev/seed_fixture_fs.py + +cd /opt/mynode/dev +exec python3 dev_server.py diff --git a/dev/dev-ui/fake_bin/bitcoin-cli b/dev/dev-ui/fake_bin/bitcoin-cli new file mode 100644 index 00000000..d974dc12 --- /dev/null +++ b/dev/dev-ui/fake_bin/bitcoin-cli @@ -0,0 +1,35 @@ +#!/bin/bash +# Fake bitcoin-cli: returns canned responses for the bitcoin-cli page. +CMD="" +for arg in "$@"; do + case "$arg" in + -*|--*) ;; + *) CMD="$arg"; break ;; + esac +done + +case "$CMD" in + getblockcount) + echo "905123" + ;; + getblockchaininfo) + cat << 'EOF' +{ + "chain": "main", + "blocks": 905123, + "headers": 905123, + "bestblockhash": "00000000000000000000mockmockmockmockmockmockmockmockmockmock01", + "difficulty": 126411437451912.23, + "verificationprogress": 0.9999, + "pruned": false, + "warnings": "(mocked bitcoin-cli output from myNode UI dev mode)" +} +EOF + ;; + help|"") + echo "(mock bitcoin-cli) Try: getblockcount, getblockchaininfo" + ;; + *) + echo "(mock bitcoin-cli) command '$CMD' is not mocked - this is the myNode UI dev container" + ;; +esac diff --git a/dev/dev-ui/fake_bin/dmesg b/dev/dev-ui/fake_bin/dmesg new file mode 100644 index 00000000..bd14f338 --- /dev/null +++ b/dev/dev-ui/fake_bin/dmesg @@ -0,0 +1,14 @@ +#!/bin/bash +# Fake dmesg (also handles "dmesg --follow" by blocking forever). +for arg in "$@"; do + if [ "$arg" = "--follow" ] || [ "$arg" = "-w" ]; then + echo "[ 0.000000] mock kernel log follow mode (myNode UI dev container)" + exec sleep infinity + fi +done +cat << 'EOF' +[ 0.000000] Booting mock kernel for myNode UI dev container +[ 1.234567] usb 1-1: new high-speed USB device number 2 +[ 2.345678] EXT4-fs (sda1): mounted filesystem with ordered data mode +[ 5.678901] mynode: all systems nominal (this log is mocked) +EOF diff --git a/dev/dev-ui/fake_bin/gen_seed.py b/dev/dev-ui/fake_bin/gen_seed.py new file mode 100644 index 00000000..e89180ea --- /dev/null +++ b/dev/dev-ui/fake_bin/gen_seed.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +print("mock abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor") diff --git a/dev/dev-ui/fake_bin/get_local_ip.py b/dev/dev-ui/fake_bin/get_local_ip.py new file mode 100644 index 00000000..98cd33f7 --- /dev/null +++ b/dev/dev-ui/fake_bin/get_local_ip.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +print("192.168.1.100") diff --git a/dev/dev-ui/fake_bin/journalctl b/dev/dev-ui/fake_bin/journalctl new file mode 100644 index 00000000..03909227 --- /dev/null +++ b/dev/dev-ui/fake_bin/journalctl @@ -0,0 +1,63 @@ +#!/bin/bash +# Fake journalctl: emits a plausible canned log that reflects the service's +# current mock state (/tmp/mock_services/), so a service shown red on +# the UI has a matching error log. +# Usage seen in the code: journalctl -r --unit= --no-pager | head -n 300 +UNIT="mock" +for arg in "$@"; do + case "$arg" in + --unit=*) UNIT="${arg#--unit=}" ;; + esac +done + +NOW=$(date "+%b %d %H:%M:%S") +HOST="mynode" + +STATE="running" +[ -f "/tmp/mock_services/$UNIT" ] && STATE=$(cat "/tmp/mock_services/$UNIT") + +# Non-running services get a matching error/inactive log +case "$STATE" in + failed) + cat << EOF +$NOW $HOST systemd[1]: $UNIT.service: Main process exited, code=exited, status=1/FAILURE +$NOW $HOST $UNIT[321]: [mock] ERROR: service '$UNIT' crashed - see above +$NOW $HOST $UNIT[321]: [mock] starting up +$NOW $HOST systemd[1]: Started mock service $UNIT. +EOF + exit 0 + ;; + stopped|disabled) + cat << EOF +$NOW $HOST systemd[1]: $UNIT.service: Deactivated successfully. +$NOW $HOST systemd[1]: Stopped mock service $UNIT. +EOF + exit 0 + ;; +esac + +case "$UNIT" in + electrs) + cat << EOF +$NOW $HOST electrs[512]: [mock] serving Electrum RPC on 0.0.0.0:50001 +$NOW $HOST electrs[512]: [mock] indexing 1 blocks +$NOW $HOST electrs[512]: [mock] chain updated: tip=00000000000000000001mock +$NOW $HOST systemd[1]: Started Electrum Rust Server. +EOF + ;; + lnd) + cat << EOF +$NOW $HOST lnd[433]: [mock] [INF] LTND: Waiting for wallet encryption password. +$NOW $HOST lnd[433]: [mock] [INF] LTND: Version: 0.18.3-beta +$NOW $HOST systemd[1]: Started LND Lightning Daemon. +EOF + ;; + *) + cat << EOF +$NOW $HOST $UNIT[321]: [mock] Service '$UNIT' log line 3 - everything is fine +$NOW $HOST $UNIT[321]: [mock] Service '$UNIT' log line 2 - initialization complete +$NOW $HOST $UNIT[321]: [mock] Service '$UNIT' log line 1 - starting up +$NOW $HOST systemd[1]: Started mock service $UNIT. +EOF + ;; +esac diff --git a/dev/dev-ui/fake_bin/lsb_release b/dev/dev-ui/fake_bin/lsb_release new file mode 100644 index 00000000..bf7f8491 --- /dev/null +++ b/dev/dev-ui/fake_bin/lsb_release @@ -0,0 +1,6 @@ +#!/bin/bash +case "$*" in + *-c*) echo "bookworm" ;; + *-r*) echo "12" ;; + *) echo "Debian GNU/Linux 12 (bookworm) (mock)" ;; +esac diff --git a/dev/dev-ui/fake_bin/lsusb b/dev/dev-ui/fake_bin/lsusb new file mode 100644 index 00000000..6a7d098b --- /dev/null +++ b/dev/dev-ui/fake_bin/lsusb @@ -0,0 +1,12 @@ +#!/bin/bash +if [ "$1" = "-t" ]; then + cat << 'EOF' +/: Bus 02.Port 1: Dev 1, Class=root_hub, Driver=xhci_hcd/4p, 5000M (mock) + |__ Port 1: Dev 2, If 0, Class=Mass Storage, Driver=uas, 5000M (mock) +EOF +else + cat << 'EOF' +Bus 002 Device 002: ID 174c:55aa ASMedia Technology Inc. SATA 6Gb/s bridge (mock) +Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub (mock) +EOF +fi diff --git a/dev/dev-ui/fake_bin/mynode-get-device-serial b/dev/dev-ui/fake_bin/mynode-get-device-serial new file mode 100644 index 00000000..6b2709a1 --- /dev/null +++ b/dev/dev-ui/fake_bin/mynode-get-device-serial @@ -0,0 +1,2 @@ +#!/bin/bash +echo "MOCKDEV1234567890" diff --git a/dev/dev-ui/fake_bin/mynode-get-device-type b/dev/dev-ui/fake_bin/mynode-get-device-type new file mode 100644 index 00000000..c0c6d0eb --- /dev/null +++ b/dev/dev-ui/fake_bin/mynode-get-device-type @@ -0,0 +1,2 @@ +#!/bin/bash +echo "raspi4" diff --git a/dev/dev-ui/fake_bin/mynode-get-quicksync-status b/dev/dev-ui/fake_bin/mynode-get-quicksync-status new file mode 100644 index 00000000..a3cd1a3f --- /dev/null +++ b/dev/dev-ui/fake_bin/mynode-get-quicksync-status @@ -0,0 +1,8 @@ +#!/bin/bash +cat << 'EOF' +(mock) QuickSync Status +Torrent: mynode_quicksync +Progress: 100% +Peers: 12 +Upload rate: 2.4 MB/s +EOF diff --git a/dev/dev-ui/fake_bin/mynode_update_latest_version_files.sh b/dev/dev-ui/fake_bin/mynode_update_latest_version_files.sh new file mode 100644 index 00000000..23bec4aa --- /dev/null +++ b/dev/dev-ui/fake_bin/mynode_update_latest_version_files.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Fake version of /usr/bin/mynode_update_latest_version_files.sh. +# Sources the real app version definitions (copied into /usr/share/mynode by +# the fixture seeder) and writes every _version_latest file, exactly like +# the real script - but generically, so it never goes stale. +source /usr/share/mynode/mynode_app_versions.sh 2>/dev/null || exit 0 + +for file_var in $(compgen -A variable | grep '_LATEST_VERSION_FILE$'); do + version_var="${file_var%_LATEST_VERSION_FILE}_VERSION" + file="${!file_var}" + version="${!version_var}" + if [ -n "$file" ] && [ -n "$version" ]; then + echo "$version" > "$file" + fi +done +exit 0 diff --git a/dev/dev-ui/fake_bin/noop b/dev/dev-ui/fake_bin/noop new file mode 100644 index 00000000..d901155d --- /dev/null +++ b/dev/dev-ui/fake_bin/noop @@ -0,0 +1,4 @@ +#!/bin/bash +# Generic no-op stand-in for device scripts/binaries that are irrelevant in +# the mocked UI dev container. Succeeds silently. +exit 0 diff --git a/dev/dev-ui/fake_bin/systemctl b/dev/dev-ui/fake_bin/systemctl new file mode 100644 index 00000000..8d7269d4 --- /dev/null +++ b/dev/dev-ui/fake_bin/systemctl @@ -0,0 +1,57 @@ +#!/bin/bash +# Fake systemctl for the mocked UI dev container. +# Backs every service with a state file in /tmp/mock_services/ whose +# content is one of: running | stopped | failed | disabled. +# The python mock (mock_pynode/systemctl_info.py) reads the same files, so raw +# "os.system('systemctl ...')" calls in the real code stay consistent with the +# python view of the world. + +STATE_DIR=/tmp/mock_services +mkdir -p "$STATE_DIR" + +VERB="" +SERVICE="" +for arg in "$@"; do + case "$arg" in + --*) ;; # ignore flags like --no-pager + *) + if [ -z "$VERB" ]; then + VERB="$arg" + elif [ -z "$SERVICE" ]; then + SERVICE="$arg" + fi + ;; + esac +done + +state_file="$STATE_DIR/$SERVICE" +state="disabled" +[ -n "$SERVICE" ] && [ -f "$state_file" ] && state=$(cat "$state_file") + +case "$VERB" in + is-enabled) + [ "$state" != "disabled" ] && exit 0 || exit 1 + ;; + is-active|status) + [ "$state" = "running" ] && exit 0 || exit 3 + ;; + enable) + [ "$state" = "disabled" ] && echo "stopped" > "$state_file" + exit 0 + ;; + disable) + echo "disabled" > "$state_file" + exit 0 + ;; + start|restart) + echo "running" > "$state_file" + exit 0 + ;; + stop) + [ "$state" != "disabled" ] && echo "stopped" > "$state_file" + exit 0 + ;; + daemon-reload|*) + exit 0 + ;; +esac diff --git a/dev/dev-ui/fake_bin/torify b/dev/dev-ui/fake_bin/torify new file mode 100644 index 00000000..625b63b2 --- /dev/null +++ b/dev/dev-ui/fake_bin/torify @@ -0,0 +1,3 @@ +#!/bin/bash +# Fake torify: just run the wrapped command directly (no tor in the container). +exec "$@" diff --git a/dev/dev-ui/fixtures/bitcoin.json b/dev/dev-ui/fixtures/bitcoin.json new file mode 100644 index 00000000..ed3446e5 --- /dev/null +++ b/dev/dev-ui/fixtures/bitcoin.json @@ -0,0 +1,93 @@ +{ + "blockchain_info": { + "chain": "main", + "blocks": 905123, + "headers": 905123, + "bestblockhash": "00000000000000000001a7c1mockmockmockmockmockmockmockmockmock01", + "difficulty": 126411437451912.23, + "time": 1752470000, + "mediantime": 1752468000, + "verificationprogress": 0.9999999, + "initialblockdownload": false, + "chainwork": "00000000000000000000000000000000000000009ae4d6mockmockmockmock", + "size_on_disk": 689194767360, + "pruned": false, + "warnings": "" + }, + "peers": [ + {"id": 1, "addr": "84.112.33.7:8333", "network": "ipv4", "relaytxes": true, "conntime": 1752200000, "timeoffset": 0, "pingtime": 0.042, "minping": 0.038, "bytessent": 123456789, "bytesrecv": 987654321, "version": 70016, "subver": "/Satoshi:29.0.0/", "inbound": false, "startingheight": 904800, "synced_headers": 905123, "synced_blocks": 905123, "minfeefilter": 0.00001, "pingwait": 0}, + {"id": 2, "addr": "5.9.150.112:8333", "network": "ipv4", "relaytxes": true, "conntime": 1752260000, "timeoffset": -1, "pingtime": 0.087, "minping": 0.081, "bytessent": 23456789, "bytesrecv": 87654321, "version": 70016, "subver": "/Satoshi:28.1.0/", "inbound": false, "startingheight": 904900, "synced_headers": 905123, "synced_blocks": 905123, "minfeefilter": 0.00001, "pingwait": 0}, + {"id": 3, "addr": "kx4mock5onionpeeraddressxyzmockmock.onion:8333", "network": "onion", "relaytxes": true, "conntime": 1752300000, "timeoffset": 0, "pingtime": 0.412, "minping": 0.377, "bytessent": 3456789, "bytesrecv": 7654321, "version": 70016, "subver": "/Satoshi:27.0.0/", "inbound": false, "startingheight": 905000, "synced_headers": 905123, "synced_blocks": 905123, "minfeefilter": 0.00001, "pingwait": 0}, + {"id": 4, "addr": "203.0.113.45:52108", "network": "ipv4", "relaytxes": true, "conntime": 1752330000, "timeoffset": 2, "pingtime": 0.121, "minping": 0.101, "bytessent": 456789, "bytesrecv": 654321, "version": 70016, "subver": "/Satoshi:26.0.0/", "inbound": true, "startingheight": 905050, "synced_headers": 905123, "synced_blocks": 905123, "minfeefilter": 0.00001, "pingwait": 0}, + {"id": 5, "addr": "198.51.100.23:8333", "network": "ipv4", "relaytxes": true, "conntime": 1752400000, "timeoffset": 0, "pingtime": 0.064, "minping": 0.06, "bytessent": 56789, "bytesrecv": 54321, "version": 70016, "subver": "/Satoshi:29.0.0/", "inbound": false, "startingheight": 905100, "synced_headers": 905123, "synced_blocks": 905123, "minfeefilter": 0.00001, "pingwait": 0}, + {"id": 6, "addr": "mock7hz2onionpeeraddressabcmockmock.onion:8333", "network": "onion", "relaytxes": true, "conntime": 1752410000, "timeoffset": 0, "pingtime": 0.533, "minping": 0.49, "bytessent": 6789, "bytesrecv": 4321, "version": 70016, "subver": "/Satoshi:28.0.0/", "inbound": true, "startingheight": 905110, "synced_headers": 905123, "synced_blocks": 905123, "minfeefilter": 0.00001, "pingwait": 0} + ], + "network_info": { + "version": 290300, + "subversion": "/Satoshi:29.3.0/", + "protocolversion": 70016, + "localservices": "0000000000000409", + "localservicesnames": ["NETWORK", "WITNESS", "NETWORK_LIMITED"], + "localrelay": true, + "timeoffset": 0, + "networkactive": true, + "connections": 6, + "connections_in": 2, + "connections_out": 4, + "networks": [], + "relayfee": 0.00001, + "incrementalfee": 0.00001, + "localaddresses": [], + "warnings": "" + }, + "mempool_info": { + "loaded": true, + "size": 41372, + "bytes": 38123456, + "usage": 152345678, + "total_fee": 1.23456789, + "maxmempool": 300000000, + "mempoolminfee": 0.00001, + "minrelaytxfee": 0.00001, + "unbroadcastcount": 0 + }, + "wallets": { + "wallet.dat": { + "walletname": "wallet.dat", + "walletversion": 169900, + "format": "bdb", + "balance": 0.01523419, + "unconfirmed_balance": 0.0, + "immature_balance": 0.0, + "txcount": 14, + "keypoolsize": 1000, + "paytxfee": 0.0, + "private_keys_enabled": true, + "avoid_reuse": false, + "scanning": false, + "descriptors": false + }, + "joinmarket_wallet.dat": { + "walletname": "joinmarket_wallet.dat", + "walletversion": 169900, + "format": "bdb", + "balance": 0.0, + "unconfirmed_balance": 0.0, + "immature_balance": 0.0, + "txcount": 0, + "keypoolsize": 1000, + "paytxfee": 0.0, + "private_keys_enabled": true, + "avoid_reuse": false, + "scanning": false, + "descriptors": false + } + }, + "recommended_fees": { + "fastestFee": 12, + "halfHourFee": 8, + "hourFee": 5, + "economyFee": 3, + "minimumFee": 2 + } +} diff --git a/dev/dev-ui/fixtures/device.json b/dev/dev-ui/fixtures/device.json new file mode 100644 index 00000000..c9401bc9 --- /dev/null +++ b/dev/dev-ui/fixtures/device.json @@ -0,0 +1,18 @@ +{ + "serial": "MOCKDEV1234567890", + "device_type": "raspi4", + "arch": "aarch64", + "ram": "4", + "debian_codename": "bookworm", + "debian_version": 12, + "tor_version": "0.4.9.2", + "temp": "44.2", + "local_ip": "192.168.1.100", + "public_ip": "203.0.113.99", + "bitcoin_version": "v29.3", + "lnd_version": "0.20.1-beta", + "loop_version": "0.31.1-beta", + "pool_version": "0.6.5-beta", + "lit_version": "0.14.1-alpha", + "electrs_version": "v0.10.9" +} diff --git a/dev/dev-ui/fixtures/lightning.json b/dev/dev-ui/fixtures/lightning.json new file mode 100644 index 00000000..d2e171e6 --- /dev/null +++ b/dev/dev-ui/fixtures/lightning.json @@ -0,0 +1,98 @@ +{ + "/v1/getinfo": { + "version": "0.20.1-beta commit=v0.20.1-beta", + "commit_hash": "mock0000000000000000000000000000000000000000", + "identity_pubkey": "03aa434mock11pubkey000000000000000000000000000000000000000000000f1", + "alias": "mynode_mock", + "color": "#68f442", + "num_pending_channels": 0, + "num_active_channels": 4, + "num_inactive_channels": 1, + "num_peers": 5, + "block_height": 905123, + "block_hash": "00000000000000000001a7c1mockmockmockmockmockmockmockmockmock01", + "best_header_timestamp": -120, + "synced_to_chain": true, + "synced_to_graph": true, + "chains": [{"chain": "bitcoin", "network": "mainnet"}], + "uris": ["03aa434mock11pubkey000000000000000000000000000000000000000000000f1@mocklnd2addrxyzmockmock.onion:9735"], + "features": {} + }, + "/v1/peers": { + "peers": [ + {"pub_key": "02aaaa1111mockpeer0000000000000000000000000000000000000000000000a1", "address": "84.112.33.7:9735", "bytes_sent": 5234123, "bytes_recv": 6234123, "sat_sent": "150000", "sat_recv": "230000", "inbound": false, "ping_time": "112000", "sync_type": "ACTIVE_SYNC"}, + {"pub_key": "02bbbb2222mockpeer0000000000000000000000000000000000000000000000b2", "address": "peermock2xyzonionaddr.onion:9735", "bytes_sent": 1234123, "bytes_recv": 2234123, "sat_sent": "50000", "sat_recv": "70000", "inbound": false, "ping_time": "420000", "sync_type": "PASSIVE_SYNC"}, + {"pub_key": "02cccc3333mockpeer0000000000000000000000000000000000000000000000c3", "address": "198.51.100.99:9735", "bytes_sent": 434123, "bytes_recv": 534123, "sat_sent": "12000", "sat_recv": "9000", "inbound": true, "ping_time": "88000", "sync_type": "ACTIVE_SYNC"}, + {"pub_key": "02dddd4444mockpeer0000000000000000000000000000000000000000000000d4", "address": "peermock4abconionaddr.onion:9735", "bytes_sent": 34123, "bytes_recv": 44123, "sat_sent": "3000", "sat_recv": "1000", "inbound": true, "ping_time": "512000", "sync_type": "PASSIVE_SYNC"}, + {"pub_key": "02eeee5555mockpeer0000000000000000000000000000000000000000000000e5", "address": "203.0.113.7:9735", "bytes_sent": 9123, "bytes_recv": 8123, "sat_sent": "500", "sat_recv": "800", "inbound": false, "ping_time": "97000", "sync_type": "ACTIVE_SYNC"} + ] + }, + "/v1/channels": { + "channels": [ + {"active": true, "remote_pubkey": "02aaaa1111mockpeer0000000000000000000000000000000000000000000000a1", "channel_point": "mocktxid01:0", "chan_id": "995123000000000001", "capacity": "2000000", "local_balance": "1200000", "remote_balance": "780000", "commit_fee": "2810", "commit_weight": "772", "fee_per_kw": "2500", "num_updates": "8211", "csv_delay": 144, "private": false, "lifetime": "5184000", "uptime": "5100000"}, + {"active": true, "remote_pubkey": "02bbbb2222mockpeer0000000000000000000000000000000000000000000000b2", "channel_point": "mocktxid02:1", "chan_id": "995123000000000002", "capacity": "1000000", "local_balance": "150000", "remote_balance": "830000", "commit_fee": "2810", "commit_weight": "772", "fee_per_kw": "2500", "num_updates": "912", "csv_delay": 144, "private": false, "lifetime": "2592000", "uptime": "2500000"}, + {"active": true, "remote_pubkey": "02cccc3333mockpeer0000000000000000000000000000000000000000000000c3", "channel_point": "mocktxid03:0", "chan_id": "995123000000000003", "capacity": "5000000", "local_balance": "2500000", "remote_balance": "2480000", "commit_fee": "3320", "commit_weight": "772", "fee_per_kw": "2500", "num_updates": "44120", "csv_delay": 432, "private": false, "lifetime": "10368000", "uptime": "10300000"}, + {"active": true, "remote_pubkey": "02dddd4444mockpeer0000000000000000000000000000000000000000000000d4", "channel_point": "mocktxid04:0", "chan_id": "995123000000000004", "capacity": "500000", "local_balance": "480000", "remote_balance": "3000", "commit_fee": "1900", "commit_weight": "724", "fee_per_kw": "2500", "num_updates": "72", "csv_delay": 144, "private": true, "lifetime": "864000", "uptime": "850000"}, + {"active": false, "remote_pubkey": "02eeee5555mockpeer0000000000000000000000000000000000000000000000e5", "channel_point": "mocktxid05:1", "chan_id": "995123000000000005", "capacity": "800000", "local_balance": "400000", "remote_balance": "380000", "commit_fee": "2100", "commit_weight": "772", "fee_per_kw": "2500", "num_updates": "3311", "csv_delay": 144, "private": false, "lifetime": "1728000", "uptime": "1500000"} + ] + }, + "/v1/balance/channels": { + "balance": "4730000", + "pending_open_balance": "0", + "local_balance": {"sat": "4730000", "msat": "4730000000"}, + "remote_balance": {"sat": "4473000", "msat": "4473000000"} + }, + "/v1/balance/blockchain": { + "total_balance": "1523419", + "confirmed_balance": "1523419", + "unconfirmed_balance": "0" + }, + "/v1/transactions": { + "transactions": [ + {"tx_hash": "mockchainmocktx0000000000000000000000000000000000000000000000001", "amount": "500000", "num_confirmations": 4402, "block_height": 900721, "time_stamp": -2592000, "total_fees": "312", "label": ""}, + {"tx_hash": "mockchainmocktx0000000000000000000000000000000000000000000000002", "amount": "1200000", "num_confirmations": 2519, "block_height": 902604, "time_stamp": -1512000, "total_fees": "254", "label": ""}, + {"tx_hash": "mockchainmocktx0000000000000000000000000000000000000000000000003", "amount": "-180000", "num_confirmations": 913, "block_height": 904210, "time_stamp": -604800, "total_fees": "188", "label": ""}, + {"tx_hash": "mockchainmocktx0000000000000000000000000000000000000000000000004", "amount": "3419", "num_confirmations": 121, "block_height": 905002, "time_stamp": -86400, "total_fees": "0", "label": ""} + ] + }, + "/v1/payments": { + "payments": [ + {"payment_hash": "mockpayhash000000000000000000000000000000000000000000000000000001", "value_sat": "21000", "fee": "3", "creation_date": -1900800, "status": "SUCCEEDED", "payment_preimage": "00", "path": []}, + {"payment_hash": "mockpayhash000000000000000000000000000000000000000000000000000002", "value_sat": "4500", "fee": "1", "creation_date": -950400, "status": "SUCCEEDED", "payment_preimage": "00", "path": []}, + {"payment_hash": "mockpayhash000000000000000000000000000000000000000000000000000003", "value_sat": "150000", "fee": "42", "creation_date": -432000, "status": "SUCCEEDED", "payment_preimage": "00", "path": []}, + {"payment_hash": "mockpayhash000000000000000000000000000000000000000000000000000004", "value_sat": "820", "fee": "0", "creation_date": -172800, "status": "SUCCEEDED", "payment_preimage": "00", "path": []}, + {"payment_hash": "mockpayhash000000000000000000000000000000000000000000000000000005", "value_sat": "33000", "fee": "7", "creation_date": -21600, "status": "SUCCEEDED", "payment_preimage": "00", "path": []} + ] + }, + "/v1/invoices": { + "invoices": [ + {"r_hash": "mockinvhash00000000000000000000000000000000000000000000000000001", "memo": "Coffee%20fund", "value": "10000", "settled": true, "creation_date": -2160000, "settle_date": -2159000, "state": "SETTLED"}, + {"r_hash": "mockinvhash00000000000000000000000000000000000000000000000000002", "memo": "Podcast%20boost", "value": "2500", "settled": true, "creation_date": -1209600, "settle_date": -1209000, "state": "SETTLED"}, + {"r_hash": "mockinvhash00000000000000000000000000000000000000000000000000003", "memo": "Invoice%20for%20design%20work", "value": "250000", "settled": true, "creation_date": -518400, "settle_date": -518000, "state": "SETTLED"}, + {"r_hash": "mockinvhash00000000000000000000000000000000000000000000000000004", "memo": "Lunch", "value": "18000", "settled": true, "creation_date": -259200, "settle_date": -258000, "state": "SETTLED"}, + {"r_hash": "mockinvhash00000000000000000000000000000000000000000000000000005", "memo": "Unpaid%20test%20invoice", "value": "5000", "settled": false, "creation_date": -3600, "state": "OPEN"} + ] + }, + "/v1/newaddress": { + "address": "bc1qmockdepositaddressxxxxxxxxxxxxxxxxxxxxx" + }, + "/v2/watchtower/server": { + "pubkey": "A6o0NDNtb2NrdG93ZXJwdWJrZXk=", + "listeners": ["[::]:9911"], + "uris": ["03aa34mocktowerpubkey@mocktowerxyzonionaddr.onion:9911"] + }, + "/v2/watchtower/client": { + "towers": [] + }, + "/v2/watchtower/client/stats": { + "num_backups": 812, + "num_pending_backups": 0, + "num_failed_backups": 0, + "num_sessions_acquired": 4, + "num_sessions_exhausted": 1 + }, + "/v2/watchtower/client/policy": { + "max_updates": 1024, + "sweep_sat_per_vbyte": 10 + } +} diff --git a/dev/dev-ui/fixtures/price.json b/dev/dev-ui/fixtures/price.json new file mode 100644 index 00000000..1dfac328 --- /dev/null +++ b/dev/dev-ui/fixtures/price.json @@ -0,0 +1,3 @@ +{ + "start_price": 63250.0 +} diff --git a/dev/dev-ui/fixtures/services.json b/dev/dev-ui/fixtures/services.json new file mode 100644 index 00000000..f1039982 --- /dev/null +++ b/dev/dev-ui/fixtures/services.json @@ -0,0 +1,42 @@ +{ + "bitcoin": "running", + "lnd": "running", + "loop": "running", + "pool": "running", + "electrs": "running", + "btcrpcexplorer": "running", + "mempool": "running", + "rtl": "running", + "thunderhub": "running", + "lndhub": "running", + "tor@default": "running", + "nginx": "running", + "www": "running", + "mynode": "running", + "ufw": "running", + "netdata": "disabled", + "vpn": "disabled", + "docker": "disabled", + "docker_images": "running", + "quicksync": "disabled", + "lndconnect": "disabled", + "webssh2": "disabled", + "dojo": "disabled", + "whirlpool": "disabled", + "joinmarket-api": "disabled", + "lnbits": "disabled", + "specter": "disabled", + "caravan": "disabled", + "ckbunker": "disabled", + "sphinxrelay": "disabled", + "wardenterminal": "disabled", + "pyblock": "disabled", + "bos": "disabled", + "lndmanage": "disabled", + "joininbox": "disabled", + "btcpayserver": "disabled", + "premium_plus_connect": "disabled", + "check_in": "running", + "usb_extras": "disabled", + "rathole": "disabled" +} diff --git a/dev/dev-ui/mock_pynode/_mockutil.py b/dev/dev-ui/mock_pynode/_mockutil.py new file mode 100644 index 00000000..76517075 --- /dev/null +++ b/dev/dev-ui/mock_pynode/_mockutil.py @@ -0,0 +1,101 @@ +"""Support code for the mock shim layer. + +How the shim works +------------------ +This directory sits FIRST on PYTHONPATH, before the real /var/www/mynode and +/var/pynode directories. When the real app does `import bitcoin_info` (or +`from bitcoin_info import *`), python finds the mock module here instead. + +Each mock module follows the same three-step pattern: + + _real = load_real("bitcoin_info") # exec the real file under an alias + def fake(...): ... + _real.some_function = fake # patch INTO the real namespace + export(globals(), _real) # re-export the full patched surface + +Patching into the real module namespace (instead of just defining same-named +functions in the mock) is essential: real function bodies resolve names +through their own module globals, and several consumers copy function objects +at import time via `from X import *`. Patching before export covers both. + +While the real file is being exec'd, its own imports (e.g. lightning_info's +`from bitcoin_info import *`) resolve through sys.path and therefore pick up +the sibling mocks - so there is exactly one (patched) namespace per module. +""" +import copy +import importlib.util +import json +import os +import sys + +REAL_SEARCH_PATHS = ["/var/pynode", "/var/www/mynode"] +FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "fixtures") +MOCK_STATE_DIR = "/tmp/mock_state" + +REAL_ALIAS_SUFFIX = "__real" + + +def load_real(name): + """Exec the real module file under the alias '__real' and return it.""" + alias = name + REAL_ALIAS_SUFFIX + if alias in sys.modules: + return sys.modules[alias] + for search_path in REAL_SEARCH_PATHS: + file_path = os.path.join(search_path, name + ".py") + if os.path.isfile(file_path): + spec = importlib.util.spec_from_file_location(alias, file_path) + module = importlib.util.module_from_spec(spec) + sys.modules[alias] = module + try: + spec.loader.exec_module(module) + except Exception: + del sys.modules[alias] + raise + return module + raise ImportError("mock shim: real module '{}' not found in {}".format(name, REAL_SEARCH_PATHS)) + + +def real(name): + """Return the already-loaded real module object (for dev endpoints that + need to poke module-level globals).""" + return sys.modules[name + REAL_ALIAS_SUFFIX] + + +def export(mock_globals, real_module): + """Copy the real module's (patched) namespace into the mock module so both + `import X` and `from X import *` see the complete surface.""" + for key, value in vars(real_module).items(): + if key.startswith("__"): + continue + mock_globals[key] = value + + +_fixture_cache = {} + + +def fixture(name): + """Load fixtures/ as JSON. Returns a deep copy so callers can mutate. + Reloads automatically when the file changes on disk (live-editable).""" + path = os.path.join(FIXTURES_DIR, name) + mtime = os.path.getmtime(path) + cached = _fixture_cache.get(name) + if cached is None or cached[0] != mtime: + with open(path) as f: + _fixture_cache[name] = (mtime, json.load(f)) + cached = _fixture_cache[name] + return copy.deepcopy(cached[1]) + + +def get_knob(name, default=None): + """Read a dev-panel knob from /tmp/mock_state/.json.""" + try: + with open(os.path.join(MOCK_STATE_DIR, name + ".json")) as f: + return json.load(f) + except Exception: + return dict(default or {}) + + +def set_knob(name, data): + os.makedirs(MOCK_STATE_DIR, exist_ok=True) + with open(os.path.join(MOCK_STATE_DIR, name + ".json"), "w") as f: + json.dump(data, f) diff --git a/dev/dev-ui/mock_pynode/application_info.py b/dev/dev-ui/mock_pynode/application_info.py new file mode 100644 index 00000000..13023b49 --- /dev/null +++ b/dev/dev-ui/mock_pynode/application_info.py @@ -0,0 +1,87 @@ +"""Mock shim for pynode/application_info.py. + +App catalog, install markers, status and cache logic all stay REAL - they are +plain file/JSON operations that work against the fixture filesystem. Only the +actions that would run installer scripts are replaced with simulations that +flip the same marker files the real code reads, then trigger the simulated +reboot so the real "Installing..." page and redirect flow plays out.""" +import os +import time + +from _mockutil import load_real, export + +_real = load_real("application_info") + + +def _write_install_log(action, app, lines_file): + os.makedirs("/home/admin/upgrade_logs", exist_ok=True) + latest = "/home/admin/upgrade_logs/upgrade_log_latest.txt" + lines = [ + "===== {} {} (MOCKED - UI dev container) =====".format(action, app), + "Stopping app services...", + "Downloading {}... done".format(app), + "Extracting files... done", + "Running install scripts... done", + "Enabling service...", + "===== {} {} complete =====".format(action, app), + ] + for path in (latest, lines_file): + with open(path, "w"): + pass + for line in lines: + for path in (latest, lines_file): + with open(path, "a") as f: + f.write(line + "\n") + time.sleep(0.6) + + +def _set_current_version_to_latest(app): + try: + info = _real.get_application(app) + if info and info.get("latest_version") not in (None, "unknown", "error"): + _real.set_file_contents( + "/home/bitcoin/.mynode/{}_version".format(app), info["latest_version"]) + except Exception as e: + _real.log_message("[mock] could not set version for {}: {}".format(app, e)) + + +def reinstall_app(app): + if _real.is_upgrade_running(): + return + _real.mark_upgrade_started() + _real.log_message("[mock] reinstall_app({}) - simulating install".format(app)) + _real.clear_application_cache() + _write_install_log("Install", app, "/home/admin/upgrade_logs/reinstall_{}.txt".format(app)) + _real.mark_app_installed(app) + _set_current_version_to_latest(app) + # Newly installed apps come up enabled + running + _real.enable_service(app) + _real.clear_application_cache() + time.sleep(1) + _real.reboot_device() # simulated (mock device_info) + + +def uninstall_app(app): + _real.log_message("[mock] uninstall_app({}) - simulating uninstall".format(app)) + _real.disable_service(app) + _real.clear_app_installed(app) + _real.delete_file("/home/bitcoin/.mynode/{}_version".format(app)) + _real.delete_file("/mnt/hdd/mynode/settings/{}_version".format(app)) + _real.clear_application_cache() + _write_install_log("Uninstall", app, "/home/admin/upgrade_logs/uninstall_{}.txt".format(app)) + + +def remove_app(app): + # Like the real remove_app for dynamic apps, but keeps the app definition + # under /usr/share/mynode_apps so it can be "reinstalled" in dev mode. + _real.log_message("[mock] remove_app({})".format(app)) + _real.clear_app_installed(app) + _real.disable_service(app) + _real.clear_application_cache() + + +_real.reinstall_app = reinstall_app +_real.uninstall_app = uninstall_app +_real.remove_app = remove_app + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/bitcoin_info.py b/dev/dev-ui/mock_pynode/bitcoin_info.py new file mode 100644 index 00000000..ebde7375 --- /dev/null +++ b/dev/dev-ui/mock_pynode/bitcoin_info.py @@ -0,0 +1,131 @@ +"""Mock shim for pynode/bitcoin_info.py. + +Instead of overriding the update functions, this replaces AuthServiceProxy +(the bitcoind JSON-RPC client) with a fixture-backed fake, so the REAL +update_bitcoin_main_info()/update_bitcoin_other_info() code runs unchanged - +including its data formatting, caching and wallet handling. The sync state is +driven by the /tmp/mock_state/bitcoin.json knob (dev panel).""" +import hashlib +import time +import urllib.parse + +from _mockutil import load_real, export, fixture, get_knob + +_real = load_real("bitcoin_info") + + +def _sync_view(): + """Compute (headers, blocks, verificationprogress) from fixture + knob.""" + info = fixture("bitcoin.json")["blockchain_info"] + headers = int(info["headers"]) + knob = get_knob("bitcoin", {"behind_blocks": 0}) + behind = int(knob.get("behind_blocks", 0)) + blocks = max(0, headers - behind) + if behind <= 0: + progress = 0.9999999 + elif knob.get("progress") is not None: + progress = float(knob["progress"]) + else: + progress = float(blocks) / headers + return headers, blocks, progress + + +def _fake_block_hash(height): + return "0000000000000000000" + hashlib.sha256(str(height).encode()).hexdigest()[:45] + + +class FakeAuthServiceProxy(object): + """Duck-typed stand-in for bitcoinrpc.authproxy.AuthServiceProxy.""" + + _hash_to_height = {} + + def __init__(self, service_url, timeout=None): + self._wallet = None + if "/wallet/" in service_url: + self._wallet = urllib.parse.unquote(service_url.rsplit("/wallet/", 1)[1]) + + def getblockchaininfo(self): + info = fixture("bitcoin.json")["blockchain_info"] + headers, blocks, progress = _sync_view() + info["blocks"] = blocks + info["verificationprogress"] = progress + info["bestblockhash"] = _fake_block_hash(blocks) + return info + + def getblockhash(self, height): + block_hash = _fake_block_hash(height) + FakeAuthServiceProxy._hash_to_height[block_hash] = height + return block_hash + + def getblock(self, block_hash): + height = FakeAuthServiceProxy._hash_to_height.get(block_hash, 0) + headers, blocks, _ = _sync_view() + # Deterministic-ish per-height values so the UI looks alive + seed = int(hashlib.sha256(str(height).encode()).hexdigest()[:8], 16) + return { + "hash": block_hash, + "confirmations": max(1, blocks - height + 1), + "height": height, + "version": 0x20000000, + "merkleroot": _fake_block_hash(height + 1000000)[4:], + "time": int(time.time()) - (blocks - height) * 600, + "mediantime": int(time.time()) - (blocks - height) * 600 - 1800, + "nonce": seed, + "bits": "17034219", + "difficulty": None, # stripped by the API anyway (JSON float issue) + "nTx": 1500 + seed % 2500, + "size": 1400000 + seed % 500000, + "weight": 3900000 + seed % 90000, + "tx": [], + "previousblockhash": _fake_block_hash(height - 1), + } + + def getpeerinfo(self): + return fixture("bitcoin.json")["peers"] + + def getnetworkinfo(self): + return fixture("bitcoin.json")["network_info"] + + def getmempoolinfo(self): + return fixture("bitcoin.json")["mempool_info"] + + def listwallets(self): + return list(fixture("bitcoin.json")["wallets"].keys()) + + def getwalletinfo(self): + wallets = fixture("bitcoin.json")["wallets"] + return wallets.get(self._wallet, next(iter(wallets.values()))) + + +class _FakeFeeResponse(object): + def json(self): + return fixture("bitcoin.json")["recommended_fees"] + + +class _FakeRequests(object): + """bitcoin_info only uses requests for the local mempool fee API.""" + + def get(self, url, timeout=None): + return _FakeFeeResponse() + + +def get_bitcoin_version(): + return fixture("device.json")["bitcoin_version"] + + +def run_bitcoincli_command(cmd): + # Route through the fake bitcoin-cli binary for canned output + try: + import subprocess + return _real.to_string(subprocess.check_output( + "bitcoin-cli " + cmd, stderr=subprocess.STDOUT, shell=True)) + except Exception as e: + return str(e) + + +_real.AuthServiceProxy = FakeAuthServiceProxy +_real.requests = _FakeRequests() +_real.get_bitcoin_version = get_bitcoin_version +_real.run_bitcoincli_command = run_bitcoincli_command + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/device_info.py b/dev/dev-ui/mock_pynode/device_info.py new file mode 100644 index 00000000..a9fad6fb --- /dev/null +++ b/dev/dev-ui/mock_pynode/device_info.py @@ -0,0 +1,219 @@ +"""Mock shim for pynode/device_info.py. + +Overrides only the functions that touch hardware or would take the container +down (uptime, serial/type/arch, temperature, reboot/shutdown/upgrade). The +state machine (get_mynode_status, STATE_* constants), marker-file helpers and +warning logic all stay real - they are driven by the fixture filesystem and +the dev panel.""" +import glob +import os +import threading +import time + +from _mockutil import load_real, export, fixture + +_real = load_real("device_info") + +_dev = fixture("device.json") + +FAKE_BOOT_TIME_FILE = "/tmp/fake_boot_time" + + +def _get_fake_boot_time(): + try: + with open(FAKE_BOOT_TIME_FILE) as f: + return float(f.read().strip()) + except Exception: + return time.time() - 7200 + + +def _set_fake_boot_time(value): + with open(FAKE_BOOT_TIME_FILE, "w") as f: + f.write(str(value)) + + +def get_system_uptime_in_seconds(): + return int(time.time() - _get_fake_boot_time()) + + +def get_system_uptime(): + s = get_system_uptime_in_seconds() + return "{} days {} hour(s) {} minute(s)".format(s // 86400, (s % 86400) // 3600, (s % 3600) // 60) + + +def get_device_serial(): + return _dev["serial"] + + +def get_device_type(): + return _dev["device_type"] + + +def get_device_arch(): + return _dev["arch"] + + +def get_device_ram(): + return _dev["ram"] + + +def get_debian_codename(): + return _dev["debian_codename"] + + +def get_debian_version(): + return int(_dev["debian_version"]) + + +def get_tor_version(): + return _dev["tor_version"] + + +def get_device_temp(): + return _dev["temp"] + + +def get_local_ip(): + return _dev["local_ip"] + + +def get_quicksync_log(): + return "(mock) QuickSync is disabled in the UI dev container" + + +def get_firewall_rules(): + return ("Status: active (mocked)\n\n" + "To Action From\n" + "-- ------ ----\n" + "22/tcp ALLOW Anywhere\n" + "80/tcp ALLOW Anywhere\n" + "443/tcp ALLOW Anywhere\n" + "8333/tcp ALLOW Anywhere\n") + + +#================================== +# Simulated power cycle +#================================== +# The real reboot page (reboot.html) polls /api/ping and redirects home once +# it sees uptime DECREASE. Resetting the fake boot time reproduces the whole +# reboot UX without touching the container. + +# Uptime the device "comes back" with after a simulated reboot. It must be +# LESS than the pre-reboot uptime so reboot.html (which redirects home when it +# sees uptime decrease) detects the reboot. At 170s we land just under the +# homepage's "just booted" gate (uptime < 180 -> "Starting..." page), so the +# real Starting screen shows briefly (~10s) before the page auto-refreshes into +# the stable homepage. +POST_REBOOT_UPTIME = 170 + + +def _simulate_power_cycle(downtime=6): + def _do(): + _real.touch("/tmp/shutting_down") + with open("/tmp/.mynode_status", "w") as f: + f.write("shutting_down") + time.sleep(downtime) + # Clear one-shot markers exactly like a real reboot would + for marker in glob.glob("/tmp/mark_reboot___*"): + try: + os.remove(marker) + except OSError: + pass + for f in ["/tmp/upgrade_started", "/tmp/shutting_down", "/tmp/skip_base_upgrades"]: + if os.path.exists(f): + os.remove(f) + _set_fake_boot_time(time.time() - POST_REBOOT_UPTIME) + with open("/tmp/.mynode_status", "w") as f: + f.write("stable") + + threading.Thread(target=_do, daemon=True).start() + + +def reboot_device(): + _real.log_message("[mock] reboot_device: simulating reboot") + _simulate_power_cycle(downtime=6) + + +def shutdown_device(): + # A real shutdown never comes back; for dev convenience the mock "powers + # on" again after a longer pause. + _real.log_message("[mock] shutdown_device: simulating shutdown (auto power-on in 15s)") + _simulate_power_cycle(downtime=15) + + +def factory_reset(): + _real.log_message("[mock] factory_reset: no-op + simulated reboot") + reboot_device() + + +def reset_docker(): + _real.log_message("[mock] reset_docker: no-op + simulated reboot") + reboot_device() + + +def _write_fake_upgrade_log(action, extra_lines=None): + os.makedirs("/home/admin/upgrade_logs", exist_ok=True) + lines = [ + "===== {} started (MOCKED - UI dev container) =====".format(action), + "Stopping services...", + "Downloading files... done", + "Installing... done", + ] + (extra_lines or []) + [ + "===== {} complete - rebooting =====".format(action), + ] + latest = "/home/admin/upgrade_logs/upgrade_log_latest.txt" + with open(latest, "w") as f: + pass + for line in lines: + with open(latest, "a") as f: + f.write(line + "\n") + time.sleep(0.7) + + +def upgrade_device(): + if not _real.is_upgrade_running(): + _real.mark_upgrade_started() + _write_fake_upgrade_log("Upgrade to {}".format(_real.get_latest_version())) + time.sleep(1) + reboot_device() + + +def upgrade_device_beta(): + if not _real.is_upgrade_running(): + _real.mark_upgrade_started() + _write_fake_upgrade_log("Beta upgrade") + time.sleep(1) + reboot_device() + + +def install_custom_bitcoin_version(version): + _real.mark_upgrade_started() + _write_fake_upgrade_log("Custom bitcoin install ({})".format(version)) + time.sleep(1) + reboot_device() + + +# Patch overrides into the real namespace so intra-module calls and +# star-import copies both resolve to the mocks. +_real.get_system_uptime_in_seconds = get_system_uptime_in_seconds +_real.get_system_uptime = get_system_uptime +_real.get_device_serial = get_device_serial +_real.get_device_type = get_device_type +_real.get_device_arch = get_device_arch +_real.get_device_ram = get_device_ram +_real.get_debian_codename = get_debian_codename +_real.get_debian_version = get_debian_version +_real.get_tor_version = get_tor_version +_real.get_device_temp = get_device_temp +_real.get_local_ip = get_local_ip +_real.get_quicksync_log = get_quicksync_log +_real.get_firewall_rules = get_firewall_rules +_real.reboot_device = reboot_device +_real.shutdown_device = shutdown_device +_real.factory_reset = factory_reset +_real.reset_docker = reset_docker +_real.upgrade_device = upgrade_device +_real.upgrade_device_beta = upgrade_device_beta +_real.install_custom_bitcoin_version = install_custom_bitcoin_version + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/drive_info.py b/dev/dev-ui/mock_pynode/drive_info.py new file mode 100644 index 00000000..a91fa2a1 --- /dev/null +++ b/dev/dev-ui/mock_pynode/drive_info.py @@ -0,0 +1,30 @@ +"""Mock shim for pynode/drive_info.py. + +/mnt/hdd is a real tmpfs mount in the container, so the mount checks and size +queries work naturally. Only the usage percentages are overridden - they come +from the /tmp/mock_state/drive.json knob so the dev panel can push the UI into +the low-disk-space error paths (>= 95%).""" +from _mockutil import load_real, export, get_knob + +_real = load_real("drive_info") + +_DEFAULTS = {"data": "61%", "os": "34%"} + + +def get_data_drive_usage(): + return get_knob("drive", _DEFAULTS).get("data", "61%") + + +def get_os_drive_usage(): + return get_knob("drive", _DEFAULTS).get("os", "34%") + + +def get_mynode_drive_size(): + return 1863 # ~2TB drive, in GB like the real function returns + + +_real.get_data_drive_usage = get_data_drive_usage +_real.get_os_drive_usage = get_os_drive_usage +_real.get_mynode_drive_size = get_mynode_drive_size + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/electrum_info.py b/dev/dev-ui/mock_pynode/electrum_info.py new file mode 100644 index 00000000..1ab21779 --- /dev/null +++ b/dev/dev-ui/mock_pynode/electrum_info.py @@ -0,0 +1,43 @@ +"""Mock shim for pynode/electrum_info.py. + +Patches `requests` inside the real module with a fake that serves prometheus +metrics text, so the REAL update_electrs_info() runs unchanged: it parses the +metrics, tracks the index height against the bitcoin block height and sets +electrs_active itself.""" +from _mockutil import load_real, export, fixture, real + +_real = load_real("electrum_info") + + +class _FakeMetricsResponse(object): + @property + def text(self): + height = real("bitcoin_info").bitcoin_block_height + return ("# HELP electrs_index_height Electrs index height\n" + "# TYPE electrs_index_height gauge\n" + "electrs_index_height {}\n".format(height)) + + +class _FakeRequests(object): + def get(self, url, timeout=None): + return _FakeMetricsResponse() + + +def get_electrs_version(): + return fixture("device.json")["electrs_version"] + + +def get_electrs_db_size(is_testnet=False): + return "97G" + + +def get_from_electrum(method, params=[]): + return {"id": 0, "result": "(mocked electrum response)"} + + +_real.requests = _FakeRequests() +_real.get_electrs_version = get_electrs_version +_real.get_electrs_db_size = get_electrs_db_size +_real.get_from_electrum = get_from_electrum + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/lightning_info.py b/dev/dev-ui/mock_pynode/lightning_info.py new file mode 100644 index 00000000..97c080a9 --- /dev/null +++ b/dev/dev-ui/mock_pynode/lightning_info.py @@ -0,0 +1,133 @@ +"""Mock shim for pynode/lightning_info.py. + +Overrides the two REST chokepoints (lnd_get / lnd_get_v2) with fixture-backed +lookups so the real update_lightning_info() populates all module globals +through the genuine code paths. LND "readiness" is driven by the +/tmp/mock_state/lnd.json knob.""" +import hashlib +import time + +from _mockutil import load_real, export, fixture, get_knob + +_real = load_real("lightning_info") + +_ALIAS_WORDS = [ + "SatoshiRelay", "ThunderNode", "LightningLlama", "BoltBeacon", + "ZapZeppelin", "PlasmaPeer", "VoltViking", "AmpereApe", +] + + +def _now_ts(offset): + """Fixture timestamps are stored as negative offsets from 'now'.""" + return str(int(time.time()) + int(offset)) + + +def _resolve_offsets(obj): + """Recursively convert negative time offsets to absolute timestamps.""" + time_keys = ("time_stamp", "creation_date", "settle_date", "best_header_timestamp") + if isinstance(obj, dict): + for k, v in list(obj.items()): + if k in time_keys and isinstance(v, (int, float)) and v <= 0: + obj[k] = _now_ts(v) + else: + _resolve_offsets(v) + elif isinstance(obj, list): + for item in obj: + _resolve_offsets(item) + return obj + + +def _lnd_ready_knob(): + return bool(get_knob("lnd", {"ready": True}).get("ready", True)) + + +def lnd_get(path, timeout=10, params={}): + if path.startswith("/graph/node/"): + pubkey = path.rsplit("/", 1)[1] + idx = int(hashlib.sha256(pubkey.encode()).hexdigest()[:4], 16) + return {"node": {"alias": _ALIAS_WORDS[idx % len(_ALIAS_WORDS)], "pub_key": pubkey}} + + data = fixture("lightning.json").get("/v1" + path) + if data is None: + return {"error": "no fixture for /v1" + path} + if path == "/getinfo": + data["synced_to_chain"] = _lnd_ready_knob() + data["block_height"] = _real.bitcoin_block_height + return _resolve_offsets(data) + + +def lnd_get_v2(path, timeout=10): + path = path.split("?", 1)[0] # fixture keys have no query strings + data = fixture("lightning.json").get("/v2" + path) + if data is None: + return {"error": "no fixture for /v2" + path} + return _resolve_offsets(data) + + +def get_macaroon(): + return "0201036C6E6402F801030A10MOCK" + + +def gen_new_wallet_seed(): + return ("mock abandon ability able about above absent absorb abstract " + "absurd abuse access accident account accuse achieve acid acoustic " + "acquire across act action actor") + + +def get_lnd_version(): + return "v" + fixture("device.json")["lnd_version"] + + +def get_loop_version(): + return "v" + fixture("device.json")["loop_version"] + + +def get_pool_version(): + return "v" + fixture("device.json")["pool_version"] + + +def get_lit_version(): + return "v" + fixture("device.json")["lit_version"] + + +def lnd_get_channel_db_size(): + return "142M" + + +def restart_lnd_actual(): + # Brief visual blip: LND drops out and comes back a few seconds later + _real.lnd_ready = False + import threading + + def _back(): + time.sleep(4) + _real.lnd_ready = _lnd_ready_knob() + + threading.Thread(target=_back, daemon=True).start() + + +def is_lnd_logged_in(): + return _lnd_ready_knob() + + +def create_wallet(seed): + _real.log_message("[mock] create_wallet called") + _real.touch(_real.get_lightning_wallet_file()) + time.sleep(1) + return True + + +_real.lnd_get = lnd_get +_real.lnd_get_v2 = lnd_get_v2 +_real.get_macaroon = get_macaroon +_real.gen_new_wallet_seed = gen_new_wallet_seed +_real.get_lnd_version = get_lnd_version +_real.get_loop_version = get_loop_version +_real.get_pool_version = get_pool_version +_real.get_lit_version = get_lit_version +_real.lnd_get_channel_db_size = lnd_get_channel_db_size +_real.restart_lnd_actual = restart_lnd_actual +_real.is_lnd_logged_in = is_lnd_logged_in +_real.create_wallet = create_wallet + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/pam.py b/dev/dev-ui/mock_pynode/pam.py new file mode 100644 index 00000000..dfa36a87 --- /dev/null +++ b/dev/dev-ui/mock_pynode/pam.py @@ -0,0 +1,15 @@ +"""Mock `pam` module: authenticates the web UI login against the DEV_PASSWORD +environment variable (default 'bolt', the myNode factory password) instead of +the OS admin user. The real login flow in user_management.py (rate limiting, +session handling) runs unchanged.""" +import os + + +class _MockPam(object): + def authenticate(self, username, password, **kwargs): + expected = os.environ.get("DEV_PASSWORD", "bolt") + return password == expected + + +def pam(): + return _MockPam() diff --git a/dev/dev-ui/mock_pynode/price_info.py b/dev/dev-ui/mock_pynode/price_info.py new file mode 100644 index 00000000..9fef10f8 --- /dev/null +++ b/dev/dev-ui/mock_pynode/price_info.py @@ -0,0 +1,42 @@ +"""Mock shim for www/mynode/price_info.py (shadows the www module). + +Replaces the `torify curl blockchain.info` price fetch with a random walk +around the fixture price, and seeds 24h of history so the ticker/delta UI has +data immediately. All getters stay real.""" +import random +import time + +from _mockutil import load_real, export, fixture + +_real = load_real("price_info") + + +def _seed_history(): + if _real.price_data: + return + base = float(fixture("price.json")["start_price"]) + now = int(time.time()) + rng = random.Random(42) + price = base + points = [] + for i in range(288): # 24h of 5-minute samples + price = max(1000.0, price + rng.uniform(-120, 130)) + points.append({"time": now - (288 - i) * 300, "price": round(price, 2)}) + _real.price_data.extend(points) + + +def update_price_info(): + if not _real.get_ui_setting("price_ticker"): + return + _seed_history() + last = _real.price_data[-1]["price"] if _real.price_data else float(fixture("price.json")["start_price"]) + now = int(time.time()) + _real.price_data.append({"time": now, "price": round(max(1000.0, last + random.uniform(-120, 130)), 2)}) + while _real.price_data and _real.price_data[0]["time"] < now - 24 * 60 * 60: + _real.price_data.pop(0) + + +_seed_history() +_real.update_price_info = update_price_info + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/systemctl_info.py b/dev/dev-ui/mock_pynode/systemctl_info.py new file mode 100644 index 00000000..b482c239 --- /dev/null +++ b/dev/dev-ui/mock_pynode/systemctl_info.py @@ -0,0 +1,68 @@ +"""Mock of pynode/systemctl_info.py - the fully centralized service-status +module. Backed by /tmp/mock_services/ state files (running | stopped | +failed | disabled) which the fake `systemctl` binary manipulates, so raw +`os.system("systemctl ...")` calls elsewhere in the real code stay consistent +with this python view. + +This mock is standalone (it does not exec the real module) because every +function needs replacing and it avoids a shell fork per app tile on the +homepage. The import surface mirrors the real module.""" +import os +import subprocess +import time +from utilities import * + +MOCK_SERVICES_DIR = "/tmp/mock_services" + +service_enabled_cache = {} + + +def _service_state(service_name): + try: + with open(os.path.join(MOCK_SERVICES_DIR, service_name)) as f: + return f.read().strip() + except Exception: + return "disabled" + + +def clear_service_enabled_cache(): + global service_enabled_cache + service_enabled_cache = {} + + +def is_service_enabled(service_name, force_refresh=False): + return _service_state(service_name) != "disabled" + + +def get_service_status_code(service_name): + return 0 if _service_state(service_name) == "running" else 3 + + +def get_service_status_basic_text(service_name): + state = _service_state(service_name) + if state == "disabled": + return "Disabled" + if state == "running": + return "Running" + return "Error" + + +def get_service_status_color(service_name): + state = _service_state(service_name) + if state == "disabled": + return "gray" + if state == "running": + return "green" + return "red" + + +def get_journalctl_log(service_name): + # The fake journalctl binary produces service-aware canned output; going + # through it keeps python and shell views identical. + try: + log = to_string(subprocess.check_output( + "journalctl -r --unit={} --no-pager | head -n 300".format(service_name), + shell=True).decode("utf8")) + except Exception: + log = "ERROR" + return log diff --git a/dev/dev-ui/mock_pynode/thread_functions.py b/dev/dev-ui/mock_pynode/thread_functions.py new file mode 100644 index 00000000..0f9975eb --- /dev/null +++ b/dev/dev-ui/mock_pynode/thread_functions.py @@ -0,0 +1,45 @@ +"""Mock shim for www/mynode/thread_functions.py (shadows the www module). + +The thread wrapper functions stay real (they call the already-mocked update +functions). Overridden: the 5-second-blocking CPU sampler, the external +public-IP lookup and the dmesg follower.""" +import time + +import psutil + +from _mockutil import load_real, export, fixture + +_real = load_real("thread_functions") + + +def update_device_info(): + try: + _real.reload_throttled_data() + _real.cpu_usage = "{:.1f}%".format(psutil.cpu_percent(interval=0.2)) + if _real.os_drive_usage_details == "...": + _real.os_drive_usage_details = ( + "App Storage
2.1G	/opt/mynode/ (mocked)

" + "User Storage
350M	/home/ (mocked)
") + _real.data_drive_usage_details = ( + "Disk Format

ext4

Data Storage
" + "
689G	/mnt/hdd/mynode/ (mocked)
") + except Exception as e: + _real.log_message("[mock] update_device_info: {}".format(e)) + + +def find_public_ip(): + _real.public_ip = fixture("device.json")["public_ip"] + + +def monitor_dmesg(): + # Nothing to monitor in the container; sleep forever so the thread wrapper + # (if enabled via DEV_REAL_THREADS) doesn't spin. + while True: + time.sleep(3600) + + +_real.update_device_info = update_device_info +_real.find_public_ip = find_public_ip +_real.monitor_dmesg = monitor_dmesg + +export(globals(), _real) diff --git a/dev/dev-ui/mock_pynode/transmissionrpc.py b/dev/dev-ui/mock_pynode/transmissionrpc.py new file mode 100644 index 00000000..d09b5bc7 --- /dev/null +++ b/dev/dev-ui/mock_pynode/transmissionrpc.py @@ -0,0 +1,13 @@ +"""Stub for the (ancient) transmissionrpc package, imported at mynode.py top +level but only used on the QuickSync download page, where it is wrapped in +try/except. Raising here produces the real 'Waiting on download client to +start...' UI.""" + + +class TransmissionError(Exception): + pass + + +class Client(object): + def __init__(self, *args, **kwargs): + raise TransmissionError("mocked: no transmission daemon in UI dev mode") diff --git a/dev/dev-ui/requirements-dev.txt b/dev/dev-ui/requirements-dev.txt new file mode 100644 index 00000000..6774b6e5 --- /dev/null +++ b/dev/dev-ui/requirements-dev.txt @@ -0,0 +1,12 @@ +# Dependencies for the mocked UI dev container. +# Pins follow rootfs/standard/usr/share/mynode/mynode_pip3_requirements.txt where +# the device pins matter (flask must stay <2.4 because mynode.py imports Markup +# from flask). +flask==2.2.5 +werkzeug==2.2.3 +requests==2.31.0 +psutil +prometheus_client +python-bitcoinrpc +qrcode +pillow diff --git a/dev/dev-ui/seed_fixture_fs.py b/dev/dev-ui/seed_fixture_fs.py new file mode 100644 index 00000000..7303b093 --- /dev/null +++ b/dev/dev-ui/seed_fixture_fs.py @@ -0,0 +1,333 @@ +"""Build the fixture filesystem for the mocked UI dev container. + +The real myNode code reads absolute paths (/mnt/hdd/..., /home/bitcoin/..., +/usr/share/mynode/..., /tmp/...) as inline literals, so instead of trying to +intercept paths we recreate the device's filesystem inside the container. + +Idempotent: safe to run on every container start and on werkzeug reloads. +Knob/state files that a developer may have changed via the dev panel are only +reset when force=True (the /dev/reset endpoint). +""" +import json +import os +import shutil +import stat +import time + +DEV_DIR = os.path.dirname(os.path.abspath(__file__)) +FIXTURES_DIR = os.path.join(DEV_DIR, "fixtures") +FAKE_BIN_DIR = os.path.join(DEV_DIR, "fake_bin") +SHARE_SOURCE_DIR = "/opt/mynode/share" # repo's usr/share/mynode (ro mount) + +# Legacy apps that appear "installed" by default in the mocked UI. Everything +# else shows up in the marketplace as installable. +DEFAULT_INSTALLED_APPS = [ + "bitcoin", "lnd", "loop", "pool", "lndconnect", + "electrs", "btcrpcexplorer", "mempool", "rtl", "thunderhub", "lndhub", + "tor", "vpn", "netdata", +] + +# Scripts/binaries referenced by the real code that just need to exist and +# succeed. Installed as copies of fake_bin/noop. +NOOP_BINS = [ + "reboot", "shutdown", "ufw", "killall", "docker", "xxd", "hdparm", + "bitcoind", "lnd", "lncli", "loopd", "poold", "litd", "electrs", "tor", + "mynode_chpasswd.sh", "mynode_stop_critical_services.sh", + "mynode_get_latest_version.sh", "mynode_gen_cert.sh", + "mynode_gen_debug_tarball.sh", "mynode_reinstall_app.sh", + "mynode_uninstall_app.sh", "mynode_upgrade.sh", + "mynode_upgrade_running.sh", "mynode-install-custom-bitcoin", + "mynode-get-quicksync-progress", "create_lnd_wallet.tcl", + "mynode_restart_quicksync.sh", "mynode_stop_quicksync.sh", +] + + +def _write(path, contents, overwrite=True): + if not overwrite and os.path.exists(path): + return + with open(path, "w") as f: + f.write(contents) + + +def _touch(path): + if not os.path.exists(path): + open(path, "a").close() + + +def _load_fixture(name): + with open(os.path.join(FIXTURES_DIR, name)) as f: + return json.load(f) + + +def install_fake_bins(): + """Install fake binaries into /usr/bin. + + The real code calls many of these via absolute /usr/bin/... paths, so PATH + manipulation is not enough - they must exist at /usr/bin inside the + container. + """ + for name in os.listdir(FAKE_BIN_DIR): + if name == "noop": + continue + dst = os.path.join("/usr/bin", name) + shutil.copyfile(os.path.join(FAKE_BIN_DIR, name), dst) + os.chmod(dst, 0o755) + + noop_src = os.path.join(FAKE_BIN_DIR, "noop") + for name in NOOP_BINS: + dst = os.path.join("/usr/bin", name) + if os.path.basename(dst) in os.listdir(FAKE_BIN_DIR): + continue # a dedicated fake exists + shutil.copyfile(noop_src, dst) + os.chmod(dst, 0o755) + + os.makedirs("/usr/bin/service_scripts", exist_ok=True) + + +def seed_share_dir(): + """Copy the repo's /usr/share/mynode content into place and add the files + the web UI expects that only exist on a provisioned device.""" + os.makedirs("/usr/share/mynode", exist_ok=True) + if os.path.isdir(SHARE_SOURCE_DIR): + shutil.copytree(SHARE_SOURCE_DIR, "/usr/share/mynode", dirs_exist_ok=True) + + version = "unknown" + try: + with open("/usr/share/mynode/version") as f: + version = f.read().strip() + except Exception: + pass + # No upgrade banner by default (latest == current). The dev panel can bump + # latest_version to exercise the upgrade banner. + _write("/usr/share/mynode/latest_version", version, overwrite=False) + _write( + "/usr/share/mynode/changelog", + "=== myNode {v} (mocked changelog) ===\n" + "- This device is running the mocked UI dev container\n" + "- Sample changelog entry two\n" + "- Sample changelog entry three\n".format(v=version), + overwrite=False, + ) + os.makedirs("/usr/share/mynode_apps", exist_ok=True) + + +def seed_data_drive(): + """Populate the tmpfs at /mnt/hdd like a provisioned data drive.""" + settings = "/mnt/hdd/mynode/settings" + os.makedirs(settings, exist_ok=True) + os.makedirs("/mnt/hdd/mynode/bitcoin", exist_ok=True) + os.makedirs("/mnt/hdd/mynode/lnd/data/chain/bitcoin/mainnet", exist_ok=True) + os.makedirs("/mnt/hdd/mynode/electrs/bitcoin", exist_ok=True) + os.makedirs("/mnt/hdd/mynode/quicksync", exist_ok=True) + _touch("/mnt/hdd/.mynode") + + # Bitcoin looks synced by default (the dev panel can flip this) + _touch("/mnt/hdd/mynode/.mynode_bitcoin_synced") + _touch("/mnt/hdd/mynode/.mynode_bitcoin_synced_at_least_once") + + _write(os.path.join(settings, ".btcrpcpw"), "mock_rpc_password", overwrite=False) + _write( + os.path.join(settings, "ui.json"), + json.dumps({ + "darkmode": False, + "price_ticker": True, + "pinned_bitcoin_details": False, + "pinned_lightning_details": False, + "background": "digital", + }), + overwrite=False, + ) + # QuickSync disabled: hides sync-related noise on the settings page + _touch(os.path.join(settings, "quicksync_disabled")) + + # Bitcoin config + a sample debug log (the "bitcoin" log page tails it) + if os.path.isfile("/usr/share/mynode/bitcoin.conf"): + if not os.path.isfile("/mnt/hdd/mynode/bitcoin/bitcoin.conf"): + shutil.copyfile("/usr/share/mynode/bitcoin.conf", "/mnt/hdd/mynode/bitcoin/bitcoin.conf") + now = time.strftime("%Y-%m-%dT%H:%M:%SZ") + _write( + "/mnt/hdd/mynode/bitcoin/debug.log", + "".join( + "{} [mock] UpdateTip: new best=00000000000000000000mock{:04d} height={} progress=0.999999\n".format(now, i, 905114 + i) + for i in range(10) + ), + overwrite=False, + ) + + # LND files: wallet exists, macaroon + tls cert present + _touch("/mnt/hdd/mynode/lnd/data/chain/bitcoin/mainnet/wallet.db") + _touch("/mnt/hdd/mynode/lnd/data/chain/bitcoin/mainnet/admin.macaroon") + _touch("/mnt/hdd/mynode/lnd/tls.cert") + if os.path.isfile("/usr/share/mynode/lnd.conf"): + if not os.path.isfile("/mnt/hdd/mynode/lnd/lnd.conf"): + shutil.copyfile("/usr/share/mynode/lnd.conf", "/mnt/hdd/mynode/lnd/lnd.conf") + + +def seed_home_dirs(): + home = "/home/bitcoin/.mynode" + os.makedirs(home, exist_ok=True) + os.makedirs("/home/admin/upgrade_logs", exist_ok=True) + os.makedirs("/home/bitcoin/lnd_backup", exist_ok=True) + + # Non-default hash so the "please change your password" warning is hidden + _write(os.path.join(home, ".hashedpw"), "mocked_password_hash_not_default", overwrite=False) + + # Channel backup exists (LND page) + _touch("/home/bitcoin/lnd_backup/channel.backup") + + +def seed_edition(premium=True, force=False): + """Set the device edition. The container defaults to premium (product key + present + healthy check-in) so premium-only UI (onion URLs, support status) + renders. On a warm restart the current edition is preserved unless force is + set; the dev panel toggles it at runtime via /dev/edition.""" + home = "/home/bitcoin/.mynode" + settings = "/mnt/hdd/mynode/settings" + pk_files = [os.path.join(home, ".product_key"), os.path.join(settings, ".product_key")] + skip_files = [os.path.join(home, ".product_key_skipped"), + os.path.join(settings, ".product_key_skipped")] + checkin = "/tmp/check_in_response.json" + + # Preserve a session's edition choice across warm restarts + already_set = any(os.path.exists(p) for p in pk_files + skip_files) + if already_set and not force: + return + + if premium: + for p in skip_files: + if os.path.exists(p): + os.remove(p) + for p in pk_files: + _write(p, "MOCKPRODUCTKEY123456") + _write(checkin, json.dumps({ + "status": "OK", + "support": {"active": True, "days_remaining": 300}, + })) + else: + for p in pk_files: + if os.path.exists(p): + os.remove(p) + for p in skip_files: + _touch(p) + if os.path.exists(checkin): + os.remove(checkin) + + +def seed_installed_apps(force=False): + """Mark the default set of apps as installed and generate version files.""" + # Generate _version_latest files from the real version definitions + os.system("/usr/bin/mynode_update_latest_version_files.sh") + + if force: + # Drop install markers added via the dev panel + for f in os.listdir("/home/bitcoin/.mynode"): + if f.startswith("install_") and f[len("install_"):] not in DEFAULT_INSTALLED_APPS: + os.remove(os.path.join("/home/bitcoin/.mynode", f)) + + seed_markers = force or not _dev_panel_touched_apps() + for app in DEFAULT_INSTALLED_APPS: + marker = "/home/bitcoin/.mynode/install_" + app + if seed_markers: + _touch(marker) + # Current version matches latest so nothing shows as upgradable + latest_file = "/home/bitcoin/.mynode/{}_version_latest".format(app) + version_file = "/home/bitcoin/.mynode/{}_version".format(app) + if os.path.isfile(latest_file) and not os.path.isfile(version_file): + shutil.copyfile(latest_file, version_file) + + +_apps_seeded_marker = "/tmp/.mock_apps_seeded" + + +def _dev_panel_touched_apps(): + # After first seeding, don't fight the developer's install/uninstall + # actions on subsequent (idempotent) runs. + if os.path.isfile(_apps_seeded_marker): + return True + _touch(_apps_seeded_marker) + return False + + +def seed_services(force=False): + """Seed /tmp/mock_services from fixtures/services.json (used by both the + python systemctl mock and the fake systemctl binary).""" + os.makedirs("/tmp/mock_services", exist_ok=True) + services = _load_fixture("services.json") + for name, state in services.items(): + path = os.path.join("/tmp/mock_services", name) + if force or not os.path.exists(path): + _write(path, state) + # Real code also checks/creates _enabled marker files + enabled_marker = "/mnt/hdd/mynode/settings/{}_enabled".format(name) + if state != "disabled": + _touch(enabled_marker) + elif force and os.path.exists(enabled_marker): + os.remove(enabled_marker) + + +def seed_onion_hostnames(): + """Mock tor hidden-service hostnames so onion URLs render in premium mode + (community edition shows 'not_available' regardless).""" + services = ["mynode", "mynode_ssh", "mynode_btc", "mynode_lnd", + "mynode_electrs", "mynode_lndhub", "mynode_lnbits", + "mynode_btcpay", "mynode_sphinx", "mynode_rtl", + "mynode_btcrpcexplorer", "mynode_mempool", "mynode_thunderhub"] + for service in services: + folder = os.path.join("/var/lib/tor", service) + os.makedirs(folder, exist_ok=True) + _write(os.path.join(folder, "hostname"), + "{}mockmockmockmockmockmockmockmockmockmockmockmockmock.onion\n".format( + service.replace("_", "")[:9]), + overwrite=False) + + +def seed_tmp_state(force=False): + os.makedirs("/tmp/mock_state", exist_ok=True) + os.makedirs("/tmp/flask_uploads", exist_ok=True) + os.makedirs("/opt/mynode/custom", exist_ok=True) + os.makedirs("/var/log", exist_ok=True) + + _write("/tmp/.mynode_status", "stable", overwrite=force) + # Uptime gate in index() requires > 180s; pretend we booted 2 hours ago + if force or not os.path.isfile("/tmp/fake_boot_time"): + _write("/tmp/fake_boot_time", str(time.time() - 7200)) + + _write("/tmp/lnd_deposit_address", "bc1qmockdepositaddressxxxxxxxxxxxxxxxxxxxxx", overwrite=False) + _write("/tmp/mock_state/bitcoin.json", json.dumps({"behind_blocks": 0}), overwrite=force) + _write("/tmp/mock_state/drive.json", json.dumps({"data": "61%", "os": "34%"}), overwrite=force) + + if force: + # Clear dev-panel side effects + for f in os.listdir("/tmp"): + if f.startswith("mark_reboot___") or f.startswith("warning_skipped_"): + os.remove(os.path.join("/tmp", f)) + for f in ["/tmp/get_throttled_data", "/tmp/fsck_error", "/tmp/fsck_results", + "/tmp/usb_error", "/tmp/oom_error", "/tmp/upgrade_started", + "/tmp/shutting_down", "/tmp/skip_base_upgrades"]: + if os.path.exists(f): + os.remove(f) + _touch("/mnt/hdd/mynode/.mynode_bitcoin_synced") + version = "unknown" + try: + with open("/usr/share/mynode/version") as f: + version = f.read().strip() + except Exception: + pass + _write("/usr/share/mynode/latest_version", version) + + +def ensure(force=False): + install_fake_bins() + seed_share_dir() + seed_data_drive() + seed_home_dirs() + seed_edition(premium=True, force=force) + seed_onion_hostnames() + seed_services(force=force) + seed_installed_apps(force=force) + seed_tmp_state(force=force) + + +if __name__ == "__main__": + ensure() + print("[seed_fixture_fs] fixture filesystem ready") diff --git a/rootfs/standard/var/www/mynode/templates/lnd_watchtower.html b/rootfs/standard/var/www/mynode/templates/lnd_watchtower.html index 609f5c5f..8a8c8645 100644 --- a/rootfs/standard/var/www/mynode/templates/lnd_watchtower.html +++ b/rootfs/standard/var/www/mynode/templates/lnd_watchtower.html @@ -10,7 +10,7 @@