From 7b3e7578170a27f52a0a3829252b13364b018e0e Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Wed, 24 Jun 2026 09:31:04 +0200 Subject: [PATCH 1/2] feat(mother): idempotent MCP setup, injection-proof node-register, hardened wrapper + builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates mother MCP infrastructure into packaging/mother/: - setup-mother.sh: idempotent deploy script (binaries, MCP tools, SSH keys, PostgreSQL peer auth, jq-merge external-mcp.json, daemon env update) - node-register-mcp: UPSERT hw-probe JSON into mother_hive.usb_nodes using psql -v :variable heredoc (bound parameter, no SQL interpolation) - colibri-mcp-ssh: SSH forced-command wrapper with allowlist (only "" → stdio MCP mode, "tools" → discovery; everything else rejected) - build-colibri.sh: branch-allowlisted builder (main + semver tags + COLIBRI_BUILD_ALLOW_BRANCHES), features validated, array-quoted cargo args - MOTHER-SETUP.md: architecture document with security properties Security fixes vs. the clawdie-iso versions: - node-register-mcp: was E${ESCAPED} (vulnerable to E backslash interpretation); now psql -v :variable in a heredoc - colibri-mcp-ssh: was unquoted ${SSH_ORIGINAL_COMMAND} passthrough; now case-match allowlist - build-colibri.sh: was arbitrary git checkout + unquoted cargo flags; now branch allowlist + features validation + array args - USB spawn args: no trailing "colibri-mcp" remote command; forced-command wrapper handles empty command - Key management: one key per trust domain (mother-mcp != Forgejo); key lives on seed partition, not baked into image --- packaging/mother/MOTHER-SETUP.md | 161 +++++++++++++++++ packaging/mother/build-colibri.sh | 119 ++++++++++++ packaging/mother/colibri-mcp-ssh | 32 ++++ packaging/mother/node-register-mcp | 73 ++++++++ packaging/mother/setup-mother.sh | 280 +++++++++++++++++++++++++++++ 5 files changed, 665 insertions(+) create mode 100644 packaging/mother/MOTHER-SETUP.md create mode 100755 packaging/mother/build-colibri.sh create mode 100755 packaging/mother/colibri-mcp-ssh create mode 100755 packaging/mother/node-register-mcp create mode 100755 packaging/mother/setup-mother.sh diff --git a/packaging/mother/MOTHER-SETUP.md b/packaging/mother/MOTHER-SETUP.md new file mode 100644 index 0000000..39e6997 --- /dev/null +++ b/packaging/mother/MOTHER-SETUP.md @@ -0,0 +1,161 @@ +# Mother Node MCP Setup + +## What this is + +The mother node (OSA) runs the Colibri MCP host for USB operator node +coordination. USB nodes send hardware profiles via SSH → MCP → +PostgreSQL. This directory contains the scripts and schema for that +infrastructure. + +## Files + +| File | Purpose | +| ------------------- | ---------------------------------------------------------------- | +| `setup-mother.sh` | Idempotent deploy — run once as root, safe to re-run | +| `mother_schema.sql` | PostgreSQL schema (usb_nodes, build_queue, audit_log + triggers) | +| `node-register-mcp` | MCP tool: receive hw-probe JSON → UPSERT into usb_nodes | +| `geodesic-dome-mcp` | MCP tool: geodesic dome wireframe + structural BOM | +| `build-colibri.sh` | MCP tool: build a colibri crate (branch-allowlisted) | +| `colibri-mcp-ssh` | SSH forced-command wrapper (allowlisted: `""`, `"tools"` only) | + +## Architecture + +``` +USB node Mother (osa) +──────── ──────────── +clawdie-hw-probe PostgreSQL (mother_hive) + │ ▲ + ▼ │ +colibri-daemon │ INSERT / UPSERT + │ (external MCP) │ + ▼ │ +ssh colibri@mother (no cmd) │ + │ (mother-mcp key) │ + ▼ │ +colibri-mcp-ssh ◄─ authorized_keys command="..." + │ allowlist: "" or "tools" + ▼ +colibri-mcp ──► node-register-mcp ──► psql -v :'variable' (heredoc) + (parameterized UPSERT, no shell interpolation of SQL) +``` + +The USB's `colibri-mcp` spawns `ssh -i ~/.ssh/mother-mcp colibri@mother` +with NO remote command. SSH is restricted via `command=` + `restrict` to +run only `colibri-mcp-ssh`, which delegates to `colibri-mcp` in stdio MCP +mode. The `node_register` tool on mother receives the hw-probe JSON and +UPSERTs it into PostgreSQL using `psql -v :'variable'` quoting in a +heredoc — the JSON blob is a bound variable, never interpolated into SQL. + +## Security properties + +- **colibri-mcp-ssh**: allowlists `SSH_ORIGINAL_COMMAND` to `""` (stdio MCP + mode) or `"tools"` (one-shot discovery). All other values are rejected. + This prevents callers from passing arbitrary colibri-mcp flags through + the forced-command boundary. +- **build-colibri.sh**: branch is validated against an allowlist (`main`, + semver `v*` tags, plus `COLIBRI_BUILD_ALLOW_BRANCHES` colon-separated + extras). Features are validated against `^[A-Za-z0-9_,-]+$`. Cargo + args use a shell array — no unquoted string concatenation. +- **node-register-mcp**: `psql -v :'variable'` quoting in a heredoc (not + `-c`). The JSON blob is dollar-quoted by psql; shell never interpolates + it into SQL. +- **mother-mcp key**: NOT baked into images, NOT reused for Forgejo. + Lives on seed partition (`CLAWDIESEED/colibri/ssh/mother-mcp`). One key + per trust domain — blast radius is MCP-over-SSH only. + +## Setup (one-time) + +On mother, as root: + +```sh +# Build colibri from current main first, then: +cd /home/clawdie/ai/colibri +sudo ./packaging/mother/setup-mother.sh + +# The script prints the mother-mcp private key — copy it for USB nodes. +``` + +### What setup-mother.sh does + +1. Installs colibri binaries from `target/release` +2. Installs MCP server scripts +3. jq-merges mother servers into `/usr/local/etc/colibri/external-mcp.json` +4. Creates `colibri` OS user + SSH authorized_keys with `command=` wrapper +5. Generates mother-mcp keypair (prints private key for seed placement) +6. Configures PostgreSQL peer auth for colibri on mother_hive +7. Runs `mother_schema.sql` (idempotent) +8. Updates colibri daemon env (`COLIBRI_AUTOSPAWN`, `COLIBRI_MCP_EXTERNAL_CALL`) +9. Restarts `colibri_daemon` + +Idempotent — running again is a no-op. + +## Key management + +The mother-mcp key is NOT baked into images. It lives: + +- **On mother:** `/var/db/colibri/.ssh/mother-mcp` + `.pub` (auto-generated + by setup-mother.sh) +- **On USB nodes:** placed on the seed partition at + `CLAWDIESEED/colibri/ssh/mother-mcp` (the live-seed importer picks it up + at first boot and installs it at `~/.ssh/mother-mcp`) + +One key per trust domain. The mother-mcp key is not reused for Forgejo +or other services — its blast radius is limited to MCP over SSH. + +## USB-side external-mcp.json + +The USB node registers mother as an external MCP server. Note: NO +remote command — the forced-command wrapper handles entry: + +```json +{ + "servers": { + "mother": { + "command": "ssh", + "args": [ + "-i", "/home/clawdie/.ssh/mother-mcp", + "-o", "StrictHostKeyChecking=accept-new", + "colibri@100.72.229.63" + ], + "env": {} + } + } +} +``` + +The colibri-daemon spawner pipes JSON-RPC on stdin/stdout. SSH connects +with an empty remote command; the forced-command wrapper starts +`colibri-mcp` in stdio MCP mode automatically. + +## Verification + +```sh +# On mother: check the external MCP tools are live +colibri-mcp tools 2>&1 | grep external + +# On mother: test node_register with a sample hw-probe +sudo clawdie-hw-probe 2>/dev/null | \ + jq '{jsonrpc:"2.0",method:"tools/call",id:1, + params:{name:"node_register", + arguments:{hostname:"test-node",hw_profile:.}}}' | \ + /usr/local/bin/node-register-mcp + +# Check PostgreSQL +sudo -u colibri psql -d mother_hive -c \ + "SELECT hostname, status, capabilities FROM usb_nodes;" + +# From a USB node with the mother-mcp key: +ssh colibri@mother tools | grep node_register +``` + +## Adding a USB node + +1. Copy the mother-mcp private key to the USB node's seed partition + (`CLAWDIESEED/colibri/ssh/mother-mcp`) +2. On the USB, install `external-mcp.json` as shown above +3. On the USB, install `clawdie-hw-probe` from clawdie-iso +4. Restart `colibri_daemon` — autospawn runs hw-probe and registers + the node with mother + +See `docs/USB-MOTHER-MCP-CONNECTION.md` in clawdie-iso for the full +USB-side procedure. diff --git a/packaging/mother/build-colibri.sh b/packaging/mother/build-colibri.sh new file mode 100755 index 0000000..e429e3f --- /dev/null +++ b/packaging/mother/build-colibri.sh @@ -0,0 +1,119 @@ +#!/bin/sh +# build-colibri MCP tool — build a colibri crate from an allowed git branch. +# +# Accepts a JSON-RPC tools/call request on stdin, builds the requested +# crate, and returns the result as a JSON content block to stdout. +# +# Expected input (MCP tools/call): +# {"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"build_colibri","arguments":{"crate":"colibri-daemon","branch":"main","features":"","release":"true"}}} +# +# Parameters: +# crate — workspace member to build (default: colibri-daemon) +# branch — git ref to build (allowlisted: main, v* tags; default: main) +# features — optional comma-separated feature names (validated: ^[A-Za-z0-9_,-]*$) +# release — "true" for --release (default), anything else for debug +# +# Security: +# - Branch is validated against an allowlist (main + semver tags + +# COLIBRI_BUILD_ALLOW_BRANCHES env). Arbitrary branch RCE via +# build.rs / proc-macros is the threat model. +# - Features are validated against ^[A-Za-z0-9_,-]*$ to prevent cargo +# flag injection (e.g. --config … smuggled through a feature name). +# - Cargo args use a shell array with proper quoting throughout. +# +# Output: MCP JSON-RPC response with text content block. +set -eu + +REPO="/home/clawdie/ai/colibri" +CRATE_DEFAULT="colibri-daemon" +BRANCH_DEFAULT="main" + +# ── helpers ─────────────────────────────────────────────────────────── + +die() { + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"%s"}}\n' \ + "$ID" "$(printf '%s' "$1" | sed 's/"/\\"/g')" + exit 1 +} + +# ── parse request ───────────────────────────────────────────────────── + +INPUT=$(cat) +ID=$(echo "$INPUT" | jq -r '.id // "1"') +CRATE=$(echo "$INPUT" | jq -r '.params.arguments.crate // "'"$CRATE_DEFAULT"'"') +BRANCH=$(echo "$INPUT" | jq -r '.params.arguments.branch // "'"$BRANCH_DEFAULT"'"') +FEATURES=$(echo "$INPUT" | jq -r '.params.arguments.features // ""') +RELEASE=$(echo "$INPUT" | jq -r '.params.arguments.release // "true"') + +# ── validate branch (allowlist) ─────────────────────────────────────── + +# Allow: main, any tag matching v (semver), plus operator overrides. +# The -w flag is a FreeBSD grep extension; on Linux use grep -E. +valid_branch() { + local b="$1" + [ "$b" = "main" ] && return 0 + # semver tags: v0.12.0, v1.2.3, v0.2.42, etc. + printf '%s' "$b" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._-]+)?$' && return 0 + # Operator-configured extra branches (colon-separated). + if [ -n "${COLIBRI_BUILD_ALLOW_BRANCHES:-}" ]; then + local saved_IFS="$IFS" + IFS=':' + for allowed in $COLIBRI_BUILD_ALLOW_BRANCHES; do + [ "$b" = "$allowed" ] && { IFS="$saved_IFS"; return 0; } + done + IFS="$saved_IFS" + fi + return 1 +} + +if ! valid_branch "$BRANCH"; then + die "branch not in allowlist: ${BRANCH}" +fi + +# ── validate features ───────────────────────────────────────────────── + +if [ -n "$FEATURES" ]; then + if ! printf '%s' "$FEATURES" | grep -Eq '^[A-Za-z0-9_,-]+$'; then + die "features must match ^[A-Za-z0-9_,-]+$" + fi +fi + +# ── validate crate (workspace member name only) ─────────────────────── + +# Prevent path traversal via crate name. +if ! printf '%s' "$CRATE" | grep -Eq '^[a-z][a-z0-9_-]+$'; then + die "crate must match ^[a-z][a-z0-9_-]+$" +fi + +# ── build ───────────────────────────────────────────────────────────── + +START_TS=$(date +%s) + +cd "$REPO" +git fetch origin 2>&1 || true +if ! git checkout "$BRANCH" 2>&1; then + die "git checkout failed: ${BRANCH}" +fi +COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + +BUILD_LOG=$(mktemp) +trap 'rm -f "$BUILD_LOG"' EXIT + +# Build cargo args as an array (no unquoted string concatenation). +set -- -p "$CRATE" +[ "$RELEASE" = "true" ] && set -- "$@" --release +[ -n "$FEATURES" ] && set -- "$@" --features "$FEATURES" + +if cargo build "$@" >"$BUILD_LOG" 2>&1; then + DURATION=$(( $(date +%s) - START_TS )) + BINARY=$(find target -maxdepth 3 -type f -name "$CRATE" -perm -111 | grep -v '/deps/' | head -1) + SIZE=$(stat -f '%z' "$BINARY" 2>/dev/null || echo "0") + tail -5 "$BUILD_LOG" >&2 + printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"{\\"success\\":true,\\"binary\\":\\"%s\\",\\"duration_s\\":%s,\\"branch\\":\\"%s\\",\\"commit\\":\\"%s\\",\\"size_bytes\\":%s}"}]}}\n' \ + "$ID" "$BINARY" "$DURATION" "$BRANCH" "$COMMIT" "$SIZE" +else + DURATION=$(( $(date +%s) - START_TS )) + ERROR=$(tail -20 "$BUILD_LOG" | head -10 | tr '\n' ' ' | sed 's/"/\\"/g') + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"build failed in %ss: %s"}}\n' \ + "$ID" "$DURATION" "$ERROR" +fi diff --git a/packaging/mother/colibri-mcp-ssh b/packaging/mother/colibri-mcp-ssh new file mode 100755 index 0000000..9310df2 --- /dev/null +++ b/packaging/mother/colibri-mcp-ssh @@ -0,0 +1,32 @@ +#!/bin/sh +# SSH forced-command wrapper for mother MCP entrypoint. +# +# SSH's authorized_keys command="..." restriction replaces the client's +# command with this script and stores the original in $SSH_ORIGINAL_COMMAND. +# +# Allowlist: +# "" → colibri-mcp in stdio MCP mode (persistent JSON-RPC channel) +# "tools" → colibri-mcp tools (one-shot discovery, debugging) +# everything else → rejected with exit 1 +# +# Why: the wrapper's job is to constrain what callers can do through the +# SSH forced-command boundary. Without an allowlist, the caller can pass +# any colibri-mcp subcommand or flag — including ones not yet written. +# +# Installed by setup-mother.sh into /usr/local/bin/. +# Referenced from: ~/.ssh/authorized_keys command="/usr/local/bin/colibri-mcp-ssh" +set -eu + +case "${SSH_ORIGINAL_COMMAND:-}" in + "") + exec /usr/local/bin/colibri-mcp + ;; + "tools") + exec /usr/local/bin/colibri-mcp tools + ;; + *) + printf '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"rejected: %s"}}\n' \ + "$(printf '%s' "${SSH_ORIGINAL_COMMAND}" | sed 's/"/\\"/g')" >&2 + exit 1 + ;; +esac diff --git a/packaging/mother/node-register-mcp b/packaging/mother/node-register-mcp new file mode 100755 index 0000000..2cbf52f --- /dev/null +++ b/packaging/mother/node-register-mcp @@ -0,0 +1,73 @@ +#!/bin/sh +# node-register — MCP tool: register a USB node's hardware profile in PostgreSQL. +# +# Accepts a JSON-RPC tools/call request on stdin, UPSERTs the hw_profile into +# mother_hive.usb_nodes, and returns the result to stdout. The +# derive_capabilities() trigger auto-computes has_gpu, gpu_vendor, +# can_run_local_llm, has_wifi, etc. on INSERT/UPDATE. +# +# Expected input: +# {"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"node_register","arguments":{"hostname":"clawdie-usb","hw_profile":{...}}}} +# +# Output on success: +# {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"registered\":true,\"hostname\":\"clawdie-usb\",\"capabilities\":{...}}"}]}} +# +# Security: uses psql :'variable' quoting (dollar-quotes the value) to prevent +# SQL injection. No shell string interpolation touches the JSON blobs. +# +# PostgreSQL access: peer auth for the 'colibri' OS user. The operator must +# run once as postgres (or setup-mother.sh does it): +# CREATE ROLE colibri WITH LOGIN; +# GRANT CONNECT ON DATABASE mother_hive TO colibri; +# GRANT INSERT, UPDATE ON usb_nodes TO colibri; +# GRANT USAGE ON SEQUENCE usb_nodes_id_seq TO colibri; +set -eu + +DB="mother_hive" + +# Read JSON-RPC from stdin +INPUT=$(cat) +ID=$(echo "$INPUT" | jq -r '.id // "1"') +HOSTNAME=$(echo "$INPUT" | jq -r '.params.arguments.hostname // ""') +HW_PROFILE=$(echo "$INPUT" | jq -c '.params.arguments.hw_profile // {}') + +if [ -z "$HOSTNAME" ]; then + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"missing required argument: hostname"}}\n' "$ID" + exit 1 +fi + +if [ "$HW_PROFILE" = "{}" ]; then + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"missing required argument: hw_profile"}}\n' "$ID" + exit 1 +fi + +# Pass the JSON blobs via psql -v with :'variable' quoting. +# :'variable' dollar-quotes the value so backslashes, quotes, and newlines +# inside the JSON cannot break out of the SQL string literal. +# NOTE: we use a heredoc (not -c) because psql only expands :'variable' +# in SQL read from stdin / -f, not from -c. +RESULT=$(psql -d "$DB" -tA -v hostname="$HOSTNAME" -v hw_profile="$HW_PROFILE" <<'PSQL' +BEGIN; +INSERT INTO usb_nodes (hostname, hw_profile, status, last_seen) +VALUES (:'hostname', (:'hw_profile')::jsonb, 'online', now()) +ON CONFLICT (hostname) DO UPDATE +SET hw_profile = EXCLUDED.hw_profile, + status = 'online', + last_seen = now(); +SELECT json_build_object( + 'registered', true, + 'hostname', hostname, + 'capabilities', capabilities +) FROM usb_nodes WHERE hostname = :'hostname'; +COMMIT; +PSQL +) 2>&1 || { + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"psql failed: %s"}}\n' \ + "$ID" "$(printf '%s' "$RESULT" | sed 's/"/\\"/g' | tr '\n' ' ')" + exit 1 +} + +# RESULT is the JSON object from json_build_object (one line). +# Escape double quotes for JSON embedding in the MCP text field. +printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"%s"}]}}\n' \ + "$ID" "$(printf '%s' "$RESULT" | sed 's/"/\\"/g')" diff --git a/packaging/mother/setup-mother.sh b/packaging/mother/setup-mother.sh new file mode 100755 index 0000000..95c8e02 --- /dev/null +++ b/packaging/mother/setup-mother.sh @@ -0,0 +1,280 @@ +#!/bin/sh +# setup-mother.sh — idempotent mother-node MCP setup. +# +# Deploys the mother MCP infrastructure on a FreeBSD host (osa or equivalent). +# Run as root. Safe to re-run: every step is idempotent. +# +# What it does: +# 1. Installs colibri binaries (colibri-mcp, colibri-daemon, colibri) from +# the prebuilt artifact dir (default: ../target/release). +# 2. Installs MCP server scripts (geodesic-dome-mcp, build-colibri.sh, +# node-register-mcp, colibri-mcp-ssh). +# 3. jq-merges mother servers into external-mcp.json (preserves existing). +# 4. Creates the colibri OS user if missing, sets up SSH authorized_keys +# for the mother-mcp key (command="/usr/local/bin/colibri-mcp-ssh"). +# 5. Configures PostgreSQL peer auth for the colibri user on mother_hive. +# 6. Generates mother-mcp keypair if absent, prints the private key for +# placement on USB node seed partitions. +# 7. Updates colibri daemon env (COLIBRI_AUTOSPAWN, COLIBRI_MCP_EXTERNAL_CALL). +# 8. Reloads colibri_daemon (or starts it). +# +# Idempotency: every step checks before acting. Running twice is a no-op. +# +# Usage: +# sudo ./setup-mother.sh # default artifact dir +# sudo COLIBRI_ARTIFACT_DIR=/path ./setup-mother.sh +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ARTIFACT_DIR="${COLIBRI_ARTIFACT_DIR:-${SCRIPT_DIR}/../../target/release}" +COST_MODE="${COLIBRI_COST_MODE:-smart}" +MCP_CONFIG="/usr/local/etc/colibri/external-mcp.json" +COLIBRI_HOME="/var/db/colibri" +SSH_DIR="${COLIBRI_HOME}/.ssh" +PROVIDER_ENV="/usr/local/etc/colibri/provider.env" + +echo "=== mother MCP setup ===" +echo " artifact dir : ${ARTIFACT_DIR}" +echo " cost mode : ${COST_MODE}" +echo "" + +# ── helpers ─────────────────────────────────────────────────────────── + +idempotent() { + local desc="$1" fn="$2" + printf " %-50s" "${desc}..." + eval "$fn" && echo "OK" || { echo "FAIL"; exit 1; } +} + +ensure_user() { + if id colibri >/dev/null 2>&1; then + echo "(already exists)" + return 0 + fi + pw useradd colibri -d "${COLIBRI_HOME}" -s /usr/sbin/nologin +} + +ensure_dir() { + mkdir -p "$1" + chown colibri:colibri "$1" + chmod 750 "$1" +} + +# ── 1. colibri binaries ─────────────────────────────────────────────── + +echo "--- 1. colibri binaries ---" +idempotent "colibri-mcp" ' + if [ -x "$ARTIFACT_DIR/colibri-mcp" ]; then + install -m 755 "$ARTIFACT_DIR/colibri-mcp" /usr/local/bin/colibri-mcp + else + echo "MISSING: $ARTIFACT_DIR/colibri-mcp — build colibri first"; exit 1 + fi +' +idempotent "colibri-daemon" ' + if [ -x "$ARTIFACT_DIR/colibri-daemon" ]; then + install -m 755 "$ARTIFACT_DIR/colibri-daemon" /usr/local/bin/colibri-daemon + else + echo "MISSING: $ARTIFACT_DIR/colibri-daemon"; exit 1 + fi +' +idempotent "colibri (CLI)" ' + if [ -x "$ARTIFACT_DIR/colibri" ]; then + install -m 755 "$ARTIFACT_DIR/colibri" /usr/local/bin/colibri + else + echo "MISSING: $ARTIFACT_DIR/colibri"; exit 1 + fi +' + +# ── 2. MCP server scripts ───────────────────────────────────────────── + +echo "--- 2. MCP server scripts ---" +idempotent "colibri-mcp-ssh wrapper" ' + install -m 755 "$SCRIPT_DIR/colibri-mcp-ssh" /usr/local/bin/colibri-mcp-ssh +' +idempotent "geodesic-dome-mcp" ' + install -m 755 "$SCRIPT_DIR/geodesic-dome-mcp" /usr/local/bin/geodesic-dome-mcp +' +idempotent "build-colibri.sh" ' + install -m 755 "$SCRIPT_DIR/build-colibri.sh" /usr/local/bin/build-colibri.sh +' +idempotent "node-register-mcp" ' + install -m 755 "$SCRIPT_DIR/node-register-mcp" /usr/local/bin/node-register-mcp +' + +# ── 3. external-mcp.json (jq-merge, preserve existing) ──────────────── + +echo "--- 3. external-mcp.json ---" +MOTHER_SERVERS='{ + "mother-build": { + "command": "/usr/local/bin/build-colibri.sh", + "args": [], + "env": {} + }, + "geodesic-dome": { + "command": "/usr/local/bin/geodesic-dome-mcp", + "args": [], + "env": {} + }, + "node-register": { + "command": "/usr/local/bin/node-register-mcp", + "args": [], + "env": {} + } +}' + +mkdir -p "$(dirname "$MCP_CONFIG")" +if [ -f "$MCP_CONFIG" ]; then + # Preserve servers we don't own; overwrite our three with latest. + TMP_MCP=$(mktemp) + jq --argjson ours "$MOTHER_SERVERS" '.servers = (.servers // {}) * $ours' \ + "$MCP_CONFIG" > "$TMP_MCP" + mv "$TMP_MCP" "$MCP_CONFIG" + echo " merged node-register + geodesic-dome + mother-build" +else + printf '{"servers": %s}\n' "$MOTHER_SERVERS" | jq . > "$MCP_CONFIG" + echo " created" +fi +chmod 644 "$MCP_CONFIG" + +# ── 4. colibri user + SSH ──────────────────────────────────────────── + +echo "--- 4. colibri user + SSH ---" +idempotent "colibri user" 'ensure_user' +idempotent "colibri home" 'ensure_dir "$COLIBRI_HOME"' +idempotent "colibri .ssh" 'ensure_dir "$SSH_DIR"' + +idempotent "mother-mcp keypair" ' + if [ -f "${SSH_DIR}/mother-mcp" ]; then + echo "(already exists)" + else + ssh-keygen -t ed25519 -f "${SSH_DIR}/mother-mcp" \ + -C "mother-mcp-$(date +%Y%m%d)" -N "" >/dev/null 2>&1 + chown colibri:colibri "${SSH_DIR}/mother-mcp" "${SSH_DIR}/mother-mcp.pub" + chmod 600 "${SSH_DIR}/mother-mcp" + chmod 644 "${SSH_DIR}/mother-mcp.pub" + fi +' + +idempotent "authorized_keys command= wrapper" ' + PUBKEY=$(cat "${SSH_DIR}/mother-mcp.pub") + AUTH_FILE="${SSH_DIR}/authorized_keys" + EXPECTED="command=\"/usr/local/bin/colibri-mcp-ssh\",restrict $PUBKEY" + if [ -f "$AUTH_FILE" ] && grep -qF "$PUBKEY" "$AUTH_FILE" 2>/dev/null; then + echo "(already present)" + else + printf "command=\"/usr/local/bin/colibri-mcp-ssh\",restrict %s\n" "$PUBKEY" >> "$AUTH_FILE" + chown colibri:colibri "$AUTH_FILE" + chmod 600 "$AUTH_FILE" + fi +' + +# Print the private key for seed placement +echo "" +echo " >>> mother-mcp private key (copy to USB seed partition) <<<" +echo " ===========================================================" +cat "${SSH_DIR}/mother-mcp" +echo " ===========================================================" +echo "" + +# ── 5. PostgreSQL peer auth ────────────────────────────────────────── + +echo "--- 5. PostgreSQL peer auth ---" +idempotent "DB role colibri" ' + su - postgres -c "psql -tAc \"DO \\\$\\\$ BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '\''colibri'\'') THEN + CREATE ROLE colibri WITH LOGIN; + END IF; + END \\\$\\\$;\"" 2>&1 +' +idempotent "GRANT CONNECT on mother_hive" ' + su - postgres -c "psql -d mother_hive -tAc \ + \"GRANT CONNECT ON DATABASE mother_hive TO colibri;\"" 2>&1 +' +idempotent "GRANT INSERT,UPDATE on usb_nodes" ' + su - postgres -c "psql -d mother_hive -tAc \ + \"GRANT INSERT, UPDATE ON usb_nodes TO colibri; + GRANT USAGE ON SEQUENCE usb_nodes_id_seq TO colibri;\"" 2>&1 +' +idempotent "GRANT INSERT on audit_log" ' + su - postgres -c "psql -d mother_hive -tAc \ + \"GRANT INSERT ON audit_log TO colibri; + GRANT USAGE ON SEQUENCE audit_log_id_seq TO colibri;\"" 2>&1 +' + +# Configure pg_hba.conf for peer auth on /var/run/postgresql socket +PG_HBA="/var/db/postgres/data/pg_hba.conf" +if [ -f "$PG_HBA" ]; then + PEER_LINE="local mother_hive colibri peer" + if grep -qF "$PEER_LINE" "$PG_HBA"; then + echo " pg_hba peer auth: (already present)" + else + echo "$PEER_LINE" >> "$PG_HBA" + service postgresql reload >/dev/null 2>&1 || true + echo " pg_hba peer auth: added + reloaded" + fi +fi + +# ── 6. schema (idempotent) ──────────────────────────────────────────── + +echo "--- 6. mother schema ---" +idempotent "mother_schema.sql" ' + su - postgres -c "psql -d mother_hive -f $SCRIPT_DIR/mother_schema.sql" 2>&1 | tail -1 +' + +# ── 7. daemon env update ────────────────────────────────────────────── + +echo "--- 7. daemon env ---" +mkdir -p /usr/local/etc/colibri + +if [ -f "$PROVIDER_ENV" ]; then + # Update COLIBRI_AUTOSPAWN_PI → COLIBRI_AUTOSPAWN (0.12 rename) + if grep -q 'COLIBRI_AUTOSPAWN_PI' "$PROVIDER_ENV"; then + sed -i '' 's/COLIBRI_AUTOSPAWN_PI/COLIBRI_AUTOSPAWN/' "$PROVIDER_ENV" + echo " renamed COLIBRI_AUTOSPAWN_PI → COLIBRI_AUTOSPAWN" + fi + # Ensure external calls are enabled + if ! grep -q 'COLIBRI_MCP_EXTERNAL_CALL' "$PROVIDER_ENV"; then + echo 'COLIBRI_MCP_EXTERNAL_CALL="1"' >> "$PROVIDER_ENV" + echo " added COLIBRI_MCP_EXTERNAL_CALL=1" + else + echo " COLIBRI_MCP_EXTERNAL_CALL already present" + fi + # Set cost mode if missing + if ! grep -q 'COLIBRI_COST_MODE' "$PROVIDER_ENV"; then + echo "COLIBRI_COST_MODE=\"${COST_MODE}\"" >> "$PROVIDER_ENV" + echo " added COLIBRI_COST_MODE=${COST_MODE}" + fi +else + cat > "$PROVIDER_ENV" << ENVEOF +COLIBRI_AUTOSPAWN=YES +COLIBRI_MCP_EXTERNAL_CALL="1" +COLIBRI_COST_MODE="${COST_MODE}" +ENVEOF + chmod 600 "$PROVIDER_ENV" + echo " created provider.env" +fi + +# ── 8. reload daemon ────────────────────────────────────────────────── + +echo "--- 8. colibri daemon ---" +if service colibri_daemon status >/dev/null 2>&1; then + service colibri_daemon restart >/dev/null 2>&1 || true + echo " restarted" +else + service colibri_daemon start >/dev/null 2>&1 || true + echo " started" +fi + +# ── verify ──────────────────────────────────────────────────────────── + +echo "" +echo "=== setup complete ===" +echo "" +echo "Verification:" +echo " colibri-mcp tools | grep external" +echo " sudo -u colibri psql -d mother_hive -c \"SELECT * FROM usb_nodes;\"" +echo " ssh colibri@localhost tools" +echo "" +echo "To add a USB node: copy the mother-mcp private key above to the" +echo "USB seed partition (CLAWDIESEED/colibri/ssh/mother-mcp) or install" +echo "it manually at ~/.ssh/mother-mcp on the USB node." -- 2.45.3 From e941fdd49412027489b6952bfdc49cad94c0db39 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Wed, 24 Jun 2026 09:45:39 +0200 Subject: [PATCH 2/2] =?UTF-8?q?mother:=20rename=20usb=5Fnodes=E2=86=92hive?= =?UTF-8?q?=5Fnodes=20(+node=5Ftype),=20harden=20setup/register?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass on the mother MCP infra: - Rename usb_nodes → hive_nodes: a node is any host that joined the hive (live-usb/disk/vps/mother), not just a USB boot. Add a first-class node_type column (live-usb|disk|vps|mother|unknown). The schema migrates an existing osa DB in place (ALTER TABLE + ALTER SEQUENCE, guarded by to_regclass) and ADD COLUMN IF NOT EXISTS for already-renamed tables — data preserved, idempotent. FKs/trigger/indexes follow. - node-register-mcp: accepts + validates node_type, UPSERTs into hive_nodes. Add ON_ERROR_STOP=1 (psql otherwise exits 0 on SQL error → false success) and fold stderr into the captured result so failures are reported. - setup-mother.sh: apply schema BEFORE granting on its tables (fresh installs had no tables when grants ran); pipe the schema via stdin so the postgres user need not read the repo checkout; locate pg_hba via SHOW hba_file (was hardcoded) and PREPEND the peer rule (pg_hba is first-match); grants target hive_nodes/hive_nodes_id_seq. - build-colibri.sh: fast-forward a checked-out branch to origin so it builds current upstream code, not a stale local copy. Validated: prettier + sh -n green. Schema migration/UPSERT to be exercised on osa (no local postgres server here). Co-Authored-By: Claude Opus 4.8 --- packaging/mother/MOTHER-SETUP.md | 36 ++++++++-------- packaging/mother/build-colibri.sh | 5 +++ packaging/mother/mother_schema.sql | 45 ++++++++++++++++---- packaging/mother/node-register-mcp | 55 +++++++++++++++--------- packaging/mother/setup-mother.sh | 67 ++++++++++++++++++------------ 5 files changed, 135 insertions(+), 73 deletions(-) diff --git a/packaging/mother/MOTHER-SETUP.md b/packaging/mother/MOTHER-SETUP.md index 39e6997..3154eac 100644 --- a/packaging/mother/MOTHER-SETUP.md +++ b/packaging/mother/MOTHER-SETUP.md @@ -9,14 +9,14 @@ infrastructure. ## Files -| File | Purpose | -| ------------------- | ---------------------------------------------------------------- | -| `setup-mother.sh` | Idempotent deploy — run once as root, safe to re-run | -| `mother_schema.sql` | PostgreSQL schema (usb_nodes, build_queue, audit_log + triggers) | -| `node-register-mcp` | MCP tool: receive hw-probe JSON → UPSERT into usb_nodes | -| `geodesic-dome-mcp` | MCP tool: geodesic dome wireframe + structural BOM | -| `build-colibri.sh` | MCP tool: build a colibri crate (branch-allowlisted) | -| `colibri-mcp-ssh` | SSH forced-command wrapper (allowlisted: `""`, `"tools"` only) | +| File | Purpose | +| ------------------- | ----------------------------------------------------------------- | +| `setup-mother.sh` | Idempotent deploy — run once as root, safe to re-run | +| `mother_schema.sql` | PostgreSQL schema (hive_nodes, build_queue, audit_log + triggers) | +| `node-register-mcp` | MCP tool: receive hw-probe JSON → UPSERT into hive_nodes | +| `geodesic-dome-mcp` | MCP tool: geodesic dome wireframe + structural BOM | +| `build-colibri.sh` | MCP tool: build a colibri crate (branch-allowlisted) | +| `colibri-mcp-ssh` | SSH forced-command wrapper (allowlisted: `""`, `"tools"` only) | ## Architecture @@ -40,27 +40,27 @@ colibri-mcp ──► node-register-mcp ──► psql -v :'variable' (heredoc) ``` The USB's `colibri-mcp` spawns `ssh -i ~/.ssh/mother-mcp colibri@mother` -with NO remote command. SSH is restricted via `command=` + `restrict` to +with NO remote command. SSH is restricted via `command=` + `restrict` to run only `colibri-mcp-ssh`, which delegates to `colibri-mcp` in stdio MCP -mode. The `node_register` tool on mother receives the hw-probe JSON and +mode. The `node_register` tool on mother receives the hw-probe JSON and UPSERTs it into PostgreSQL using `psql -v :'variable'` quoting in a heredoc — the JSON blob is a bound variable, never interpolated into SQL. ## Security properties - **colibri-mcp-ssh**: allowlists `SSH_ORIGINAL_COMMAND` to `""` (stdio MCP - mode) or `"tools"` (one-shot discovery). All other values are rejected. + mode) or `"tools"` (one-shot discovery). All other values are rejected. This prevents callers from passing arbitrary colibri-mcp flags through the forced-command boundary. - **build-colibri.sh**: branch is validated against an allowlist (`main`, semver `v*` tags, plus `COLIBRI_BUILD_ALLOW_BRANCHES` colon-separated - extras). Features are validated against `^[A-Za-z0-9_,-]+$`. Cargo + extras). Features are validated against `^[A-Za-z0-9_,-]+$`. Cargo args use a shell array — no unquoted string concatenation. - **node-register-mcp**: `psql -v :'variable'` quoting in a heredoc (not - `-c`). The JSON blob is dollar-quoted by psql; shell never interpolates + `-c`). The JSON blob is dollar-quoted by psql; shell never interpolates it into SQL. - **mother-mcp key**: NOT baked into images, NOT reused for Forgejo. - Lives on seed partition (`CLAWDIESEED/colibri/ssh/mother-mcp`). One key + Lives on seed partition (`CLAWDIESEED/colibri/ssh/mother-mcp`). One key per trust domain — blast radius is MCP-over-SSH only. ## Setup (one-time) @@ -99,12 +99,12 @@ The mother-mcp key is NOT baked into images. It lives: `CLAWDIESEED/colibri/ssh/mother-mcp` (the live-seed importer picks it up at first boot and installs it at `~/.ssh/mother-mcp`) -One key per trust domain. The mother-mcp key is not reused for Forgejo +One key per trust domain. The mother-mcp key is not reused for Forgejo or other services — its blast radius is limited to MCP over SSH. ## USB-side external-mcp.json -The USB node registers mother as an external MCP server. Note: NO +The USB node registers mother as an external MCP server. Note: NO remote command — the forced-command wrapper handles entry: ```json @@ -123,7 +123,7 @@ remote command — the forced-command wrapper handles entry: } ``` -The colibri-daemon spawner pipes JSON-RPC on stdin/stdout. SSH connects +The colibri-daemon spawner pipes JSON-RPC on stdin/stdout. SSH connects with an empty remote command; the forced-command wrapper starts `colibri-mcp` in stdio MCP mode automatically. @@ -142,7 +142,7 @@ sudo clawdie-hw-probe 2>/dev/null | \ # Check PostgreSQL sudo -u colibri psql -d mother_hive -c \ - "SELECT hostname, status, capabilities FROM usb_nodes;" + "SELECT hostname, status, capabilities FROM hive_nodes;" # From a USB node with the mother-mcp key: ssh colibri@mother tools | grep node_register diff --git a/packaging/mother/build-colibri.sh b/packaging/mother/build-colibri.sh index e429e3f..72d8109 100755 --- a/packaging/mother/build-colibri.sh +++ b/packaging/mother/build-colibri.sh @@ -94,6 +94,11 @@ git fetch origin 2>&1 || true if ! git checkout "$BRANCH" 2>&1; then die "git checkout failed: ${BRANCH}" fi +# If BRANCH is a local branch (not a detached tag), fast-forward to the fetched +# remote so we build current upstream code, not a stale local copy. +if git show-ref --verify --quiet "refs/heads/${BRANCH}"; then + git merge --ff-only "origin/${BRANCH}" 2>&1 || true +fi COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_LOG=$(mktemp) diff --git a/packaging/mother/mother_schema.sql b/packaging/mother/mother_schema.sql index 7be17ca..6c17211 100644 --- a/packaging/mother/mother_schema.sql +++ b/packaging/mother/mother_schema.sql @@ -1,6 +1,29 @@ -CREATE TABLE IF NOT EXISTS usb_nodes ( +-- Mother hive schema. Idempotent: safe to re-run (setup-mother.sh applies it). +-- +-- A "node" is any host that has joined the hive and reports a hardware +-- profile — live-USB, disk-installed FreeBSD, a Linux box, a VPS, or the +-- mother itself. The boot/provisioning medium is an attribute (node_type), +-- not the table's identity. + +-- Migration: usb_nodes → hive_nodes (rename in place; preserves data + the +-- owning sequence). Runs only on hosts created under the old name; a no-op on +-- fresh installs and on already-migrated hosts. +DO $$ +BEGIN + IF to_regclass('public.usb_nodes') IS NOT NULL + AND to_regclass('public.hive_nodes') IS NULL THEN + ALTER TABLE usb_nodes RENAME TO hive_nodes; + IF to_regclass('public.usb_nodes_id_seq') IS NOT NULL THEN + ALTER SEQUENCE usb_nodes_id_seq RENAME TO hive_nodes_id_seq; + END IF; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS hive_nodes ( id SERIAL PRIMARY KEY, hostname TEXT NOT NULL UNIQUE, + -- Provisioning medium: live-usb | disk | vps | mother | unknown. + node_type TEXT NOT NULL DEFAULT 'unknown', last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), freebsd_version JSONB, @@ -10,12 +33,17 @@ CREATE TABLE IF NOT EXISTS usb_nodes ( tags TEXT[] DEFAULT '{}', last_cap_sync TIMESTAMPTZ ); -CREATE INDEX IF NOT EXISTS idx_nodes_status ON usb_nodes (status); -CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON usb_nodes (last_seen DESC); -CREATE INDEX IF NOT EXISTS idx_nodes_cap_has_gpu ON usb_nodes ((capabilities->>'has_gpu')); +-- Add node_type to tables migrated from the old usb_nodes definition. +ALTER TABLE hive_nodes ADD COLUMN IF NOT EXISTS node_type TEXT NOT NULL DEFAULT 'unknown'; + +CREATE INDEX IF NOT EXISTS idx_nodes_status ON hive_nodes (status); +CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON hive_nodes (last_seen DESC); +CREATE INDEX IF NOT EXISTS idx_nodes_type ON hive_nodes (node_type); +CREATE INDEX IF NOT EXISTS idx_nodes_cap_has_gpu ON hive_nodes ((capabilities->>'has_gpu')); + CREATE TABLE IF NOT EXISTS build_queue ( id SERIAL PRIMARY KEY, - node_id INTEGER REFERENCES usb_nodes(id), + node_id INTEGER REFERENCES hive_nodes(id), crate TEXT NOT NULL DEFAULT 'colibri-daemon', branch TEXT NOT NULL DEFAULT 'main', release BOOLEAN NOT NULL DEFAULT true, @@ -31,11 +59,12 @@ CREATE TABLE IF NOT EXISTS build_queue ( error_log TEXT ); CREATE INDEX IF NOT EXISTS idx_build_status ON build_queue (status, priority DESC, queued_at); + CREATE TABLE IF NOT EXISTS audit_log ( id BIGSERIAL PRIMARY KEY, event_ts TIMESTAMPTZ NOT NULL DEFAULT now(), event_type TEXT NOT NULL, - node_id INTEGER REFERENCES usb_nodes(id), + node_id INTEGER REFERENCES hive_nodes(id), build_id INTEGER REFERENCES build_queue(id), details JSONB ); @@ -79,7 +108,7 @@ BEGIN END; $$ LANGUAGE plpgsql; -DROP TRIGGER IF EXISTS trg_derive_capabilities ON usb_nodes; +DROP TRIGGER IF EXISTS trg_derive_capabilities ON hive_nodes; CREATE TRIGGER trg_derive_capabilities - BEFORE INSERT OR UPDATE OF hw_profile ON usb_nodes + BEFORE INSERT OR UPDATE OF hw_profile ON hive_nodes FOR EACH ROW EXECUTE FUNCTION derive_capabilities(); diff --git a/packaging/mother/node-register-mcp b/packaging/mother/node-register-mcp index 2cbf52f..9e7f34c 100755 --- a/packaging/mother/node-register-mcp +++ b/packaging/mother/node-register-mcp @@ -1,26 +1,27 @@ #!/bin/sh -# node-register — MCP tool: register a USB node's hardware profile in PostgreSQL. +# node-register — MCP tool: register a hive node's hardware profile in PostgreSQL. # -# Accepts a JSON-RPC tools/call request on stdin, UPSERTs the hw_profile into -# mother_hive.usb_nodes, and returns the result to stdout. The +# A node is any host that joined the hive (live-usb, disk, vps, mother), keyed +# by hostname. Accepts a JSON-RPC tools/call request on stdin, UPSERTs the +# hw_profile into mother_hive.hive_nodes, and returns the result to stdout. The # derive_capabilities() trigger auto-computes has_gpu, gpu_vendor, # can_run_local_llm, has_wifi, etc. on INSERT/UPDATE. # # Expected input: -# {"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"node_register","arguments":{"hostname":"clawdie-usb","hw_profile":{...}}}} +# {"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"node_register","arguments":{"hostname":"clawdie-node","node_type":"live-usb","hw_profile":{...}}}} # # Output on success: -# {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"registered\":true,\"hostname\":\"clawdie-usb\",\"capabilities\":{...}}"}]}} +# {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"registered\":true,\"hostname\":\"clawdie-node\",\"capabilities\":{...}}"}]}} # -# Security: uses psql :'variable' quoting (dollar-quotes the value) to prevent -# SQL injection. No shell string interpolation touches the JSON blobs. +# Security: psql :'variable' substitution expands to a safely single-quoted SQL +# string literal — no shell interpolation touches the JSON blobs. # # PostgreSQL access: peer auth for the 'colibri' OS user. The operator must # run once as postgres (or setup-mother.sh does it): # CREATE ROLE colibri WITH LOGIN; # GRANT CONNECT ON DATABASE mother_hive TO colibri; -# GRANT INSERT, UPDATE ON usb_nodes TO colibri; -# GRANT USAGE ON SEQUENCE usb_nodes_id_seq TO colibri; +# GRANT INSERT, UPDATE ON hive_nodes TO colibri; +# GRANT USAGE ON SEQUENCE hive_nodes_id_seq TO colibri; set -eu DB="mother_hive" @@ -29,8 +30,15 @@ DB="mother_hive" INPUT=$(cat) ID=$(echo "$INPUT" | jq -r '.id // "1"') HOSTNAME=$(echo "$INPUT" | jq -r '.params.arguments.hostname // ""') +NODE_TYPE=$(echo "$INPUT" | jq -r '.params.arguments.node_type // "unknown"') HW_PROFILE=$(echo "$INPUT" | jq -c '.params.arguments.hw_profile // {}') +# node_type is a small enum; validate so a bad value can't land an odd row. +case "$NODE_TYPE" in + live-usb|disk|vps|mother|unknown) ;; + *) NODE_TYPE="unknown" ;; +esac + if [ -z "$HOSTNAME" ]; then printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"missing required argument: hostname"}}\n' "$ID" exit 1 @@ -41,27 +49,34 @@ if [ "$HW_PROFILE" = "{}" ]; then exit 1 fi -# Pass the JSON blobs via psql -v with :'variable' quoting. -# :'variable' dollar-quotes the value so backslashes, quotes, and newlines -# inside the JSON cannot break out of the SQL string literal. -# NOTE: we use a heredoc (not -c) because psql only expands :'variable' -# in SQL read from stdin / -f, not from -c. -RESULT=$(psql -d "$DB" -tA -v hostname="$HOSTNAME" -v hw_profile="$HW_PROFILE" <<'PSQL' +# Pass the JSON blobs via psql -v with :'variable' quoting. :'variable' +# expands to a safely single-quoted SQL string literal (backslashes, quotes, +# and newlines inside the JSON are escaped), so untrusted input cannot break +# out of the literal — no shell interpolation ever touches the SQL. +# NOTE: a heredoc (not -c) is required — psql only expands :'variable' in SQL +# read from stdin / -f, not from -c. +# ON_ERROR_STOP=1 makes psql exit non-zero on a SQL error; without it psql +# would continue past a failed statement and exit 0, hiding failures. stderr is +# folded into RESULT so the error branch can report what went wrong. +RESULT=$(psql -d "$DB" -tA -v ON_ERROR_STOP=1 \ + -v hostname="$HOSTNAME" -v node_type="$NODE_TYPE" -v hw_profile="$HW_PROFILE" 2>&1 <<'PSQL' BEGIN; -INSERT INTO usb_nodes (hostname, hw_profile, status, last_seen) -VALUES (:'hostname', (:'hw_profile')::jsonb, 'online', now()) +INSERT INTO hive_nodes (hostname, node_type, hw_profile, status, last_seen) +VALUES (:'hostname', :'node_type', (:'hw_profile')::jsonb, 'online', now()) ON CONFLICT (hostname) DO UPDATE -SET hw_profile = EXCLUDED.hw_profile, +SET node_type = EXCLUDED.node_type, + hw_profile = EXCLUDED.hw_profile, status = 'online', last_seen = now(); SELECT json_build_object( 'registered', true, 'hostname', hostname, + 'node_type', node_type, 'capabilities', capabilities -) FROM usb_nodes WHERE hostname = :'hostname'; +) FROM hive_nodes WHERE hostname = :'hostname'; COMMIT; PSQL -) 2>&1 || { +) || { printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"psql failed: %s"}}\n' \ "$ID" "$(printf '%s' "$RESULT" | sed 's/"/\\"/g' | tr '\n' ' ')" exit 1 diff --git a/packaging/mother/setup-mother.sh b/packaging/mother/setup-mother.sh index 95c8e02..73ebbe9 100755 --- a/packaging/mother/setup-mother.sh +++ b/packaging/mother/setup-mother.sh @@ -13,10 +13,12 @@ # 4. Creates the colibri OS user if missing, sets up SSH authorized_keys # for the mother-mcp key (command="/usr/local/bin/colibri-mcp-ssh"). # 5. Configures PostgreSQL peer auth for the colibri user on mother_hive. -# 6. Generates mother-mcp keypair if absent, prints the private key for -# placement on USB node seed partitions. -# 7. Updates colibri daemon env (COLIBRI_AUTOSPAWN, COLIBRI_MCP_EXTERNAL_CALL). -# 8. Reloads colibri_daemon (or starts it). +# Generates the mother-mcp keypair if absent, prints the private key for +# placement on a node's seed partition. +# 5. Applies the schema (create + usb_nodes→hive_nodes migration), then +# configures PostgreSQL peer auth + grants for the colibri user. +# 6. Updates colibri daemon env (COLIBRI_AUTOSPAWN, COLIBRI_MCP_EXTERNAL_CALL). +# 7. Reloads colibri_daemon (or starts it). # # Idempotency: every step checks before acting. Running twice is a no-op. # @@ -186,14 +188,24 @@ idempotent "DB role colibri" ' END IF; END \\\$\\\$;\"" 2>&1 ' + +# Apply the schema BEFORE granting on its tables (fresh installs have no tables +# until now; this also runs the usb_nodes → hive_nodes migration). Pipe the file +# via stdin so the postgres user need not read it from the repo checkout, and +# stop on the first error so a failed migration is not silently skipped. +idempotent "mother schema (create + migrate)" ' + su - postgres -c "psql -d mother_hive -v ON_ERROR_STOP=1 -f -" \ + < "$SCRIPT_DIR/mother_schema.sql" 2>&1 | tail -1 +' + idempotent "GRANT CONNECT on mother_hive" ' su - postgres -c "psql -d mother_hive -tAc \ \"GRANT CONNECT ON DATABASE mother_hive TO colibri;\"" 2>&1 ' -idempotent "GRANT INSERT,UPDATE on usb_nodes" ' +idempotent "GRANT INSERT,UPDATE on hive_nodes" ' su - postgres -c "psql -d mother_hive -tAc \ - \"GRANT INSERT, UPDATE ON usb_nodes TO colibri; - GRANT USAGE ON SEQUENCE usb_nodes_id_seq TO colibri;\"" 2>&1 + \"GRANT INSERT, UPDATE ON hive_nodes TO colibri; + GRANT USAGE ON SEQUENCE hive_nodes_id_seq TO colibri;\"" 2>&1 ' idempotent "GRANT INSERT on audit_log" ' su - postgres -c "psql -d mother_hive -tAc \ @@ -201,29 +213,30 @@ idempotent "GRANT INSERT on audit_log" ' GRANT USAGE ON SEQUENCE audit_log_id_seq TO colibri;\"" 2>&1 ' -# Configure pg_hba.conf for peer auth on /var/run/postgresql socket -PG_HBA="/var/db/postgres/data/pg_hba.conf" -if [ -f "$PG_HBA" ]; then +# Configure pg_hba.conf for peer auth. Locate the real file (path varies by +# PostgreSQL major/pkg) instead of hardcoding it. +PG_HBA=$(su - postgres -c "psql -tAc 'SHOW hba_file;'" 2>/dev/null | tr -d '[:space:]') +if [ -n "$PG_HBA" ] && [ -f "$PG_HBA" ]; then PEER_LINE="local mother_hive colibri peer" if grep -qF "$PEER_LINE" "$PG_HBA"; then echo " pg_hba peer auth: (already present)" else - echo "$PEER_LINE" >> "$PG_HBA" + # Prepend: pg_hba is first-match, so this specific rule must precede any + # generic 'local all all ...' line. cat-redirect preserves owner/perms. + TMP_HBA=$(mktemp) + { printf '%s\n' "$PEER_LINE"; cat "$PG_HBA"; } > "$TMP_HBA" + cat "$TMP_HBA" > "$PG_HBA" + rm -f "$TMP_HBA" service postgresql reload >/dev/null 2>&1 || true - echo " pg_hba peer auth: added + reloaded" + echo " pg_hba peer auth: prepended + reloaded" fi +else + echo " pg_hba: could not locate hba_file — configure peer auth manually" fi -# ── 6. schema (idempotent) ──────────────────────────────────────────── +# ── 6. daemon env update ────────────────────────────────────────────── -echo "--- 6. mother schema ---" -idempotent "mother_schema.sql" ' - su - postgres -c "psql -d mother_hive -f $SCRIPT_DIR/mother_schema.sql" 2>&1 | tail -1 -' - -# ── 7. daemon env update ────────────────────────────────────────────── - -echo "--- 7. daemon env ---" +echo "--- 6. daemon env ---" mkdir -p /usr/local/etc/colibri if [ -f "$PROVIDER_ENV" ]; then @@ -254,9 +267,9 @@ ENVEOF echo " created provider.env" fi -# ── 8. reload daemon ────────────────────────────────────────────────── +# ── 7. reload daemon ────────────────────────────────────────────────── -echo "--- 8. colibri daemon ---" +echo "--- 7. colibri daemon ---" if service colibri_daemon status >/dev/null 2>&1; then service colibri_daemon restart >/dev/null 2>&1 || true echo " restarted" @@ -272,9 +285,9 @@ echo "=== setup complete ===" echo "" echo "Verification:" echo " colibri-mcp tools | grep external" -echo " sudo -u colibri psql -d mother_hive -c \"SELECT * FROM usb_nodes;\"" +echo " sudo -u colibri psql -d mother_hive -c \"SELECT hostname, node_type, status FROM hive_nodes;\"" echo " ssh colibri@localhost tools" echo "" -echo "To add a USB node: copy the mother-mcp private key above to the" -echo "USB seed partition (CLAWDIESEED/colibri/ssh/mother-mcp) or install" -echo "it manually at ~/.ssh/mother-mcp on the USB node." +echo "To add a node: copy the mother-mcp private key above to the node's" +echo "seed partition (CLAWDIESEED/colibri/ssh/mother-mcp) or install it" +echo "manually at ~/.ssh/mother-mcp on the node." -- 2.45.3