Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .claude/hooks/guard-taboos-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ check deny "python3 -c \"open('/dev/sda','wb').write(b'0'*4096)\""
check deny "perl -e 'open(D,\">\",\"/dev/nvme0n1\"); print D chr(0)'"
check deny "node -e \"require('fs').writeFileSync('/dev/vda','')\""

# --- SSH connection sharing (rules/ssh-connections.md) ---------
# heinzel's control socket must not read as key material, or
# every remote rm/mv/chmod sent with the standard options is
# denied (see the accepted false positives in the guard).
check pass 'ssh -o ControlMaster=auto -o ControlPath=~/.cache/heinzel/ssh-%C root@h "rm -f /var/tmp/old.log; chmod 644 /etc/motd"'

# --- must pass -------------------------------------------------
check pass 'fdisk -l'
check pass 'sfdisk -l /dev/sda'
Expand Down
4 changes: 4 additions & 0 deletions .claude/hooks/guard-taboos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@
# - `cp /etc/ssh/sshd_config /tmp/` is blocked although it only
# reads the file — copy out via `cat /etc/ssh/sshd_config >
# /tmp/copy` instead.
# - An ssh ControlPath under .ssh/ makes any rm, mv or chmod in
# the same command look like a key operation. heinzel keeps
# its sockets in ~/.cache/heinzel for that reason
# (rules/ssh-connections.md).
#
# Being blocked is EXPECTED behavior. Explain it to the user.
# Never rephrase, re-quote, or otherwise obfuscate a command to
Expand Down
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
"timeout": 15
}
]
},
{
"hooks": [
{
"type": "command",
"command": "mkdir -p -m 700 \"$HOME/.cache/heinzel\"",
"timeout": 5
}
]
}
],
"PreToolUse": [
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/heinzel-fleet-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ servers" — that maps to single-host housekeeping.

3. **Probe in parallel.** For each in-scope host, run the
probes from `references/probes.md` in a single batched
SSH command. Use `ssh -o BatchMode=yes -o ConnectTimeout=5`.
SSH command, with the standard options from `CLAUDE.md` →
SSH Options.
Hosts that time out or refuse the connection go on a
"skipped: unreachable" list.

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/heinzel-fleet-audit/references/probes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ read-only. Group them into a single SSH invocation per host
to minimise round-trips:

```bash
ssh -o BatchMode=yes -o ConnectTimeout=5 USER@HOST '
ssh <standard options from CLAUDE.md → SSH Options> USER@HOST '
echo "###ua###"; <ua probe>
echo "###sshd###"; <sshd probe>
echo "###fw###"; <firewall probe>
Expand Down
28 changes: 26 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,33 @@ user frames as quick.
### SSH Options

Always use these options on every SSH and
SCP/rsync-over-SSH command:
SCP/rsync-over-SSH command (for rsync inside
`-e "ssh …"`):

ssh -o BatchMode=yes -o ConnectTimeout=5 …
ssh -o BatchMode=yes -o ConnectTimeout=5 \
-o ControlMaster=auto \
-o ControlPath=~/.cache/heinzel/ssh-%C \
-o ControlPersist=10m \
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 …

They share one connection per host and remote user
across calls. A SessionStart hook creates the socket
directory; where hooks do not run (OpenCode), run
`mkdir -p -m 700 ~/.cache/heinzel` first.

**Fresh-login options** — for access tests and the
single retry after a hanging call:

ssh -o BatchMode=yes -o ConnectTimeout=5 \
-o ControlMaster=no -o ControlPath=none …

Use them **instead of** the standard options, never
appended to them: for a repeated option, SSH keeps
the first value it sees.

Rate limits count connections, not commands: read
`rules/ssh-connections.md`. When SSH stops
answering, read `rules/ssh-unreachable.md`.

## Access Control (Blacklist & Read-Only)

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ changes — no changes until you say go.
This is not needed for local administration
(localhost / your own machine).

Heinzel shares one SSH connection per host and
keeps it open for 10 minutes after the last call.
The sockets live in `~/.cache/heinzel` (mode 0700),
so any process of your local user can use an open
connection without asking for the key again.

Quick setup: generate a key with `ssh-keygen`,
copy it to the server with `ssh-copy-id user@host`,
and test with `ssh user@host`. See the
Expand Down Expand Up @@ -959,6 +965,10 @@ rules/ — Upstream rule files (git-tracked)
privilege-escalation.md — Sudo, root SSH, unprivileged mode
os-detection.md — OS detection procedure
ssh-user.md — SSH username & language management
ssh-connections.md — Bundled, shared SSH connections
(rate limits count connections)
ssh-unreachable.md — No retry loops; blocked path vs
broken host
server-memory.md — Server memory file format
changelog.md — Session logging procedure
activity-check.md — Recent-activity summary on connect
Expand Down
6 changes: 5 additions & 1 deletion rules/privilege-escalation.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ the sudo flag.
## Root SSH Fallback

When sudo is unusable and a privileged action is
needed, probe root SSH access once:
needed, probe root SSH access once, with the
fresh-login options (`CLAUDE.md` → SSH Options) — a
shared root connection opened earlier would answer
even if root login has been disabled since:

```
ssh -o BatchMode=yes -o ConnectTimeout=5 \
-o ControlMaster=no -o ControlPath=none \
root@hostname "id" 2>&1
```

Expand Down
118 changes: 118 additions & 0 deletions rules/ssh-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# SSH Connections: Few and Shared

Firewall rules that rate-limit new connections to
port 22 (`ufw limit`: 6 in 30 seconds; iptables
`recent` or `hashlimit`) and network IPS signatures
for SSH scans count **TCP connections, not
commands**, successful logins included. A busy
heinzel session can trip them and lock itself out,
and the block looks like a broken host. If that has
already happened, see `rules/ssh-unreachable.md`.

fail2ban, sshguard and sshd's own
`PerSourcePenalties` (OpenSSH 9.8+) count failed and
aborted logins, not successful ones. Sharing changes
nothing for them; only avoiding failed logins does.

## 1. Bundle commands

One call per logical step, not one per command:

ssh … host 'sh -s' <<'EOS'
cmd1
cmd2
EOS

- Send several files in one `scp`/`rsync`.
- Do not poll a host every few seconds. Run a long
job on the host (`nohup`, `systemd-run`, `daemon`)
and read its log at an interval of minutes.
- `ProxyJump` costs a connection to the jump host
**and** one to the target.

## 2. Share connections

The standard options in `CLAUDE.md` → SSH Options
turn on OpenSSH connection sharing (`ControlMaster`):
repeated calls ride **one TCP connection per local
user, remote user, host and port**. Later calls are
several times faster, open no new connection, and
key agents that confirm each use ask only once.

It does not help with the first connection per host
and remote user, with the first one after
`ControlPersist` expires, or with **failed logins**,
which are never shared.

### Why these values

Keep them as they are:

- **Command line, not `~/.ssh/config`:** works on
every machine without setup and overrides a
`ControlMaster` block the user keeps for
themselves.
- **`~/.cache/heinzel/`, not `~/.ssh/` or `/tmp`:**
the taboo guard reads any path under `.ssh/` as key
material and would block every remote `rm`, `mv` or
`chmod`; `/tmp` is writable by other users.
- **`%C`:** a fixed-length hash. Readable names can
pass the 104-byte socket path limit on macOS, and
SSH then fails the call (`ControlPath too long`).
- **`ServerAliveInterval=15`,
`ServerAliveCountMax=3`:** retire a master with a
dead network path after 45 seconds, whatever
`~/.ssh/config` says. `ConnectTimeout` does not
cover a call over an existing connection.

### Fresh-login options

The second option set in `CLAUDE.md` → SSH Options.
Use it for:

- **Access tests.** Anything that answers "can this
login still succeed?" — after changing
`authorized_keys`, an account, its shell or groups,
PAM, host keys, or firewall rules on the SSH port,
and the root SSH probe in
`rules/privilege-escalation.md`. A shared master
answers from the login **before** the change, so a
broken login reads as working. Make all related
changes first, then run one fresh-login call that
also prints what you need (`id; groups`).
- **A call that hangs or fails while sharing:** a
stale master. Follow `rules/ssh-unreachable.md`.

`Session open refused by peer` is different: the
master is fine but full (sshd `MaxSessions`, default
10 per connection). Let your own parallel calls to
that host finish, then repeat the call as usual.

### Caveats

- **The socket directory must exist.** If it is
missing, every call fails *after* the login with
`unix_listener: cannot bind to path … No such file
or directory` and exit 255. The host is fine:
create the directory
(`mkdir -p -m 700 ~/.cache/heinzel`) and repeat
the call.
- **`scp` never starts a master.** It passes
`-oControlMaster=no` ahead of your options, so it
only reuses one. Open it with an `ssh` call first;
the onboarding already does.
- **Never close a master you did not start.** Other
heinzel sessions and scripts on the same local
account use the same socket path, and
`ssh -O exit`/`-O stop` ends their sessions too.
For experiments, use a complete option set with its
own path (e.g. `~/.cache/heinzel/test-%C`) and close
only that one.
- **Login records understate activity.** `last`,
`who` and the sshd log show one login for many
calls. Use `rules/activity-check.md` to judge
whether someone else is working on the host.
- **Security.** While a master is open, any process
of the same local user can use it without key
approval. Keep `~/.cache/heinzel` at `0700`; on a
shared account, shorten `ControlPersist`.
65 changes: 65 additions & 0 deletions rules/ssh-unreachable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# When SSH Stops Answering

A host that suddenly stops accepting SSH is often
fine: a filter on the way blocks the client,
frequently because of heinzel's own connections (see
`rules/ssh-connections.md`).

## Do not retry in a loop

Reconnecting on failure is exactly the pattern rate
limits and IPS rules punish, and it keeps an existing
block alive.

1. Retry **once** with the fresh-login options
(`CLAUDE.md` → SSH Options) plus `-v`. A stale
shared connection is the cheap explanation, and
`-v` shows every address tried (see Dual-stack).
2. If that fails too, stop. Wait several minutes
before the next attempt, and never wrap SSH in an
automatic retry.

## Target or path?

Signs that something **on the way** blocks, not the
host: the address answers ping but the SSH port
times out (no `Connection refused`); `Connection
timed out during banner exchange`; it worked a few
times, then stopped, and recovers by itself after
minutes; another service on the same address still
answers:

curl -sS -o /dev/null -w '%{http_code}\n' \
https://<host>/

Then the host is up and a filter on the client's
path blocks SSH. Wait, or tell the user. Do not
chase routing, NAT or MTU. A check from inside the
target's network sees a clean path and proves
nothing about the client's. Firewall and IPS
changes: `CLAUDE.md` → Firewall & network.

## Dual-stack

OpenSSH tries a host's addresses one after another,
usually IPv6 first, and moves on only when an
address fails or `ConnectTimeout` runs out. A filter
that drops one family therefore shows as a delay of
`ConnectTimeout` on every new connection, and as a
failure once both families are blocked.

The `-v` retry shows it without an extra connection:

debug1: Connecting to <host> [<address>] port 22.
debug1: connect to address <address> port 22: <timeout>

One family timing out while the other connects
points to a per-family filter. A firewall or IPS
exception covers only the family written in it:
after adding one, test both with a fresh login
(`ssh -4 …`, `ssh -6 …`).

Do not probe with `nc -z` or `ssh-keyscan`: fail2ban
(modes `ddos` and `aggressive`) and sshd's
`PerSourcePenalties` count a connection that never
logs in. A successful login counts for neither.