Skip to content
Open
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
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 @@ -56,7 +56,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, with the standard options from `CLAUDE.md` →
SSH Options.
SSH Options and the port from `- SSH port:` in the host's
`memory.md`.
Hosts that time out or refuse the connection go on a
"skipped: unreachable" list.

Expand Down
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ Use them **instead of** the standard options, never
appended to them: for a repeated option, SSH keeps
the first value it sees.

**Non-standard port:** when server memory holds
`- SSH port: <port>`, add `-o Port=<port>` to either
set on every call. New host or 22 refused: read
`rules/ssh-port.md`. Never scan for a port.

Rate limits count connections, fail2ban counts
failed logins: read `rules/ssh-connections.md`.
When SSH stops answering, read
Expand Down Expand Up @@ -169,7 +174,8 @@ remote connection before any other work.
a port in a `listenaddress` line counts too) and
keep every one open: `ufw allow OpenSSH` and
firewalld's `ssh` service cover 22 only.
- **Never remove or block SSH port 22.** If the user
- **Never remove or block the SSH port** — 22, or
any other port sshd listens on. If the user
asks, explain the risk and refuse. Offer
alternatives (e.g. restricting to specific IPs).
- **Verify the default incoming policy is
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,15 @@ addresses. The first hostname becomes the canonical
name; additional names become symlinks that share the
same memory. Each alias can have its own SSH user.

### Non-standard SSH ports

A server whose sshd is not on port 22 gets its port
in server memory, from `host:2222` or the first
successful login. List the ports you use yourself as
`Alternative SSH ports:` in `memory/user.md`; on a
new host whose port 22 refuses, Heinzel tries those
before it asks. It never scans for a port.

### Memory across sessions

After working on a machine, Heinzel remembers it.
Expand Down
6 changes: 6 additions & 0 deletions memory/user.md.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ Default: your-username

## Per-server overrides
# - hostname.example.com: different-username

# SSH Ports

# Ports you run sshd on instead of 22, tried in this
# order on first contact when port 22 refuses.
# Alternative SSH ports: 52222, 2222
2 changes: 1 addition & 1 deletion rules/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ compromised.
## Firewall

No special firewall changes needed. Deployment
uses the existing SSH port (22). Do not open
uses the existing SSH port. Do not open
additional ports for the deploy user.

## Server Memory
Expand Down
4 changes: 3 additions & 1 deletion rules/dns-aliases.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ symlink):

2. **Compare against known servers.** Scan existing
`memory/servers/*/memory.md` files (skip
symlinks) for a matching `- IP:` line.
symlinks) for a matching `- IP:` line and the
same SSH port (`rules/ssh-port.md` → NAT and
aliases).

3. **Match found -> alias.**
- Create symlink:
Expand Down
7 changes: 5 additions & 2 deletions rules/first-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ moment") — don't skip.
detection. Known hostname: verify the current IP
still matches the `- IP:` field in server memory.
See `rules/dns-aliases.md` for both.
4. **SSH user lookup** (first connection only). See
`rules/ssh-user.md`.
4. **SSH user and port lookup.** User: first
connection only, see `rules/ssh-user.md`. Port:
the one the user named, else `- SSH port:` from
the memory read in step 3; a new host without one
goes through `rules/ssh-port.md` → first contact.
5. **OS detection.** See `rules/os-detection.md`.
6. **Server memory file.** Create on first
connection, read on every subsequent connection.
Expand Down
4 changes: 3 additions & 1 deletion rules/server-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ overrides — see CLAUDE.md → Rule Overrides).
```

Adapt fields to OS (add Arch, Homebrew for macOS;
add `Mode: local` for localhost).
add `Mode: local` for localhost). Add
`- SSH port: <port>` only for a port other than 22
(`rules/ssh-port.md`).

**Update memory immediately after any system
change.** Keep it compact (~30 lines max). Remove
Expand Down
2 changes: 1 addition & 1 deletion rules/ssh-connections.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SSH Connections: Few and Shared

Firewall rules that rate-limit new connections to
port 22 (`ufw limit`: 6 in 30 seconds; iptables
the SSH port (`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
Expand Down
154 changes: 154 additions & 0 deletions rules/ssh-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# SSH Port

The port sshd answers on is a fact of the host, not
a personal preference. It lives in server memory
(`memory/servers/<hostname>/memory.md`), so a team
that shares server memory shares it too — unlike
the SSH user name, which is personal and lives in
`memory/user.md` (`rules/ssh-user.md`).

- SSH port: 2222

No line means port 22. Never write `- SSH port: 22`.

## Known port

- **The user names one** — `host:2222`,
`ssh://user@host:2222`, `-p 2222`, "port 2222".
Strip the port from the hostname before any memory
lookup, blacklist check or directory name:
`host:2222` is `memory/servers/host/`. An IPv6
address carries a port only in brackets
(`[2001:db8::1]:2222`); a bare one has none.
- **Known host, different port named:** replace the
line and say so in one line. A port named once for
a single call ("try 2200 this time") is not
stored.
- **`~/.ssh/config` sets `Port`** for the host: ssh
uses it without help, so heinzel adds nothing and
does not copy it into memory — two sources drift.

On a first connection the memory file does not
exist until step 6 of `rules/first-connection.md`.
Use the port for every call of the onboarding and
write the line when the file is created.

## Use

With a stored port, add `-o Port=<port>` to the
standard or the fresh-login options
(`CLAUDE.md` → SSH Options) on every call:

ssh -o BatchMode=yes … -o Port=2222 user@host
scp -o BatchMode=yes … -o Port=2222 file user@host:
rsync -e "ssh -o BatchMode=yes … -o Port=2222" …

Use `-o Port=` rather than the short flags: ssh
takes `-p`, but scp and sftp take `-P`, and a mix-up
sends the call to port 22.

A stored port wins over a `Port` in
`~/.ssh/config` (`CLAUDE.md` → SSH Options).

## Finding the port on first contact

Only when the user named no port, server memory has
none and the host is new. Known hosts never go
through this: if their port stops answering, follow
`rules/ssh-unreachable.md`.

### 1. Look in known_hosts — no connection

Skip this when the user has no alternative ports
(step 3). Otherwise check each of them in one call:

ssh-keygen -F '[<host>]:52222'; ssh-keygen -F '[<host>]:2222'

A `# Host [<host>]:<port> found` line means the user
accepted that host's key on that port before, hashed
entries included. Use that port for the first call;
if it refuses or times out, continue with step 2
and skip it in step 3. A `Port` in `~/.ssh/config` needs no check: the plain
call in step 2 already uses it.

### 2. Try port 22

As usual. Read the error:

- `Connection refused` — the host is up, nothing
listens on 22. Go to step 3.
- Timeout — the host is down or a filter drops the
packets. Do **not** try other ports: that adds
connections to a block that may be heinzel's own.
Follow `rules/ssh-unreachable.md`, then ask
(step 4).
- Any other answer (`Permission denied`, `Host key
verification failed`, a login) — sshd is on 22.
Handle it as usual.

### 3. Try the user's alternative ports

`memory/user.md` may list the ports the user runs
sshd on instead of 22 (`rules/ssh-user.md`):

Alternative SSH ports: 52222, 2222

Try them in that order, **once each**, at most
three. A refused port: next one. A timeout: stop and
ask (step 4) — the host is now dropping packets. A
successful login: record the port as `- SSH port:`.
Any other answer (`Permission denied`, a banner
error): stop and ask — something listens there, but
it may be a container's SSH, not the host's sshd.

heinzel keeps **no** list of "common" ports: several
ports in a row on one host look like a port scan to
psad, portsentry and provider IPS. Never scan
(`nc -z`, `nmap`, `ssh-keyscan`;
`rules/ssh-connections.md` → 3).

### 4. Ask

Same picker rules as `rules/ssh-user.md`
(`AskUserQuestion`, ASCII fallback). Question:
*"Port 22 on `<host>` <refused / timed out>. Which
SSH port should heinzel use?"* Options:

1. the first alternative port not yet tried —
"from your alternative ports"
2. `2222` — "a frequent alternative"
3. `Other…` — "type a port"

Drop an option whose port was already tried. If
both are gone, ask for the port as plain text.

Connect once with the answer. On success, record it
as `- SSH port:`. If the port is not yet in
`Alternative SSH ports:`, ask once more, on its own:
*"Also try `<port>` on new hosts when 22 refuses?"*
(yes / no). Yes appends it in `memory/user.md`.

## NAT and aliases

Behind a port forward, one address can reach
several hosts: `gw.example.com:2201` and
`gw.example.com:2202` are two machines. For alias
detection (`rules/dns-aliases.md`) a match needs the
same address **and** the same SSH port. A host's port
is the one the user named, else its `- SSH port:`
line, else the `port` line of `ssh -G <name>`, which
covers a `Port` in `~/.ssh/config` and opens no
connection. Detection already runs it for the new
name.

If the user named no port for the new name and the
address matches known hosts with a stored port, the
port is still open at that point. Ask which known
host it is (picker: each match as `host:port`, plus
"a different machine") instead of assuming 22.

The port heinzel connects to can also differ from
the port sshd listens on: a router forwards 2222 to
22 inside. The stored port is the connect port; a
firewall on the host keeps open the ports sshd
listens on (`CLAUDE.md` → Firewall & network).
2 changes: 2 additions & 0 deletions rules/ssh-unreachable.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ block alive.
2. If that fails too, stop. Wait several minutes
before the next attempt, and never wrap SSH in an
automatic retry.
3. Never try another port in its place
(`rules/ssh-port.md`).

## Target or path?

Expand Down
4 changes: 3 additions & 1 deletion rules/ssh-user.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ All SSH usernames are stored in `memory/user.md` —
never in server memory files. Read this file at the
start of every session.

The file has two parts:
The file has three parts:
- **Default** — the fallback username.
- **Per-server overrides** — `- hostname: username`
entries.
- **Alternative SSH ports** — optional; see
`rules/ssh-port.md`.

## Interview format

Expand Down
Loading