Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .devcontainer/setup-remote-ssh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <dev|prod> <cmd> # 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 -- <dev|prod> # List backup images"
Expand Down
67 changes: 67 additions & 0 deletions ansible/roles/server/files/observer-allowed-commands.list
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions ansible/roles/server/files/observer-run.sh
Original file line number Diff line number Diff line change
@@ -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 \
-- "$@"
3 changes: 3 additions & 0 deletions ansible/roles/server/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
102 changes: 102 additions & 0 deletions ansible/roles/server/tasks/observer.yml
Original file line number Diff line number Diff line change
@@ -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'
Loading