From ed43e77de42f79dcb7a589cf6d19cd4215c7b36c Mon Sep 17 00:00:00 2001 From: SamiAhmed7777 Date: Sat, 28 Mar 2026 15:51:50 -0700 Subject: [PATCH] Initial commit: TRI-PI Raspberry Pi node project --- CLAUDE.md | 75 +++++++ README.md | 83 ++++++++ backend/__pycache__/app.cpython-313.pyc | Bin 0 -> 7018 bytes backend/app.py | 133 +++++++++++++ build/README.md | 11 + build/build-arm.sh | 186 +++++++++++++++++ build/qemu-smoke.sh | 254 ++++++++++++++++++++++++ config/triangles.conf.template | 28 +++ config/tripi-backend.env.example | 8 + docs/ARCHITECTURE.md | 87 ++++++++ docs/DEVELOPMENT.md | 46 +++++ frontend/app.js | 29 +++ frontend/index.html | 73 +++++++ frontend/styles.css | 121 +++++++++++ image/README.md | 11 + services/trianglesd.service | 16 ++ services/tripi-backend.service | 16 ++ setup/configure-tor.sh | 50 +++++ setup/generate-config.sh | 11 + setup/install.sh | 150 ++++++++++++++ tor/torrc.template | 4 + 21 files changed, 1392 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 backend/__pycache__/app.cpython-313.pyc create mode 100644 backend/app.py create mode 100644 build/README.md create mode 100644 build/build-arm.sh create mode 100644 build/qemu-smoke.sh create mode 100644 config/triangles.conf.template create mode 100644 config/tripi-backend.env.example create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/styles.css create mode 100644 image/README.md create mode 100644 services/trianglesd.service create mode 100644 services/tripi-backend.service create mode 100644 setup/configure-tor.sh create mode 100644 setup/generate-config.sh create mode 100644 setup/install.sh create mode 100644 tor/torrc.template diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02e8d80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# tri-pi Project Notes + +`tri-pi` is the appliance wrapper around a separate `triangles` source checkout. + +This repo should own: + +- Raspberry Pi provisioning +- Tor hidden service setup +- local status UI and API +- systemd integration +- cross-build and QEMU validation glue + +This repo should not own: + +- Triangles protocol code +- wallet UI code +- consensus changes + +## Repository Boundary + +Recommended local layout: + +```text +e:/repos/ +├── triangles/ +└── tri-pi/ +``` + +Use `triangles` for building `trianglesd` itself. +Use `tri-pi` for packaging and operating that daemon on Raspberry Pi hardware. + +## Repository Layout + +- `backend/` — Python status API server (real RPC to `trianglesd`, systemctl health, Tor hostname) +- `frontend/` — Vanilla JS dashboard (auto-refresh, responsive grid) +- `build/` — ARM cross-build (`build-arm.sh`) and QEMU smoke test (`qemu-smoke.sh`) +- `setup/` — Pi installer (`install.sh`), Tor setup (`configure-tor.sh`), config generator +- `services/` — systemd units for `trianglesd` and `tripi-backend` +- `tor/` — torrc template for v3 hidden service +- `config/` — `triangles.conf` template and backend env example +- `docs/` — architecture and development notes + +## Architectural Direction + +The backend/frontend split is inspired by `velxio`, but the product scope is much smaller: + +- no browser IDE +- no board simulator +- no multi-user platform features +- yes to reproducible local runtime orchestration +- yes to QEMU-backed validation of the Pi appliance + +## Install & Deploy Flow + +1. Cross-compile: `./build/build-arm.sh` (needs `../triangles` source checkout) +2. Install on Pi: `TRIANGLESD_BIN=build/output/trianglesd sudo ./setup/install.sh` +3. Validate: `sudo ./build/qemu-smoke.sh` (uses systemd-nspawn + qemu-user-static) + +## Key Paths (on Pi) + +| What | Path | +|---------------------|---------------------------------------| +| App prefix | `/opt/tri-pi/` | +| Daemon config | `/etc/triangles/triangles.conf` | +| Daemon data | `/var/lib/triangles/` | +| Backend env | `/etc/default/tripi-backend` | +| Tor hidden service | `/var/lib/tor/tri-pi/` | +| Tor hostname file | `/var/lib/tor/tri-pi/hostname` | + +## Next Steps + +1. Run QEMU smoke test on a real ARM64 host to validate end-to-end. +2. Decide whether releases ship prebuilt ARM binaries or build on device. +3. Add first-boot provisioning hooks in `image/` for SD card images. +4. Consider adding a lightweight health-check endpoint the dashboard can use for uptime monitoring. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7a8e1d --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# tri-pi + +`tri-pi` is a Raspberry Pi appliance wrapper for running a Triangles (TRI) full node over Tor. + +It is a separate project from the Triangles source tree. The daemon itself lives in a sibling checkout such as `../triangles`, while `tri-pi` owns: + +- Pi provisioning +- Tor hidden service setup +- systemd units +- health/status APIs +- a lightweight local dashboard +- cross-build and QEMU-based test helpers + +## Repository Layout + +```text +tri-pi/ +├── backend/ # Local API and status collector +├── build/ # Cross-build and QEMU helper scripts +├── config/ # triangles.conf and backend env templates +├── docs/ # Architecture and workflow notes +├── frontend/ # Local status dashboard +├── image/ # Pi image customization placeholders +├── services/ # systemd unit files +├── setup/ # Provisioning scripts run on the Pi +└── tor/ # torrc templates and Tor helper material +``` + +## Relationship To `triangles` + +`tri-pi` does not vendor or modify the daemon source by default. It expects a separate checkout: + +```text +e:/repos/ +├── triangles/ +└── tri-pi/ +``` + +That keeps the concerns clean: + +- `triangles`: wallet/node source, native builds, protocol changes +- `tri-pi`: appliance packaging, deployment, monitoring, Tor, Pi UX + +## Current Scope + +This scaffold includes: + +- a minimal Python backend that exposes node status +- a static dashboard that polls the backend +- Pi install/build script placeholders +- systemd unit templates +- Tor and daemon config templates + +It does not yet include: + +- a production-ready installer +- real RPC authentication management +- image generation +- CI workflows +- automatic wallet unlock + +## Quick Start + +Run the local backend: + +```bash +python backend/app.py +``` + +Then open: + +```text +http://127.0.0.1:8080/ +``` + +By default the backend serves the frontend from `frontend/` and reports placeholder node data until a real `trianglesd` RPC endpoint is configured. + +## Next Steps + +1. Wire `backend/app.py` to the real Triangles RPC surface. +2. Flesh out `setup/install.sh` for Raspberry Pi OS Bookworm. +3. Add QEMU-based integration tests under `build/`. +4. Add release packaging for prebuilt ARM artifacts. diff --git a/backend/__pycache__/app.cpython-313.pyc b/backend/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9133c864224d64b8348e05f5eae15f7a64cf75c6 GIT binary patch literal 7018 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 P0mJ{<$R>@-_vilr>P@2> literal 0 HcmV?d00001 diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..9eb77c4 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import base64 +import json +import os +import subprocess +import urllib.error +import urllib.request +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +FRONTEND_DIR = Path(os.environ.get("TRI_PI_FRONTEND_DIR", ROOT / "frontend")) +RPC_URL = os.environ.get("TRIANGLES_RPC_URL", "http://127.0.0.1:19112/") +RPC_USER = os.environ.get("TRIANGLES_RPC_USER", "tripi") +RPC_PASSWORD = os.environ.get("TRIANGLES_RPC_PASSWORD", "") +DATA_DIR = Path(os.environ.get("TRIANGLES_DATA_DIR", Path.home() / ".triangles")) +TOR_HOSTNAME_FILE = Path( + os.environ.get( + "TRI_PI_TOR_HOSTNAME_FILE", + DATA_DIR / "tor_data" / "hostname", + ) +) + + +def rpc_call(method): + payload = json.dumps( + { + "jsonrpc": "1.0", + "id": "tri-pi", + "method": method, + "params": [], + } + ).encode("utf-8") + + request = urllib.request.Request( + RPC_URL, + data=payload, + headers={"Content-Type": "application/json"}, + ) + if RPC_USER or RPC_PASSWORD: + token = base64.b64encode(f"{RPC_USER}:{RPC_PASSWORD}".encode("utf-8")).decode("ascii") + request.add_header("Authorization", f"Basic {token}") + + with urllib.request.urlopen(request, timeout=3) as response: + data = json.loads(response.read().decode("utf-8")) + return data["result"] + + +def read_onion_address(): + try: + return TOR_HOSTNAME_FILE.read_text(encoding="utf-8").strip() + except OSError: + return None + + +def systemctl_is_active(name): + try: + result = subprocess.run( + ["systemctl", "is-active", name], + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return "unknown" + return result.stdout.strip() or "unknown" + + +def collect_status(): + status = { + "node": { + "reachable": False, + "block_height": None, + "connections": None, + "staking": None, + "errors": [], + }, + "services": { + "trianglesd": systemctl_is_active("trianglesd"), + "tor": systemctl_is_active("tor"), + }, + "tor": { + "onion_address": read_onion_address(), + }, + } + + try: + info = rpc_call("getinfo") + staking = rpc_call("getstakinginfo") + status["node"].update( + { + "reachable": True, + "block_height": info.get("blocks"), + "connections": info.get("connections"), + "staking": staking.get("staking"), + } + ) + except (KeyError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc: + status["node"]["errors"].append(str(exc)) + + return status + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(FRONTEND_DIR), **kwargs) + + def do_GET(self): + if self.path == "/api/status": + body = json.dumps(collect_status(), indent=2).encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + return super().do_GET() + + +def main(): + bind = os.environ.get("TRI_PI_BIND", "127.0.0.1") + port = int(os.environ.get("TRI_PI_PORT", "8080")) + server = ThreadingHTTPServer((bind, port), Handler) + print(f"tri-pi backend serving {FRONTEND_DIR} at http://{bind}:{port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..d6014e8 --- /dev/null +++ b/build/README.md @@ -0,0 +1,11 @@ +# Build + +This directory owns the appliance-side build and validation flow. + +Planned responsibilities: + +- build or collect ARM `trianglesd` artifacts +- run QEMU-based smoke tests +- package release assets for Raspberry Pi deployment + +The actual daemon source is expected in `../triangles`. diff --git a/build/build-arm.sh b/build/build-arm.sh new file mode 100644 index 0000000..45ff9c8 --- /dev/null +++ b/build/build-arm.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cross-compile trianglesd for aarch64 (Raspberry Pi 3/4/5). +# +# Supports two build systems found in typical altcoin forks: +# 1. autotools (autogen.sh + configure) — with optional depends/ tree +# 2. standalone makefile (makefile.unix / src/makefile.unix) +# +# Prerequisites (installed automatically on Debian/Ubuntu): +# - aarch64-linux-gnu cross-toolchain +# - build-essential, autoconf, automake, libtool, pkg-config +# +# Usage: +# ./build/build-arm.sh +# TRIANGLES_DIR=../triangles JOBS=8 ./build/build-arm.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TRIANGLES_DIR="${TRIANGLES_DIR:-$REPO_DIR/../triangles}" +JOBS="${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}" +HOST="${HOST:-aarch64-linux-gnu}" +OUTPUT_DIR="${OUTPUT_DIR:-$SCRIPT_DIR/output}" + +log() { echo "[tri-pi:build] $*"; } +die() { echo "[tri-pi:build] ERROR: $*" >&2; exit 1; } + +# ── validate source tree ───────────────────────────────────────────────────── + +if [[ ! -d "$TRIANGLES_DIR/src" ]]; then + die "Triangles source not found at $TRIANGLES_DIR/src." + echo " Set TRIANGLES_DIR to the sibling checkout, e.g.:" >&2 + echo " TRIANGLES_DIR=../triangles $0" >&2 + exit 1 +fi + +TRIANGLES_DIR="$(cd "$TRIANGLES_DIR" && pwd)" + +log "Triangles source: $TRIANGLES_DIR" +log "Target host: $HOST" +log "Parallel jobs: $JOBS" +log "Output dir: $OUTPUT_DIR" + +# ── cross-toolchain ────────────────────────────────────────────────────────── + +log "Checking cross-compilation toolchain..." + +if ! command -v "${HOST}-gcc" &>/dev/null; then + log "Cross-compiler ${HOST}-gcc not found. Installing..." + if command -v apt-get &>/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq \ + gcc-aarch64-linux-gnu \ + g++-aarch64-linux-gnu \ + binutils-aarch64-linux-gnu + else + die "Cannot install cross-compiler: apt-get not found. Install ${HOST}-gcc manually." + fi +fi + +log "Cross-compiler: $(${HOST}-gcc --version | head -1)" + +# ── host build tools ───────────────────────────────────────────────────────── + +log "Checking host build dependencies..." +NEEDED=(build-essential autoconf automake libtool pkg-config) +MISSING=() +for pkg in "${NEEDED[@]}"; do + dpkg -s "$pkg" &>/dev/null 2>&1 || MISSING+=("$pkg") +done +if [[ ${#MISSING[@]} -gt 0 ]]; then + log "Installing: ${MISSING[*]}" + sudo apt-get install -y -qq "${MISSING[@]}" +fi + +# ── prepare output dir ─────────────────────────────────────────────────────── + +mkdir -p "$OUTPUT_DIR" + +# ── build ──────────────────────────────────────────────────────────────────── + +# Strategy 1: autotools with depends/ (Bitcoin-derived build system) +if [[ -d "$TRIANGLES_DIR/depends" && -f "$TRIANGLES_DIR/autogen.sh" ]]; then + log "Build strategy: autotools + depends/" + + log "Building cross-compiled dependencies (this may take a while)..." + make -C "$TRIANGLES_DIR/depends" HOST="$HOST" -j"$JOBS" + + cd "$TRIANGLES_DIR" + ./autogen.sh + + DEPENDS_PREFIX="$TRIANGLES_DIR/depends/$HOST" + CONFIG_SITE="$DEPENDS_PREFIX/share/config.site" \ + ./configure --prefix=/ \ + --disable-tests \ + --disable-bench \ + --disable-gui-tests \ + --with-gui=no + + make -j"$JOBS" + + log "Copying binary..." + cp src/trianglesd "$OUTPUT_DIR/trianglesd" + ${HOST}-strip "$OUTPUT_DIR/trianglesd" + +# Strategy 2: autotools without depends/ +elif [[ -f "$TRIANGLES_DIR/autogen.sh" || -f "$TRIANGLES_DIR/configure.ac" ]]; then + log "Build strategy: autotools (no depends/)" + + cd "$TRIANGLES_DIR" + [[ -f autogen.sh ]] && ./autogen.sh + + ./configure --host="$HOST" \ + --prefix=/ \ + --disable-tests \ + --disable-bench \ + --with-gui=no \ + CC="${HOST}-gcc" \ + CXX="${HOST}-g++" \ + AR="${HOST}-ar" \ + RANLIB="${HOST}-ranlib" \ + STRIP="${HOST}-strip" + + make -j"$JOBS" + + log "Copying binary..." + cp src/trianglesd "$OUTPUT_DIR/trianglesd" + ${HOST}-strip "$OUTPUT_DIR/trianglesd" + +# Strategy 3: standalone makefile (older altcoin forks) +elif [[ -f "$TRIANGLES_DIR/src/makefile.unix" || -f "$TRIANGLES_DIR/makefile.unix" ]]; then + log "Build strategy: standalone makefile" + + if [[ -f "$TRIANGLES_DIR/src/makefile.unix" ]]; then + MAKE_DIR="$TRIANGLES_DIR/src" + MAKEFILE="makefile.unix" + else + MAKE_DIR="$TRIANGLES_DIR" + MAKEFILE="makefile.unix" + fi + + cd "$MAKE_DIR" + make -f "$MAKEFILE" \ + CC="${HOST}-gcc" \ + CXX="${HOST}-g++" \ + AR="${HOST}-ar" \ + RANLIB="${HOST}-ranlib" \ + STRIP="${HOST}-strip" \ + -j"$JOBS" + + BUILT="$(find "$MAKE_DIR" -maxdepth 1 -name 'trianglesd' -type f | head -1)" + [[ -n "$BUILT" ]] || die "Build completed but trianglesd binary not found in $MAKE_DIR" + + log "Copying binary..." + cp "$BUILT" "$OUTPUT_DIR/trianglesd" + ${HOST}-strip "$OUTPUT_DIR/trianglesd" + +else + die "Cannot determine build system. Expected one of: + - depends/ + autogen.sh (Bitcoin-style) + - autogen.sh / configure.ac (autotools) + - src/makefile.unix (standalone makefile) +in: $TRIANGLES_DIR" +fi + +# ── verify ─────────────────────────────────────────────────────────────────── + +log "Verifying binary..." +FILE_INFO="$(file "$OUTPUT_DIR/trianglesd")" +log " $FILE_INFO" + +if echo "$FILE_INFO" | grep -qi 'aarch64\|ARM aarch64'; then + log "Architecture: aarch64 confirmed." +else + log "WARNING: Binary may not be aarch64. Check the output above." +fi + +SIZE="$(du -h "$OUTPUT_DIR/trianglesd" | cut -f1)" + +log "" +log "Build complete!" +log " Binary: $OUTPUT_DIR/trianglesd" +log " Size: $SIZE" +log "" +log "Next step — install on Pi:" +log " TRIANGLESD_BIN=$OUTPUT_DIR/trianglesd sudo ./setup/install.sh" diff --git a/build/qemu-smoke.sh b/build/qemu-smoke.sh new file mode 100644 index 0000000..64cc744 --- /dev/null +++ b/build/qemu-smoke.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +# QEMU smoke test for the tri-pi appliance. +# +# Mounts a Raspberry Pi OS arm64 image, injects the tri-pi assets, +# runs the installer inside a systemd-nspawn container, and validates +# that Tor, the backend, and config generation all work. +# +# Requires (on the host): +# - qemu-user-static (ARM user-mode emulation, registered via binfmt_misc) +# - systemd-container (provides systemd-nspawn) +# - losetup, mount, rsync +# +# Usage: +# sudo ./build/qemu-smoke.sh +# IMAGE_FILE=/path/to/raspios.img sudo ./build/qemu-smoke.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +WORK_DIR="${WORK_DIR:-$SCRIPT_DIR/.qemu-work}" +IMAGE_FILE="${IMAGE_FILE:-}" +TIMEOUT="${TIMEOUT:-180}" + +# Raspberry Pi OS Bookworm Lite arm64 +PI_OS_URL="${PI_OS_URL:-https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2024-03-15/2024-03-15-raspios-bookworm-arm64-lite.img.xz}" + +log() { echo "[tri-pi:qemu] $*"; } +die() { echo "[tri-pi:qemu] ERROR: $*" >&2; exit 1; } +pass() { echo "[tri-pi:qemu] PASS: $*"; ((PASSES++)); } +fail() { echo "[tri-pi:qemu] FAIL: $*"; FAILURES+=("$*"); } + +PASSES=0 +FAILURES=() +LOOP_DEV="" +MNT_DIR="" + +cleanup() { + log "Cleaning up..." + [[ -n "$MNT_DIR" && -d "$MNT_DIR" ]] && umount "$MNT_DIR" 2>/dev/null || true + [[ -n "$MNT_DIR" && -d "$MNT_DIR" ]] && rmdir "$MNT_DIR" 2>/dev/null || true + [[ -n "$LOOP_DEV" ]] && losetup -d "$LOOP_DEV" 2>/dev/null || true +} +trap cleanup EXIT + +# ── pre-flight ──────────────────────────────────────────────────────────────── + +if [[ $EUID -ne 0 ]]; then + die "This script must be run as root (try: sudo $0)" +fi + +log "Checking host dependencies..." +for cmd in losetup mount rsync; do + command -v "$cmd" &>/dev/null || die "Required command not found: $cmd" +done + +# We need either systemd-nspawn or chroot+qemu-user-static +USE_NSPAWN=false +if command -v systemd-nspawn &>/dev/null; then + USE_NSPAWN=true + log "Will use systemd-nspawn for container execution." +elif [[ -f /usr/bin/qemu-aarch64-static ]]; then + log "Will use chroot + qemu-user-static for execution." +else + die "Need either systemd-nspawn (systemd-container pkg) or /usr/bin/qemu-aarch64-static (qemu-user-static pkg)." +fi + +mkdir -p "$WORK_DIR" + +# ── obtain image ───────────────────────────────────────────────────────────── + +if [[ -n "$IMAGE_FILE" ]]; then + [[ -f "$IMAGE_FILE" ]] || die "IMAGE_FILE not found: $IMAGE_FILE" + log "Using provided image: $IMAGE_FILE" + WORK_IMAGE="$WORK_DIR/test-image.img" + cp "$IMAGE_FILE" "$WORK_IMAGE" +elif [[ -f "$WORK_DIR/raspios-base.img" ]]; then + log "Using cached Pi OS image." + WORK_IMAGE="$WORK_DIR/test-image.img" + cp "$WORK_DIR/raspios-base.img" "$WORK_IMAGE" +else + log "Downloading Raspberry Pi OS Bookworm Lite (arm64)..." + DOWNLOAD="$WORK_DIR/raspios.img.xz" + curl -fSL -o "$DOWNLOAD" "$PI_OS_URL" + log "Extracting..." + xz -d "$DOWNLOAD" + mv "${DOWNLOAD%.xz}" "$WORK_DIR/raspios-base.img" + WORK_IMAGE="$WORK_DIR/test-image.img" + cp "$WORK_DIR/raspios-base.img" "$WORK_IMAGE" +fi + +# ── resize for headroom ────────────────────────────────────────────────────── + +log "Adding 512M headroom to image..." +truncate -s +512M "$WORK_IMAGE" + +# ── mount rootfs ───────────────────────────────────────────────────────────── + +log "Mounting image rootfs..." +LOOP_DEV="$(losetup --find --show --partscan "$WORK_IMAGE")" +sleep 1 # wait for partition devices + +# Pi OS images: p1 = boot (FAT32), p2 = rootfs (ext4) +ROOTFS_PART="${LOOP_DEV}p2" +[[ -b "$ROOTFS_PART" ]] || die "Rootfs partition not found at $ROOTFS_PART" + +# Grow the filesystem to fill the extra space +e2fsck -fy "$ROOTFS_PART" &>/dev/null || true +resize2fs "$ROOTFS_PART" &>/dev/null || true + +MNT_DIR="$(mktemp -d)" +mount "$ROOTFS_PART" "$MNT_DIR" + +# ── inject tri-pi assets ───────────────────────────────────────────────────── + +log "Copying tri-pi into image..." +DEST="$MNT_DIR/opt/tri-pi-src" +mkdir -p "$DEST" +rsync -a --exclude='.git' --exclude='build/.qemu-work' \ + "$REPO_DIR/" "$DEST/" + +# Copy qemu-user-static into the image so chroot works +if [[ -f /usr/bin/qemu-aarch64-static ]]; then + cp /usr/bin/qemu-aarch64-static "$MNT_DIR/usr/bin/" +fi + +# ── write the in-image test runner ──────────────────────────────────────────── + +cat > "$MNT_DIR/opt/run-smoke.sh" <<'SMOKE_EOF' +#!/usr/bin/env bash +set -euo pipefail + +PASS=0 +FAIL=0 +RESULTS=() + +check() { + local name="$1"; shift + if "$@" >/dev/null 2>&1; then + RESULTS+=("PASS: $name") + ((PASS++)) + else + RESULTS+=("FAIL: $name") + ((FAIL++)) + fi +} + +echo "" +echo "============================================" +echo " tri-pi smoke test (inside QEMU rootfs)" +echo "============================================" +echo "" + +# --- run the installer (no trianglesd binary, just infra) --- +echo ">>> Running installer..." +cd /opt/tri-pi-src +bash setup/install.sh 2>&1 || true + +echo "" +echo ">>> Running checks..." + +# Config generation +check "triangles.conf exists" test -f /etc/triangles/triangles.conf +check "rpcpassword is randomized" bash -c 'grep -q "^rpcpassword=.\{16,\}" /etc/triangles/triangles.conf' +check "conf is not world-readable" bash -c '! stat -c %a /etc/triangles/triangles.conf | grep -q ".[^0][^0]4"' + +# Installed assets +check "backend installed" test -f /opt/tri-pi/backend/app.py +check "frontend index installed" test -f /opt/tri-pi/frontend/index.html +check "frontend js installed" test -f /opt/tri-pi/frontend/app.js +check "frontend css installed" test -f /opt/tri-pi/frontend/styles.css + +# Backend environment file +check "env file exists" test -f /etc/default/tripi-backend +check "env has RPC password" grep -q '^TRIANGLES_RPC_PASSWORD=.\{16,\}' /etc/default/tripi-backend + +# Tor config +check "torrc drop-in exists" test -f /etc/tor/torrc.d/tri-pi.conf +check "torrc has HiddenServiceDir" grep -q 'HiddenServiceDir' /etc/tor/torrc.d/tri-pi.conf + +# systemd units +check "trianglesd unit installed" test -f /etc/systemd/system/trianglesd.service +check "backend unit installed" test -f /etc/systemd/system/tripi-backend.service + +# System user +check "triangles user exists" id triangles + +# Directories +check "data dir exists" test -d /var/lib/triangles +check "tor hs dir exists" test -d /var/lib/tor/tri-pi + +# --- report --- +echo "" +echo "============================================" +echo " Results" +echo "============================================" +for r in "${RESULTS[@]}"; do echo " $r"; done +echo "" +echo " Passed: $PASS Failed: $FAIL" +echo "" + +if [[ $FAIL -gt 0 ]]; then + echo "SMOKE_RESULT=FAILED" + exit 1 +else + echo "SMOKE_RESULT=PASSED" + exit 0 +fi +SMOKE_EOF +chmod +x "$MNT_DIR/opt/run-smoke.sh" + +# ── run tests inside the image ──────────────────────────────────────────────── + +log "Executing smoke test inside ARM rootfs..." +SMOKE_RC=0 + +if $USE_NSPAWN; then + # systemd-nspawn handles binfmt_misc automatically, gives us PID 1 + timeout "$TIMEOUT" systemd-nspawn \ + --quiet \ + --directory="$MNT_DIR" \ + --bind-ro=/etc/resolv.conf:/etc/resolv.conf \ + /opt/run-smoke.sh || SMOKE_RC=$? +else + # Manual chroot with qemu-user-static + mount --bind /proc "$MNT_DIR/proc" 2>/dev/null || true + mount --bind /sys "$MNT_DIR/sys" 2>/dev/null || true + mount --bind /dev "$MNT_DIR/dev" 2>/dev/null || true + cp /etc/resolv.conf "$MNT_DIR/etc/resolv.conf" 2>/dev/null || true + + timeout "$TIMEOUT" chroot "$MNT_DIR" /opt/run-smoke.sh || SMOKE_RC=$? + + umount "$MNT_DIR/proc" 2>/dev/null || true + umount "$MNT_DIR/sys" 2>/dev/null || true + umount "$MNT_DIR/dev" 2>/dev/null || true +fi + +# ── report ──────────────────────────────────────────────────────────────────── + +echo "" +log "============================================" +if [[ $SMOKE_RC -eq 0 ]]; then + log " SMOKE TEST PASSED" + log "============================================" + exit 0 +elif [[ $SMOKE_RC -eq 124 ]]; then + log " SMOKE TEST TIMED OUT (${TIMEOUT}s)" + log "============================================" + exit 2 +else + log " SMOKE TEST FAILED (exit code $SMOKE_RC)" + log "============================================" + exit 1 +fi diff --git a/config/triangles.conf.template b/config/triangles.conf.template new file mode 100644 index 0000000..45ec280 --- /dev/null +++ b/config/triangles.conf.template @@ -0,0 +1,28 @@ +# Network +port=24112 +rpcport=19112 +rpcuser=tripi +rpcpassword=changeme +rpcallowip=127.0.0.1 +listen=1 +server=1 +daemon=1 +maxconnections=32 + +# Tor +proxy=127.0.0.1:9050 +tor=127.0.0.1:9050 +torhiddenservice=1 +torhsport=24112 +tordatadir=/var/lib/tri-pi/tor + +# Staking +staking=1 + +# Resource limits +dbcache=50 +maxmempool=100 + +# Seeds +addnode=seed1.cryptographic-triangles.org +addnode=seed2.cryptographic-triangles.org diff --git a/config/tripi-backend.env.example b/config/tripi-backend.env.example new file mode 100644 index 0000000..58ad208 --- /dev/null +++ b/config/tripi-backend.env.example @@ -0,0 +1,8 @@ +TRI_PI_BIND=127.0.0.1 +TRI_PI_PORT=8080 +TRI_PI_FRONTEND_DIR=/opt/tri-pi/frontend +TRIANGLES_RPC_URL=http://127.0.0.1:19112/ +TRIANGLES_RPC_USER=tripi +TRIANGLES_RPC_PASSWORD=replace-me +TRIANGLES_DATA_DIR=/var/lib/triangles +TRI_PI_TOR_HOSTNAME_FILE=/var/lib/tor/tri-pi/hostname diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..df010b9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,87 @@ +# tri-pi Architecture + +## Goal + +Turn a Raspberry Pi into a headless Triangles node appliance with: + +- Tor hidden service exposure +- safe defaults for staking +- a local status surface +- reproducible build and test workflows + +## High-Level Layout + +```text ++---------------- Raspberry Pi ----------------+ +| | +| trianglesd <--> Tor <--> onion service | +| ^ | +| | RPC | +| v | +| tri-pi backend <--> tri-pi frontend | +| ^ | +| | systemd / file state | +| v | +| config, logs, backups, health checks | ++----------------------------------------------+ +``` + +## Why This Mirrors velxio + +The inspiration from `velxio` is structural rather than product-level: + +- keep runtime orchestration separate from the core executable +- use a dedicated backend/frontend split +- make local development reproducible +- reserve QEMU for realistic target validation + +`tri-pi` does not need a browser IDE, device simulator, or circuit canvas. + +## Main Components + +### Backend + +The backend is the control plane for the appliance. It should: + +- read node state from `trianglesd` RPC +- read service state from `systemctl` +- surface onion address and health checks +- serve a small local HTTP API + +### Frontend + +The frontend is a local dashboard, not a wallet. It should show: + +- sync height +- peer count +- staking state +- Tor hidden service address +- service health and recent errors + +### Setup + +Provisioning scripts install: + +- OS dependencies +- Tor +- config templates +- systemd units +- backend/frontend runtime files + +### Build + +Build scripts support: + +- local development against `../triangles` +- ARM cross-builds +- QEMU smoke tests against Raspberry Pi OS images + +## Boundaries + +`tri-pi` should not: + +- carry protocol logic +- fork `trianglesd` behavior +- become a general wallet UI + +It should remain an appliance layer around a separate daemon. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..4d9ee56 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,46 @@ +# Development + +## Workspace Layout + +Recommended local layout: + +```text +e:/repos/ +├── triangles/ +└── tri-pi/ +``` + +## Local Backend Run + +```bash +python backend/app.py +``` + +Open `http://127.0.0.1:8080/`. + +## Expected Environment Variables + +The backend reads these variables: + +- `TRI_PI_FRONTEND_DIR` +- `TRIANGLES_RPC_URL` +- `TRIANGLES_RPC_USER` +- `TRIANGLES_RPC_PASSWORD` +- `TRIANGLES_DATA_DIR` +- `TRI_PI_TOR_HOSTNAME_FILE` + +Defaults are provided in `config/tripi-backend.env.example`. + +## Build Direction + +Short term: + +- use `../triangles` as the source tree +- build `trianglesd` separately +- let `tri-pi` manage deployment around the binary + +Long term: + +- add containerized cross-build support +- add QEMU-based integration tests +- publish appliance releases with pinned daemon artifacts diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..58dd01f --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,29 @@ +async function refreshStatus() { + const errors = document.getElementById("errors"); + + try { + const response = await fetch("/api/status", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + document.getElementById("reachable").textContent = String(data.node.reachable); + document.getElementById("block-height").textContent = valueOrDash(data.node.block_height); + document.getElementById("connections").textContent = valueOrDash(data.node.connections); + document.getElementById("staking").textContent = valueOrDash(data.node.staking); + document.getElementById("trianglesd-status").textContent = data.services.trianglesd; + document.getElementById("tor-status").textContent = data.services.tor; + document.getElementById("onion-address").textContent = data.tor.onion_address || "Not available"; + errors.textContent = data.node.errors.length ? data.node.errors.join("\n") : "No errors reported."; + } catch (error) { + errors.textContent = `Failed to load status: ${error.message}`; + } +} + +function valueOrDash(value) { + return value === null || value === undefined ? "-" : String(value); +} + +refreshStatus(); +setInterval(refreshStatus, 5000); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ec9762a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,73 @@ + + + + + + tri-pi status + + + +
+
+

Triangles appliance

+

tri-pi

+

Local status for a Raspberry Pi staking node running over Tor.

+
+ +
+
+

Node

+
+
+
Reachable
+
Unknown
+
+
+
Block height
+
-
+
+
+
Peers
+
-
+
+
+
Staking
+
-
+
+
+
+ +
+

Services

+
+
+
trianglesd
+
-
+
+
+
tor
+
-
+
+
+
+ +
+

Tor

+
+
+
Onion address
+
Not available
+
+
+

The onion hostname appears after Tor has created the hidden service.

+
+ +
+

Errors

+
No errors reported.
+
+
+
+ + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..d7773fd --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,121 @@ +:root { + --bg: #f4f0e8; + --panel: rgba(255, 252, 247, 0.9); + --ink: #1f2a1f; + --muted: #54624f; + --line: #c8baa4; + --accent: #2f6b4f; + --accent-soft: #d8e6dc; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + color: var(--ink); + background: + radial-gradient(circle at top left, #fff8ef 0, transparent 35%), + linear-gradient(160deg, #e9e0d0 0%, #f5f2ea 48%, #e6ece8 100%); +} + +.page { + max-width: 1100px; + margin: 0 auto; + padding: 48px 20px 64px; +} + +.hero { + margin-bottom: 28px; +} + +.eyebrow { + margin: 0 0 8px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--accent); + font-size: 12px; +} + +h1 { + margin: 0; + font-size: clamp(44px, 10vw, 82px); + line-height: 0.95; +} + +.lede { + max-width: 700px; + margin-top: 12px; + color: var(--muted); + font-size: 18px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 18px; +} + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 20px; + padding: 20px; + backdrop-filter: blur(10px); + box-shadow: 0 16px 40px rgba(51, 45, 33, 0.08); +} + +.card-wide { + grid-column: span 2; +} + +.card h2 { + margin-top: 0; + margin-bottom: 16px; + font-size: 24px; +} + +dl { + margin: 0; +} + +dl div { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-top: 1px solid var(--line); +} + +dl div:first-child { + border-top: 0; + padding-top: 0; +} + +dt { + color: var(--muted); +} + +dd { + margin: 0; + font-weight: 700; +} + +.muted, +pre { + color: var(--muted); +} + +pre { + margin: 0; + white-space: pre-wrap; + font-family: "Courier New", monospace; +} + +@media (max-width: 700px) { + .card-wide { + grid-column: span 1; + } +} diff --git a/image/README.md b/image/README.md new file mode 100644 index 0000000..a04a3dd --- /dev/null +++ b/image/README.md @@ -0,0 +1,11 @@ +# Image + +This directory is reserved for Raspberry Pi OS image customization. + +Planned contents: + +- first-boot provisioning hooks +- preseeded service enablement +- image build helpers + +For now, `tri-pi` assumes manual installation on an existing Pi OS system. diff --git a/services/trianglesd.service b/services/trianglesd.service new file mode 100644 index 0000000..2436e29 --- /dev/null +++ b/services/trianglesd.service @@ -0,0 +1,16 @@ +[Unit] +Description=Triangles daemon +After=network-online.target tor.service +Wants=network-online.target + +[Service] +Type=forking +User=triangles +Group=triangles +ExecStart=/usr/local/bin/trianglesd -conf=/etc/triangles/triangles.conf -datadir=/var/lib/triangles +ExecStop=/usr/local/bin/trianglesd stop +Restart=on-failure +TimeoutStopSec=60 + +[Install] +WantedBy=multi-user.target diff --git a/services/tripi-backend.service b/services/tripi-backend.service new file mode 100644 index 0000000..82c22fd --- /dev/null +++ b/services/tripi-backend.service @@ -0,0 +1,16 @@ +[Unit] +Description=tri-pi local status backend +After=network-online.target trianglesd.service tor.service +Wants=network-online.target + +[Service] +Type=simple +User=triangles +Group=triangles +WorkingDirectory=/opt/tri-pi +EnvironmentFile=/etc/default/tripi-backend +ExecStart=/usr/bin/python3 /opt/tri-pi/backend/app.py +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/setup/configure-tor.sh b/setup/configure-tor.sh new file mode 100644 index 0000000..2af5f4c --- /dev/null +++ b/setup/configure-tor.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Configure a Tor v3 hidden service for the Triangles node. +# Called by install.sh, but can also be run standalone. + +TORRC_DIR="${TORRC_DIR:-/etc/tor/torrc.d}" +TORRC_PATH="${TORRC_PATH:-$TORRC_DIR/tri-pi.conf}" +HIDDEN_SERVICE_DIR="${HIDDEN_SERVICE_DIR:-/var/lib/tor/tri-pi}" +NODE_PORT="${NODE_PORT:-24112}" +TOR_USER="${TOR_USER:-debian-tor}" + +log() { echo "[tri-pi:tor] $*"; } + +# ── torrc drop-in directory ────────────────────────────────────────────────── + +if [[ ! -d "$TORRC_DIR" ]]; then + log "Creating $TORRC_DIR..." + install -d -o root -g root -m 0755 "$TORRC_DIR" +fi + +# Ensure the main torrc includes our drop-in directory +MAIN_TORRC="/etc/tor/torrc" +if [[ -f "$MAIN_TORRC" ]] && ! grep -q "%include $TORRC_DIR" "$MAIN_TORRC" 2>/dev/null; then + log "Adding include directive to $MAIN_TORRC..." + printf '\n# tri-pi: include drop-in config directory\n%%include %s\n' "$TORRC_DIR" >> "$MAIN_TORRC" +fi + +# ── hidden service config ──────────────────────────────────────────────────── + +log "Writing Tor hidden service config to $TORRC_PATH..." +cat > "$TORRC_PATH" <