diff --git a/README.md b/README.md index e764ade..db0432e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,87 @@ # homelab-ansible-lxc-meridian -Meridian — local Anthropic API powered by Claude Max OAuth, for HAOS LLM integration \ No newline at end of file +Ansible config for the Meridian LXC (CTID 457 on pve01, `192.168.1.184`). + +## What it is + +[Meridian](https://github.com/rynfar/meridian) is a local Anthropic API server +backed by the Claude Code SDK. It translates `/v1/messages` calls into Claude +Code SDK `query()` calls so any Anthropic-compatible client can run against +Chuck's Claude Max subscription instead of paid API tokens. + +Primary consumer: HAOS's built-in `anthropic` conversation integration (via a +custom_component fork that adds `CONF_BASE_URL`). + +## Architecture + +- Debian 12 LXC, Node 22 from NodeSource apt repo +- `@rynfar/meridian` installed globally via `npm` +- systemd unit `meridian.service` running as user `meridian`, binding to + `0.0.0.0:3456` +- OAuth credentials live at `/opt/meridian/.claude/` (transferred manually + after first deploy — see Bootstrap below) +- **No auth at the Meridian layer.** LAN-only reachability is the entire + security model — no Caddy public vhost, no Cloudflare tunnel. + +## Bootstrap + +1. Provision the LXC via `homelab-terraform/lxc` (`terraform apply`). +2. Run the LXC bootstrap one-liner from `feedback_lxc_bootstrap_user`: + ``` + ssh root@192.168.1.184 'apt-get update && apt-get install -y sudo && useradd -m -s /bin/bash cbalders && echo "cbalders ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/90-cbalders && chmod 440 /etc/sudoers.d/90-cbalders' + ``` + (Plus authorized_keys for cbalders.) +3. Local first deploy (Semaphore can't reach a fresh host): + ``` + ./deploy.sh + ``` + Expect: Node 22 installed, `@rynfar/meridian` installed, systemd unit deployed + and enabled but **not started** (no creds yet — `claude_creds.stat.exists` + gates the start task). +4. On Chuck's Mac: + ``` + npm i -g @anthropic-ai/claude-code + claude login # browser flow → ~/.claude/.credentials.json + scp -r ~/.claude cbalders@192.168.1.184:/tmp/.claude-bootstrap + ``` +5. On the LXC: + ``` + sudo cp -r /tmp/.claude-bootstrap/. /opt/meridian/.claude/ + sudo chown -R meridian:meridian /opt/meridian/.claude/ + sudo systemctl start meridian + ``` +6. Smoke from a LAN host: + ``` + curl http://192.168.1.184:3456/v1/messages \ + -H 'Content-Type: application/json' \ + -H 'x-api-key: placeholder' \ + -H 'anthropic-version: 2023-06-01' \ + -d '{"model":"claude-sonnet-4-5","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}' + ``` + +## Operations + +- **Subsequent deploys**: via Semaphore template "Meridian Deploy" (added to + the sync-semaphore-state.py manifest). +- **Token refresh**: handled automatically by the Claude Code SDK; if it ever + fails, `sudo -u meridian /usr/bin/meridian refresh-token` from the LXC. +- **Restart after creds change**: `sudo systemctl restart meridian`. +- **Logs**: `journalctl -u meridian -f`. + +## Files + +``` +roles/meridian/ Node 22 install + npm i meridian + systemd unit +roles/node_exporter/ Prometheus exporter for fleet metrics +vars/main.yml base packages, ssh keys, meridian config +site.yml playbook entrypoint +inventory.ini single host (192.168.1.184) +deploy.sh wrapper for local first-run +``` + +## Memory pointers + +- `project_meridian` — overall design, OAuth model, consumers +- `feedback_local_dns_only` — DNS convention (no public CF for services) +- `feedback_lxc_bootstrap_user` — root bootstrap pattern for fresh LXCs +- `feedback_fresh_host_bootstrap` — Semaphore can't reach fresh hosts diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..21e4d89 --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,6 @@ +[defaults] +interpreter_python = /usr/bin/python3 +deprecation_warnings = False +host_key_checking = False +result_format = yaml +callbacks_enabled = profile_tasks, timer diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..45ba8ac --- /dev/null +++ b/deploy.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# ============================================================================== +# deploy.sh — Deploy Meridian LXC +# +# Usage: +# ./deploy.sh # full deploy +# ./deploy.sh --tags meridian # meridian role only +# ./deploy.sh -v # verbose output +# ============================================================================== +set -euo pipefail + +HOST_IP="$(grep -E '^[0-9]' inventory.ini | head -1 | awk '{print $1}')" +HOST_USER="$(grep -o 'ansible_user=[^ ]*' inventory.ini | head -1 | cut -d= -f2)" + +echo "==> Checking connectivity to ${HOST_USER}@${HOST_IP} ..." +if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "${HOST_USER}@${HOST_IP}" true 2>/dev/null; then + echo " Cannot SSH to ${HOST_IP} — refreshing host key ..." + ssh-keygen -R "$HOST_IP" 2>/dev/null || true + ssh-keyscan -H "$HOST_IP" >> ~/.ssh/known_hosts 2>/dev/null +fi + +echo "==> Installing Ansible collections ..." +ansible-galaxy collection install -r requirements.yml --force 2>/dev/null + +echo "==> Running deploy playbook ..." +ansible-playbook -i inventory.ini site.yml "$@" + +echo "==> Verifying ..." +ssh "${HOST_USER}@${HOST_IP}" bash -s <<'VERIFY' +echo "Node: $(node --version 2>/dev/null || echo missing)" +echo "Meridian binary: $(which meridian 2>/dev/null || echo missing)" +echo "Service:" +systemctl is-enabled meridian 2>&1 +systemctl is-active meridian 2>&1 +if systemctl is-active --quiet meridian; then + curl -sf --max-time 3 http://127.0.0.1:3456/v1/messages -X POST -H 'Content-Type: application/json' -d '{}' >/dev/null 2>&1 && echo "API reachable on :3456" || echo "API on :3456 not responding (expected if OAuth creds missing)" +fi +VERIFY + +echo "==> Done." diff --git a/inventory.ini b/inventory.ini new file mode 100644 index 0000000..4cc4ce1 --- /dev/null +++ b/inventory.ini @@ -0,0 +1 @@ +192.168.1.184 ansible_user=cbalders ansible_become=yes diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..9dbb90c --- /dev/null +++ b/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.general + - name: ansible.posix diff --git a/roles/meridian/handlers/main.yml b/roles/meridian/handlers/main.yml new file mode 100644 index 0000000..9df3b32 --- /dev/null +++ b/roles/meridian/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: reload systemd + systemd: + daemon_reload: true + +- name: restart meridian + systemd: + name: meridian + state: restarted diff --git a/roles/meridian/tasks/main.yml b/roles/meridian/tasks/main.yml new file mode 100644 index 0000000..49bdbf9 --- /dev/null +++ b/roles/meridian/tasks/main.yml @@ -0,0 +1,175 @@ +--- +# ---------- System setup ---------- +- name: Set timezone + copy: + content: "{{ timezone }}" + dest: /etc/timezone + owner: root + group: root + mode: '0644' + +- name: Link localtime + file: + src: "/usr/share/zoneinfo/{{ timezone }}" + dest: /etc/localtime + state: link + force: true + +- name: Install base packages + apt: + name: "{{ packages }}" + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Ensure users exist + user: + name: "{{ item.name }}" + groups: "{{ item.groups }}" + shell: "{{ item.shell }}" + append: true + loop: "{{ users }}" + +- name: Authorize SSH keys for cbalders + authorized_key: + user: cbalders + key: "{{ item }}" + state: present + loop: "{{ ssh_authorized_keys }}" + +- name: Passwordless sudo for cbalders + copy: + content: "cbalders ALL=(ALL) NOPASSWD:ALL\n" + dest: /etc/sudoers.d/90-cbalders + owner: root + group: root + mode: '0440' + +# ---------- Node 22 (NodeSource apt repo) ---------- +- name: Ensure /etc/apt/keyrings exists + file: + path: /etc/apt/keyrings + state: directory + mode: '0755' + +- name: Add NodeSource GPG key + get_url: + url: https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key + dest: /etc/apt/keyrings/nodesource.asc + mode: '0644' + +- name: Add NodeSource apt repo + copy: + content: "deb [signed-by=/etc/apt/keyrings/nodesource.asc] https://deb.nodesource.com/node_{{ meridian_node_major }}.x nodistro main\n" + dest: /etc/apt/sources.list.d/nodesource.list + owner: root + group: root + mode: '0644' + register: nodesource_repo + +- name: apt update after NodeSource add + apt: + update_cache: true + when: nodesource_repo.changed + +- name: Install Node.js + apt: + name: nodejs + state: present + +- name: Verify Node major version + command: node --version + register: node_version + changed_when: false + +- name: Fail if Node major mismatch + fail: + msg: "Node {{ node_version.stdout }} installed; expected v{{ meridian_node_major }}.x" + when: not node_version.stdout.startswith("v" ~ meridian_node_major | string ~ ".") + +# ---------- Meridian user + home ---------- +- name: Ensure meridian system user + user: + name: "{{ meridian_user }}" + system: true + home: "{{ meridian_home }}" + shell: /usr/sbin/nologin + create_home: true + state: present + +- name: Ensure meridian home perms + file: + path: "{{ meridian_home }}" + state: directory + owner: "{{ meridian_user }}" + group: "{{ meridian_user }}" + mode: '0750' + +- name: Ensure .claude credentials dir + file: + path: "{{ meridian_home }}/.claude" + state: directory + owner: "{{ meridian_user }}" + group: "{{ meridian_user }}" + mode: '0700' + +# ---------- Install Meridian via npm ---------- +- name: Install @rynfar/meridian globally + npm: + name: "@rynfar/meridian" + global: true + state: latest + register: meridian_install + +- name: Resolve meridian binary path + command: which meridian + register: meridian_bin + changed_when: false + +# ---------- systemd unit ---------- +- name: Deploy meridian systemd unit + template: + src: meridian.service.j2 + dest: /etc/systemd/system/meridian.service + owner: root + group: root + mode: '0644' + notify: reload systemd + +- name: Flush handlers (reload systemd before enable) + meta: flush_handlers + +- name: Enable meridian service + systemd: + name: meridian + enabled: true + daemon_reload: true + +# OAuth creds (~/.claude/) are scp'd in manually after first deploy. +# Start the service only if creds are present — otherwise the SDK would +# crash-loop on missing credentials. +- name: Check for Claude OAuth credentials + stat: + path: "{{ meridian_home }}/.claude/.credentials.json" + register: claude_creds + +- name: Start meridian if credentials present + systemd: + name: meridian + state: started + when: claude_creds.stat.exists + +- name: Bootstrap reminder + debug: + msg: | + Meridian installed at {{ meridian_bin.stdout }}. + OAuth credentials not yet present at {{ meridian_home }}/.claude/.credentials.json. + Bootstrap on your Mac: + npm i -g @anthropic-ai/claude-code && claude login + Then transfer creds: + scp -r ~/.claude cbalders@{{ inventory_hostname }}:/tmp/.claude-bootstrap + On the LXC: + sudo cp -r /tmp/.claude-bootstrap/. {{ meridian_home }}/.claude/ + sudo chown -R {{ meridian_user }}:{{ meridian_user }} {{ meridian_home }}/.claude/ + sudo systemctl start meridian + when: not claude_creds.stat.exists diff --git a/roles/meridian/templates/meridian.service.j2 b/roles/meridian/templates/meridian.service.j2 new file mode 100644 index 0000000..dbfeda4 --- /dev/null +++ b/roles/meridian/templates/meridian.service.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=Meridian (Anthropic API → Claude Code SDK bridge) +Documentation=https://github.com/rynfar/meridian +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={{ meridian_user }} +Group={{ meridian_user }} +WorkingDirectory={{ meridian_home }} +Environment=HOME={{ meridian_home }} +Environment=MERIDIAN_HOST={{ meridian_host }} +Environment=MERIDIAN_PORT={{ meridian_port }} +Environment=MERIDIAN_IDLE_TIMEOUT_SECONDS={{ meridian_idle_timeout_seconds }} +ExecStart={{ meridian_bin.stdout }} +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/roles/node_exporter/handlers/main.yml b/roles/node_exporter/handlers/main.yml new file mode 100644 index 0000000..9a267e9 --- /dev/null +++ b/roles/node_exporter/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: restart node_exporter + systemd: + name: node_exporter + state: restarted + daemon_reload: yes diff --git a/roles/node_exporter/tasks/main.yml b/roles/node_exporter/tasks/main.yml new file mode 100644 index 0000000..f5fb6a3 --- /dev/null +++ b/roles/node_exporter/tasks/main.yml @@ -0,0 +1,120 @@ +--- +- name: Check if node_exporter is installed + stat: + path: /usr/local/bin/node_exporter + register: ne_bin + +- name: Set architecture + set_fact: + ne_arch: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" + +- name: Get latest node_exporter version + uri: + url: https://api.github.com/repos/prometheus/node_exporter/releases/latest + return_content: yes + register: ne_release + when: not ne_bin.stat.exists + +- name: Set node_exporter version + set_fact: + ne_version: "{{ ne_release.json.tag_name | regex_replace('^v', '') }}" + when: not ne_bin.stat.exists + +- name: Download node_exporter + get_url: + url: "https://github.com/prometheus/node_exporter/releases/download/v{{ ne_version }}/node_exporter-{{ ne_version }}.linux-{{ ne_arch }}.tar.gz" + dest: /tmp/node_exporter.tar.gz + when: not ne_bin.stat.exists + +- name: Extract node_exporter + unarchive: + src: /tmp/node_exporter.tar.gz + dest: /tmp/ + remote_src: yes + when: not ne_bin.stat.exists + +- name: Install node_exporter binary + copy: + src: "/tmp/node_exporter-{{ ne_version }}.linux-{{ ne_arch }}/node_exporter" + dest: /usr/local/bin/node_exporter + owner: root + group: root + mode: '0755' + remote_src: yes + when: not ne_bin.stat.exists + notify: restart node_exporter + +- name: Create node_exporter user + user: + name: node_exporter + system: yes + shell: /usr/sbin/nologin + create_home: no + +- name: Deploy node_exporter systemd service + copy: + content: | + [Unit] + Description=Prometheus Node Exporter + After=network-online.target + Wants=network-online.target + + [Service] + User=node_exporter + Group=node_exporter + Type=simple + ExecStart=/usr/local/bin/node_exporter --collector.textfile.directory=/var/lib/node_exporter/textfile + Restart=on-failure + RestartSec=5 + + [Install] + WantedBy=multi-user.target + dest: /etc/systemd/system/node_exporter.service + owner: root + group: root + mode: '0644' + notify: restart node_exporter + +- name: Create textfile collector directory + file: + path: /var/lib/node_exporter/textfile + state: directory + owner: node_exporter + group: node_exporter + mode: '0755' + +- name: Deploy CPU temperature collector script + copy: + content: | + #!/bin/bash + TEMP=$(vcgencmd measure_temp 2>/dev/null | grep -oP '[0-9.]+') + if [ -n "$TEMP" ]; then + echo "# HELP node_cpu_temperature_celsius CPU temperature from vcgencmd" + echo "# TYPE node_cpu_temperature_celsius gauge" + echo "node_cpu_temperature_celsius $TEMP" + fi > /var/lib/node_exporter/textfile/cpu_temp.prom + dest: /usr/local/bin/collect-cpu-temp.sh + mode: '0755' + +- name: Schedule CPU temperature collection (every minute) + cron: + name: "node_exporter cpu temp" + user: node_exporter + job: "/usr/local/bin/collect-cpu-temp.sh" + +- name: Run initial temperature collection + command: /usr/local/bin/collect-cpu-temp.sh + changed_when: false + +- name: Enable and start node_exporter + systemd: + name: node_exporter + enabled: yes + state: started + daemon_reload: yes + +- name: Clean up download + file: + path: /tmp/node_exporter.tar.gz + state: absent + when: not ne_bin.stat.exists diff --git a/site.yml b/site.yml new file mode 100644 index 0000000..208b959 --- /dev/null +++ b/site.yml @@ -0,0 +1,33 @@ +--- +# ============================================================================== +# Meridian LXC — Site Playbook +# ============================================================================== +# Local Anthropic API powered by Chuck's Claude Max OAuth subscription. +# Bridges the Claude Code SDK to /v1/messages so HAOS's anthropic conversation +# integration (and any Anthropic-compatible client) can use the Max subscription +# instead of paid API tokens. +# +# Security: Meridian has no auth layer of its own. LAN-only reachability is +# the entire security model — no Caddy public vhost, no Cloudflare tunnel. +# OAuth bootstrap is manual: `claude login` on Chuck's Mac, scp ~/.claude/ to +# /opt/meridian/.claude/ on the LXC, then `systemctl restart meridian`. +# +# Usage: +# ./deploy.sh # full deploy +# ./deploy.sh --tags meridian # meridian role only +# ============================================================================== + +- name: Deploy Meridian LXC + hosts: all + become: true + vars_files: + - vars/main.yml + + pre_tasks: + - name: Deploy banner + debug: + msg: "===== {{ ansible_play_name }} → {{ inventory_hostname }} ({{ ansible_host | default(inventory_hostname) }}) =====" + + roles: + - meridian + - node_exporter diff --git a/vars/main.yml b/vars/main.yml new file mode 100644 index 0000000..297945d --- /dev/null +++ b/vars/main.yml @@ -0,0 +1,35 @@ +--- +timezone: America/Toronto + +packages: + - apt-utils + - bash-completion + - ca-certificates + - curl + - git + - gnupg + - htop + - net-tools + - openssh-server + - python3 + - python3-pip + - sudo + - vim + - wget + +users: + - name: cbalders + groups: sudo + shell: /bin/bash + +ssh_authorized_keys: + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINALaic1jpoP6t1urbZqJLI1eU5NeTVD9k8AAMAvOvvk OfficeMini" + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGzTHdCiQjhIHsGB8oMpyKtr9TZXrXeIRKwcwe698zMW Generated By Termius" + +# Meridian +meridian_user: meridian +meridian_home: /opt/meridian +meridian_port: 3456 +meridian_host: "0.0.0.0" +meridian_idle_timeout_seconds: 300 +meridian_node_major: 22