From 0270a6cbcf1ff3f27690b1cd34bae3ada1f7a9a7 Mon Sep 17 00:00:00 2001 From: Wander Grevink Date: Wed, 25 Feb 2026 16:47:18 +0000 Subject: [PATCH 1/3] do not try to launch the tunnel automatically --- .devcontainer/devcontainer.json | 3 -- .devcontainer/tunnel-start.sh | 6 ++-- Taskfile.yml | 21 +++++++++----- docs/remote-ssh.md | 9 +++--- tasks/Taskfile.server.yml | 45 ----------------------------- tasks/Taskfile.tunnel.yml | 51 +++++++++++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 63 deletions(-) create mode 100644 tasks/Taskfile.tunnel.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7a353e9..df7b5bf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -47,9 +47,6 @@ "remoteUser": "vscode", "postCreateCommand": "bash .devcontainer/devcontainer-setup.sh", - - "postStartCommand": "bash .devcontainer/tunnel-start.sh", - "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} } diff --git a/.devcontainer/tunnel-start.sh b/.devcontainer/tunnel-start.sh index f60886b..c1b1096 100755 --- a/.devcontainer/tunnel-start.sh +++ b/.devcontainer/tunnel-start.sh @@ -22,11 +22,11 @@ RETRIES=3 INTERVAL=3 for i in $(seq 1 "$RETRIES"); do - if task server:tunnel-start -- "$WORKSPACE" > "$LOG" 2>&1; then + if task tunnel:start -- "$WORKSPACE" > "$LOG" 2>&1; then echo "✅ Tunnel to $WORKSPACE is running." echo "" echo "📊 Dashboards: http://localhost:8080/dashboard/ (Traefik), http://localhost:5080/ (OpenObserve)" - echo "💡 Stop: task server:tunnel-stop -- $WORKSPACE" + echo "💡 Stop: task tunnel:stop -- $WORKSPACE" exit 0 fi if [[ $i -lt $RETRIES ]]; then @@ -36,7 +36,7 @@ for i in $(seq 1 "$RETRIES"); do done echo "⚠️ Tunnel could not be started after $RETRIES attempts (server down or unreachable)." -echo " Devcontainer is ready. To retry: task server:tunnel-start -- $WORKSPACE" +echo " Devcontainer is ready. To retry: task tunnel:start -- $WORKSPACE" echo " Details: $LOG" cat "$LOG" exit 0 diff --git a/Taskfile.yml b/Taskfile.yml index ef3e4ef..d3b94b4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,6 +16,7 @@ includes: secrets: tasks/Taskfile.secrets.yml ansible: tasks/Taskfile.ansible.yml server: tasks/Taskfile.server.yml + tunnel: tasks/Taskfile.tunnel.yml backup: tasks/Taskfile.backup.yml test: tasks/Taskfile.test.yml registry: tasks/Taskfile.registry.yml @@ -46,17 +47,21 @@ tasks: echo " task ansible:bootstrap -- # One-time bootstrap as root (dev or prod)" echo " task ansible:run -- # Configure server (Docker, Traefik, security)" echo "" + echo "Application Deployment:" + echo " task app:deploy -- # Deploy application to dev or prod" + echo " task app:versions -- # List available image versions" + echo "" echo "Testing:" echo " task test:run # Run all tests (validate, format check, security scan)" echo "" + echo "SSH Tunnel:" + echo " task tunnel:start -- # Start SSH tunnel (dev or prod)" + echo " task tunnel:stop -- # Stop SSH tunnel (dev or prod)" + echo "" echo "Secrets Management (SOPS):" echo " task secrets:keygen # Generate age key pair" echo " task secrets:generate-sops-config # Generate .sops.yaml" echo "" - echo "Application Deployment:" - echo " task app:deploy -- " - echo " task app:versions -- " - echo "" echo "Registry:" echo " task registry:overview # List tags (TAG, CREATED, DESCRIPTION) for all repos" echo "" @@ -65,18 +70,20 @@ tasks: echo " task server:list-hetzner-keys # List Hetzner SSH keys with IDs" echo "" echo "Backups (Hetzner):" - echo " task backup:list -- # List backup images" - echo " task backup:restore -- # Restore from backup (destructive)" - echo " task backup:restore-latest -- # Restore from latest backup (destructive)" + echo " task backup:list -- # List backup images" + echo " task backup:restore -- # Restore from backup (destructive)" + echo " task backup:restore-latest -- # Restore from latest backup (destructive)" # Internal validation tasks - used by other tasks via deps or direct calls _check:sops-key: + desc: "(internal) Ensure SOPS key exists" internal: true preconditions: - sh: test -f "{{.SOPS_KEY_FILE}}" msg: "SOPS key file not found. Run 'task secrets:keygen' first." _check:workspace: + desc: "(internal) Validate workspace argument (dev|prod)" internal: true preconditions: - sh: '[ -n "{{.WORKSPACE}}" ]' diff --git a/docs/remote-ssh.md b/docs/remote-ssh.md index 9564caa..25547b7 100644 --- a/docs/remote-ssh.md +++ b/docs/remote-ssh.md @@ -24,17 +24,16 @@ flowchart LR --- -## Option 1: Devcontainer tunnel (default) +## Option 1: SSH tunnel -Open this repo in the devcontainer. A tunnel to the server starts automatically (with retries). +Open this repo in the devcontainer. When you need the admin UIs, start the tunnel manually: `task tunnel:start -- dev` (or `prod`). | What | URL | |------|-----| | Traefik dashboard (internal) | http://localhost:8080/dashboard/ | | OpenObserve (internal) | http://localhost:5080/ | -- **Server down?** The devcontainer still opens. Start the tunnel later: `task server:tunnel-start -- dev` (or `prod`). -- **Tunnel to prod by default?** Set `AUTO_START_TUNNEL=prod` in your environment. +Close the tunnel again with: `task tunnel:stop -- dev` (or `prod`). --- @@ -67,6 +66,6 @@ Traefik and OpenObserve are internal only (no public DNS); use the tunnel URLs. | Traefik dashboard | http://localhost:8080/dashboard/ | Basic auth (see `/etc/traefik/auth/htpasswd` on server) | | OpenObserve | http://localhost:5080/ | `openobserve_username@observe.local`, password from `secrets/infrastructure-secrets.yml` | -**Traefik or OpenObserve not loading?** The tunnel may have dropped (e.g. after a reboot). Run `task server:tunnel-start -- dev` (or `prod`) again. +**Traefik or OpenObserve not loading?** The tunnel may have dropped (e.g. after a reboot). Run `task tunnel:start -- dev` (or `prod`) again. --- diff --git a/tasks/Taskfile.server.yml b/tasks/Taskfile.server.yml index 69aa19c..8b9b886 100644 --- a/tasks/Taskfile.server.yml +++ b/tasks/Taskfile.server.yml @@ -34,48 +34,3 @@ tasks: echo "" echo "💡 Use the 'ID' column for secrets/infrastructure-secrets.yml (ssh_keys: [\"ID\", ...])" - tunnel-start: - desc: "Start SSH tunnel to workspace for admin UIs. Use: task server:tunnel-start -- dev|prod" - silent: true - vars: - WORKSPACE: "{{.CLI_ARGS}}" - preconditions: - - sh: '[ -n "{{.WORKSPACE}}" ]' - msg: "Workspace required. Usage: task server:tunnel-start -- " - - sh: '[ "{{.WORKSPACE}}" = "dev" ] || [ "{{.WORKSPACE}}" = "prod" ]' - msg: "Invalid workspace. Use: dev or prod" - cmds: - - | - HOSTNAME=$(task hostkeys:hostname -- "{{.WORKSPACE}}") - if pgrep -f "ssh.*-L 5080:localhost:5080.*ubuntu@$HOSTNAME" >/dev/null 2>&1; then - echo "Tunnel to {{.WORKSPACE}} already running." - echo " Traefik Dashboard: http://localhost:8080/dashboard/" - echo " OpenObserve: http://localhost:5080/" - exit 0 - fi - ssh -f -N -L 5080:localhost:5080 -L 8080:localhost:8080 -o StrictHostKeyChecking=accept-new "ubuntu@$HOSTNAME" || { echo "Failed to start tunnel."; exit 1; } - echo "Tunnel to {{.WORKSPACE}} started." - echo " Traefik Dashboard: http://localhost:8080/dashboard/" - echo " OpenObserve: http://localhost:5080/" - echo " Stop: task server:tunnel-stop -- {{.WORKSPACE}}" - - tunnel-stop: - desc: "Stop SSH tunnel for workspace. Use: task server:tunnel-stop -- dev|prod" - silent: true - vars: - WORKSPACE: "{{.CLI_ARGS}}" - preconditions: - - sh: '[ -n "{{.WORKSPACE}}" ]' - msg: "Workspace required. Usage: task server:tunnel-stop -- " - - sh: '[ "{{.WORKSPACE}}" = "dev" ] || [ "{{.WORKSPACE}}" = "prod" ]' - msg: "Invalid workspace. Use: dev or prod" - cmds: - - | - HOSTNAME=$(task hostkeys:hostname -- "{{.WORKSPACE}}") - PIDS=$(pgrep -f "ssh.*-L 5080:localhost:5080.*ubuntu@$HOSTNAME" 2>/dev/null) || true - if [ -z "$PIDS" ]; then - echo "No tunnel to {{.WORKSPACE}} running." - else - echo "$PIDS" | xargs kill 2>/dev/null || true - echo "Tunnel to {{.WORKSPACE}} stopped." - fi diff --git a/tasks/Taskfile.tunnel.yml b/tasks/Taskfile.tunnel.yml new file mode 100644 index 0000000..9fdbe8f --- /dev/null +++ b/tasks/Taskfile.tunnel.yml @@ -0,0 +1,51 @@ +# yaml-language-server: $schema=https://taskfile.dev/schema.json +version: '3' + +# SSH tunnel for admin UIs (Traefik, OpenObserve). Run manually when needed. + +tasks: + start: + desc: "Start SSH tunnel to workspace for admin UIs. Use: task tunnel:start -- dev|prod" + silent: true + vars: + WORKSPACE: "{{.CLI_ARGS}}" + preconditions: + - sh: '[ -n "{{.WORKSPACE}}" ]' + msg: "Workspace required. Usage: task tunnel:start -- " + - sh: '[ "{{.WORKSPACE}}" = "dev" ] || [ "{{.WORKSPACE}}" = "prod" ]' + msg: "Invalid workspace. Use: dev or prod" + cmds: + - | + HOSTNAME=$(task hostkeys:hostname -- "{{.WORKSPACE}}") + if pgrep -f "ssh.*-L 5080:localhost:5080.*ubuntu@$HOSTNAME" >/dev/null 2>&1; then + echo "Tunnel to {{.WORKSPACE}} already running." + echo " Traefik Dashboard: http://localhost:8080/dashboard/" + echo " OpenObserve: http://localhost:5080/" + exit 0 + fi + ssh -f -N -L 5080:localhost:5080 -L 8080:localhost:8080 -o StrictHostKeyChecking=accept-new "ubuntu@$HOSTNAME" || { echo "Failed to start tunnel."; exit 1; } + echo "Tunnel to {{.WORKSPACE}} started." + echo " Traefik Dashboard: http://localhost:8080/dashboard/" + echo " OpenObserve: http://localhost:5080/" + echo " Stop: task tunnel:stop -- {{.WORKSPACE}}" + + stop: + desc: "Stop SSH tunnel for workspace. Use: task tunnel:stop -- dev|prod" + silent: true + vars: + WORKSPACE: "{{.CLI_ARGS}}" + preconditions: + - sh: '[ -n "{{.WORKSPACE}}" ]' + msg: "Workspace required. Usage: task tunnel:stop -- " + - sh: '[ "{{.WORKSPACE}}" = "dev" ] || [ "{{.WORKSPACE}}" = "prod" ]' + msg: "Invalid workspace. Use: dev or prod" + cmds: + - | + HOSTNAME=$(task hostkeys:hostname -- "{{.WORKSPACE}}") + PIDS=$(pgrep -f "ssh.*-L 5080:localhost:5080.*ubuntu@$HOSTNAME" 2>/dev/null) || true + if [ -z "$PIDS" ]; then + echo "No tunnel to {{.WORKSPACE}} running." + else + echo "$PIDS" | xargs kill 2>/dev/null || true + echo "Tunnel to {{.WORKSPACE}} stopped." + fi From cb8fe9cacfaecf1ab7ca43bd47c12f50f22bf13f Mon Sep 17 00:00:00 2001 From: Wander Grevink Date: Sat, 28 Feb 2026 13:25:56 +0000 Subject: [PATCH 2/3] observer --- .devcontainer/setup-remote-ssh.sh | 15 +++ Taskfile.yml | 5 +- .../files/observer-allowed-commands.list | 67 ++++++++++ ansible/roles/server/files/observer-run.sh | 76 ++++++++++++ ansible/roles/server/tasks/main.yml | 3 + ansible/roles/server/tasks/observer.yml | 102 +++++++++++++++ docs/cursor-agent-observer.md | 117 ++++++++++++++++++ tasks/Taskfile.server.yml | 15 +++ tasks/Taskfile.tunnel.yml | 10 +- 9 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 ansible/roles/server/files/observer-allowed-commands.list create mode 100644 ansible/roles/server/files/observer-run.sh create mode 100644 ansible/roles/server/tasks/observer.yml create mode 100644 docs/cursor-agent-observer.md diff --git a/.devcontainer/setup-remote-ssh.sh b/.devcontainer/setup-remote-ssh.sh index bf11c16..9d03f66 100644 --- a/.devcontainer/setup-remote-ssh.sh +++ b/.devcontainer/setup-remote-ssh.sh @@ -32,6 +32,21 @@ Host prod StrictHostKeyChecking accept-new LocalForward 5080 localhost:5080 LocalForward 8080 localhost:8080 + +# Same host as dev/prod, User observer, no port forwards (avoids conflict when tunnel is up) +Host dev-observer + HostName $DEV_HOSTNAME + User observer + IdentityFile ~/.ssh/id_rsa + IdentitiesOnly yes + StrictHostKeyChecking accept-new + +Host prod-observer + HostName $PROD_HOSTNAME + User observer + IdentityFile ~/.ssh/id_rsa + IdentitiesOnly yes + StrictHostKeyChecking accept-new EOF chmod 600 "$FILE" diff --git a/Taskfile.yml b/Taskfile.yml index d3b94b4..f7b7ddd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -66,8 +66,9 @@ tasks: echo " task registry:overview # List tags (TAG, CREATED, DESCRIPTION) for all repos" echo "" echo "Utilities:" - echo " task server:check-status # Check if servers are up (checks both dev and prod)" - echo " task server:list-hetzner-keys # List Hetzner SSH keys with IDs" + echo " task server:observe -- # Run read-only command on server (observer); use for investigation" + echo " task server:check-status # Check if servers are up (checks both dev and prod)" + echo " task server:list-hetzner-keys # List Hetzner SSH keys with IDs" echo "" echo "Backups (Hetzner):" echo " task backup:list -- # List backup images" diff --git a/ansible/roles/server/files/observer-allowed-commands.list b/ansible/roles/server/files/observer-allowed-commands.list new file mode 100644 index 0000000..b6c3d77 --- /dev/null +++ b/ansible/roles/server/files/observer-allowed-commands.list @@ -0,0 +1,67 @@ +# observer-run allowlist: one entry per line. Add a line to allow a new command; no need to edit the script. +# Format: "docker:sub" or "docker:sub:sub2", "systemctl:sub", or plain command basename (e.g. cat, journalctl). +# Lines starting with # and blank lines are ignored. + +# docker (read-only subcommands) +docker:ps +docker:logs +docker:inspect +docker:stats +docker:images +docker:version +docker:info +docker:events +docker:top +docker:port +docker:system:df +docker:volume:ls +docker:image:ls +docker:container:ls + +# systemctl (read-only subcommands) +systemctl:status +systemctl:show +systemctl:list-units +systemctl:list-unit-files +systemctl:cat +systemctl:list-dependencies +systemctl:is-active +systemctl:is-enabled + +# general (command basename) +journalctl +cat +ls +head +tail +less +grep +find +readlink +stat +file +df +free +id +whoami +uname +hostname +date +diff +basename +dirname +realpath +awk +sed +cut +tr +sort +uniq +wc +zcat +zgrep +jq +column +env +printenv +which diff --git a/ansible/roles/server/files/observer-run.sh b/ansible/roles/server/files/observer-run.sh new file mode 100644 index 0000000..47fac46 --- /dev/null +++ b/ansible/roles/server/files/observer-run.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# observer-run: run a command as root in a read-only environment (observer mode). +# Uses bubblewrap (bwrap) and an allowlist. See docs/cursor-agent-observer.md. + +set -euo pipefail + +ALLOWLIST_FILE="${OBSERVER_ALLOWLIST_FILE:-/etc/observer-allowed-commands}" + +# Must run as root (sudo observer-run) +if [ "$(id -u)" -ne 0 ]; then + echo "observer-run: must be run as root (use: sudo observer-run ...)" >&2 + exit 1 +fi + +if [ $# -eq 0 ]; then + echo "observer-run: no command given" >&2 + exit 1 +fi + +# If command was passed as one quoted string (e.g. "docker ps"), split into words +if [ $# -eq 1 ] && [[ "$1" == *" "* ]]; then + set -- $1 +fi + +# Load allowlist: strip comments and blank lines +allowed_list="" +if [ -r "$ALLOWLIST_FILE" ]; then + allowed_list=$(grep -v '^[[:space:]]*#' "$ALLOWLIST_FILE" | grep -v '^[[:space:]]*$' | sed 's/[[:space:]].*//' | tr '\n' ' ') +fi + +is_allowed() { + local key="$1" + [[ " ${allowed_list} " == *" ${key} "* ]] +} + +# Build allowlist key from argv +cmd="$1" +cmd_basename="$(basename "$cmd")" +sub="${2:-}" +sub2="${3:-}" + +case "$cmd_basename" in + docker) + if [[ "$sub" == "system" || "$sub" == "volume" || "$sub" == "image" || "$sub" == "container" ]]; then + key="docker:${sub}:${sub2}" + else + key="docker:${sub}" + fi + ;; + systemctl) + key="systemctl:${sub}" + ;; + *) + key="$cmd_basename" + ;; +esac + +if ! is_allowed "$key"; then + echo "observer-run: not allowed: $* (allowlist: $ALLOWLIST_FILE)" >&2 + exit 1 +fi + +if ! command -v bwrap >/dev/null 2>&1; then + echo "observer-run: bwrap (bubblewrap) is required but not installed. Install the bubblewrap package." >&2 + exit 1 +fi + +exec bwrap \ + --ro-bind / / \ + --bind /home/observer /home/observer \ + --bind /tmp /tmp \ + --bind /home/ubuntu /home/ubuntu \ + --ro-bind /sys /sys \ + --dev /dev \ + --proc /proc \ + -- "$@" diff --git a/ansible/roles/server/tasks/main.yml b/ansible/roles/server/tasks/main.yml index f7a220e..dcabfbc 100644 --- a/ansible/roles/server/tasks/main.yml +++ b/ansible/roles/server/tasks/main.yml @@ -28,3 +28,6 @@ - name: Configure OpenObserve ansible.builtin.import_tasks: openobserve.yml + +- name: Configure observer (read-only server access for agent) + ansible.builtin.import_tasks: observer.yml diff --git a/ansible/roles/server/tasks/observer.yml b/ansible/roles/server/tasks/observer.yml new file mode 100644 index 0000000..4d43649 --- /dev/null +++ b/ansible/roles/server/tasks/observer.yml @@ -0,0 +1,102 @@ +--- +# Observer: read-only server access for the agent (replaces cursor.yml). +# Create observer user and observer-run wrapper for diagnostics-only SSH. +# See docs/cursor-agent-observer.md. + +# --- Observer user and SSH --- + +- name: Create observer user + ansible.builtin.user: + name: observer + shell: /bin/bash + create_home: true + home: /home/observer + system: false + +- name: Ensure observer .ssh directory exists + ansible.builtin.file: + path: /home/observer/.ssh + state: directory + owner: observer + group: observer + mode: '0700' + +- name: Copy ubuntu authorized_keys to observer (same SSH key access) + ansible.builtin.slurp: + src: /home/ubuntu/.ssh/authorized_keys + register: ubuntu_authorized_keys_slurp + changed_when: false + failed_when: false + +- name: Set observer authorized_keys from ubuntu + ansible.builtin.copy: + content: "{{ ubuntu_authorized_keys_slurp.content | b64decode }}" + dest: /home/observer/.ssh/authorized_keys + owner: observer + group: observer + mode: '0600' + when: (ubuntu_authorized_keys_slurp.content | default('') | length) > 0 + +# --- observer-run: sudo wrapper so observer can run "observer-run" without typing sudo --- + +- name: Ensure observer bin directory exists + ansible.builtin.file: + path: /home/observer/bin + state: directory + owner: observer + group: observer + mode: '0755' + +- name: Install observer-run wrapper (observer-run without sudo) + ansible.builtin.copy: + content: | + #!/bin/sh + exec sudo /usr/local/bin/observer-run "$@" + dest: /home/observer/bin/observer-run + owner: observer + group: observer + mode: '0755' + +- name: Add ~/bin to observer PATH in .profile + ansible.builtin.lineinfile: + path: /home/observer/.profile + line: 'export PATH="$HOME/bin:$PATH"' + create: true + owner: observer + group: observer + mode: '0644' + +# --- observer-run (real script) and sudo --- + +- name: Install bubblewrap (bwrap) for observer read-only sandbox + ansible.builtin.apt: + name: bubblewrap + state: present + update_cache: true + +- name: Install observer allowlist + ansible.builtin.copy: + src: observer-allowed-commands.list + dest: /etc/observer-allowed-commands + owner: root + group: root + mode: '0644' + +- name: Install observer-run script + ansible.builtin.copy: + src: observer-run.sh + dest: /usr/local/bin/observer-run + owner: root + group: root + mode: '0755' + +- name: Configure sudo for observer (only observer-run) + ansible.builtin.copy: + content: | + # Observer: read-only diagnostics. Only observer-run is allowed. + # See docs/cursor-agent-observer.md. + observer ALL=(ALL) NOPASSWD: /usr/local/bin/observer-run * + Defaults:observer !requiretty + dest: /etc/sudoers.d/90-observer + mode: '0440' + validate: 'visudo -cf %s' diff --git a/docs/cursor-agent-observer.md b/docs/cursor-agent-observer.md new file mode 100644 index 0000000..dea2bc6 --- /dev/null +++ b/docs/cursor-agent-observer.md @@ -0,0 +1,117 @@ +# Observer: read-only server access for the agent + +The Cursor agent can run in the **devcontainer** (B1) and talk to the server only via SSH. That keeps the agent off the server and is safer. This document describes **observer mode**: a server-side user and wrapper so that when the agent runs SSH from the devcontainer, it gets full read access and diagnostics but **no** writes or state changes. + +**Status:** Implemented. Use **observer@dev** or **observer@prod** for read-only SSH; **ubuntu@dev** / **ubuntu@prod** for admin. + +--- + +## Cursor agent: use observer only for server investigation + +**When you (the Cursor agent) need to run commands on the server to inspect logs, status, or config, use only this entry point:** + +```bash +task server:observe -- '' +``` + +Examples: +- `task server:observe -- dev 'docker ps'` +- `task server:observe -- dev 'journalctl -u traefik -n 50'` +- `task server:observe -- prod 'systemctl status docker'` + +**Do not use** `ssh dev`, `ssh prod`, or the ubuntu user for investigation. Use `task server:observe` so all server inspection goes through the read-only observer channel. + +--- + +## Overview + +- **Goal:** The agent (in the devcontainer) uses SSH to run commands on the server. Those sessions should be **read-only**: full read and listing/diagnostics, no filesystem writes (except in allowed dirs), no starting/stopping services or containers. +- **Means:** A dedicated **observer** user on the server. It has no direct sudo; it can only run one wrapper, **observer-run**, which runs the command as root inside **bubblewrap (bwrap)** for a read-only filesystem, with an **allowlist** for `docker` and `systemctl` (only read-only subcommands; all other commands allowed). +- **Usage:** Same hosts **dev** and **prod**. **ubuntu@dev** / **ubuntu@prod** = admin. **observer@dev** / **observer@prod** = read-only. + +--- + +## Architecture: agent in devcontainer (B1) + +The recommended setup is **B1**: the agent runs only in the devcontainer. It never runs on the server. When it needs server data (logs, status, config), it runs `ssh '...'` from the devcontainer. The server is just a target. + +| Where | What runs | +|-------------|-------------------------------------| +| Devcontainer| Cursor, agent, Task, Ansible, repo | +| Server | SSH target; observer or ubuntu user| + +So observer is a **server-side** user and script. The agent stays in the devcontainer and reaches the server via SSH as observer (read-only) or ubuntu (when you run Ansible/deploys yourself). + +--- + +## Observer on the server + +### User and sudo + +| User | Purpose | Sudo | +|-----------|----------------------------|------| +| **ubuntu**| Ansible, manual admin | Full (unchanged) | +| **observer** | Read-only diagnostics | Only `observer-run` | + +- **observer** is a separate system user. Same SSH keys as ubuntu (or a subset), so one key can connect as either user: `ssh ubuntu@dev` or `ssh observer@dev`. +- Single sudo rule: `observer ALL=(ALL) NOPASSWD: /usr/local/bin/observer-run *`. No other sudo. + +### observer-run wrapper + +A root-owned script `/usr/local/bin/observer-run` that: + +1. **Allowlist:** Only entries in **`/etc/observer-allowed-commands`** are allowed. One entry per line; lines starting with `#` and blank lines are ignored. To allow a new command, add a line and redeploy (no script change). Format: + - **docker:** `docker:ps`, `docker:logs`, …; two-word subcommands as `docker:system:df`, `docker:volume:ls`, etc. + - **systemctl:** `systemctl:status`, `systemctl:show`, … + - **Other:** plain basename, e.g. `cat`, `journalctl`, `jq`. The file ships with a default set (docker read-only subcommands, systemctl read-only subcommands, and common read-only CLI tools). +2. **bwrap (bubblewrap):** Runs the command as root inside a bwrap sandbox: **read-only /** (including **/run**, so the Docker socket and D-Bus are visible but not writable) with writable **/home/observer**, **/tmp**, **/home/ubuntu**. Also **/dev**, **/proc**, **/sys**. Must be invoked as **sudo observer-run** (script exits with a clear error if not root). Requires the **bubblewrap** package (installed by Ansible). Keeping /run read-only means e.g. `journalctl --vacuum-*` cannot remove log files. + +Result: + +- **Allowed:** Read any file, list and inspect (e.g. `sudo observer-run cat /etc/shadow`, `sudo observer-run docker ps`, `sudo observer-run journalctl -u traefik -n 50`). Writes only under `/home/observer`, `/tmp`, `/home/ubuntu`. +- **Blocked:** Any `docker` or `systemctl` subcommand not on the allowlist; any other command whose basename is not in the general allowlist (e.g. `bash`, `curl`, `python3`); writes outside the allowed dirs (bwrap). + +### Allowed vs blocked + +| Allowed | Blocked | +|---------|---------| +| Read any file (with sudo observer-run) | Write outside `/home/observer`, `/tmp`, `/home/ubuntu` (bwrap) | +| Any entry in `/etc/observer-allowed-commands` (docker/systemctl subcommands + general basenames) | Anything not in the allowlist file | + +### How to connect + +- **ubuntu@dev**, **ubuntu@prod** — admin (or `ssh dev` / `ssh prod`). +- **observer@dev**, **observer@prod** — read-only. Use **dev-observer** / **prod-observer** in SSH config when the tunnel is up (no port forwards). A wrapper in the observer’s `~/bin` runs `sudo observer-run` so you can run `observer-run docker ps` without typing `sudo`. + +Examples: `ssh dev-observer 'observer-run docker ps'`, `ssh dev-observer 'observer-run journalctl -u traefik -n 50'`. A wrapper in `~/bin` runs `sudo observer-run` for you, so you don’t need to type `sudo`; you can still use `sudo observer-run ...` if you prefer. + +--- + +## Implementation (when you add observer) + +- **Ansible:** Create user **observer**, copy authorized_keys from ubuntu (or manage keys separately). Install **bubblewrap**, deploy **`/etc/observer-allowed-commands`** (allowlist), install `/usr/local/bin/observer-run`, deploy `/etc/sudoers.d/90-observer`. Install a wrapper at **`/home/observer/bin/observer-run`** that runs `sudo /usr/local/bin/observer-run "$@"`, and add `~/bin` to PATH in observer’s `.profile`, so the observer can run `observer-run …` without typing `sudo`. To allow another command: add a line to the allowlist file in the role and redeploy. +- **observer-run:** Reads allowlist from **`/etc/observer-allowed-commands`**; if the command is allowed, runs it in bwrap. Requires **bubblewrap** package. To extend: add a line to the allowlist file (and redeploy); no need to edit the script. +- **SSH config:** **dev** / **prod** only (User ubuntu). Connect as observer with **observer@dev** / **observer@prod**. Written by `setup-remote-ssh.sh`. + +--- + +## Edge cases and caveats + +- **CLI tools that write outside allowed dirs:** Some programs write to `/var/cache` or `~/.cache`. With only `/home/observer`, `/tmp`, and optionally `/home/ubuntu` writable, those writes fail. You can add `--bind /var/cache /var/cache` (and/or `/var/tmp`) to observer-run if acceptable. +- **Docker socket:** `/run` is part of the read-only root, so `/run/docker.sock` is visible and the Docker client can connect (socket connections don’t require write). Only allowlisted docker subcommands run; `docker run`, `docker system prune`, etc. are rejected by the allowlist. +- **Allowlist:** Stored in **`/etc/observer-allowed-commands`**. Add a line to allow a new command (e.g. `jq` or `docker:exec` if you ever need it); no script edit. Keeps the script small and avoids interpreters/network tools by default. + +--- + +## Alternatives considered + +- **Restrict sudo for ubuntu:** Would break Ansible (runs as ubuntu with sudo). Not viable. +- **Same user, “observer mode” via sudo:** Sudo can’t express “this session is read-only.” Need a separate user. +- **Agent on the server (Remote-SSH):** Agent runs on the server as ubuntu or observer. Works but puts the agent on the server; B1 keeps it in the devcontainer only. + +--- + +## Summary + +- **Observer** = server user + **observer-run** wrapper (bwrap read-only + allowlist for docker/systemctl). Use **observer@dev** / **observer@prod**. +- **B1:** Agent in devcontainer; SSH to server as observer (read-only) or ubuntu (admin). diff --git a/tasks/Taskfile.server.yml b/tasks/Taskfile.server.yml index 8b9b886..6e48ec5 100644 --- a/tasks/Taskfile.server.yml +++ b/tasks/Taskfile.server.yml @@ -2,6 +2,21 @@ version: '3' tasks: + observe: + desc: Run a read-only command on the server (observer channel). Use this for all server investigation. + vars: + WORKSPACE: + sh: echo "{{.CLI_ARGS}}" | awk '{print $1}' + CMD: + sh: echo "{{.CLI_ARGS}}" | awk '{$1=""; print substr($0,2)}' + preconditions: + - sh: '[ "{{.WORKSPACE}}" = "dev" ] || [ "{{.WORKSPACE}}" = "prod" ]' + msg: "Usage: task server:observe -- " + - sh: '[ -n "{{.CMD}}" ]' + msg: "Usage: task server:observe -- " + cmds: + - ssh "{{.WORKSPACE}}-observer" "sudo observer-run {{.CMD}}" + check-status: desc: Check if servers are up and accessible (checks both dev and prod) silent: true diff --git a/tasks/Taskfile.tunnel.yml b/tasks/Taskfile.tunnel.yml index 9fdbe8f..ad0e9fb 100644 --- a/tasks/Taskfile.tunnel.yml +++ b/tasks/Taskfile.tunnel.yml @@ -16,15 +16,14 @@ tasks: msg: "Invalid workspace. Use: dev or prod" cmds: - | - HOSTNAME=$(task hostkeys:hostname -- "{{.WORKSPACE}}") - if pgrep -f "ssh.*-L 5080:localhost:5080.*ubuntu@$HOSTNAME" >/dev/null 2>&1; then + if pgrep -f "ssh.*-f.*-N.*{{.WORKSPACE}}" >/dev/null 2>&1; then echo "Tunnel to {{.WORKSPACE}} already running." echo " Traefik Dashboard: http://localhost:8080/dashboard/" echo " OpenObserve: http://localhost:5080/" exit 0 fi - ssh -f -N -L 5080:localhost:5080 -L 8080:localhost:8080 -o StrictHostKeyChecking=accept-new "ubuntu@$HOSTNAME" || { echo "Failed to start tunnel."; exit 1; } - echo "Tunnel to {{.WORKSPACE}} started." + ssh -f -N {{.WORKSPACE}} || { echo "Failed to start tunnel."; exit 1; } + echo "Tunnel to {{.WORKSPACE}} started (uses Host {{.WORKSPACE}} from SSH config)." echo " Traefik Dashboard: http://localhost:8080/dashboard/" echo " OpenObserve: http://localhost:5080/" echo " Stop: task tunnel:stop -- {{.WORKSPACE}}" @@ -41,8 +40,7 @@ tasks: msg: "Invalid workspace. Use: dev or prod" cmds: - | - HOSTNAME=$(task hostkeys:hostname -- "{{.WORKSPACE}}") - PIDS=$(pgrep -f "ssh.*-L 5080:localhost:5080.*ubuntu@$HOSTNAME" 2>/dev/null) || true + PIDS=$(pgrep -f "ssh.*-f.*-N.*{{.WORKSPACE}}" 2>/dev/null) || true if [ -z "$PIDS" ]; then echo "No tunnel to {{.WORKSPACE}} running." else From e5cb03c399d97cf318d6b09d1f060791727b615e Mon Sep 17 00:00:00 2001 From: Wander Grevink Date: Sat, 28 Feb 2026 13:40:23 +0000 Subject: [PATCH 3/3] fix test --- tasks/Taskfile.tunnel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/Taskfile.tunnel.yml b/tasks/Taskfile.tunnel.yml index ad0e9fb..faf5923 100644 --- a/tasks/Taskfile.tunnel.yml +++ b/tasks/Taskfile.tunnel.yml @@ -22,7 +22,7 @@ tasks: echo " OpenObserve: http://localhost:5080/" exit 0 fi - ssh -f -N {{.WORKSPACE}} || { echo "Failed to start tunnel."; exit 1; } + ssh -f -N "{{.WORKSPACE}}" || { echo "Failed to start tunnel."; exit 1; } echo "Tunnel to {{.WORKSPACE}} started (uses Host {{.WORKSPACE}} from SSH config)." echo " Traefik Dashboard: http://localhost:8080/dashboard/" echo " OpenObserve: http://localhost:5080/"